new Function() 的用法与原理

先从一个会让人卡住的问题开始。

你平时写函数,无非这么三种:

// 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 才算真的理解:

  1. 你是在拼字符串,不是在写代码。 compile 里的 body 是一个不断用 += 拼出来的字符串,你拼的是什么,最后 new Function 就执行什么。所以拼的时候要格外小心引号、换行、分号——一个拼错,函数体就是语法错误。

  2. 数据要安全地塞进去。 为什么普通文字要用 JSON.stringify 包一层?因为如果用户输入里带了个引号或者 </script>,直接拼进去要么报错,要么被注入执行。JSON.stringify 会把它变成安全的字符串字面量。

  3. 模板标记之间的内容会被当成 JS 执行。 <%%> 本身不是 JS 语法,是模板自己约定的分隔符——compile 认出它们之后,会把夹在中间的内容原样插进函数体。所以里面写 forif 才能跑,<%= 才被翻译成 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 Functioneval 多一个好处:编译一次,复用多次。 模板引擎里,模板通常编译一次,之后每个数据都能反复渲染,这比每次 eval 都要重新解析高效得多。


六、代价:慢,而且有安全边界

别看到"动态"就觉得万能。它有两个实打实的代价:

1. 慢。 每次 new Function 都要走一遍完整的解析编译流程。实测同一段加法,静态函数跑一百万次 14ms,new Function 每次新建再调用是 786ms——慢了几十倍。所以正确的姿势是编译一次,反复用(把返回的函数缓存起来),而不是在循环里反复 new

2. 受 CSP 限制。 浏览器、Electron、部分小程序/扩展环境会设置 CSP(内容安全策略)。如果策略里没开 unsafe-evalnew 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 对你就不再是个黑盒了。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容