JavaScript - ECMAScript关于eval()函数的更新介绍

287 阅读8分钟
  • 2022-04-05更新:
    • 重写了 "ShadowRealms中的全局数据是平台全局数据的一个子集 "一节。
    • 新增 "评估代码中抛出的未捕获错误 "一节。
    • 在 "用于跨领域传输对象的简单库 "一节中做了较长的解释。
    • 将Rick Waldron添加到作者/支持者名单中。

这篇博文描述了由Dave Herman, Caridy Patiño, Mark S. Miller, Leo Balter和Rick Waldron提出的ECMAScript提案"ShadowRealm API"

ShadowRealm ,提供了一种在运行时评估代码的新方法--想想看eval() ,但效果更好:

  • 每个实例都有自己的全局JavaScript范围。
  • 代码在这个范围内被评估。如果它改变了全局数据,这只影响ShadowRealm,但不影响真正的全局数据。

一个境界是JavaScript平台的一个实例:它的全局环境中所有的内置数据都被正确设置。

例如,每个iframe都有一个相关的realm。

文档的全局对象与iframe的全局对象不同(A行)。全局变量,如Array ,也是不同的(B行)。

ShadowRealm API

引述该提案

本提案的主要目标是提供一个适当的机制来控制程序的执行,提供一个新的全局对象,一组新的内在因素,不访问跨领域的对象,一个独立的模块图和两个领域之间的同步通信。

ShadowRealms执行代码的JavaScript堆与创建ShadowRealm的周围环境相同。代码在同一个线程中同步运行。

ShadowRealm 是一个具有以下类型签名的类。

declare class ShadowRealm {
  constructor();
  evaluate(sourceText: string): PrimitiveValueOrCallable;
  importValue(specifier: string, bindingName: string): Promise;
}

ShadowRealm 的每个实例都有自己的境界。两个方法允许我们在该领域内评估代码。

  • .evaluate() 同步评估ShadowRealm内的一个字符串 sourceText。这个方法与 eval()宽松地相似。
  • .importValue()异步:在ShadowRealm内异步导入,并通过Promise返回结果。这意味着我们得到了非阻塞的评估(对于第三方脚本等)。

我们实例化ShadowRealm 的境界被称为孵化器境界。该实例被称为子境界

shadowRealm.evaluate()

方法.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'
);

如果.evaluate() 返回一个函数,该函数被包装起来,以便从外部调用它,在ShadowRealm内运行它。

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');

每当一个值被传入或传出ShadowRealm时,它必须是原始的或可调用的。如果不是,就会抛出一个异常。

> new ShadowRealm().evaluate('[]')
TypeError: value passing between realms must be callable or primitive

稍后会有更多关于在境界之间传递值的内容。

shadowRealm.importValue()

方法.importValue() 具有以下类型签名。

importValue(specifier: string, bindingName: string): Promise;

在它的ShadowRealm中,它从一个指定为specifier 的模块中导入名称为bindingName 的导入,并通过一个Promise异步返回其值。与.evaluate() 一样,函数被包装起来,以便从ShadowRealm外部调用它们,在ShadowRealm内部运行。

目前,该API需要参数bindingName 。在未来,省略它可能会返回一个模块命名空间对象(一个Promise)。

因此,如果我们只想加载一个模块而不导入任何东西(例如,一个改变全局数据的polyfill),我们必须使用一个变通方法--例如。

.evaluate() ,传入或传出ShadowRealms的值(包括参数和跨realm函数调用的结果)必须是原始的或可调用的。更多关于这个问题的内容即将发布。

在评估的代码中抛出的未捕获错误

代码中的语法错误会导致抛出一个SyntaxError

> new ShadowRealm().evaluate('someFunc(')
SyntaxError: Unexpected end of script

ShadowRealm内部的未捕获错误会通过TypeError

> new ShadowRealm().evaluate(`throw new RangeError('The message')`)
TypeError: Error encountered during evaluation

唉,目前我们还没有得到原始错误的名称、消息或堆栈跟踪。也许这在将来会有所改变。

ShadowRealms中的全局数据是平台的全局数据的一个子集

