来源: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
提案介绍
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)。