先从一个会让人卡住的问题开始。
你平时写函数,无非这么三种:
// 1. 函数声明
function add(a, b) { return a + b; }
// 2. 函数表达式
const add2 = function (a, b) { return a + b; };
// 3. 箭头函数
const add3 = (a, b) => a + b;
这三种有个共同点:函数长什么样,写代码的那一刻就已经固定死了。 你写的时候就知道参数是什么、函数体是什么。
但有时候你做不到这一点——你要执行的逻辑,是运行到一半才拿到手的。它可能来自用户输入、来自网络返回、来自一个配置文件、来自一段模板字符串。
这时候你就需要 new Function():它把一段字符串,变成一个能真正执行的函数。
const add = new Function("a", "b", "return a + b");
add(2, 3); // 5
一句话:其他三种写法是"先有函数,后有调用";new Function 是"先有字符串,后有函数"。
一、基本用法:最后一个是函数体
new Function 的参数规则就一条,好记:最后一个参数永远是函数体,前面的都是形参名。
// 逐个写参数
new Function("a", "b", "return a + b");
// 也可以把参数写成逗号分隔的一个字符串
new Function("a,b", "return a + b");
// 没有参数时,只写一个函数体
new Function("return 1 + 1")(); // 2
注意,参数和函数体都是字符串。这就决定了它的本质:你是在用字符串去"拼"出一个函数。
跟 new 其他东西不同,这里没有对象、没有构造器,它就是在运行时调用引擎,让它把一段文字重新编译成一个函数。
二、最容易踩的坑:它看不到外层变量
这是 new Function 和普通函数最不一样的地方,也是面试最常问的点。
看这段:
function make() {
const secret = "hello";
return new Function("return secret");
}
make(); // ReferenceError: secret is not defined
函数明明是在 make 里面创建的,为什么拿不到 make 里的 secret?
因为 new Function 造出来的函数,作用域链只连到全局,不连到创建它的那一层。
它认识的,是 globalThis 上的东西:
globalThis.topLevel = "全局可见";
new Function("return topLevel")(); // "全局可见"
为什么是这样?想想引擎在干什么:
- 普通函数声明,引擎在解析你代码文件的时候就把它编译好了,它知道自己出生在哪个作用域里。
-
new Function里的字符串,引擎是运行到这一行才拿去做语法解析、编译的。编译的时候,它根本不知道"我是在make函数里被创建的"这回事——它能看到的上下文,只有全局。
再想想反面:如果 new Function 能拿到闭包变量,那它就和 eval 几乎没区别了。它俩的关键差别正在这里(后面第五节会展开)。
一个直观的理解方式:普通函数像你在自己家里认得的亲戚,抬头就能叫名字;new Function 造出来的函数像你临时请来的陌生人,它只认识门口(全局)站着的那些人,你屋里(闭包)的人它一个也不认识。
三、为什么要这么用:逻辑本身成了数据
现在回到核心问题:费这么大劲把字符串变成函数,图什么?
图的是"动态性"。当要执行的逻辑本身是数据的时候,你就没法提前写死,只能在运行时现造。
真实的场景有这些:
1. 模板引擎
模板是一段字符串,里面夹着变量和逻辑。你想让它跑起来,最快的办法就是把它编译成一个函数:
function compile(tpl) {
let body = "var out = [];\n";
const re = /<%([\s\S]*?)%>/g;
let last = 0, match;
while ((match = re.exec(tpl))) {
// 标记之外的普通文字(上个标记结束 → 本次 <% 之前)直接输出
body += "out.push(" + JSON.stringify(tpl.slice(last, match.index)) + ");\n";
const code = match[1].trim();
// <%= expr %> 是输出;<% stmt %> 是执行语句
body += (code[0] === "=" ? "out.push(" + code.slice(1) + ");\n" : code + "\n");
last = re.lastIndex;
}
body += "out.push(" + JSON.stringify(tpl.slice(last)) + ");\n";
body += "return out.join(\"\");";
return new Function("data", body);
}
const t = compile("你好,<%= data.name %>!你有 <%= data.count %> 条消息。");
t({ name: "小明", count: 5 }); // "你好,小明!你有 5 条消息。"
const loop = compile("<% for (var i=0;i<data.items.length;i++){ %><%= data.items[i] %>,<% } %>");
loop({ items: ["a", "b", "c"] }); // "a,b,c,"
看这个 compile 干了什么:它把模板字符串翻译成一段 JS 代码文本,再丢给 new Function 变成一个函数。老牌的模板库(EJS、Lodash 的 _.template、早期 Vue 的模板编译器)底层就是这一招。
2. 公式 / 表达式计算器
用户在界面上输入一个公式,比如 a * b + c / 2,你要算出结果。你不能为每个可能的公式都提前写函数,只能把输入当成代码动态执行:
function makeCalc(expr) {
return new Function("a", "b", "c", "return " + expr);
}
makeCalc("a * b + c / 2")(4, 5, 6); // 23
3. 动态加载一段代码
你从服务端拿到一段 JS 逻辑,要在本地执行。比如热更新、插件系统、远程下发的一段计算规则。用 new Function 包一层,就能把它变成可调用的函数。
共同点很清楚:这些场景里,代码不是开发者写死的,而是运行时才知道的。 字符串是"数据",new Function 是把数据"激活"成代码的那道工序。
四、重建即理解:手写一遍你就懂了
上面那个模板引擎,建议你亲手敲一遍。敲的时候你会被迫想清楚三件事,这三件事想通了,new Function 才算真的理解:
你是在拼字符串,不是在写代码。
compile里的body是一个不断用+=拼出来的字符串,你拼的是什么,最后new Function就执行什么。所以拼的时候要格外小心引号、换行、分号——一个拼错,函数体就是语法错误。数据要安全地塞进去。 为什么普通文字要用
JSON.stringify包一层?因为如果用户输入里带了个引号或者</script>,直接拼进去要么报错,要么被注入执行。JSON.stringify会把它变成安全的字符串字面量。模板标记之间的内容会被当成 JS 执行。
<%和%>本身不是 JS 语法,是模板自己约定的分隔符——compile认出它们之后,会把夹在中间的内容原样插进函数体。所以里面写for、if才能跑,<%=才被翻译成out.push(...)。这也意味着——你能执行什么,取决于模板里写了什么。这既是它强大的地方,也是危险的地方。
五、和 eval 的区别
很多人第一反应:"这不就是 eval 吗?" 确实像,但有一条关键区别:
eval |
new Function |
|
|---|---|---|
| 作用域 | 在调用位置执行,能读外层变量 | 只在全局执行,读不到闭包 |
| 返回值 | 直接执行,返回表达式的值 | 返回一个函数,你可以存起来复用 |
| 使用方式 | 一段代码执行一次 | 先编译成函数,之后反复调用 |
| CSP 限制 | 受 unsafe-eval 限制 |
同样受 unsafe-eval 限制 |
| 注入风险 | 高 | 高 |
用实测对比最直观:
function make() {
const secret = "hello";
console.log(eval("secret")); // "hello" —— 能读到
console.log(new Function("return secret")()); // ReferenceError —— 读不到
}
make();
eval 更像是"在你此刻站的地方,把这段代码念一遍";new Function 更像是"把这段代码单独造成一个新函数,再让它在全局跑"。
正因为它返回的是函数,new Function 比 eval 多一个好处:编译一次,复用多次。 模板引擎里,模板通常编译一次,之后每个数据都能反复渲染,这比每次 eval 都要重新解析高效得多。
六、代价:慢,而且有安全边界
别看到"动态"就觉得万能。它有两个实打实的代价:
1. 慢。 每次 new Function 都要走一遍完整的解析编译流程。实测同一段加法,静态函数跑一百万次 14ms,new Function 每次新建再调用是 786ms——慢了几十倍。所以正确的姿势是编译一次,反复用(把返回的函数缓存起来),而不是在循环里反复 new。
2. 受 CSP 限制。 浏览器、Electron、部分小程序/扩展环境会设置 CSP(内容安全策略)。如果策略里没开 unsafe-eval,new Function 会直接抛 EvalError。这也是为什么很多现代框架后来抛弃了运行时编译模板的做法,改成构建期预编译(如 Vue 3 默认用 SFC 编译),就是为了在更严格的 CSP 下也能跑。
3. 注入风险。 永远别把用户输入直接拼进函数体。下面的写法是灾难:
// 危险:用户输入直接成了代码
const userInput = "}; process.exit(1); //";
new Function("a", "return " + userInput);
用户输入永远当数据处理(用 JSON.stringify 转义),不能当代码拼接。这句话适用于 new Function,也适用于 eval。
七、什么时候用,什么时候别用
该用的时候:
- 逻辑是运行时才知道的:模板、用户公式、远程下发的规则、插件脚本。
- 你确实需要"把字符串变成函数"并复用。
别用的时候:
- 逻辑在写代码时就是固定的——直接用普通函数声明。
- 只是想在局部作用域里执行一段动态代码——考虑
eval是否更合适(虽然通常两者都不推荐)。 - 环境 CSP 严格、开不了
unsafe-eval——这时运行时编译这条路根本走不通,得换成构建期预编译。
小结
new Function 干的其实只有一件事:在运行时,把一段字符串编译成一个函数。 你平时写函数是"代码先于运行",它是"代码晚于运行、由数据生成"。
记住三个关键词就够:
- 动态:逻辑来自数据,不是写死的。
- 全局:它造出的函数只认全局,认不到闭包变量。
- 有代价:慢、受 CSP 限制、有注入风险——所以编译一次复用多次,且绝不让用户输入直接进函数体。
搞清楚"什么时候它认得到变量、什么时候认不到",再亲手拼一遍那个模板引擎,new Function 对你就不再是个黑盒了。