ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaScript new 操作符:原型链、this 绑定与手写实现

JavaScript new 操作符:原型链、this 绑定与手写实现

new这个操作符,写 JavaScript 的人几乎每天都在敲,可一旦面试官把它单独拎出来问"你说说 new 到底干了什么",能答利索的人反而不多。多数人的答案停在那套背了无数遍的四个步骤上:创建空对象、绑定原型、执行构造函数、判断返回值。这套答案不算错,但它只是个提纲,一旦面试官往下追问半句——比如"返回值是数组算不算对象""原型上放个 null 会怎么样""你不用 new 能不能把同样的效果手搓出来"——很多人当场就卡住了。我前后带过几个刚入行的朋友准备面试,这道题的翻车率比闭包、this、事件循环这些老牌难点都高,原因很简单:它太熟了,熟到没人愿意再往下想一层。这篇就按面试现场的真实节奏来拆,从考察意图、底层机制、手写实现,一路说到边界情况和追问应对,不管你是刚开始刷题的应届生,还是工作几年想回头补基础的老手,都能从中拎出点能直接用上的东西。

1. 这道题到底在考什么:先搞懂面试官的评分表

1.1 标准四步答案为什么只能拿及格分

先把这套标准答案完整摆出来,因为后面所有的展开都建立在它上面。new Foo()大体做了四件事:第一步,在内存里新建一个普通对象;第二步,把这个对象的原型指向Foo.prototype;第三步,把构造函数体内部的this绑定到这个新对象上并执行函数体,于是构造函数里写的this.xxx = xxx全都挂到了新对象身上;第四步,检查构造函数有没有返回值——如果有且返回值是对象类型,那就用这个返回值,否则一律返回第一步创建的那个新对象。

这四步背下来,面试官一般会给个及格分,因为它确实说对了主干。但问题在于它太像一份"结论摘要",看不出你到底有没有在运行时里真的观察过这些行为。我面别人的时候最常见的情况是:四步说完,我追问一句"第二步为什么不是把obj.__proto__赋过去",对方就开始含糊了,说"好像也行吧"。这一含糊,就把"背过"和"懂"的区别暴露干净了。

真正拉开档次的,是你能不能说出这四步背后的规范行为、能不能指出这套表述里被简化掉的细节,以及能不能现场手搓一个等价实现。所以我建议准备这道题的时候,把四步当成目录而不是答案,每一步都往下追一层,追到你能用代码验证为止。

1.2 面试官真正想听到的三层递进

我把这道题的考察深度拆成三层,你可以对照看看自己现在停在哪一层。

第一层是行为层:说清楚new造成了什么结果。也就是那四步,能说明"实例是怎么来的""方法为什么能从原型上找到"。

第二层是机制层:说清楚为什么必须这么设计。比如为什么原型要指向Foo.prototype而不是拷贝一份方法过去——这里就牵扯到内存复用和方法共享的问题;为什么返回值的判断只认对象不认原始值——这里牵扯到原始值无法承载引用语义;为什么this要先绑定再执行而不是先执行再绑定——因为构造函数体里每一步都在往this上写东西,顺序反了就没地方落笔。

第三层是实现层:在白板或编辑器里手写一个myNew,能跑通常规用例,也能扛住几个刁钻的边界,同时知道自己手写版本和原生new在哪些地方还有差距。

面试时间有限,通常聊到第二层就差不多了,如果你能顺手把第三层演示出来,这一题基本就锁分了。下面几节我就按这个顺序往下推。

2. 底层机制拆解:从构造函数调用到对象落地的完整链路

2.1 原型指向这一步,到底发生了什么

第二步是整个new里最容易被讲糊的一步,也是最能体现功底的一步。当我们说"把新对象的原型指向Foo.prototype",本质上是新对象内部那个隐藏属性[[Prototype]]被设置成了Foo.prototype这个对象。注意,是"指向"而不是"复制",新对象身上并没有多出一份方法副本,它只是记住了一个"找不到东西时去哪里问"的地址。

