天天看點

ECMAScript 雙月報告:Pipeline Operator 進入 Stage 2(2021/8)Stage 3 → Stage 4Stage 1 → Stage 2Stage 0 → Stage 1

作者|吳成忠(昭朗) Alibaba F2E 

這次的 TC39 會議隻有兩天的時間,如 JavaScript 運算符重載與十進制計算、Wasm-JS 互操作性等議題都延遲到了 10 月份讨論。即使如此,這次會議還是有許多對于開發者來說非常有幫助的議題如 Pipeline 運算符、JavaScript 固定布局對象等有了階段性進展。

Stage 3 → Stage 4

從 Stage 3 進入到 Stage 4 有以下幾個門檻:

  1. 必須編寫與所有提案内容對應的 tc39/test262 測試,用于給各大 JavaScript 引擎和 transpiler 等實作檢查與标準的相容程度,并且 test262 已經合入了提案所需要的測試用例;
  2. 至少要有兩個實作能夠相容上述 Test 262 測試,并釋出到正式版本中;
  3. 發起了将提案内容合入正式标準文本 tc39/ecma262 的 Pull Request,并被 ECMAScript 編輯簽署同意意見。

Relative indexing .at() method

提案連結:

https://github.com/tc39/proposal-relative-indexing-method

很多時候,類似于 Python 中的數組負值索引可以非常實用。比如在 Python 中我們可以通過 

arr[-1]

 來通路數組中的最後一個元素,而不用通過目前 JavaScript 中的方式來通路 

arr[arr.length-1]

。這裡的負數是作為從起始元素(即

arr[0]

)開始的反向索引。

但是現在 JavaScript 中的問題是,

[]

 這個文法不僅僅隻是在數組中使用(當然在 Python 中也不是),而在數組中也不僅僅隻可以作為索引使用。像

arr[1]

一樣通過索引引用一個值,事實上引用的是這個對象的 

"1"

 這個屬性。是以 

arr[-1]

 已經完全可以在現在的 JavaScript 引擎中使用,隻是它可能不是代表的我們想要表達的意思而已:它引用的是目标對象的 

"-1"

 這個屬性,而不是一個反向索引。

這個提案提供了一個通用的方案,我們可以通過任意可索引的類型(Array,String,和 TypedArray)上的 

.at

 方法,來通路任意一個反向索引、或者是正向索引的元素。

// 數組
[0, 1, 2, 3].at(-1); // => 3
// 字元串
'0123'.at(-1); // => '3'      

這個特性已經在 Chrome 92、Firefox 90 中預設啟用。

Object.hasOwn

https://github.com/tc39/proposal-accessible-object-hasownproperty

現在我們就可以通過 

Object.prototype.hasOwnProperty

 來使用提案所包含的特性。但是直接通過對象自身的 

hasOwnProperty

 來使用 

obj.hasOwnProperty('foo')

 是不安全的,因為這個 

obj

 可能覆寫了 

hasOwnProperty

 的定義,MDN 上也對這種使用方式進行了警告。

JavaScript 并沒有保護 

hasOwnProperty

 這個屬性名,是以,當某個對象可能自有一個占用該屬性名的屬性時,就需要使用外部的 

hasOwnProperty

 獲得正确的結果...

Object.create(null).hasOwnProperty("foo")
// Uncaught TypeError: Object.create(...).hasOwnProperty is not a function

let object = {
  hasOwnProperty() {
    throw new Error("gotcha!")
  }
}

object.hasOwnProperty("foo")
// Uncaught Error: gotcha!      

是以一個正确的方式就得寫成這樣繁瑣的方式:

let hasOwnProperty = Object.prototype.hasOwnProperty

if (hasOwnProperty.call(object, "foo")) {
  console.log("has property foo")
}      

而提案在 

Object

 上增加了一個 

hasOwn

 方法,便于大部分場景使用:

let object = { foo: false }
Object.hasOwn(object, "foo") // true

let object2 = Object.create({ foo: true })
Object.hasOwn(object2, "foo") // false

let object3 = Object.create(null)
Object.hasOwn(object3, "foo") // false      

這個特性将在 Chrome 93、Firefox 91 中預設啟用。

Class static initialization blocks

https://github.com/tc39/proposal-class-static-block

自從有了 Class Private Fields,對于類的文法是不斷地有新的實踐與需求。這個提案提議的 Class Static 初始化塊會在類被執行、初始化時被執行。Java 等語言中也有類似的靜态初始化代碼塊的能力,Static Initialization Blocks (

https://docs.oracle.com/javase/tutorial/java/javaOO/initial.html

)。

提案中定義的初始化代碼塊可以獲得 class 内的作用域,如同 class 的方法一樣,也意味着可以通路類的 

#字段