ShadowRealms包含以下全局数据:

  • ECMAScript的全局数据

  • 平台特定的全局数据的子集(如浏览器中的document 或Node.js上的process )。一个关键的要求是,全局对象的所有平台特定属性必须是可配置的(这是属性的属性之一),这样它们就可以被删除。这让我们可以选择从我们评估的代码中隐藏这些功能。

内容安全策略(CSP)如何影响ShadowRealms

  • 不允许一个页面的unsafe-eval ,就不能在ShadowRealms中通过.evaluate() 进行同步评估。
  • 诸如default-src 等指令会影响哪些模块可以通过.importValue() 加载。

在境界之间传递值

有几种方法可以在孵化器领域和子领域之间传递数值:

  • 向一个ShadowRealm发送值。
    • 将参数传递给来自ShadowRealm的函数。
  • 从一个ShadowRealm接收值。
    • .evaluate() 的结果和.importValue()
    • 调用一个来自ShadowRealm的函数的结果。

每当一个值跨越境界时,内部规范操作GetWrappedValue() 被用来包装它:

  • 一个原始的值被返回,没有任何变化。

  • 一个可调用的对象被包裹在一个被包裹的函数中。稍后会有更多关于包装函数的内容。

  • 任何其他类型的对象都会导致一个异常。

    > 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'
    

非可调用对象不能跨越领域

为什么不允许非可调用对象跨域?我们的目标是在境界之间完全分离。在ShadowRealm中执行的代码不应该能够访问其孵化器领域。

对象有原型链。当把一个原型链转移到另一个境界时,我们有两个选择:

  • 我们可以只复制原型链中的第一个对象,让原型链在另一个境界中继续。唉,几乎所有的原型链都让我们访问Function ,这让我们可以在源界执行任意代码(见下面的代码)。

  • 我们可以复制完整的原型链。然而,没有简单的方法来转移对象,如Array.prototypeObject.prototype

几乎所有的对象都有标准的属性.constructor,指的是对象的类别:

> ({}).constructor === Object
true
> [].constructor === Array
true

通过这个属性,我们可以获得对globalThis

ShadowRealms资源库有一个问题,其中对这个话题进行了更详细的讨论。上面的代码是受Joseph Griego的一篇博文启发。

我们稍后会看到,我们仍然可以在境界之间转移对象(剧透:它可能会在未来作为一项功能加入,但也可以通过一个库来实现)。

当函数跨越境界时被包裹起来

每当一个函数跨越境界时,它就被包裹起来:

  • 被包装者是一个来自外国领域的函数。
  • 一个所谓的被包装的函数将被包装者包装起来。包装者保护被包装者不受本地领域的影响,而本地领域也不受被包装者的影响。

这就是被包装的函数如何保持境界分离:

  • 函数调用参数进入被包装者的领域,并为该领域进行包装(通过GetWrappedValue() )。隐含的参数this ,也是这样处理的。

  • 被包装者返回的值进入包装者的领域,并为该领域进行包装。

  • wrappee在一个新的作用域中被执行,这个作用域的父作用域就是wrappee的出生作用域。因此,全局作用域是被包装者的领域的全局作用域。

  • 只有包装器的一个功能被暴露。封装器会转发函数调用。然而:

    • 该包装器不能被new-invoked。
    • 包装者的属性和它的原型都不能从包装者的领域中访问。

请注意,被包装的函数永远不会被解除包装。如果一个包装器被传回wrappee的领域,它就会被再次包装。详情请见本期内容

下面的代码演示了如果一个来自ShadowRealm的sloppy-mode函数被包装和调用会发生什么。它的this 是ShadowRealm的globalThis ,而不是incubator realm的globalThis

如果包装者是一个严格模式的函数,就会发生这种情况:

const sr = new ShadowRealm();
const strictFunc = sr.evaluate(`
  (function () {
    'use strict';
    switch (this) {
      undefined:
        return 'this is undefined';
      globalThis:
        return 'this is globalThis';
      default:
        throw new Error();
    }
  })
`);
console.assert(strictFunc() === 'this is undefined');

.evaluate() 如何评估其代码?

陷阱:没有静态导入

在由.evaluate() 解析的代码中,我们不能使用静态导入。为什么?由于顶层的await静态导入可以是异步的 - 而.evaluate() 应该是同步工作的。

一个变通方法是通过以下方式动态导入 import():

代码被解析为一连串的语句

.evaluate() 所用的语法规则是 Script- 与非模块的