來源:Alibaba F2E公衆号
作者:吳成忠(昭朗)
7月的 TC39 會議在上周結束了。這次的會議有如 private-in 等提案進入了 Stage 4,Realms、
Object.hasOwn
等提案進入了 Stage 3,相信很快大家就可以在開發者版本的浏覽器、最新版 Node.js 中見到這些 API 了。那麼這些提案提供了什麼樣的能力,我們該如何使用?
Stage 3 → Stage 4
從 Stage 3 進入到 Stage 4 有以下幾個門檻:
- 必須編寫與所有提案内容對應的 https://github.com/tc39/test262 測試,用于給各大 JavaScript 引擎和 transpiler 等實作檢查與标準的相容程度,并且 test262 已經合入了提案所需要的測試用例;
- 至少要有兩個實作能夠相容上述 Test 262 測試,并釋出到正式版本中;
- 發起了将提案内容合入正式标準文本 https://github.com/tc39/ecma262 的 Pull Request,并被 ECMAScript 編輯簽署同意意見。
Private Fields In Operator
提案連結:
https://github.com/tc39/proposal-private-fields-in-in這個提案提供了使用
in
操作符來判斷前不久正式進入 Stage 4 的 Class Private Fields 提案中引入的
#字段
是否在一個對象中存在。相比于直接通過通路私有字段
try { obj.#foo } catch { /* #foo not exist in obj */ }
來判斷一個對象是否有安裝對應的
#字段
來說,Private-In 可以區分是通路錯誤,還是真正沒有
#字段
,如以下場景通過 try-catch 就無法區分是否是通路異常還是
#字段
确實不存在:
class C {
get #getter() { throw new Error('gotcha'); }
static isC(obj) {
try {
obj.#getter;
return true;
} catch {
return false;
}
}
}
而通過 Private-In 可以簡單、直白地、與普通字段類似的
in
操作符語義來判斷一個
#field
是否存在在一個對象上:
class C {
#brand;
#method() {}
get #getter() {}
static isC(obj) {
return #brand in obj && #method in obj && #getter in obj;
}
}
值得注意的是,這個提案并沒有改變
#field
的語義。也就是說,在對象詞法作用域的外部還是無法通路這些
#字段
,同樣也無法判斷這些
#字段
是否存在。另外,因為
in
通常用在 duck-type 的場景,而對于
#字段
來說,下面這個例子雖然看起來字段名是一樣的,但是對于每一個類來說,他們的
#字段
都是不同的(即使名字一樣),是以與普通字段的
in
還是有些許因
#字段
帶來的不同。
class C {
#foo;
staitc isC(obj) {
return #foo in obj;
}
}
class D {
#foo;
staitc isD(obj) {
return #foo in obj;
}
}
const d = new D();
C.isC(d);
// => false;
另一個值得注意的事情是,對于庫作者來說,使用者在某些場景下是可以在同一個 JavaScript 程式中使用多個版本的庫,比如 lodash 3.1.1 和 lodash 3.1.2,這取決于依賴管理的定義。而如果這些庫中使用了私有字段,則他們的對象是不具有互操作性的。這與上面這個同名字段例子的原因相同,這些不同版本的庫雖然庫名相同,但是實際上他們在程式中是具有獨立的運作上下文的,
class C
會被定義兩次,并且這兩次是毫無關系的,是以對于 3.1.1 版本的
C.isC
判斷會對 3.1.2 版本的執行個體
c
會判斷為
false
。這對于庫作者維護來說,是非常需要注意的一個事項。
最後需要注意的是,在下面這個場景中,Private-in 檢查可以造成正确但是不符合直覺的結果:
class Foo extends function(o) { return o; } {
#first = 1;
#second = (() => { throw null })();
static hasFirst = o => checkHas(() => o.#first);
static hasSecond = o => checkHas(() => o.#second);
}
let checkHas = fn => { try { return fn(), true; } catch { return false; } };
let obj = {};
try { new Foo(obj); } catch {}
console.log('obj.#first exists', Foo.hasFirst(obj)); // true
console.log('obj.#second exists', Foo.hasSecond(obj)); // false
這其中的重點是 JavaScript 的 class constructor 和多個
#字段
是可以部分初始化的(雖然這裡
extends
一個函數的寫法可能也是讓人無法挪開視線,直拍大腿:你咋這麼能呢?但這不是問題重點 :D)。而如果使用 Private-in 來做 class brand check,即嚴格類型檢查,則有可能因為部分初始化而隻檢查了成功初始化的
#first
但是沒檢查
#seond
是否存在,導緻通路
#second
的時候可能抛出異常。實際場景中我們可能有非常多的
#字段
,為了解決這個問題,目前同樣有一個的提案 Class Brand Checks 可以幫助解決這種問題。
提案所提出的适用于
#字段
的
in
操作符已經可以在 Chrome 91、Firefox 90 中發行,可供使用。
Stage 2 → Stage 3
提案從 Stage 2 進入到 Stage 3 有以下幾個門檻:
- 撰寫了包含提案所有内容的标準文本,并有指定的 TC39 成員審閱并簽署了同意意見;
- ECMAScript 編輯簽署了同意意見。
Accessible
Object.prototype.hasOwnProperty
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
Array find from last
https://github.com/tc39/proposal-array-find-from-last這個提案引入了
Array.prototype.findLast
與
Array.prototype.findLastIndex
(同樣的還有
%TypedArray.prototype%.findLast
%TypedArray.prototype%.findLastIndex
)。從
Array.prototype.find
和
Array.prototype.findIndex
可以衍生得出這兩個新的 API 語義是類似的,不過新的 API 是從數組的尾部開始周遊尋找符合期望的元素。
const array = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }];
// find/findLast
array.findLast(n => n.value % 2 === 1); // => { value: 3 }
array.findLast(n => n.value === 42); // => undefined
// findIndex/findLastIndex
array.findLastIndex(n => n.value % 2 === 1); // => 2
array.findLastIndex(n => n.value === 42); // => -1
Realms
https://github.com/tc39/proposal-realms标準定義:
https://tc39.es/proposal-realms/提案介紹
Realms 提案為在 JavaScript 程式中以獨立的 Global 環境執行 JavaScript 代碼的需求提供了一個新的方案。
目前提案包含的 API 可以在 JavaScript 中以一定程度的虛拟化來執行不同的程式,并且期望後續能夠在多種 JavaScript 環境中如浏覽器、Node.js 中無縫相容。而這些能力在目前的 Web 浏覽器上是難以實作的。
提供所設計的 API 十分簡單,僅僅隻有 3 個函數:
class Realm {
constructor();
importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
evaluate(sourceText: string): PrimitiveValueOrCallable;
}
除了
constructor
之外,對于使用者來說,實際使用的函數隻有
importValue
evaluate
,我們下面将一個一個來分析。
我們可以看到
importValue
evaluate
的傳回值都是一個
PrimitiveValueOrCallable
(或者這個類型的 Promise)。這意味着什麼呢?其實,在 Realms 提案之前,開發者們為了實作類似的全局對象沙盒機制,在浏覽器上、Node.js 中都有類似的實作。我們發現這些允許對象跨“執行環境”的機制都存在着嚴重的對象身份不連續的問題,比如如果我們從 iframe 中擷取到一些對象,這些對象在 iframe 的外部擁有着不一樣的身份:這是因為每一個 iframe 中都有獨立的 Global 與 Object、Array 等 ECMAScript 内置對象。我們看下面這個例子:
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeArray = iframe.contentWindow.Array;
console.assert(iframeArray !== Array);
const list = iframeArray('a', 'b', 'c');
list instanceof Array; // => false
[] instanceof iframeArray; // => false
Array.isArray(list); // => true
同樣的,使用 Node.js 的 vm 子產品也存在同樣的問題:
const vm = require('vm');
const ctx = vm.createContext();
const vmArray = vm.runInContext('Array', ctx);
console.assert(vmArray !== Array);
const list = vmArray('a', 'b', 'c');
list instanceof Array; // false
[] instanceof vmArray; // false
Array.isArray(list); // true
是以,為了避免出現更多的 footgun,Realms 之間無法直接交換除了原始 JavaScript 值(number, string, bigint, symbol 等,他們的身份是以值确定的)和 Callable (即函數,但是與函數對象有所不同)以外的 JavaScript 值:
- Realms 中透出的函數會以一個 Callable 的包裝對象傳回,這個包裝對象上不能通路到原函數對象上的屬性,并且隻能用來調用(參數也隻能是原始 JavaScript 值或者 Callable);
- 可以通過目前的 API 建立對象橋接(如共享 JSON 對象資料),可以參閱 POC https://github.com/caridy/irealm 擷取更多資訊;
- 目前不支援互動 ArrayBuffer/TypedArray 對象;
除此之外,我們看到 Realm 提供的
importValue
方法即是原生支援 ECMAScript Module 的方案。每一個 Realm 都有自己的子產品圖,也就是說同一個子產品在不同的 Realm 中都需要重新以這個 Realm 的 Global 環境執行并被使用,避免造成資料沖突或者洩露。
不過,常用浏覽器 iframe 與 Node.js 的 vm 子產品的同學可能注意到了,iframe 中的全局對象中是存在各種常見的浏覽器 API 如 setTimeout、URL、TextEncoder 等等。而 Node.js 中的 vm 子產品預設初始情況下隻有 ECMAScript 原生的内置對象,并不存在如 setTimeout、URL、TextEncoder 等常用 API。那麼 Realm 提供的全局對象是否是純淨的 ECMAScript 環境呢?目前提案的建議方案是由平台自行決定是否需要注入平台定義的 API,如浏覽器可以将其認為非常重要而通用的 API 如 URL、TextEncoder 注入 Realm 環境,這取決于後續 Web 标準化組織如何定義 Web 标準。
說了這麼多,我們來看一看 Realm API 的大緻使用例子:
const red = new Realm();
// realm 可以導入子產品,并且這些子產品會在 realm 自己的環境中執行,不會影響到 realm 外部。
// 通過這個 API,我們也可以擷取到子產品導出值。當然,這些導出值也會被以
// 類似 `PrimitiveValueOrCallable` 的方式轉化成安全的跨 Realm 值。
const redAdd = await red.importValue('./sandboxed-code.js', 'add');
// redAdd 是一個被包裝的函數對象,可以通過調用它來間接調用對應 red 中綁定的真實函數。
let result = redAdd(2, 3);
console.log(result === 5); // => true
// 修改外部的 globalThis 不會影響到 realm 中的 globalThis
globalThis.someValue = 1;
// 修改 realm 中的 globalThis 不會影響到外部的 globalThis
red.evaluate('globalThis.someValue = 2');
console.log(globalThis.someValue === 1); // => true
// 調用包裝的函數時可以傳遞一個函數作為參數(或者原始值)
const setUniqueValue =
await red.importValue('./sandboxed-code.js', 'setUniqueValue');
result = setUniqueValue((x) => x ** 3);
console.log(result === 16); // => true
/** sandboxed-code.js 檔案内容 */
export function add(lhs, rhs) {
return lhs + rhs;
}
export function setUniqueValue(cb) {
return cb(globalThis.someValue) * 2;
}
總結來說,目前的 API 形态與生化實驗室中的手套箱十分相似:

我們可以在這個保證一定程度隔離的環境中預先安裝需要的設施,然後通過一個手套來間接接觸、操作隔離環境中的部件。但是這個隔離環境并不是非常強效的隔離,如果其中在進行的操作十分危險,同樣可能将危險洩漏到外部環境中去。
了解了提案的大緻形态後,我們下面會快速了解一下 Realms 所能應用的場景:
- 基于 Web 的 IDE 或者提供三方代碼執行;
- DOM 虛拟化(如 Google AMP);
- 測試架構與報告生成(在浏覽器中運作多種測試,也包括 Node.js 中使用 vm 子產品執行測試的 jest 等);
- 測試工具、mock 工具(如 jsdom);
- Web 的插件機制(如表格函數等);
- 沙盒(如 Oasis 項目);
- 服務端渲染(避免資料沖突與資料洩露);
- 浏覽器上的代碼編輯器;
- 浏覽器上的代碼轉譯(如 TypeScript);
Use Cases
1、可信的第三方代碼執行
我們常見有非常多的第三方代碼執行的需求。通常這些需求都不需要或者不能啟動一個新的浏覽器環境,并且對于惡意代碼的防護、XSS 注入等等安全問題都沒有需求。同時,一個同步的跨“執行環境”的調用方案對于需求的實作也是非常重要的情況下,Realm 就是一個非常好的解決方案:
const realm = new Realm();
// 導入 pluginFramework 和 pluginScript 并執行
const [ init, ready ] = await Promise.all([
realm.importValue('./pluginFramework.js', 'init'),
realm.importValue('./pluginScript.js', 'ready'),
]);
// 初始化并執行插件架構和插件腳本
init(ready);
2、測試
現在有許多測試架構會向執行測試的環境中注入一些特有的全局對象如 console、setTimeoue 等等 API 來輔助開發者定位測試中碰到的問題。而對于這些 API 的注入勢必需要全局對象的環境隔離。此時 Realm 就能很好地在其中扮演這個對象隔離邊界,讓這些全局對象的注入不會互相沖突:
const realm = new Realm();
const [ runTests, getReportString, suite ] = await Promise.all([
realm.importValue('testFramework', 'runTests'),
realm.importValue('testFramework', 'getReportString'),
realm.importValue('./my-tests.js', 'suite'),
]);
// 開始測試運作
runTests(suite);
// 請求以 tag 格式輸出的測試結果
getReportString('tap', res => console.log(res));
3、DOM 虛拟化
很多時候,我們希望能夠模拟一個不需要非常多資源的 DOM 環境。如果通過如 Web Worker 或者種種其他跨程序的使用異步通信的方案時,DOM 有很多同步 API 我們無法通過異步的通信方式來模拟,比如
Element.getBoundingClientRect()
等。此時 Realms 提供的同步跨“執行環境”的通信方式就能很好地适應這些需求。并且,這些異步通信方案中通常我們也無法傳遞非 HTML Transferable 對象,如函數、Proxy 等,而多個 Realm 之間能夠通過 Callable 機制打開這個可能性。
const realm = new Realm();
const initVirtualDocument = await realm.importValue('virtual-document', 'init');
await realm.importValue('./publisher-amin.js', 'symbolId');
init();
除了上述的浏覽器等環境上使用場景之外,目前常見的還有在 Node.js 環境中的 JSDOM。Realm API 提供了類似基于 Node.js 内置 vm 子產品的 JSDOM 的技術基礎,但是 Realm 将在後續可以同時相容浏覽器與 Node.js 環境。
4、虛拟環境
有些時候,我們希望能完全控制一個虛拟環境,比如控制哪些全局對象能在這個虛拟環境中提供。不過,這在目前通過 iframe 實作虛拟環境的情況下是不可能的,比如 iframe 中的 global 是一個 Proxy 對象:
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeGlobal = iframe.contentWindow; // same as iframe.contentWindow.globalThis
Object.freeze(iframeGlobal); // TypeError, cannot freeze window proxy
或者是部分在 iframe 中全局對象上的 API 無法删除:
Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(globalThis)).filter(it => !it[1].configurable));
// => {
// document: {...},
// location: {...},
// top: {...},
// window: {...},
// ...
// }
// 這些屬性都是非 configurable,無法 delete 或者修改
這也意味着我們無法使用 iframe 來做一個模拟 DOM 的環境,他們有真實的 DOM API 通路權限與對應的資源,并且我們無法删除、替換。
而 Realm 則不會存在類似的問題,我們可以完全地控制這個新的執行環境與它所能通路的 API(全局對象上的
Infinity
、
NaN
undefined
除外)。
Stage 0 → Stage 1
從 Stage 0 進入到 Stage 1 有以下門檻:
- 找到一個 TC39 成員作為 champion 負責這個提案的演進;
- 明确提案需要解決的問題與需求和大緻的解決方案;
- 有問題、解決方案的例子;
- 對 API 形式、關鍵算法、語義、實作風險等有讨論、分析。
Stage 1 的提案會有可預見的比較大的改動,以下列出的例子并不代表提案最終會是例子中的文法、語義。
ArrayBuffer to/from Base64
https://github.com/tc39/proposal-arraybuffer-base64目前 ECMAScript 中的 ArrayBuffer 不支援如同 Node.js 中的 Buffer 的各種便捷編碼、序列化方法。而如 TextEncoder 等又隻提供了文本便捷碼的支援,并不支援如 base64 等序列化方案。這個提案期望為 ArrayBuffer 增加一系列 base64 序列化與反序列化支援。
let buffer = new TextEncoder().encode('hello');
console.log(buffer.toBase64()); // => 'aGVsbG8='
buffer = ArrayBuffer.fromBase64('aGVsbG8=');
buffer.byteLength; // => 5
可能有人想問,base64 也有多種标準,ECMAScript 中我們能支援哪幾種 base64 呢?目前,廣泛使用的 base64 方案通常為 RFC 4648,目前提案期望預設使用這個标準。同時提案也期望通過如下方式使用如 URL 安全的 Base64 方案(base64url,将
+
/
替換為
-
_
):
buffer.toBase64({ variant: 'base64url', padding: false });
ArrayBuffer.fromBase64('_w', { variant: 'base64url', padding: false });
Array Grouping
https://github.com/tc39/proposal-array-grouping這個提案期望将 lodash 的
_.groupBy
方法引入 ECMAScript。
const array = [1, 2, 3, 4, 5];
// `groupBy` 可以将元素按任意鍵分類。
// 在這裡例子中,我們将元素分類為奇數('odd')或者偶數('even')。
array.groupBy(i => {
return i % 2 === 0 ? 'even': 'odd';
});
// => { odd: [1, 3, 5], even: [2, 4] }
總結
由賀師俊牽頭,阿裡巴巴前端标準化小組等多方參與組建的 JavaScript 中文興趣小組(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上開放讨論各種 ECMAScript 的問題,非常歡迎有興趣的同學參與讨論:esdiscuss (
https://github.com/JSCIG/es-discuss/discussions)。