。通過這個定義,我們就可以實作 JavaScript 中的 Friend 類了。

let getX;

export class C {
  #x
  constructor(x) {
    this.#x = { data: x };
  }

  static {
    // getX has privileged access to #x
    getX = (obj) => obj.#x;
  }
}

export function readXData(obj) {
  return getX(obj).data;
}      

這個特性将在 Firefox 93、Chrome 94 中預設啟用。

Stage 1 → Stage 2

從 Stage 1 進入到 Stage 2 需要完成撰寫包含提案所有内容的标準文本的初稿。

Change Array by Copy

https://github.com/tc39/proposal-change-array-by-copy

Array.prototype

 上有非常多十分實用的方法,如 

Array.prototype.pop

Array.prototype.sort

Array.prototype.reverse

 等等,這些方法通常都是直接就地修改目前的數組對象與其中的元素内容。如果我們需要避免修改原有的數組對象的話,通常我們可以通過 

[...arr]

 來快速淺拷貝一個數組對象,然後再對這個數組對象調用剛才所說的方法。

這在 Tuple 與 Record 類型正式引入 ECMAScript 之前确實沒什麼問題。但是如果我們需要引入 Tuple (一種内容不可變的數組),同時 Tuple 想要同樣具備 

Array.prototype

 上的這些便捷方法的話,先有的 

Array.prototype

 上的就地修改的方法就不再相容 Tuple 了,而 Tuple 也不能重用這些方法名來使用非就地修改的語義。是以這個提案就準備先為 JavaScript 引入多個就地修改方法的拷貝版,後續 Tuple 也就可以隻支援這些拷貝版本的方法。

let arr = [ 3, 2, 1 ];
arr.sort();
arr; // => [ 1, 2, 3 ] 被修改了

arr = [ 3, 2, 1 ];
let sorted = arr.withSorted();
sorted; // => [ 1, 2, 3 ]
arr; // => [ 3, 2, 1 ] 沒有被修改      

Pipeline operator

https://github.com/tc39/proposal-pipeline-operator

如果我們用了比如 lodash 等常見的函數庫,我們會發現如果需要連續調用多次 lodash 函數,我們需要不斷地包裹調用:

foo(bar(1, baz(x)[0]).method())      

又或者是連續調用異步函數:

let text = await (await fetch(...)).text();      

我們可以發現這些寫法都不易讀。實際使用中,讀的順序與執行的順序也可能是相反的,造成了解上的困難。

而 Pipeline 運算符引入了調用傳回值傳播文法,讓我們可以非常友善地書寫類似的鍊式調用:

// 嵌套式調用
foo(bar(1, baz(x)[0]).method())

// Pipeline 運算符
x |> baz(%)[0] |> bar(1,%).method() |> foo(%)

// 嵌套 await 調用
let text = await (await fetch(...)).text();

// Pipeline 運算符
let text = await fetch() |> await %.text();      

目前的 Pipeline 運算符右手操作符可以是任意表達式加上一個 

%

 占位符代表傳播的值,我們可以通過下表來看目前 Pipeline 運算符支援的表達形式:

傳統寫法 Pipeline 運算符

o.m(x)

x |> o.m(%)

o.m(0, x)

x |> o.m(0, %)

new o.m(x)

x |> new o.m(%)

o[x]

x |> o[%]

x[i]

x |> %[i]

x + 1

x |> % + 1

[0, x]

x |> [0, %]

{ key: x }

x |> { key: % }

await o.m(x)

x |> await o.m(%)

yield o.m(x)

x |> (yield o.m(%))

Stage 0 → Stage 1

從 Stage 0 進入到 Stage 1 有以下門檻:

  1. 找到一個 TC39 成員作為 champion 負責這個提案的演進;
  2. 明确提案需要解決的問題與需求和大緻的解決方案;
  3. 有問題、解決方案的例子;
  4. 對 API 形式、關鍵算法、語義、實作風險等有讨論、分析。

    Stage 1 的提案會有可預見的比較大的改動,以下列出的例子并不代表提案最終會是例子中的文法、語義。

String.isUSVString

https://github.com/guybedford/proposal-is-usv-string

JavaScript 字元串都是 UTF-16 編碼的字元串。在 Web API 中,我們可以發現有些 API (如 URL、URLSearchParams 等等系列 API)都聲明了需要 USVString 作為參數。什麼是 USVString?USV 代表 Unicode Scalar Value,即 Unicode 标量值。根據 Unicode 定義,Unicode 的碼位(Code Point)可以分成幾個類别,分别是圖形碼(Graphic),格式碼(Format),控制碼(Control),私有碼(Private-Use),代理碼(Surrogate),非字元碼(Noncharacter),與保留碼(Reserved)。而其中的代理碼又分成了高位代理碼與低位代碼碼,隻有當一個高位代碼碼與一個低位代理碼組合成一個代理碼對,才是一個合法的 Unicode 字元。

