深入JS底层:new操作符执行过程与原型链机制详解
2026/9/19 6:11:36 网站建设 项目流程

1. 为什么new操作符值得你花时间从头抠一遍

先讲个真实经历。几年前我去面一家公司,技术面聊到一半,面试官突然在共享文档里敲了一行字:手写一个new操作符的实现。我当时心里一愣,心想new不就用来创建对象的吗,这有什么好写的?结果真拿起笔来,写到“让实例能访问构造函数.prototype上的方法”这一步,卡住了。那个瞬间我才意识到,自己写了好几年代码,对new的理解其实一直停在“会用”的层面,根本没过“原理”这关。

后来我把《JavaScript高级程序设计》、ECMAScript规范里关于构造器调用的部分翻了个遍,又结合V8执行时的内存表现一点点验证,才真正把new的完整执行过程啃透。啃完之后回头看,new操作符其实不是一个小知识点,它是理解JS对象模型、原型链、this绑定三者关系的钥匙。这句话我建议你记下来,因为你以后读框架源码——比如Vue 3的createComponent、React的createElement、Express的middleware机制——会发现它们底层全在跟这层东西打交道。

这篇博文适合谁?三种人特别值得看:

  1. 前端初中级开发者:写过不少构造函数和class,但没深究过new到底做了什么。
  2. 准备面试的求职者:new操作符几乎是JS基础面试的保留题目,特别是手写实现。
  3. 想系统理解原型链的人:new是理解原型链最自然的入口,比死记__proto__prototype的关系图有效得多。

这篇文章要解决的问题只有一个:当我们写下new Foo()这行代码,JavaScript引擎在背后究竟经历了哪几步、每一步为什么存在、少了任何一步会发生什么。我会从规范文档讲到底层内存视角,再带你手写一个和原生行为完全一致的myNew实现,最后把我这些年踩过的坑一并摆出来。

2. new的四步执行过程:从空对象到原型链串起来

2.1 规范文档里被写成一堆术语的操作,翻译成人话其实只有四步

ECMAScript规范里,new一个构造器会触发[[Construct]]内部方法,然后经过OrdinaryCreateFromConstructor等一系列步骤。术语看着唬人,但把所有的内部操作折叠起来,核心动作可以压缩成四步:

  1. 创建一个全新的空对象。
  2. 把这个空对象的原型([[Prototype]])指向构造函数的prototype属性指向的那个对象。
  3. 将构造函数的this绑定到这个新对象上,然后执行构造函数。
  4. 构造函数如果返回了一个对象(包括函数、数组等引用类型),就返回这个对象;否则返回第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步的本质是callapply把构造函数的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的实际返回示例
不写return'undefined'新创建的对象new Foo()
返回原始值(数字/字符串/布尔)'number' / 'string' / 'boolean'新创建的对象new Foo('bar')
返回null'object'新创建的对象new Foo()
返回undefined'undefined'新创建的对象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是window,window 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这条路找到了sayHinew操作符在这里扮演的角色,就是把这条链的第一节焊死——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); // true,Student.prototype在s的原型链上 console.log(s instanceof Object); // true,Object.prototype也在s的原型链上 console.log(s instanceof Person); // false,Person.prototype不在s的原型链上

理解了这条链,你就能解释很多“奇怪”的现象。比如:

function Foo() {} Foo.prototype = []; const f = new Foo(); console.log(f instanceof Foo); // true,Foo.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)

实际发生的事:

  1. new Child()创建新对象,把它的原型指向Child.prototype
  2. 执行Child函数体,第一行Parent.call(this, name),相当于把Parent当“方法借用者”,用当前this再执行一遍Parent的赋值逻辑。这一步不是使用new,而是直接call,目的就是避免走完整new流程。
  3. 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'),情况会更直接:普通调用下thisundefined,执行到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的对象方法简写也不能new:

const obj = { method() {} }; new obj.method(); // TypeError: obj.method is not a constructor

而如果是属性值为普通函数的写法,反而可以new:

const 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中间件、看各种工具库的工厂函数,都会有“原来是这么回事”的通透感。希望这篇更接近底层解释的长文,能帮你把那层窗户纸彻底捅破。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询