深入JS底层:new操作符执行过程与原型链机制详解
1. 为什么new操作符值得你花时间从头抠一遍先讲个真实经历。几年前我去面一家公司技术面聊到一半面试官突然在共享文档里敲了一行字手写一个new操作符的实现。我当时心里一愣心想new不就用来创建对象的吗这有什么好写的结果真拿起笔来写到“让实例能访问构造函数.prototype上的方法”这一步卡住了。那个瞬间我才意识到自己写了好几年代码对new的理解其实一直停在“会用”的层面根本没过“原理”这关。后来我把《JavaScript高级程序设计》、ECMAScript规范里关于构造器调用的部分翻了个遍又结合V8执行时的内存表现一点点验证才真正把new的完整执行过程啃透。啃完之后回头看new操作符其实不是一个小知识点它是理解JS对象模型、原型链、this绑定三者关系的钥匙。这句话我建议你记下来因为你以后读框架源码——比如Vue 3的createComponent、React的createElement、Express的middleware机制——会发现它们底层全在跟这层东西打交道。这篇博文适合谁三种人特别值得看前端初中级开发者写过不少构造函数和class但没深究过new到底做了什么。准备面试的求职者new操作符几乎是JS基础面试的保留题目特别是手写实现。想系统理解原型链的人new是理解原型链最自然的入口比死记__proto__、prototype的关系图有效得多。这篇文章要解决的问题只有一个当我们写下new Foo()这行代码JavaScript引擎在背后究竟经历了哪几步、每一步为什么存在、少了任何一步会发生什么。我会从规范文档讲到底层内存视角再带你手写一个和原生行为完全一致的myNew实现最后把我这些年踩过的坑一并摆出来。2. new的四步执行过程从空对象到原型链串起来2.1 规范文档里被写成一堆术语的操作翻译成人话其实只有四步ECMAScript规范里new一个构造器会触发[[Construct]]内部方法然后经过OrdinaryCreateFromConstructor等一系列步骤。术语看着唬人但把所有的内部操作折叠起来核心动作可以压缩成四步创建一个全新的空对象。把这个空对象的原型[[Prototype]]指向构造函数的prototype属性指向的那个对象。将构造函数的this绑定到这个新对象上然后执行构造函数。构造函数如果返回了一个对象包括函数、数组等引用类型就返回这个对象否则返回第1步创建的那个新对象。我用一段极简的代码来对应这四步function Person(name) { // 第3步this指向新对象所以这里的this.name会挂到新对象上 this.name name; } // 第2步person的隐式原型指向Person.prototype // 第1步引擎先造了一个空对象然后person变量才指向它 const person new Person(张三); // 第4步Person没有显式返回对象所以person就是那个新对象这四步单独拎出来看都很简单但组合在一起就构成了一套完整的“对象出生流程”。我逐条展开说。2.2 第1、2步空对象不是“空的”它带着原型烙印出生大多数人以为new的第1步就是“造了个空对象{}”这么理解没大毛病但有个细节容易被忽略这个对象在被创建的那一刻它的原型就已经被设定好了。准确地说第1步和第2步通常是连续完成的你先有一个没有任何自有属性的对象紧接着引擎就把它的[[Prototype]]关联到了构造函数的prototype对象上。用代码验证最直观function Foo() {} const f new Foo(); console.log(Object.getPrototypeOf(f) Foo.prototype); // true console.log(f.__proto__ Foo.prototype); // true__proto__是非标准的等价访问方式这个关联就是原型链的起点。你访问f.toString()时JS引擎先看f自身有没有toString没有就顺着f.__proto__找也就是Foo.prototype再没有就继续往上找Foo.prototype.__proto__最终拿到Object.prototype上的方法。关于“空对象”还有一个面试容易追问的细节这个新对象有没有构造函数自身的属性当然没有。属性是第3步执行构造函数时挂上去的。第1步只负责出生不负责装修。2.3 第3步this绑定这一步决定了属性能不能挂对地方第3步的本质是用call或apply把构造函数的this指到新对象上然后执行函数体。你可以想象成引擎偷偷做了这件事// 引擎视角伪代码 const obj Object.create(Foo.prototype); const result Foo.call(obj); // this obj这里有一个所有JS开发者都必须刻进DNA的结论普通函数里的this是在调用时确定的不是定义时确定的。构造函数也一样同一个构造函数用new调用和普通调用this指向完全不同。后面讲“忘记写new”的坑时会再次回扣这点。2.4 第4步返回值裁决最容易被忽略但最容易出bug普通人写构造函数函数体里一般不写return所以第4步看起来很多余。但一旦有人写了return整个行为就会分叉。我先把结论放在这只要构造函数显式返回了一个引用类型对象、数组、函数、日期等任意非原始值new的结果就会被这个返回值整锅端走如果返回的是原始值字符串、数字、布尔、null、undefined则完全不影响返回的还是最初创建的那个新对象。这一步在规范里叫CreateDataPropertyOrThrow之后的裁决逻辑翻译成人话就是引擎辛辛苦苦创建的新对象在最后关头可能被“退货”。为什么会有这种设计历史原因是JS早期设计时为了兼容某些基于构造器的既有写法允许程序员用return {}来覆盖默认实例。这在今天看来是个不大不小的坑但既然是语言规范我们只能老老实实遵守。为了让你对这个奇葩行为有身体记忆我强烈建议你把下面这段代码在控制台跑一遍然后盯着打印结果发一会儿呆function Car(color) { this.color color; return { custom: override }; } function Dog(name) { this.name name; return 旺财; // 字符串是原始值被忽略 } const car new Car(red); const dog new Dog(小黑); console.log(car); // { custom: override } console.log(car.color); // undefined因为color挂在了被丢弃的对象上 console.log(dog); // Dog { name: 小黑 }返回的是新对象 console.log(dog.name); // 小黑这个例子里的car彻底失去了color属性因为第3步执行构造函数时this.color red确实挂到了那个新对象上但第4步把这个新对象整个替换掉了。你写在构造函数里的所有this赋值全部白费。这就是为什么很多规范都强调构造函数尽量不要显式返回对象除非你知道自己正在做什么。3. 构造函数return一个对象时new到底返回谁3.1 对象、函数、数组引用类型返回值会“偷梁换柱”我在上一节说了结论这里把它展开。凡是typeof结果为object或function的值都会触发覆盖逻辑。注意数组是对象函数是对象正则也是对象日期也是对象——它们统统都能偷走new的结果。实际项目里最经典的场景是“单例模式”利用构造函数返回同一个对象来实现全局唯一实例。比如let instance null; function Singleton() { if (instance) return instance; instance this; return this; // 其实返回this和默认返回效果等价但这种写法在new流程下是合法的 } const a new Singleton(); const b new Singleton(); console.log(a b); // true但坦白说今天实现单例更推荐用class加静态属性或者模块级变量直接在构造函数里return对象容易让读代码的人产生困惑。我见过一个真实项目里同事为了“优雅”地在构造函数里返回了一个闭包对象结果所有this.xxx属性全部失效排查了一下午才发现是返回值覆盖的问题。这种坑一旦踩上报错信息往往极其误导人。3.2 原始值、null和undefined都拦不住默认返回首先要澄清一个非常普遍的误解typeof null object于是很多人想当然地认为构造函数return null也会覆盖新对象。这是错的。规范里的裁决条件是看返回值是不是对象而null在逻辑上虽然表示为“空对象引用”但在JS内部它被归类为原始值不参与覆盖。为了让你彻底记住我把所有情况整理成一张表建议你收藏构造函数返回值typeof结果new的实际返回示例不写returnundefined新创建的对象new Foo()返回原始值数字/字符串/布尔number / string / boolean新创建的对象new Foo(bar)返回nullobject新创建的对象new Foo()返回undefinedundefined新创建的对象new Foo()返回普通对象object返回的那个普通对象new Foo({})返回数组object返回的那个数组new Foo([])返回函数function返回的那个函数new Foo(() {})返回其他引用类型Date、RegExp等object返回的那个引用类型new Foo(new Date())3.3 一个让我查了一下午文档的坑构造函数返回了数组有一次我封装一个错误处理工具模块构造函数里需要收集一批错误信息我图省事直接在最后写了return errors想把这个数组作为工具的唯一出口。结果调用方拿到的确实是一个数组但我写在管道中间的一堆this.config配置全部丢了后面所有方法根本没法用。查了一下午最后在控制台打印出来才发现new返回的根本不是原本的实例对象。这件事给我留下的教训是构造函数的职责是初始化实例不是产出别的值。如果你发现自己想在构造函数里return一个数组或对象大概率是你的设计思路该调整了。正确的做法应该是把配置、错误列表都挂到this上让new正常返回实例对象。比如class ErrorCollector { constructor(options) { this.config options || {}; this.errors []; } report(error) { this.errors.push(error); } }这才是面向实例编程的姿势。return this.errors这种思路是典型的“用函数返回值的习惯去写构造函数”在new的机制下必然翻车。4. 手写一个myNew把执行过程落到每一行代码4.1 最简实现与逐行解释理解了规范的四步手写实现就是水到渠成的事。我见过各种版本的手写new有的用Object.create()有的用Object.setPrototypeOf有的直接用都不推荐、得连new.target都模拟出来。我的建议是先写一个忠实映射规范四步的版本再在这个基础上做健壮性增强。下面这个实现是我在面试和实战中反复验证过的稳、简洁、好讲function myNew(Constructor, ...args) { // 第1步 第2步创建新对象并把它的原型指向构造函数的prototype const obj Object.create(Constructor.prototype); // 第3步以obj为this执行构造函数 const result Constructor.apply(obj, args); // 第4步裁决返回值 const isObject (typeof result object result ! null); const isFunction typeof result function; return isObject || isFunction ? result : obj; }逐行拆解Object.create(Constructor.prototype)这一步同时完成了“创建空对象”和“设置原型”两件事。它创建的对象没有自有属性但[[Prototype]]已经指向了Constructor.prototype。这一步比手动{}加setPrototypeOf更纯粹也更高效。Constructor.apply(obj, args)把构造函数的this绑定到obj并展开剩余参数传入。这就是标准函数调用的那一套和Constructor.call(obj, ...args)等价我习惯用apply因为直接接收数组。返回值判断typeof result object result ! null是为了排除null的坑typeof result function是说函数作为返回值同样覆盖默认对象。4.2 为什么不建议用__proto__手动设置原型有些初学版本的实现会这样写// 不推荐 const obj {}; obj.__proto__ Constructor.prototype;__proto__在实际运行环境和Object.create效果差不多但它有两个问题一是__proto__在设计上属于非标准属性虽然所有主流引擎都实现了但规范层面官方明确不推荐在业务代码中使用二是在手写实现这种“底层演示”场景用Object.create更符合“创建新对象并设置原型”的语义也更容易在解释时和规范四步对应。还有一个细微差别Object.create创建的对象的原型是直接指定的__proto__赋值则相当于“事后修改”。在很多场景下结果一致但前者更干净不会触发setter也没有多余的可枚举属性干扰判断。4.3 我反复翻车的返回值判断为什么必须检查null跟很多朋友交流手写new时我发现大家最容易写错的就是第四步的返回值判断。最常见的错误是写成这样// 错误示范漏掉了null的判断 return (typeof result object || typeof result function) ? result : obj;问题出在如果构造函数return nulltypeof null是object这个判断会认为null是对象从而把null当作new的返回值。但引擎的真实行为是忽略null返回默认新对象。所以必须在typeof result object的基础上再加一个result ! null。我在一次内部技术分享时做过现场测试底下一半的人第一次写这个判断都会漏掉null。你敢说你对这段逻辑了然于胸不如先手写一遍再往下看。4.4 顺手加一个“忘记new”的兜底保护很多工具库里你看到的createXxx函数本质就是在内部帮用户调一次new。比如function Person(name) { // 如果不写new调用this在严格模式下是undefined非严格模式下是全局对象 if (!(this instanceof Person)) { return new Person(name); } this.name name; }这个this instanceof Person判断很巧妙你用new Person(张三)调用时this是新建的实例this instanceof Person为true正常执行你不写new直接Person(张三)在非严格模式下this是windowwindow instanceof Person为false于是自动帮你补一次new。但要注意这个“兜底保护”在ES6的class语法下完全不可行因为class必须用new调用。如果你在用class写类兜底这事就别想了老老实实让框架在实例化时走正道。5. new、原型链和instanceof三者是如何联动工作的5.1 原型链是怎么通过new一步到位串起来的new做完四步之后内存里的对象关系已经成型。我用一个非常朴素的图来示意别嫌简陋重点看关系function Person(name) { this.name name; } Person.prototype.sayHi function() { console.log(Hi, this.name); }; const p new Person(张三);内存关联如下p是一个对象自有属性name 张三。p的[[Prototype]]指向Person.prototype对象。Person.prototype是一个对象上面有sayHi方法还有一个constructor属性指回Person。Person.prototype的[[Prototype]]指向Object.prototype。所以p.sayHi()能被调用是因为引擎沿着p - Person.prototype - Object.prototype这条路找到了sayHi。new操作符在这里扮演的角色就是把这条链的第一节焊死——p.__proto__ Person.prototype。这里有个几乎必考的衍生问题p.constructor是什么大多数人会脱口而出“是p的构造函数”这话对了一半。准确地说p.constructor其实是Person.prototype.constructor因为p自身没有constructor属性它是从原型链上找到的。所以如果你手动给Person.prototype整体赋值比如Person.prototype { ... }忘了把constructor指回Person那么p.constructor就会沿着链继续往上走最终得到Object。这就是为什么很多规范写法在重写prototype时要专门补一行constructor: Person。5.2 instanceof的查找逻辑它不是检查“是不是new出来的”p instanceof Person为什么是true大多数人背过答案“检测原型链上有没有Person.prototype”但不知道具体的查找方向。instanceof的本质是沿着对象的原型链不断向上走看有没有某个节点的[[Prototype]]指向构造函数的prototype对象。换句话说它检查的是右侧函数有没有出现在左侧对象的原型链上。看个稍微绕的例子function Person() {} function Student() {} const s new Student(); console.log(s instanceof Student); // trueStudent.prototype在s的原型链上 console.log(s instanceof Object); // trueObject.prototype也在s的原型链上 console.log(s instanceof Person); // falsePerson.prototype不在s的原型链上理解了这条链你就能解释很多“奇怪”的现象。比如function Foo() {} Foo.prototype []; const f new Foo(); console.log(f instanceof Foo); // trueFoo.prototype就是那个数组它在f的原型链上 console.log(f instanceof Array); // true因为Foo.prototype本身是数组原型的后代这类例子看着冷门但实际项目里配合Symbol.hasInstance可以做很多高级玩法。比如自定义instanceof行为class Zero { static [Symbol.hasInstance](instance) { return instance 0; } } console.log(0 instanceof Zero); // true不过在面试中能把这层说清楚已经远超平均分了。5.3 继承场景里new的作用Parent和Child该怎么串原型链继承的经典写法是function Parent(name) { this.name name; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 第一次借用Parent构造函数把name挂到Child实例上 this.age age; } Child.prototype Object.create(Parent.prototype); // 第二次把Child.prototype的原型指向Parent.prototype Child.prototype.constructor Child; // 修复constructor指向在这个写法里new出现在好几个关键位置。我们先看实例化的过程const child new Child(张三, 18); console.log(child.name); // 张三来自Parent.call挂载 console.log(child.age); // 18来自Child构造函数自身 child.sayName(); // 张三来自Child.prototype链到Parent.prototype实际发生的事new Child()创建新对象把它的原型指向Child.prototype。执行Child函数体第一行Parent.call(this, name)相当于把Parent当“方法借用者”用当前this再执行一遍Parent的赋值逻辑。这一步不是使用new而是直接call目的就是避免走完整new流程。child查找sayName时沿Child.prototype - Parent.prototype - Object.prototype的链找到。这里有一个面试必问的细节为什么不用Child.prototype Parent.prototype因为那样两个原型对象就指向同一个引用往Child.prototype上添加方法Parent的实例也会同时拥有这就破坏了继承的“隔离性”。那new Parent()来设置原型行不行比如Child.prototype new Parent()这种老写法的问题在于new Parent()会执行Parent构造函数体往Child.prototype这个对象上挂一堆name之类的实例属性。理想情况下原型上不应该有实例属性。更干净的方式就是Object.create(Parent.prototype)它只做“创建对象并指定原型”完全不执行构造函数。这也是现代写法推荐的思路。6. 我踩过的new相关坑忘写new、箭头函数new不了这些6.1 忘写new污染全局 vs 直接报错这是最经典的一类坑。ES5时代很多普通函数同时充当构造函数比如function Person(name) { this.name name; } const p Person(张三); // 忘记写new非严格模式下this指向全局对象浏览器里是window于是代码变成了给window挂上一个name属性。轻则污染全局重则引起莫名其妙的跨模块变量冲突。我在一个老项目里排查过一个bug某处配置项老是自动变成“张三”最后定位出来就是有人漏掉了new。如果启动严格模式文件开头加use strict情况会更直接普通调用下this是undefined执行到this.name name时直接抛TypeError: Cannot set properties of undefined错误信息非常明确。所以我的建议是新代码一律使用ES6的class且模块默认开启严格模式。class在普通调用时直接抛错彻底杜绝这种污染。6.2 箭头函数是new不了的箭头函数没有自己的[[Construct]]方法所以不能作为构造函数const ArrowPerson (name) { this.name name; }; const p new ArrowPerson(张三); // TypeError: ArrowPerson is not a constructor为什么箭头函数没有[[Construct]]因为它压根没有自己的this它的this是定义时从外层作用域捕获的。new的第三步就是把构造函数的this绑定到新对象上而箭头函数的this不能被动态绑定所以new流程在它身上根本没有成立的前提。设计上把它排除在可构造对象之外是合理的。同类问题还有方法简写。ES6的对象方法简写也不能newconst obj { method() {} }; new obj.method(); // TypeError: obj.method is not a constructor而如果是属性值为普通函数的写法反而可以newconst obj { method: function() {} }; new obj.method(); // 可以但不建议这么写这类冷门坑在代码review时偶尔会遇到知道原因后一眼就能看出问题。6.3 new优先级比bind高但比不过class的约束先看一个有意思的代码function Person(name) { this.name name; } const BoundPerson Person.bind(null, 张三); const p new BoundPerson(); console.log(p.name); // 张三bind绑定了this为null按理说函数执行时this应该是null。但实际结果是p.name 张三说明在new调用时即使this预先被bind为null也会被new创建的新对象覆盖。这就是规范里说的new绑定优先于bind绑定、显式绑定。这个优先级排序是new绑定 显式绑定call/apply/bind 隐式绑定 默认绑定。面试官如果问this绑定优先级这里说清楚了就是加分项。不过class有更强的约束class的内建方法是不可构造的用new调用会报错class本身也只能用new构造二者互相锁死。试图用class做一些“call瞬间调用”的骚操作在class语法下会被直接拦截。6.4 new出来的包装对象new Boolean(false)其实是true最后分享一个特别容易让人怀疑人生的坑const bool new Boolean(false); if (bool) { console.log(进入了这个分支); // 真的会打印 }原因很简单new Boolean(false)返回的是一个对象而对象永远是truthy所以条件判断必然通过。我在写配置校验时踩过这个坑接口返回某个字段为false我用new Boolean(字段)做布尔转换结果条件判断永远为true费了老大劲才排查出来。相对的原始值调用Boolean(false)返回的是原始布尔false这是类型转换。生产环境中如果要做布尔转换一律用Boolean(value)或双重感叹号!!value别去碰new Boolean(value)。同样的道理也适用于new Number()和new String()它们返回的是包装对象在比较相等时会引发各种认知混乱。6.5 结合热搜词里那些关联问题的收尾顺着new这条线往下挖你会发现它还连接着JS里一堆经典话题执行上下文、事件循环、闭包、原型继承、this绑定的优先级。热搜里那个“js event loop过程”就本质而言和new之间隔着一层执行上下文管理但两者的底层都属于JavaScript引擎的运行时机制。把new的执行过程吃透再去看执行上下文、变量提升这些话题你会意识到所有操作符、语法的执行最终都能回溯到“对象是如何被创建、链接、返回”这个最基本的问题上。我个人的体会是new操作符是JS中为数不多的、从语言设计层面把“面向对象”和“原型链”绑在一起的关键语法。你真正理解了它看Vue源码里的组件实例化、看Koa中间件、看各种工具库的工厂函数都会有“原来是这么回事”的通透感。希望这篇更接近底层解释的长文能帮你把那层窗户纸彻底捅破。