目前,JavaScript 字元串并不限制這個字元串的值是否是合法的 Unicode 值,比如我們可以編碼一個字元串隻有高位代理碼,而沒有低位代理碼等等。而如嚴格的 Web URL API 定義必須要求參數字元串是合法的 Unicode 标量值,是以我們需要有方法能夠去區分一個字元串是否是合法的 Unicode 标量值。

這個提案提出給 

String

 增加一個靜态方法 

String.isUSVString

 來供開發者用來校驗字元串是否是合法 Unicode 标量值。

String.isUSVString('\ud800');       // => false
String.isUSVString('\ud800\udc00'); // => true      

Array.fromAsync

https://github.com/tc39/proposal-array-from-async

目前,我們可以通過 

Array.from(iterator)

 來友善地将一個疊代器轉換成一個數組來做後續的操作,比如:

function* f () {
  for (let i = 0; i < 4; i++)
    yield i;
}

Array.from(f()); => [0, 1, 2, 3].      

随着 Async Generator 在 Web API 與 Node.js API 中應用的範圍越來越廣,如 Node.js 的 Streams API 也已經實作了 

Symbol.asyncIterator

 協定,或者是 Web 的 ReadableStream 等等。現在我們可以通過下面的方式便捷地疊代這些 stream 對象,比如檔案流:

const fs = require('fs');

async function print(readable) {
  // 設定編碼格式為 UTF8
  readable.setEncoding('utf8');
  // 異步疊代 readable stream
  let data = '';
  for await (const chunk of readable) {
    data += chunk;
  }
  // 輸出
  console.log(data);
}

print(fs.createReadStream('file')).catch(console.error);      

提案期望提供一個如同 

Array.from

 的内置 

Array.asyncFrom

 方法,用于從一個異步疊代器從組裝出完整的數組,可以便捷地用于開發、調試等等用途:

const fs = require('fs');

async function print(readable) {
  // 設定編碼格式為 UTF8
  readable.setEncoding('utf8');
  // 異步疊代 readable stream
  const data = (await Array.asyncFrom(readable)).join('');
  // 輸出
  console.log(data);
}

print(fs.createReadStream('file')).catch(console.error);      

BigInt Math

https://github.com/tc39/proposal-bigint-math

BigInt 在 JavaScript 中提供了任意精度的整型數值計算的能力。不過,目前 JavaScript 中對于 Number 類型的常見數學計算操作如 

Math.max

Math.pow

 等 

Math

 命名空間下的函數都沒有對于 BigInt 的支援。目前如 Web Performance API、Node.js Performance API 都已經提供了基于 BigInt 的 API 實作,對于開發者來說這些常見的數學計算操作支援還是非常有必要性的。

提案提出了将 

Math

 命名空間下對于 BigInt 來說合理的函數增加 BigInt 支援,如 

Math.max

Math.pow

 等。而提案希望不包含對于 BigInt 可能造成精度丢失的函數如三角函數(

Math.sin

 等)、對數函數(

Math.log

 等)等等函數的支援,避免開發者誤用。

Get Intrinsic

https://github.com/ljharb/proposal-get-intrinsic

在 JavaScript 中,我們可以對任意對象做任意的操作,比如删除屬性、替換方法、增加實作等等,比如我們常見的 Polyfill 或者 Monkey Patch 等方案的基礎。而這同樣也造成了一定問題,比如我們引入了一個第三方庫,而這個第三方庫對于固有對象如 

Array.prototype

 上的方法做了修改,如果這個修改的健壯性與相容性有問題的話,對于其他的第三方庫、應用代碼都會造成不可預期的影響,如兩個 Polyfill 庫互相沖突等,甚至會破壞運作時的内置行為。比如 Node.js 的内置庫如 

fs

 等等或者 Deno 的内置庫都是使用 JavaScript 實作的,也就意味着他們都依賴于這些 

Array

Map

 等等 JavaScript 固有對象實作,對這些對象的修改同樣可能破壞 Node.js 與 Deno 的行為。因為,Node.js 等運作時或者 Polyfill 庫為了避免被這些問題影響,都會在加載的第一時間就儲存下這些 JavaScript 固有對象的方法引用,後續使用者代碼、第三方庫對于這些内置對象的修改就不會再影響到他們。

而這也要求了固定的啟動順序,也就是說這些需要儲存 JavaScript 固有方法的代碼都需要第一時間加載,然後才能加載使用者代碼。比如社群包 

get-intrinsic

 提供了儲存這些固有方法的能力,但是必須作為第一個包被引入。值得注意的是,如 

