聊到JS面试题,new操作符几乎是绕不开的一道。这道题看着简单,四个步骤背下来好像就完事了,但我带过不少同学,真让他手写一个myNew出来,或者在class、箭头函数、返回值判断这些边角上追问两句,能答利索的人并不多。new操作符是JS里少数几个语法糖背后藏着一整套对象创建机制的东西,它把创建对象、绑定原型、执行构造函数、处理返回值这四件事打包成了一个关键字。搞懂它,你顺手就能把原型链、this绑定、instanceof、constructor属性这几块知识串起来,形成一张网。这篇文章不谈空理论,我按自己平时带新人、看简历、面别人的路子,把new从“面试能答”讲到“代码能写”再到“边界不翻车”,准备面试的同学能直接用,工作几年但一直没细究过这块的开发者也能补上认知缺口。
1. 先搞明白:new操作符到底在解决什么问题
1.1 从工厂模式说起,为什么需要new
早年间没有class语法的时候,大家创建一批结构相同的对象,最朴素的做法是工厂函数:写一个函数,里面手动建一个空对象,往上面挂属性,最后return出去。这种写法能跑,但有两个明显的毛病。第一个是每创建一个对象,都得重新走一遍“建对象、挂属性”的流程,函数内部的逻辑和对象本身的类型没有建立任何关系;第二个更关键,工厂函数返回的对象,你没法判断它到底属于哪一类,它只有一个模糊的Object身份。假设页面里同时有用户对象、订单对象、商品对象,你拿到一个引用,想区分它是哪一类,工厂模式基本帮不上忙。
构造函数加new的组合,解决的正是“批量创建”和“类型识别”这两件事。JS设计者把创建对象的固定流程收进new这个关键字里,你只需要写一个构造函数,描述“这类对象应该有哪些属性和方法”,剩下的搭建工作交给引擎。这就像盖房子,工厂模式是你每次亲手搬砖、和泥、砌墙,而new相当于给你一套标准化的施工流程:地基自动打好,图纸自动贴上,你只管往房间里摆家具。
这里有个容易被忽略的点:构造函数本质上就是普通函数,没有任何特殊语法。它之所以叫构造函数,仅仅是因为你用了new去调用它。同一个函数,你直接调用,它就是普通函数,this指向取决于调用方式;你用new调用,它才进入“构造”模式,this指向一个全新的对象。理解这一点,后面讲this绑定和返回值判断时就不会乱。
1.2 面试官真正想听到的四步答案
面试时被问到new干了什么,最稳妥的答法是按执行顺序说清楚四件事:
- 创建一个全新的空对象。
- 把这个空对象的内部原型指向构造函数的prototype属性。
- 把构造函数内部的this绑定到这个新对象上,执行构造函数体。
- 判断构造函数的返回值:如果返回的是一个对象,就用这个返回值;否则返回第一步创建的新对象。
这四步不是背口诀,每一步都有它存在的理由。第一步保证每次new都得到一个独立的实例,实例之间互不干扰;第二步是原型链的入口,决定了实例能访问哪些共享方法;第三步让构造函数里的this.name = xxx这类赋值真正落到新对象上;第四步是很多人栽跟头的地方,它解释了为什么构造函数里写return {a:1}会覆盖掉新对象,而写return 123却毫无影响。
面试官问这道题,表层是考记忆,深层是考你对对象创建机制的理解。他接下来大概率会追问:手写一个new、new.target是什么、箭头函数能不能new、class和普通构造函数在new的时候有什么区别。这些问题都建立在上面四步之上,四步答得清楚,追问才有得聊;四步都说不利索,后面的问题基本就是送分变送命。
2. 拆解new操作符的四个核心动作
2.1 第一步:创建空对象,同时接上原型链
第一步创建的空对象,不是随便一个{}就完事。引擎在内部创建的是一个普通对象,但紧接着第二步会做一件关键的事:把这个对象的[[Prototype]](也就是我们常说的__proto__)指向构造函数的prototype属性。这两步在实际效果上几乎同时发生,拆开讲是为了让你看清因果。
为什么原型绑定这么重要?因为构造函数里通常只放实例独有的属性,比如姓名、年龄、id,而方法一般挂在prototype上共享。实例创建出来之后,你调用p.sayHello(),引擎会先在p自身找sayHello,找不到就沿着__proto__往上找,找到Person.prototype上,命中后执行。这条查找路径就是原型链。如果new的时候没做这层绑定,实例和prototype就是两张皮,共享方法全部失效,内存里每个实例都得自己存一份方法,浪费空间不说,改一个方法要改所有实例。
我见过有同学手写myNew时,用Object.create(null)创建对象,想着“空对象嘛,干净”,结果实例的__proto__是null,原型链直接断了,instanceof判断全挂。正确的做法是让新对象的原型指向构造函数的prototype,最直白的写法是Object.create(Constructor.prototype),这句本身就完成了“创建对象+绑定原型”两件事,比先建对象再改__proto__更干净。
2.2 第二步:绑定this,执行构造函数体
原型接好之后,引擎会把构造函数当成一个普通函数调用,但this被强行指定为新创建的那个对象。这里用apply或者Reflect.construct都能实现,手写实现里常见的是Constructor.apply(obj, args)。这一步执行完,构造函数里所有this.xxx = yyy的赋值都落到了新对象上,实例的私有属性就有了。
有个细节值得留意:this的绑定发生在函数执行之前,而不是执行过程中。这意味着构造函数体里无论你怎么折腾,this始终指向那个新对象,除非你手动用call/apply改,但那属于自己给自己挖坑。另外,构造函数执行过程中如果抛异常,new表达式会直接把异常往外抛,不会返回半成品对象。这一点在排查“为什么new完拿到的是undefined”时很有用,先看看构造函数里是不是某一行报错了。
参数传递也发生在这一步。new Person('A同学', 20)里的参数,会原封不动地传给构造函数。手写实现时参数要用arguments或者剩余参数收集,再展开传给apply,别漏了。我见过最冤的一种写法是只传了第一个参数,测试用例里构造函数刚好只用第一个参数,跑起来没问题,换个用例立刻暴露。
2.3 第三步:返回值判断,对象与原始值的分水岭
第四步的判断规则,是这道题区分“背过”和“懂了”的分界线。规则本身不复杂:构造函数如果显式返回一个对象(包括数组、函数、日期等),new表达式就返回这个对象;如果返回的是原始值(字符串、数字、布尔、null、undefined、Symbol),或者干脆没写return,new表达式就返回第一步创建的新对象。
为什么这么设计?因为构造函数的本职工作是初始化this,返回值是附加能力。如果允许返回原始值覆盖新对象,那new Person()拿到一个数字就很荒谬,后续所有基于实例的操作全废。允许返回对象,是为了支持一种少见但合法的场景:构造函数内部想复用已有对象,或者想返回一个代理对象。比如某些库会在构造函数里判断参数,命中缓存就直接返回缓存里的实例,这种情况下返回对象的能力就派上用场了。
判断“是不是对象”时有个坑:typeof null === 'object',但null不是对象,不能当成有效返回值。手写实现里常见写法是result !== null && typeof result === 'object',或者更严谨一点用typeof result === 'object' && result !== null || typeof result === 'function',把函数也纳入对象的范畴。函数虽然typeof是'function',但它确实是对象,可以被返回。这块细节面试官很爱抠,写代码时别偷懒。
2.4 四步串起来,伪代码长什么样
把四个动作翻译成代码,骨架大概是这样:
function myNew(Constructor, ...args) { // 1 & 2:创建对象并绑定原型 const obj = Object.create(Constructor.prototype); // 3:绑定 this 并执行构造函数 const result = Constructor.apply(obj, args); // 4:判断返回值 if (result !== null && (typeof result === 'object' || typeof result === 'function')) { return result; } return obj; }这段代码不到十行,但每一行都对应一个明确的设计意图。Object.create那一行同时完成了建对象和接原型,比手动obj.proto= ...更规范,因为直接操作__proto__在性能和可读性上都不占优。apply那一行负责执行和传参,result接住构造函数的返回值。最后那个if是返回值判断的守门员,把null和原始值挡在外面。
当然,这只是面试场景下的简化版,真实的new还要处理new.target、class构造函数不能直接apply调用等限制。手写版的意义在于让你把机制吃透,而不是替代原生new。实际项目里老老实实用new或者Reflect.construct,别自己造轮子。
3. 从规范和运行机制看new的执行细节
3.1 [[Construct]]内部方法与new.target
从语言规范的角度看,new表达式触发的是对象的[[Construct]]内部方法。普通函数、class、内置构造函数都拥有这个方法,但箭头函数没有,所以箭头函数不能new,用了直接抛TypeError。这个报错信息在不同引擎里措辞不同,但本质都是“你没有[[Construct]],别硬来”。
[[Construct]]执行时会创建一个执行上下文,里面有一个特殊的东西叫new.target。它指向被new调用的那个构造函数本身。普通调用时new.target是undefined,用new调用时它指向构造函数。这个机制最常见的用途是在构造函数里判断“我是不是被new调用的”,如果不是就抛出错误或者自动补一个new。比如:
function Person(name) { if (!new.target) { throw new Error('Person 必须用 new 调用'); } this.name = name; }这段代码在面试里是加分项。它说明你知道new不只影响this,还影响函数执行上下文里的元信息。另外,class构造函数默认就有类似保护,你不加new直接调用class会直接报错,这是class和普通构造函数在调用约束上的显著差异。
3.2 原型、constructor与instanceof的真实关系
很多人把constructor属性当成“指向构造函数”的权威标识,其实它只是prototype上的一个普通属性,默认由引擎在创建函数时挂上去,指向函数自身。它是可写的、可改的,甚至可以被删掉。真正决定instanceof结果的,是原型链上有没有出现构造函数的prototype。
instanceof的判断逻辑是:拿到右侧构造函数的prototype,沿着左侧对象的__proto__一路往上找,找到就返回true。所以如果你手写myNew时原型接错了,或者中途改了Constructor.prototype的指向,instanceof的结果就会和你预期不一致。我在排查这类问题时有个固定套路:先打印obj.proto=== Constructor.prototype,再打印Constructor.prototype.constructor === Constructor,两个都为true,原型关系才是健康的。
还有一个容易混淆的点:实例的constructor属性是从原型上继承来的,不是自己身上有的。你访问p.constructor,引擎先找p自身,没有,顺着原型找到Person.prototype.constructor,得到Person。如果你把Person.prototype整个替换成一个新对象,记得手动把constructor补回去,否则p.constructor会变成Object,调试时看着很迷惑。
3.3 参数传递与this绑定发生在哪个时刻
参数求值发生在new表达式执行之前。也就是说new Person(getName(), getAge())里,getName和getAge会先执行,拿到结果,再进入[[Construct]]。这个顺序在参数有副作用时能观察到,比如参数里打日志,日志会先于构造函数体里的日志输出。
this绑定发生在构造函数执行上下文中,绑定对象就是第一步创建的新实例。这里有个反直觉的地方:构造函数里如果返回了一个对象,this绑定的那个实例并不会被销毁,只是new表达式不返回它了。它成了一个没有引用的孤儿对象,等着被垃圾回收。这也是为什么“返回对象覆盖实例”的写法要慎用,你辛苦初始化的属性可能全白干了。
理解这些时序细节,在手写实现和排查诡异bug时特别有用。比如你发现构造函数里的this指向不对,先确认调用方式是不是new,再确认有没有在构造函数里用箭头函数包了一层导致this丢失,最后看返回值有没有把实例顶掉。按这个顺序查,基本不会绕远路。
4. 手写一个经得起追问的myNew
4.1 第一版:先让主流程跑通
面试现场时间有限,先写一版能跑的主流程,再根据面试官反馈打磨,这个节奏比较稳。第一版就按前面伪代码来:
function myNew(Constructor, ...args) { if (typeof Constructor !== 'function') { throw new TypeError('第一个参数必须是函数'); } const obj = Object.create(Constructor.prototype); const result = Constructor.apply(obj, args); return result !== null && (typeof result === 'object' || typeof result === 'function') ? result : obj; }这版已经覆盖了四步核心逻辑,还顺手加了参数类型校验,防止有人传个对象进来直接崩。写完之后拿几个用例跑一下:无返回值的构造函数、返回对象的构造函数、返回数字的构造函数、带参数的构造函数。四个用例全过,主流程就算立住了。
这里提醒一句,有些面试官会让你手写时不借助Object.create,纯手工模拟。那就得用中间函数做原型桥接:
function createObj(proto) { function F() {} F.prototype = proto; return new F(); }这种写法在Object.create出现之前是标准做法,现在写出来主要是展示你理解原型继承的底层手段,实际项目里没必要这么绕。
4.2 第二版:补上new.target与边界校验
第一版有个隐患:如果Constructor本身是个箭头函数,apply能执行,但箭头函数没有自己的this,绑定会失效,结果不符合预期。更严谨的做法是提前拦截,或者在文档里注明限制。面试场景下,主动说出“箭头函数没有[[Construct]],原生new会直接报错,我这个简化版无法完全模拟这个报错”反而是加分项,说明你知道自己实现的边界在哪。
另一个可以补的点是new.target的模拟。原生new在构造函数里能拿到new.target,手写版用apply调用,new.target是undefined。想模拟的话得用Reflect.construct,它是专门为“用指定参数和new.target调用构造函数”设计的:
function myNew(Constructor, ...args) { if (typeof Constructor !== 'function') { throw new TypeError('Constructor must be a function'); } const obj = Object.create(Constructor.prototype); const result = Reflect.construct(Constructor, args, Constructor); return result !== null && (typeof result === 'object' || typeof result === 'function') ? result : obj; }用Reflect.construct之后,构造函数内部能正确拿到new.target,class也能正常处理。不过要注意,Reflect.construct本身就会创建实例,所以前面的Object.create在返回值判断为原始值时会有点冗余。更贴近原生行为的写法是直接返回Reflect.construct的结果:
function myNew(Constructor, ...args) { if (typeof Constructor !== 'function') { throw new TypeError('Constructor must be a function'); } return Reflect.construct(Constructor, args); }这版最短,也最接近原生。面试时先写apply版展示你对四步的理解,再补一句“生产环境直接Reflect.construct更稳”,层次就出来了。
4.3 测试用例清单,别只测happy path
手写完代码,一定要主动跑测试,这是很多同学忽略的环节。下面这张表是我常用的用例清单,覆盖正常和异常路径:
| 用例 | 构造函数写法 | 预期结果 |
|---|---|---|
| 无返回值 | function A(){ this.x = 1; } | 实例含x,原型指向A.prototype |
| 返回对象 | function A(){ return { y: 2 }; } | 返回{ y: 2 },不是实例 |
| 返回数组 | function A(){ return [1,2]; } | 返回数组 |
| 返回函数 | function A(){ return function(){}; } | 返回该函数 |
| 返回数字 | function A(){ return 1; } | 返回实例 |
| 返回null | function A(){ return null; } | 返回实例 |
| 返回undefined | function A(){ return undefined; } | 返回实例 |
| 带参数 | function A(a,b){ this.sum = a+b; } | 实例sum为参数和 |
| 原型方法 | A.prototype.say = function(){} | 实例能调用say |
| instanceof | 上述实例 | obj instanceof A === true |
跑完这张表,基本能覆盖面试官会追问的八成场景。剩下两成是class、内置构造函数、Symbol等偏门情况,能说清楚原理即可,不必全部手写实现。
5. 常见翻车点与排查实录
5.1 返回值判断的三种错误写法
第一种错误是只看typeof,不排除null:if (typeof result === 'object') return result;。构造函数返回null时,这句会把null当成有效返回值返回,调用方拿到null去访问属性直接崩。修法很简单,加一个result !== null的判断。
第二种错误是漏掉函数:只判断typeof result === 'object',构造函数返回一个函数时会被当成原始值忽略,返回的仍然是实例。这在实际业务里不常见,但面试官拿这个考你“对象包含哪些类型”,答不上来就是知识盲区。修法是把typeof === 'function'也纳入判断。
第三种错误是最隐蔽的,把判断写反了:返回对象时返回实例,返回原始值时返回result。这种错误跑简单用例可能发现不了,因为常见的构造函数大多不写return,两种写法结果一样,一旦遇到显式返回对象的场景立刻暴露。排查时直接对比规范规则,别凭感觉。
5.2 instanceof失效的三条排查路径
实例instanceof判断为false,问题通常出在原型链上。我一般按三条路径排查:第一条,检查手写myNew有没有绑定原型,Object.create那一步是不是漏了或者传错了参数;第二条,检查构造函数执行过程中有没有替换prototype,如果在new之前改了Constructor.prototype,实例指向的是旧原型,而instanceof用的是新原型,两边对不上;第三条,检查有没有用Object.setPrototypeOf之类的操作中途改写了实例的原型。
还有一种情况是跨iframe或者跨执行上下文,两个上下文的Array不是同一个构造函数,用instanceof判断数组会失败。这种场景得用Array.isArray或者Object.prototype.toString.call,属于环境问题,不是new的锅,但面试聊到instanceof时顺嘴提一句,能体现你踩过坑。
5.3 箭头函数、class和内置构造函数的特殊表现
箭头函数不能new,原因是没有[[Construct]]和prototype。你写const f = () => {}; new f(),直接抛TypeError。这个知识点经常和this绑定一起考,记住结论:箭头函数的this在定义时确定,没有自己的this,也不能作为构造函数。
class用new调用时,行为和普通构造函数大体一致,但有两条额外约束:class必须用new调用,直接调用报错;class内部的constructor里如果返回原始值,同样会被忽略,返回对象则覆盖实例。另外class的方法定义在prototype上且不可枚举,这点和手动挂的方法不同,用for...in遍历实例时能看出差异。
内置构造函数如Date、Array、Error,用new调用和直接调用行为不同。Date()不带new返回字符串,带new返回日期对象;Array()带不带new都返回数组,但语义上略有区别。这些差异不需要死记,理解“new触发[[Construct]]”这个总原则,遇到具体类型查文档即可。
6. 面试现场怎么答才能稳住
6.1 分层表达:从一句话到四步再到代码
回答这道题,建议分三层递进。第一层用一句话定调:“new的核心是创建一个对象,绑定原型和this,再根据返回值决定最终返回什么。”这句话让面试官知道你抓住了主干。第二层展开四步,按执行顺序讲,每步配一句为什么。第三层如果面试官有兴趣,主动提出可以手写实现,把代码写出来再讲一遍关键行。
这个分层的好处是可控。面试官只想要个概述,你第一层就够;他想深入,你有第二层第三层接着。最怕的是一上来就背四步,背完冷场,面试官不知道你是真懂还是死记。分层表达配合主动引导,节奏就掌握在你手里了。
另外,讲的时候多用“因为所以”的连接。比如“因为构造函数的返回值可能是原始值,所以new在最后要做一次判断”,这种因果链能体现理解深度,比干巴巴列步骤强很多。
6.2 高频追问预判与应答思路
追问一:new和Object.create有什么区别?应答思路是Object.create只做原型绑定,不执行构造函数,不初始化属性;new是原型绑定加执行构造函数加返回值判断,一套完整流程。两者不是替代关系,Object.create常被用来实现继承。
追问二:不用new能不能创建实例?能,用Object.create(Constructor.prototype)建对象,再Constructor.apply(obj, args)执行构造函数,最后手动处理返回值。这正是手写myNew的思路,答出来等于把知识串起来了。
追问三:new的性能怎么样?这个问法比较开放,稳妥的答法是new本身开销很小,主要成本在构造函数体执行和原型链查找,真正影响性能的是构造函数里做了多少事,而不是new这个关键字本身。避免把话题带到没有数据支撑的猜测上。
追问四:class和function在new时行为差异?抓住三点:class必须new、class方法不可枚举、class内部严格模式。其余行为在实例创建层面基本一致。
6.3 一个容易忽略的表达细节
最后分享一个表达上的小技巧。讲四步的时候,把“创建对象”和“绑定原型”合并成一步说,很多资料是拆开写的,但实际执行上这两件事紧密相连,合并讲更符合直觉,也少一个被追问“这两步到底谁先谁后”的风险。合并后变成三步:建对象并接原型、绑this执行、判断返回值。三步比四步好记,讲的时候再补一句“拆细了就是创建、接原型、绑this、判返回值四件事”,两种说法都覆盖到,面试官怎么问都能接。
这道题我面过不下几十次,真正拉开差距的不是背没背下四步,而是能不能在手写实现时把返回值判断写对、能不能说清楚原型绑定为什么重要、能不能主动指出自己实现的边界。把这几个点吃透,new这道题就从“送分题”变成“加分题”了。