标签: eval

  • 在任意上下文执行代码,`new Function` vs `eval()`

    在任意上下文执行代码,`new Function` vs `eval()`

    0. 需求及方向

    我厂的 Navigator 扩展遇到一个需求:

    1. 动态生成一段 JS 在浏览器里运行
    2. 能重复生成、重复运行
    3. 能生成一段新代码,继续在上一次的环境里运行

    其中(3)是新需求,(1)(2)已经比较完善地实现了。主要方案是使用 <script type="module">,这样加载的 JS 会自动执行,并且与全局环境相隔离,不会污染全局环境——这样就可以重复执行。

    (3)的难点在于,上一段 JS 已经执行过了,环境也自然释放了,我要么把上一阶段 JS 的执行结果保存下来,要么把执行环境保存下来。前者需要对 Navlang 编译器做大幅度的修改;后者则有很大的实施难度。

    经过研究和思考,我选择 保存环境 为攻关方向。即在上次执行结束前,在 window 上注册一个钩子函数,然后在后续追加执行的时候调用它,把后面的代码以函数的形式传进去,以便在上一次留下的环境里继续执行。

    1. 问题:访问环境里的变量

    添加钩子函数并不难,这样就行:

    // 在上一段代码最后添加钩子函数
    window.doNext = function (func) {
      func();
    }
    
    // 在新一段代码执行函数
    window.doNext(function () {
      // 新生成的函数
    });

    这样做虽然看起来是在上一次的上下文环境中执行新的函数,但由于 JS 的闭包和作用域链特性,实际执行时,新函数并不能访问到上一次上下文的数据。换言之,目的并没有实现。

    2. new Function() vs eval()

    然后回到标题。所以我们必须在上一次的环境里重新构建函数,这并不困难,其实开发浏览器扩展时,因为要协调四、五个运行环境,所以把函数序列化传输,再重新构建执行非常常见。于是我直接操起 new Function(),然后失败了。

    查阅 MDN,原来 new Function() 构造的函数,它的上下文会绑定在全局对象上(相当于 .call(null)),所以自然无法访问到上一次环境里的变量。

    再看 eval()文档,似乎可行,于是换用之,果然有效。至此,问题解决。

    3. 代码范例

    function navlangExecuteFunction(func) {
      // 使用模版字符串构建异步函数
      eval(`(async function doNext() {
        // 捕获中间可能出现的问题
        try {
          // 执行真正的函数
          await (${func})();
          // 为兼容 node.js,mock 一个 process 来处理执行结束
          process.exit();
        } catch (e) {
           if (!e.message.startsWith('Exit code:')) {
             console.error(e);
             process.exit(1);
           }
        }
      })()`);
    }
    window.navlangExecuteFunction = navlangExecuteFunction;
    const serializeFunction = (f) => {
      const serialized = f.toString();
      // Safari serializes async arrow functions with an invalid function keyword.
      // This needs to be removed in order for the function to be interpretable.
      const safariPrefix = 'async function ';
      if (serialized.startsWith(safariPrefix)) {
        const arrowIndex = serialized.indexOf('=>');
        const bracketIndex = serialized.indexOf('{');
        if (arrowIndex > -1 && (bracketIndex === -1 || arrowIndex < bracketIndex)) {
          return async ${serialized.slice(safariPrefix.length)};
        }
      }
      return serialized;
    };
    
    let nextStep = async function () {
      // 要执行的部分
    }
    nextStep = serializeFunction(nextStep);
    navlangExecuteFunction(nextStep);

    4. 可能带来的问题

    MDN 明确建议大家 不要用 eval。理由如下:

    1. 不安全。因为 eval() 会在当前环境执行代码,意味着攻击者可能窃取任何当前环境的数据。
    2. 性能差。现代 JS 引擎会对代码进行大量优化,包括转成机器码等。eval() 会破坏这个过程,使得运行性能大大降低。

    不过在我的场景下,这两个问题并不严重。一方面,被执行的追加代码都是由 Navlang 编译器生成,而不是任意第三方,它的安全性不会比其它的代码安全性低。另一方面,这个功能是帮助用户开发调试 Navlang 的,我们可以认为它大概率不会跑在性能敏感的环境里。

    另外,对我厂的 Navigator 产品而言,这样的方案还会让 GC 变得比较难执行。不过一样从 便利开发 的角度出发,我觉得性价比完全 OK。

    5. 总结

    这个需求比较特殊,涉及到 JS 函数的很多性质,包括运行时优化的知识,还是蛮值得大家琢磨的。

    另外就是所谓“尽信书不如无书”。eval() 的确存在一些问题,不能轻易使用,但是当需求摆在面前,经过充分的分析确认之后,我们该用还是要用。