0. 需求及方向
我厂的 Navigator 扩展遇到一个需求:
- 动态生成一段 JS 在浏览器里运行
- 能重复生成、重复运行
- 能生成一段新代码,继续在上一次的环境里运行
其中(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。理由如下:
- 不安全。因为
eval()
会在当前环境执行代码,意味着攻击者可能窃取任何当前环境的数据。 - 性能差。现代 JS 引擎会对代码进行大量优化,包括转成机器码等。
eval()
会破坏这个过程,使得运行性能大大降低。
不过在我的场景下,这两个问题并不严重。一方面,被执行的追加代码都是由 Navlang 编译器生成,而不是任意第三方,它的安全性不会比其它的代码安全性低。另一方面,这个功能是帮助用户开发调试 Navlang 的,我们可以认为它大概率不会跑在性能敏感的环境里。
另外,对我厂的 Navigator 产品而言,这样的方案还会让 GC 变得比较难执行。不过一样从 便利开发 的角度出发,我觉得性价比完全 OK。
5. 总结
这个需求比较特殊,涉及到 JS 函数的很多性质,包括运行时优化的知识,还是蛮值得大家琢磨的。
另外就是所谓“尽信书不如无书”。eval()
的确存在一些问题,不能轻易使用,但是当需求摆在面前,经过充分的分析确认之后,我们该用还是要用。
欢迎吐槽,共同进步