ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一眼识破JavaScript函数是否被new调用:new.target与构造调用全解析

一眼识破JavaScript函数是否被new调用:new.target与构造调用全解析 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箭头函数不能 newES6 之后语言层面强行把“函数”分成了几类判断难度其实降低了但新坑也多了。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 GMT0800 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.ComponentReact 在渲染时会用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 就老老实实当普通函数用。判断技巧是防守的底线代码设计才是进攻的上限。
返回列表