前言
今天认识一个新的 api:ShadowRealm。今日前端早读课文章由 @贤重分享。公号:Fliggy FE 授权。
正文从这开始~~
1. ShadowRealm 是什么
ShadowRealm 是 TC39 的一个提案,现处于第三阶段。它提供了一种新机制,可以在一个新的高度隔离的运行环境(全局对象和内建对象)中执行代码。
ShadowRealm 的基本使用包含一个构造函数、两个方法。
提案:https://tc39.es/proposal-shadowrealm/
2. ShadowRealm API
declare class ShadowRealm {
constructor();
evaluate(sourceText: string): PrimitiveValueOrCallable;
impotValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
}每个 ShadowRealm 实例都有自己独立的运行环境,并提供了如下两个方法来执行 realm 中的代码:
evaluate: 同步执行代码字符串,类似eval;importValue: 导入并异步执行代码,以Promise形式返回执行结果,这意味我们可以非阻塞的执行第三方脚本。
2.1 shadowRealm.evaluate
函数签名:
evaluate(sourceText: string): PrimitiveValueOrCallable;evaluate 的工作原理和 eval 类似:
const sr = new ShadowRealm();
console.assert(
sr.evaluate(`'ab' + 'cd'`) === 'abcd'
);和 eval 不同点是,evaluate 是在独立环境中执行的:
globalThis.realm = 'incubator realm';
const sr = new ShadowRealm();
sr.evaluate(`globalThis.realm = 'child realm'`);
console.assert(
sr.evaluate(`globalThis.realm`) === 'child realm'
);
console.assert(
globalThis.realm === 'incubator realm'
);如果在 evaluate 中返回了一个函数,为了能够在外部调用则需要进行包装:
globalThis.realm = 'incubator realm';
const sr = new ShadowRealm();
sr.evaluate(`globalThis.realm = 'child realm'`);
const wrappedFunc = sr.evaluate(`() => globalThis.realm`);
console.assert(wrappedFunc() === 'child realm');evaluate 的参数或者返回值,必须是 primitive 的或者是 [Callable] 的,否则会抛错:
new ShadowRealm().evaluate('[]')
// TypeError: value passing between realms must be callable or primitive2.2 shadowRealm.importValue
importValue 的类型签名:
importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;从 specifier 模块(module)导入名称为 bindingName 的代码,并且通过 Promise 异步返回执行结果。和 evaluate 方法 一样,返回的函数也会被包装:
// main.js
const sr = new ShadowRealm();
const wrappedSum = await sr.importValue('./my-module.js', 'sum');
console.assert(wrappedSum('hi', ' ', 'folks', '!') === 'hi folks!');
// my-module.js
export function sum(...values) {
return values.reduce((prev, value) => prev + value);
}在现在,参数 bindingName 是必选的,但如果我们只是想加载(load)一个模块(module),而不想导入(importing)任何代码,我们可以用一个变通的方式,用 'default' 作为 bindingName 的值。将来我们可能可以省略掉:
// main.js
const sr = new ShadowRealm();
await sr.importValue('./my-module.js', 'default');
// my-module.js
export default true;
// ...和 evaluate 函数一样,参数或者返回值,必须是 primitive 的或者是 [Callable] 的
3. realms 之间传值
这里有几种方式在容器环境(incubator realm)和子环境(child realm)之间传值:
传入数据到
ShadowRealm:通过参数传入
从
ShadowRealm接收数据:evaluate和importValue的结果ShadowRealm返回的函数的执行结果
只要通过 realms 传值,这个值都会通过内部规定操作 GetWrappedValue 包装:
primitive类型的值不会有任何改变[callable]类型的值会通过wrapped function包装任何其他类型的值都会抛错
const sr = new ShadowRealm();
sr.evaluate('globalThis')
// TypeError: value passing between realms must be callable or primitive
sr.evaluate('({prop: 123})')
// TypeError: value passing between realms must be callable or primitive
sr.evaluate('Object.prototype')
// TypeError: value passing between realms must be callable or primitive
typeof sr.evaluate('() => {}') // OK
// 'function'
typeof sr.evaluate('123') // OK
// 'number'3.1 非 [callable] 对象不能在 realms 之间传递
为什么?目的是完全隔离 realms。ShadowRealm 中执行的代码永远不能影响容器环境(incubator realm)。
Objects 拥有原型链(prototype chains)。当我们传递一个原型链到另外的 realm,我们有两种方式:
只复制(copy)对象。原型链在其他环境依然存在,这样几乎给了源运行环境(source realm)执行这个原型链上任意代码的权限;
完整的复制原型链。但是有些对象无法简单的复制例如
Array.prototype和Object.prototype。
几乎所有对象都有标准属性(the standard property)constructor 作为对象的类型(class)
> ({}).constructor === Object
// true
> [].constructor === Array
// true通过 constructor 这个属性,我们可以拿到 globalThis:
function handleObject(someObject) {
// Bad: the constructor of an object lets us create more instances
const constructor = someObject.constructor;
// Worse: the constructor of a constructor is `Function`
// which lets us execute arbitrary code in the realm of `someObject`
const _Function = constructor.constructor;
const _globalThis = (new _Function('return globalThis'))();
// We now have access to `globalThis` and can change global data
}3.2 通过 realms 传递的函数会被包装
被包装(wrappee)是来自外部运行环境(realm)的函数。所谓的包装函数(wrapped function)包装 wrappee。这个包装(wrapper)保护 wrappee 和本地环境互不侵犯
如何保持隔离:
wrappee 的参数都会通过 GetWrappedValue 包装,隐形参数 this 也一样
参数的类型
primitive或[callable]wrappee 调用返回值也会被包装
返回值的类型
primitive或[callable]wrappee 在新的独立运行环境中执行
wrappee 只暴露了一个功能,外部可以执行函数调用。
不能用 new 调用
wrappee 的属性和原型,在外部都无权访问(读写)
(注意:被包装的函数是无法解包装(unwrapped))
示例:包装函数的执行,this 是 ShadowRealm 的 globalThis,不是容器环境的 globalThis
const sr = new ShadowRealm();
// .evaluate() executes code in sloppy mode
const sloppyFunc = sr.evaluate(`
(function () {
switch (this) {
case undefined:
return 'this is undefined';
case globalThis:
return 'this is globalThis';
default:
throw new Error();
}
})
`);
console.assert(sloppyFunc() === 'this is globalThis');笔者在严格模式下测试结果发现和非严格模式一样,应该是现有的 polyfill 方案无法完美支持的原因
const sr = new ShadowRealm();
const strictFunc = sr.evaluate(`
(function () {
'use strict';
switch (this) {
case undefined:
return 'this is undefined';
case globalThis:
return 'this is globalThis';
default:
throw new Error();
}
})
`);
console.assert(strictFunc() === 'this is undefined');4. ShadowRealm 可以做什么
在 web IDE 或 绘图应用等 web app 中运行插件等三方代码
创建一个编程环境运行用户代码
服务器可以在 ShadowRealms 中运行三方代码
运行测试,外部环境不会被影响且每个测试都可以在新环境中运行(提高可复用)
网页抓取(提取网页数据)和网页应用测试可以在 ShadowRealms 中运行
5. 与其他方案对比
简单对比一下和现有的一些相似方案的差异:
eval 和 Function
和 eval 和 Function 类似,但是有所改进:全新独立的运行环境执行代码;保护外部运行环境不受影响。
Web Workers
Web Worker 是一个比 ShadowRealms 更强大的隔离机制,它们的代码在独立进程中执行并且通过异步通信。但是,ShadowRealms 是一个更轻量的方式,可以同步计算,更便捷。
iframe
我们都知道,每个 iframe 都有自己的运行环境,我们可以在里面同步执行代码:
<body>
<iframe>
</iframe>
<script>
globalThis.realm = 'incubator';
const iframeRealm = frames[0].window;
iframeRealm.globalThis.realm = 'child';
console.log(iframeRealm.eval('globalThis.realm')); // 'child'
</script>
</body>与 ShadowRealms 相比,有以下缺点:
只能在浏览器中使用
我们需要在 DOM 中添加一个 iframe 节点用来初始化
每个 iframe 运行环境都包含完整的 DOM,限制了一些自定义的灵活度
默认情况下,对象(objects)是可以跨环境的,这导致我们需要额外的工作来保证代码运行安全
Node.js 的 vm 模块
与 ShadowRealms 类似,但是有更多的功能:缓存 JavaScript 引擎数据以便更快的启动,拦截 import,创建外部全局对象(通过 vm.createContext)。唯一的缺点只能在 node.js 环境下使用。
6. 总结
ShadowRealm 允许一个 JS 运行时创建多个高度隔离的 JS 运行环境(realm),每个 realm 具有独立的全局对象和内建对象。
ShadowRealm 有意强化了隔离性,当前和所构造的 shadowrealm 之间不能直接互相访问对象,只能传递 primitive 值和 [Callable]。注意 [callable] 其实差不多就是函数,但是是高度受限的。因为 JS 中函数也是对象,所以当一个 realm 中的函数被传递到另一个 realm 里,不能是原本的函数对象,而是一个包装对象。除了 call(函数调用)以外,其他针对包装对象的操作(比如读写属性)都与原 realm 中的函数对象无关。而函数调用本身也是受限的,其参数和返回值也只能是 primitive 值和 [Callable]。
早期的 realm 草案没有这些限制,更接近于 node 中的 vm 模块。之所以 ShadowRealm 做出这样的隔离性限制,主要是浏览器厂商认为 iframe 那样的方式,容易造成多个 realm 中的对象有意无意的互相引用,形成复杂的对象图,性能(垃圾回收)和安全性不佳。一直以来,浏览器厂商都在 web 标准中不断削弱和限制 iframe,当然不希望在 JS 规范上再开这样的口子。
7. 参考
https://2ality.com/2022/04/shadow-realms.html
https://github.com/tc39/proposal-shadowrealm
https://github.com/ambit-tsai/shadowrealm-api
关于本文
作者:@贤重
原文:https://mp.weixin.qq.com/s/nuQThAvH7ro54ibOqS-FhA
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。