1. 先搞清楚:函数到底什么时候算“被 new 了”
1.1 构造调用和普通调用的本质区别
前端圈里有个段子:你在浏览器控制台敲Math()会报错,但敲new Math()同样报错,可new Date()就能用。同样是函数,差别到底在哪?
先说结论:函数被new调用时,JavaScript 引擎会额外做四件事,少一件都不算构造调用。
- 创建一个全新的空对象,这个对象会继承函数的
prototype属性指向的原型对象; - 把这个新对象绑定为函数内部的
this; - 执行函数体,通常是往
this上挂属性; - 如果函数显式返回了一个对象,则最终结果用那个对象;否则返回第一步创建的新对象。
普通调用就没这么复杂:函数内部的this在非严格模式下指向全局对象,在严格模式下是undefined,然后正常执行,返回什么就是什么。
这就是“被 new 了”和“没被 new”的根本分界线。很多前端新手看new只记得“创建一个对象”,却忽略了它改变的是整个执行上下文和返回值规则。框架代码里大量使用“构造调用”来创建实例,如果你不能一眼判断一段代码是在做普通调用还是构造调用,读源码、调试 bug、回答面试题都会处处碰壁。
1.2 为什么这个问题会卡住很多人
我见过不少写了两年 React 的同学,函数、组件、Hooks 用得飞起,但问他“这个函数如果被人 new 了一下会发生什么”,往往要先愣住几秒。原因不难理解:现代前端开发里,我们大部分时间在写箭头函数、写组件函数,很少直接靠new来创建业务对象。真正和new强绑定的是class语法,而class本身就“声明了”它是构造函数,不需要你去猜。
可现实是,历史代码、第三方库、面试题里到处都是“普通函数被 new 了”的场景。例如 jQuery 时代的$.ajax、各种插件里的function Carousel(el) {...},你根本分不清别人调用时是Carousel(dom)还是new Carousel(dom)。一旦函数内部逻辑对“this 是不是新对象”有依赖,两种调用方式就会产生完全不同的结果。
所以“函数被 new 了没”不是一个学术问题,而是一个实打实的代码可读性、可维护性和 bug 排查问题。能一眼识破,意味着你读代码时能快速建立正确的执行模型。
2. 一眼识破的四种实战姿势
2.1 new.target:最正统的答案
ES6 引入的new.target是目前最标准的判断方式。它可以在函数体内直接读取,如果是构造调用,new.target指向被调用的构造函数;如果是普通调用,new.target是undefined。
function Foo() { console.log("new.target:", new.target); if (new.target === undefined) { console.log("普通调用"); } else { console.log("构造调用,构造函数是", new.target.name || new.target); } } Foo(); // 普通调用 new Foo(); // 构造调用这个方法好在哪?它直接反映语言层面的调用方式,不会因为this被call、apply改掉而失真。哪怕有人写出Foo.call(obj),new.target仍然是undefined,因为call再强也模拟不了new的四个步骤。
注意:
new.target在函数内部和 class 内部都可以用,但在箭头函数里会“向上继承”外层函数的new.target。箭头函数本身不能被 new,所以它拿到的一定是外层函数构造调用时的值。
2.2 instanceof 回看:也能判断,但有坑
new.target是 ES6 的,老代码里更常见的做法是用instanceof回看。思路是:构造调用时,函数内部的this一定能通过this instanceof Foo的判断,因为新对象的原型链上挂着Foo.prototype。
function Foo() { if (this instanceof Foo) { console.log("看起来是构造调用"); } else { console.log("看起来是普通调用"); } } Foo(); // 看起来是普通调用 new Foo(); // 看起来是构造调用这个方式在 ES5 时代几乎是唯一靠谱的判断手段,到现在仍有大量代码在用它。但它有两个明显的坑。
第一个坑:如果先Foo.prototype上加了东西,或者有人用Object.create(Foo.prototype)硬造了一个对象再通过Foo.call(obj)调用,this instanceof Foo会变成true,函数会误判自己是构造调用。
function Foo() { if (this instanceof Foo) { console.log("看起来是构造调用"); } else { console.log("看起来是普通调用"); } } const fake = Object.create(Foo.prototype); Foo.call(fake); // 实际是普通调用,但这里会打印“看起来是构造调用”第二个坑:如果你用class写构造函数,class本身不允许被普通调用,所以也不需要instanceof来判断。instanceof只适用于老的“函数式构造函数”。
2.3 this 指谁:传统做法的原理和局限
再往前推,有些老代码会直接看this的值。原理是:构造调用时this是一个新对象;非严格模式下普通调用时this指向全局对象(浏览器里就是window)。
function Foo() { if (this && typeof this === "object" && this !== window) { console.log("可能是构造调用"); } else { console.log("可能是普通调用"); } }这种写法问题更大。首先,函数内部如果开启严格模式,普通调用时this是undefined,判断逻辑要再分支。其次,Foo.call({})同样会让this变成普通对象,误判成构造调用。最后,在 Node.js 环境里“全局对象”不是window,还得写成globalThis,代码越写越绕。
所以我个人建议:除非你是在维护 2012 年以前的代码,否则判断“有没有被 new”直接用new.target,不要再用 this 猜了。老方案只能作为理解历史的参考,不适合写进新代码。
2.4 构造函数的返回值陷阱
判断“被 new 了没”还有一个很容易被忽视的观察点:函数显式 return 了什么。普通调用里,函数 return 什么,调用表达式就是什么。构造调用则有一套特例:
- 如果构造函数 return 了一个对象(包括函数、数组、正则等引用类型),
new表达式的结果是这个对象; - 如果构造函数 return 基本类型(数字、字符串、布尔、null、undefined),返回值被忽略,
new表达式结果是新建的那个对象; - 如果构造函数什么都没 return,同理,结果是新建对象。
来段代码感受下:
function Foo() { this.name = "foo"; return { custom: true }; } function Bar() { this.name = "bar"; return 42; } console.log(new Foo()); // { custom: true } console.log(new Bar()); // Bar { name: "bar" }很多人在 review 代码时只看到函数体里写了return {},就认为“这不是构造函数”,这是不对的。只要它是被new调用的,那个return {}会直接覆盖新对象。判断函数是否被构造调用,不能只看有没有 return 对象,要看调用方式本身。
由此还能延伸出一个面试里常考的细节:为什么不建议构造函数主动 return 对象?因为一旦这样写,外部拿到的对象不在新对象的原型链上,instanceof会失效,开发者以为是实例,其实拿到的只是一个普通对象,非常容易埋雷。
3. 防不胜防的隐藏场景
3.1 class 必须 new,箭头函数不能 new
ES6 之后,语言层面强行把“函数”分成了几类,判断难度其实降低了,但新坑也多了。
class声明出来的构造函数,只能用new调用,直接调用会直接抛TypeError: Class constructor Foo cannot be invoked without 'new'。这是语言规定,不需要你判断,也不能绕过。所以如果你看到一段代码里出现了class,它一定是“被 new 了”的那一方,即使你肉眼没看到new关键字,它也大概率是通过某种包装器被 new 的。
箭头函数则相反:它没有自己的this、没有prototype、不能作为构造函数,强行new会抛Foo is not a constructor。所以箭头函数永远不可能是构造调用,也永远不参与“new 陷阱”。
普通function最暧昧:既能普通调用又能构造调用,判断价值最大。日常写业务时,如果你想让一个函数只做普通调用,可以在函数体开头加一句严格模式的“类检查”:
function onlyCall() { "use strict"; if (new.target) { throw new Error("此函数只能用普通方式调用"); } }反过来,如果你想让某个函数只能被 new,同样用new.target拦截普通调用。这是前端项目里常用的防御式编程。
3.2 第三方库和内置对象里的构造调用
第三方库是“函数被 new 了没”问题的高发区。很多老库导出的不是一个对象,而是一个构造函数,需要你先 new 再调方法。典型例子是早期的日期处理库、轮播图插件、表单验证器。你不会每次都去翻源码,但你可以通过几个特征快速判断:
- 库名首字母通常大写,比如
Moment、Validator、Swiper; - 调用方式一般是
new 名字(配置); - 方法挂在返回的实例上,而不是挂在静态属性上。
内置对象带来的迷惑性更强。Date是最典型的一个:Date()普通调用返回的是当前时间的字符串,而new Date()返回的是 Date 对象。同样的函数名,两种调用完全不同的结果,这就是“函数内部对 new.target 做了判断”的经典案例。其它内置对象如Number、String、Boolean、Array、Object、RegExp、Error,几乎都是“可以 new 也可以不 new”的双面派,但返回值不同。
console.log(Date()); // "Mon Sep 09 2024 14:30:00 GMT+0800" console.log(new Date()); // Date 对象 console.log(Number("123")); // 123,数字 console.log(new Number("123")); // Number 包装对象你看,这就是“一眼识破”的实用价值:看到Date()和new Date()出现时,心里要立刻想到返回值类型不同,后续链式操作才不容易出错。
3.3 框架世界里“被 new 了”的组件和实例
前端框架把构造调用藏得更深。Vue 2 时代,我们经常写new Vue({ el: "#app" }),这行代码的本质就是构造调用。Vue 源码里Vue是一个普通函数,并且通过_init方法初始化实例。你是不是一眼识破,取决于你能否看出“这里必须 new,否则 Vue 内部拿不到正确的 this”。
React 里这类场景少一点,因为组件函数大多是普通调用。但 React 类组件的实例化内部也是new,只不过被 React 的协调器封装了。你写的class MyComponent extends React.Component,React 在渲染时会用new MyComponent(props)创建实例。如果你在组件外直接MyComponent(),大概率会报错或拿不到生命周期。
前端 SDK 领域也很常见。很多 SDK 的接入方式就是const sdk = new SDK({ appId: "xxx" })。如果 SDK 里封装了构造调用判断,它在非 new 情况下会给出友好报错;如果没封装,非 new 情况下this会指向全局对象,初始化逻辑全乱。所以一个成熟的 SDK 一定会写类似下面的保护代码:
function SDK(config) { if (!(this instanceof SDK)) { return new SDK(config); } this.appId = config.appId; this.init(); }这种写法叫“不用 new 也能 new”:函数内部检测到普通调用时,自动帮你补一个new。实现细节我在第 4 节专门展开。
4. 实战:写一个既能 new 又能普通调用的兼容函数
4.1 需求场景
实际项目里,我们会遇到一种需求:写一个“类”,但希望调用者哪怕忘了写new,程序也不崩,还能正常工作。最典型的场景就是对外发布的 SDK、工具库、插件。
为什么会有这种需求?因为用户习惯不同。有人看到首字母大写的函数就自动写new,有人习惯把一切函数都当普通函数调用。作为库作者,你不能要求所有用户都遵守文档,最好的办法是在函数内部做一次防御判断。
4.2 版本一:基于 new.target 的纯函数实现
用new.target实现最简单:
function LazyLoad(options = {}) { if (!new.target) { return new LazyLoad(options); } this.options = options; this.images = []; this.init(); } LazyLoad.prototype.init = function () { // 初始化逻辑 console.log("初始化,配置项:", this.options); }; // 两种调用方式结果一致 const a = LazyLoad({ threshold: 100 }); const b = new LazyLoad({ threshold: 100 }); console.log(a instanceof LazyLoad); // true console.log(b instanceof LazyLoad); // true这里的!new.target相当于“你没被 new 过”,于是函数自己给自己补一个new。加了这层保护后,调用者无论怎么写,拿到的都是规范的实例。
原理上为什么安全?因为new LazyLoad(options)在执行时会再次进入函数体,此时new.target指向LazyLoad,判断为假,继续往下走,完成真正的初始化。整个递归只有一层,不会死循环。
4.3 版本二:兼容老环境的手写原型链判断
如果你的项目还在用 ES5 环境(比如某些老版小程序、旧版安卓 WebView),没有new.target,可以用instanceof实现同样的兼容:
function LazyLoad(options) { if (!(this instanceof LazyLoad)) { return new LazyLoad(options); } this.options = options; this.init(); } LazyLoad.prototype.init = function () { ... };这个版本的思路是:普通调用时,函数内部的this不是LazyLoad的实例;而new LazyLoad(options)一旦进入函数体,this必然是新建的对象,且原型链上挂着LazyLoad.prototype。所以!(this instanceof LazyLoad)就等价于“现在不是构造调用”。
但我在第 2 节说过,instanceof判断存在被Object.create(LazyLoad.prototype)伪造的风险。对库作者来说,这种伪造概率很低,可如果代码要对安全性要求极高,还是建议用new.target。
提示:两种写法都不要直接依赖判断
this === window或this === globalThis,因为严格模式、Node 环境、call 绑定都会干扰判断。
4.4 工厂函数与构造函数的选型思路
现在前端工程化比较成熟,我更推荐的路线其实是“直接用普通函数 + 返回对象”,而不是在这里纠结要不要兼容new。也就是工厂函数模式:
function createLazyLoad(options) { const instance = { options, images: [], init() { console.log("初始化"); }, }; instance.init(); return instance; } const a = createLazyLoad({ threshold: 100 });这种写法的优势很明显:
- 调用方式永远只有一种,不存在“有没有被 new”的歧义;
- 不需要关心
new.target、instanceof、this指向这些底层细节; - 天然适合现代框架的模块化导入导出。
代价是没有“类”的语义,不能通过prototype扩展方法(除非你用Object.setPrototypeOf手动拼原型链)。对项目里的公共模块、工具函数来说,工厂函数往往是更稳的选择。如果确实需要有prototype的“类”,或者要和 ES6class互操作,再考虑“既能 new 又能普通调用”的兼容方案。
这个选型思路在我看来比任何技术技巧都重要——与其想办法“一眼识破”,不如先从设计上少制造这种模糊场景。
5. 面试题解析与速查
5.1 三道高频面试题拆解
我在面试前端候选人时,经常用下面几个问题来考察对构造调用的理解。这里把答题思路也一并给你,方便你自查。
第一题:new一个函数后返回什么?
很多人会答“返回这个函数的实例对象”,这只对了一半。准确说法是:如果没有显式返回对象,返回新创建的对象;如果显式返回了对象,则返回那个对象;如果显式返回基本类型,返回值被忽略,仍然返回新对象。答题时如果能把return的三种情况说全,说明你对构造调用有完整的执行模型。
第二题:怎么判断一个函数是不是被 new 调用的?
优先答new.target,再补充 ES5 的instanceof方案和它们的区别。如果面试官追问“箭头函数能不能判断”,可以答:箭头函数本身不能被 new,所以没有判断的必要;如果箭头函数内部访问new.target,它会继承外层函数的new.target。
第三题:手写一个函数,让用户忘记 new 时也能正常工作。
给一段类似第 4.2 节的代码,然后讲清楚递归的原理。这里有个加分回答:在class场景下这个技巧无效,因为 class 不能在没有 new 的情况下被调用,所以如果项目用 ES6 class 写 SDK,往往需要提供工厂函数或静态方法。
5.2 快速判断速查表
下面这张表建议收藏,平时刷代码时对照着看。
| 场景 | 判断方式 | 典型返回值 |
|---|---|---|
| 普通函数加普通调用 | new.target为undefined | 按函数 return 决定 |
| 普通函数加构造调用 | new.target指向函数本身 | 新对象或显式 return 的对象 |
| class 调用 | 只能用 new,直接调用抛 TypeError | 新实例 |
| 箭头函数 | 不能 new,也没有new.target自身值 | 普通调用返回值 |
Date()普通调用 | 返回字符串 | 字符串 |
new Date() | 返回 Date 对象 | Date 实例 |
老代码this instanceof Foo | 普通调用 + 伪造原型链时可能误判 | 不稳定 |
面试时把这表里的内容讲清楚,基本就覆盖了大部分有关构造调用的考点。
6. 常见问题与排查实录
6.1 new.target 在 ES5 环境下的兼容处理
有读者可能想在自己的代码里用new.target,但项目还需要兼容老的浏览器。new.target是 ES6 语法,老环境不支持。两个思路:
第一,用 Babel 编译。Babel 会把new.target转换成一个辅助函数_newArrowCheck之类的逻辑。不过转换后的代码可读性一般,而且会增加打包体积,建议只在业务代码里少量使用。
第二,如果项目实在跑不了 Babel,就退回instanceof写法。日常业务系统里,instanceof误判场景并不多见,可维护性和可读性比this === window强得多。
我个人实际维护过一个移动端 H5 老项目,里面大量第三方插件就是用instanceof写的兼容层。它们不会出问题,真正出问题的是那种“既想当函数又想当类、还自己写 return 对象”的模块。遇到这种模块,别纠结判断方式,直接把代码重构成确定性的工厂函数。
6.2 “new 的到底是哪个对象”的调试技巧
有时你在调试一个复杂调用链,隐约觉得某个函数被 new 了,但又不能确定 new 出来的是不是你期望的对象。这里分享一个调试技巧:在函数入口处临时打印new.target和this,同时打印this.constructor。
function Foo() { console.log("new.target:", new.target); console.log("this:", this); console.log("this.constructor:", this.constructor); // ...原有逻辑 }从打印结果能读到三层信息:
new.target如果是函数,说明本次是构造调用;this如果能打印出一个带原型链的对象,说明已经完成第一步“创建新对象”;this.constructor能告诉你这个实例关联的是哪个构造函数。
如果this.constructor不是Foo,而是Object,说明有人手动修改过实例原型,或者这个函数根本不是你以为的“宿主函数”。这种信息比单纯断点看调用栈更直观。
还有一个实用技巧:用Object.getPrototypeOf(this) === Foo.prototype判断当前 this 的原型是否精确等于函数的 prototype。如果不等,说明原型链被中间层改动过,class 继承时尤其常见。
6.3 代码 review 时该怎么一眼扫
最后聊聊我平时 review 代码时的“一眼扫”策略,相当于对你前面所有内容的一次实战串讲。
看到普通function,我首先看函数体开头有没有访问new.target、this instanceof Foo这类判断。如果有,说明这个函数是“双模”函数,调用方式会影响结果,我会格外注意谁在调用它、调用时有没有写new。
看到函数体里有return {}或return 对象,我会立刻确认它是否可能被当构造函数用。如果存在这种可能,我会建议改成工厂函数,或者在函数开头直接禁止普通调用,避免“返回值覆盖新对象”的隐雷。
看到class,心里自动标记“必须 new”,然后去查有没有人用Class()这样调用。一般编译器和语法检查会直接报错,但如果是通过Class.call(obj)、Class.apply(obj)这类形式调用,语法检查不会报,运行时却会抛 TypeError。这种坑在动态封装代码里偶尔出现,review 时要注意。
看到箭头函数,完全不用操心构造调用问题,但也不能简单认为“箭头函数里的 this 就安全”。它内部如果用了外层函数的new.target,那 this 的语义就随外层调用方式变化。这一点在写高阶组件、包装函数时特别容易忽略。
把这个 review 流程固化下来之后,你再读前端源码会轻松很多——因为你能在脑海里自动把每个函数“是不是构造调用”标记出来,遇到报错时定位速度会快很多。
我个人在这些年的实操里最大的体会是:与其把所有希望寄托在“一眼识破”的哨兵逻辑上,不如在写代码时主动减少函数和构造器之间的模糊地带。能用工厂函数就用工厂函数,能用 class 就用 class,普通的 function 就老老实实当普通函数用。判断技巧是防守的底线,代码设计才是进攻的上限。