get-intrinsic

 等實作是通過周遊所有已知的 JavaScript 固有方法來實作的,也意味着他們的代碼包大小是不可忽視的一個問題,如 

get-intrinsic

 包有9.7KB。

目前 JavaScript 有什麼固有對象與方法?ECMA262: 已知的固有對象(

https://tc39.es/ecma262/#sec-well-known-intrinsic-objects

另外,有些 JavaScript 的固有方法是必須通過特定文法來擷取的,比如 GeneratorFunction 與 AsyncGeneratorFunction:

const GeneratorFunction = (function* (){}).constructor;
const AsyncGeneratorFunction = (async function* (){}).constructor;      

像 

get-intrinsic

 如果需要能在低版本的 JavaScript 環境中也能正常運作,就必須通過 

eval

 等方式來間接執行 JavaScript 代碼而避免整個檔案因為不支援的文法而無法執行。而 

eval

 的問題也是顯而易見的,它可能會與 CSP 沖突而無法使用。

是以提案期望引入一個内置的 JavaScript 函數來直接擷取 JavaScript 的固有方法、對象,這樣就不需要依賴于特定的文法就可以通路這些固有方法,給 Node.js 等運作時、各種 Polyfill 庫都提供了便利性。

const GeneratorFunction = getIntrisic("%GeneratorFunction%");
const AsyncGeneratorFunction = getIntrinsic("%AsyncGeneratorFunction%");      

Fixed layout objects

https://github.com/syg/proposal-structs/

近期,關于 WebAssembly GC 提案的落地非常惹人注目。作為與 WebAssembly 互動的第一梯隊語言,JavaScript 自然也需要一個良好的能力來與 Wasm GC 來互動。這裡說的“互動”,目前提案将這個能力限制在了 JavaScript 能夠與 Wasm 互相通路,并沒有以兩者底層實作共享為目标。

WasmGC 中的對象在 JavaScript 暴露的結構就是提案所描述的固定布局對象(或者說 struct)。不過在 Wasm 中這個對象的字段是有額外的類型檢查的,而 JavaScript 中這個固定布局對象并沒有類型檢查、限制一說。JavaScript Struct 後續當然也可以反哺到 Wasm 中作為 WasmGC 的對象,不過這部分工作提案期望留給未來後續來設計,因為這可能需要引入 Struct 的字段類型檢查與限制。

對象傳遞方向 Wasm -> JS JS -> Wasm
結構體執行個體 這個提案的目标 未來提案的目标
類型與反射 不是 TC39 的目标

另外,大型應用如 GSuite(Google 套件)、Microsoft Office、TypeScript Compiler 都或多或少地碰到了性能問題,他們希望能在 JavaScript 中有更友善的記憶體共享、多線程工作負載的方式來提升應用表現。在此之前,在 JavaScript 中我們已經可以使用 SharedArrayBuffer 來共享記憶體。不過,使用 SharedArrayBuffer 操作複雜對象資料時,通常我們還需要通過一層包裝來序列化與反序列化,有額外的成本。如果我們能直接在不同的 JavaScript 執行上下文裡直接共享一系列的對象、結構體的話,就能以更具有表達能力的方式來操作共享記憶體。

目前,提案所提出的文法與能力都還是處于草稿階段,提案提出了兩種類型的 struct,第一種既是普通、非共享記憶體型。與 class 執行個體、普通 JavaScript 對象的差別是他們預設就是已經 seal 的對象 Object.seal(obj)  (

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal

),不能動态添加新的字段:

struct class Box {
  x;
  getX() { return this.x; }
}

let box = new Box();
// x 已經聲明過
box.x = 42;
// 傳回 42
box.getX();
// Structs 相當于 Object.seal 過的對象,不能添加新字段
assert.throws(() => {
  box.y = 8.8;
});      

另外一種,就是可以用于 Web API 中的 MessagePort 等 API 傳遞到其他 JavaScript 執行環境中的共享記憶體型 struct。相比于普通的 struct 來說,共享的 struct 不能有 constructor 以外的方法,不過可以有靜态方法:

shared struct class SharedBox {
  constructor(x) { this.x = x; }
  x;
}

let sharedBox = new SharedBox();

// x 聲明過
sharedBox.x = 42;

assertThrows(() => {
  // y 沒有聲明過
  sharedBox.y = 84;
});

// 将共享 struct 傳遞給 worker
let worker = new Worker('worker.js');
worker.postMessage({ sharedBox });
// 字段類型可以随意變更,就如同普通 JavaScript 對象一樣
sharedBox.x = "main";      

結語

由賀師俊牽頭,阿裡巴巴前端标準化小組等多方參與組建的 JavaScript 中文興趣小組(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上開放讨論各種 ECMAScript 的問題,非常歡迎有興趣的同學參與讨論:esdiscuss。