这就解释了一个新手经常困惑的现象:给原型上加方法,已经创建出来的实例立刻就能用。我见过有人问"为什么我给Foo.prototype.sayHi赋值之后,之前new出来的实例也能sayHi",答案就在这里——实例从来没持有方法的副本,它每次访问属性都是顺着[[Prototype]]这条链现场去原型对象上取。你要是把方法直接写在构造函数体里(this.sayHi = function(){}),那才是每个实例各持一份,一万个实例就是一万个函数对象。

提示:判断方法该放原型还是放实例,标准很简单——需要每个实例各自独立状态的放实例(比如this.name),所有实例行为一致的放原型(比如sayHi)。放错地方要么浪费内存,要么共享污染。

那为什么手写的时候推荐用Object.create(Foo.prototype)而不是obj.__proto__ = Foo.prototype?第一,__proto__本质是个访问器属性,挂在Object.prototype上,用Object.create(null)造出来的对象根本没有它,写法不可靠;第二,赋值给__proto__在引擎层面属于"改原型链"操作,会触发一系列去优化,性能很差;第三,语义上也别扭,Object.create从名字上就表达了"以某个对象为原型造一个新对象"的意图,读代码的人一眼就懂。

2.2 返回值判定:为什么对象留、原始值丢

第四步的规则只有一句话:"构造函数返回对象就替换,返回原始值就忽略",但这句话背后有明确的取舍。原始值(number、string、boolean、null、undefined、symbol、bigint)在 JS 里没有独立的身份,它们是按值传递的,new出来一个数字完全没有意义——你没法在这个数字上挂属性,也没法让它参与原型链查找。所以规范干脆把原始值返回值直接丢掉,统一回退到新建的对象上。

这里有几个高频踩坑点,我在面试里几乎每场都会问其中一两个:

构造函数返回值new的实际结果说明
不写return新对象默认返回undefined,被忽略
return 123新对象原始值被忽略
return 'abc'新对象字符串也是原始值,忽略
return null新对象null是原始值,特别注意
return {}那个字面量对象对象类型,替换
return []那个数组数组是对象,替换
return function(){}那个函数函数也是对象,替换
return new Date()那个日期对象替换

null这一行是重灾区。很多人凭直觉觉得null是"空对象",应该算对象类型,实际上typeof null === 'object'是 JS 早期遗留的著名设计缺陷,在返回值判定里null是原始值。所以手写实现里那句判断必须写全:result !== null && (typeof result === 'object' || typeof result === 'function')。少了!== null这一半,return null的用例就错了。

至于为什么要把function单独列出来,因为typeof对函数返回的是'function'而不是'object'。如果你的判断只写typeof result === 'object',构造函数return function(){}的时候就会误判成原始值、丢掉函数,这又是一个经典翻车点。

2.3prototype万一不是对象,会发生什么

这是我认为这道题最有含金量的追问。Object.create(Foo.prototype)要求参数必须是对象或null,如果你手动把Foo.prototype改成了null或者某个原始值,原生的new会怎么办?

规范里的处理是:构造函数的prototype如果不是对象,就退回用内置的Object.prototype作为新对象的原型。也就是说:

function Foo() {} Foo.prototype = null; const f = new Foo(); console.log(Object.getPrototypeOf(f) === Object.prototype); // true

而如果你手写实现里直接Object.create(Foo.prototype),Foo.prototype为null时Object.create(null)造出来的是个没有原型的"裸对象",f.toString()直接就报错了,行为跟原生不一致。

正确的兜底写法是这样:

const proto = Ctor.prototype; const isValidProto = proto !== null && (typeof proto === 'object' || typeof proto === 'function'); const obj = Object.create(isValidProto ? proto : Object.prototype);

多数人写myNew的时候压根没想过这个分支,因为平时没人会去改prototype。但面试里主动提一句"如果prototype不是对象,原生会退回Object.prototype,手写版本要补这个兜底",这就是明显强于平均水平的信号。

3. 手写myNew:从能跑通到能扛住追问

3.1 最小可用版本的逐行拆解

先给一个最朴素的版本,把四步一一对应上去,方便你建立"代码和步骤"的映射感:

function myNew(Ctor, ...args) { // 第一步:建一个空对象,同时把原型挂上去(1、2 两步合并了) const obj = Object.create(Ctor.prototype); // 第三步:把 this 绑到 obj 上执行构造函数 const result = Ctor.apply(obj, args); // 第四步:返回值是对象就用它,否则用 obj const isObjLike = result !== null && (typeof result === 'object' || typeof result === 'function'); return isObjLike ? result : obj; }

逐行说下为什么这么写。Object.create(Ctor.prototype)一行同时完成了第一步和第二步,因为用Object.create指定原型造对象,比"先{}再赋__proto__"更准确也更高效。第二步用Ctor.apply(obj, args)而不是Ctor.call(obj),是为了把调用new时传入的参数原封不动透传进构造函数——这一点很容易忽略,写完之后new Foo(1, 2)变成myNew(Foo)就把参数丢了。

第三步这里有个细节:apply的第一个参数就是构造函数体内部的this,所以构造函数里所有的this.xxx全部落到obj上。这也解释了为什么构造函数用普通函数写、不能用箭头函数——箭头函数没有自己的this,apply绑不上去。

最后那句判断,我习惯把"是不是对象"抽成一个单独的判断,因为这里最容易写漏null和function两种情况。

3.2 补上参数校验和原型兜底

能跑通之后,把前面提到的两个坑补上,代码就稳了:

function myNew(Ctor, ...args) { if (typeof Ctor !== 'function') { throw new TypeError(`${String(Ctor)} is not a constructor`); } const proto = Ctor.prototype; const isValidProto = proto !== null && (typeof proto === 'object' || typeof proto === 'function'); const obj = Object.create(isValidProto ? proto : Object.prototype); const result = Ctor.apply(obj, args); const isObjLike = result !== null && (typeof result === 'object' || typeof result === 'function'); return isObjLike ? result : obj; }

第一个typeof判断是防呆,因为你要是把myNew(123)传进去,Object.create(undefined)会抛一个语义不清的错,提前拦一下体验更好。第二个兜底就是上一节说的prototype不是对象时的处理。

这两处加起来不到十行,但在面试里能提供的"我确实想过边界"的信号非常强。我个人的经验是,面试官看你手写代码,往往不是在挑语法错误,而是在看你会不会主动想异常分支。很多人写完主流程就停了,等面试官提醒才补,那种感觉完全不一样。

3.3 用Reflect.construct写出更贴近原生的版本

上面那版能覆盖 90% 的场景,但跟原生new比还差一点:它没法正确处理new.target,也没法手写class的派生类构造。

new.target是 ES6 加进来的元属性,在一个函数内部访问它,如果这个函数是被new调用的,new.target就是那个构造函数本身;如果被普通调用,它就是undefined。这个特性常被用来做"必须用 new 调用"的保护:

function Foo() { if (!new.target) { throw new TypeError('Foo 必须通过 new 调用'); } this.a = 1; }

如果你想知道更"原生"的写法,可以借Reflect.construct:

function myNew2(Ctor, ...args) { if (typeof Ctor !== 'function') { throw new TypeError(`${String(Ctor)} is not a constructor`); } return Reflect.construct(Ctor, args); }

Reflect.construct(target, args, newTarget)第三个参数不传时默认等于target,它的行为和new target(...args)几乎完全一致,包括new.target的取值、class构造函数的处理、原型链的建立。面试里如果时间充裕,可以把这一版也抛出来,作为"我知道还有更严谨的实现方式"的补充,然后再说明为什么面试时更愿意展示上面那个手动版本——因为它能体现你对四步的理解,而Reflect.construct更像是把问题交给引擎。

3.4 一组能验证每个分支的测试用例

写完不测等于没写,我整理了一份覆盖主要分支的用例,你可以直接拿去跑:

function Person(name, age) { this.name = name; this.age = age; } Person.prototype.sayHi = function () { return `hi, ${this.name}`; }; // 用例 1:常规实例 const p = myNew(Person, 'A同学', 20); console.log(p.name, p.age); // A同学 20 console.log(p.sayHi()); // hi, A同学 console.log(p instanceof Person); // true // 用例 2:构造函数返回对象,应替换 function ReturnObj() { this.x = 1; return { y: 2 }; } console.log(myNew(ReturnObj).y); // 2 console.log(myNew(ReturnObj).x); // undefined // 用例 3:构造函数返回原始值,应忽略 function ReturnPrim() { this.x = 1; return 42; } console.log(myNew(ReturnPrim).x); // 1 // 用例 4:返回 null,应忽略 function ReturnNull() { this.x = 1; return null; } console.log(myNew(ReturnNull).x); // 1 // 用例 5:返回函数,应替换 function ReturnFn() { return function () { return 'inner'; }; } console.log(myNew(ReturnFn)()); // 'inner'

跑完之后重点看用例 4 和用例 5,这两个是最能检验判断条件写全没有的。用例 3 则验证了apply的返回值确实被正确丢弃。我建议你在本地把这些用例跑一遍,再故意把判断条件改坏(比如去掉!== null),亲眼看它挂掉一次,比读十遍解释印象都深。

4. 边界情况与高频追问:这些坑我几乎每场都见

4.1new和普通函数调用到底差在哪

这个追问出现的频率极高,我一般用一张表答,清楚又省时间:

对比项new Foo()Foo()直接调用
是否创建新对象创建不创建
函数内部this指向新创建的对象严格模式下undefined,非严格模式指向全局对象
返回值对象则用对象,否则用新对象返回什么就是什么
new.target等于Fooundefined
原型链新对象原型是Foo.prototype无关

这张表能自然带出下一个话题:this的绑定规则。你要是能在回答里顺势提一句"new绑定是this四种绑定规则里优先级最高的,比显式bind还高,所以bind过的函数被new调用时,bind指定的this会被忽略",那这一段的深度就够了。

bind那个点值得单独展开一下。const BoundFoo = Foo.bind({ a: 1 }),然后new BoundFoo(),实例的原型仍然是Foo.prototype,而不是被绑定的{ a: 1 }。这就是"new绑定优先级高于显式绑定"的具体表现,也是很多人面试时答不上来的细节。

4.2 哪些函数不能被new,为什么

这个问题看着零碎,其实考的是一条主线:一个函数能不能被new,取决于它在内部有没有[[Construct]]这个内部方法。

类型能否new原因
普通函数声明 / 函数表达式能有[[Construct]],有prototype
箭头函数不能没有[[Construct]],也没有prototype
对象里的方法简写{ foo() {} }不能方法简写不生成构造函数能力
class声明能但必须用new,直接调用会抛错
async函数不能没有[[Construct]]
generator函数不能没有[[Construct]]
内置构造器Date、Map、Set、Proxy能有[[Construct]]
Symbol、BigInt不能设计上只允许通过调用生成原始值
Math、JSON、Reflect不能是普通对象,不是函数

箭头函数那一条可以顺手补充:你可以在控制台打印一下(() => {}).prototype,结果是undefined。没有prototype属性,new的第二步无处可指,引擎干脆在第一步就拒绝执行,直接抛TypeError: xxx is not a constructor。

class那一条也很值得说。class Foo {}之后,如果你写Foo()直接调用,会抛TypeError: Class constructor Foo cannot be invoked without 'new'。这个报错本质上是引擎在[[Call]]内部方法里加的一道检查,专门为class开的特例。这也解释了为什么class的prototype属性是writable: false——引擎从属性层面就堵死了你手动替换原型的路子。

4.3 常见问题速查表与排查思路

我在帮人 review 手写实现的时候,遇到的问题高度集中在几个点上,整理成表方便你自查:

症状可能原因修复方式
instanceof判断为false用了{}创建对象再手工补属性,忘了设原型改用Object.create(Ctor.prototype)
构造函数的参数全丢了用Ctor.call(obj)没透传args改成Ctor.apply(obj, args)或Reflect.construct
return null的用例挂掉判断条件漏了result !== null补上!== null的判断
return function(){}的用例挂掉只判断了typeof === 'object'补上typeof === 'function'
构造函数里this是undefined构造函数写成了箭头函数,apply绑不上构造函数必须用普通函数
class构造函数报错手写的apply调用class会失败改用Reflect.construct
实例方法访问报错prototype被手动改成null或原始值加兜底退回Object.prototype
构造函数体里有new.target检查时报错手写版本透传不了new.target改用Reflect.construct(Ctor, args, Ctor)

这张表里前四条是"写代码时就能自查"的,后四条是"面试官追问时才暴露"的。我的建议是准备的时候先把自己写的版本逐条对着表验一遍,尤其是最后一条new.target,真正能想到的人不多。

注意:面试里被问到"你的实现和原生 new 有什么区别"时,别硬说"完全一致"。老老实实承认new.target和class派生构造这两块手写版本处理不了,主动指出可以用Reflect.construct补齐,这种"知道自己不知道什么"的回答,比强行说"一样"更能拿分。

5. 答题策略:怎么把这道题答出层次感

5.1 一套可以直接搬走的回答框架

面试现场时间紧张,我一般会按"结论先行、逐层展开、主动收口"的节奏组织答案,你可以照着套。

开口先给结论:new的核心是"创建对象、链接原型、绑定 this 执行、判定返回值"这四件事,一句话把主干交代清楚。接着用两三句话把每件事的具体行为说一遍,配一个小例子,比如function Foo(){}; Foo.prototype.x = 1; new Foo().x的结果。然后主动往下走一步:说明第二步的本质是把新对象的[[Prototype]]指向Foo.prototype,因此实例访问属性时会顺着原型链查找,这也是方法要放在原型上的原因。最后抛出手写实现,边写边讲每个分支为什么这么处理。

这个节奏的好处是:面试官不需要一句一句追问,你自己就把深度做上去了。我在实际复盘里发现,主动往下推一层的候选人,后续追问的数量明显变少,因为该说的你都说完了。

5.2 三个能主动抛出的加分点

有些点不是必答内容,但主动提出来能明显加分,我按性价比排了序。

第一个是原型链的终点。new Foo()出来的实例,[[Prototype]]指向Foo.prototype,再往上指向Object.prototype,Object.prototype的[[Prototype]]是null。这条链说出来,说明你不是孤立地记new,而是把它放进了整个对象模型里。

第二个是**constructor属性的坑**。Foo.prototype.constructor默认指向Foo,但如果你写Foo.prototype = { sayHi(){} },整个原型对象被替换掉,constructor就丢了,变成从Object.prototype继承来的Object。这也是很多老代码里instanceof判断出错的原因之一。修复方式是在新原型对象上手动补一句constructor: Foo。

第三个是**new与继承的关系**。ES5 时代实现继承,标准套路是Child.prototype = Object.create(Parent.prototype)加上Child.prototype.constructor = Child,再在子类构造函数里Parent.call(this, ...)。到了 ES6,class的extends内部也是这套逻辑,只不过引擎帮你包好了。能把这层对上说清楚,说明你对new的理解已经延伸到继承模型了。

5.3 几处容易说错的表述

最后提醒几个我在别人答案里反复听到的错误说法,避开它们能少丢不少分。

别把"新对象的__proto__指向Foo.prototype"当成规范表述。__proto__只是个历史遗留的访问器属性,规范里描述的是内部槽[[Prototype]],__proto__只是访问它的其中一种方式(还有Object.getPrototypeOf和Object.setPrototypeOf)。面试里说[[Prototype]]更准确。

别说"new会把构造函数里的属性复制到新对象上"。没有任何复制发生,构造函数是"在新对象作为this的前提下被执行了一遍",this.xxx = xxx是赋值操作,不是复制。

别说"构造函数返回undefined时用undefined"。这里有个很细的区分:不写return和写return undefined在返回值的处理上是一样的,都会被判定为原始值然后忽略。但如果有人问"构造函数里写return后面什么都不跟",那结果也是undefined,同样忽略。

还有一个容易含糊的点:new Foo不带括号和new Foo()是等价的,参数为空时括号可以省略。这个冷知识面试里不一定用得上,但偶尔会有人拿它来测你对语法细节的熟悉程度,知道总比不知道好。

我在实际带人准备面试的过程中体会到,这道题真正的门槛不在"记住几个步骤",而在"你愿不愿意为一个天天在用的东西往下多挖三层"。四步背下来十分钟,手写实现跑通半小时,把边界情况和追问都捋一遍大概一个晚上,但那一个晚上换来的东西,会在后面所有跟原型、this、继承相关的问题里反复兑现。

返回列表