☰
深入理解JavaScript构造函数、原型链与new的底层原理
2026/10/5 17:17:38 网站建设 项目流程

在 JavaScript 生态里待得越久,你会越发觉一个事实:很多人写业务代码极其熟练,组件、状态管理、性能优化都能侃侃而谈,但一碰到“构造函数、原型、原型链”这三个词就支支吾吾。我自己也是这么过来的,早年在电商项目里写公共弹窗组件,同事让我把 show、hide、destroy 这几个方法挂到 prototype 上,我二话不说写进了构造函数里,每个弹窗实例都复制了一份方法。那时候我只知道“原型链”这个名词很高级,但根本不清楚它到底是什么、怎么运作的,更别提 new 背后到底发生了哪些事情。

这篇文章就想把这条主线彻底捋一捋。从构造函数到底是什么,到 new 的执行细节,再到原型链的查找规则、继承的几种经典写法,最后补几个冷门但面试和工作都可能撞上的知识点。适合两类人:一类是刚学完 HTML、CSS、JS 基础,准备进阶的新手;另一类是写了一两年业务想系统补一补底层机制的同学。看完你会发现,所谓继承、class 语法糖、各种设计模式,底层翻来覆去讲的就是这一条链。

1. 先搞清楚三样东西到底是什么

1.1 构造函数:本质上就是一个普通函数

先破除一个最大的误解:JavaScript 里没有一个叫作“构造函数”的特殊类型。函数就是函数,一个函数既能像普通函数那样直接调用,也能用 new 关键字触发“构造”行为。区别不在函数本身,而在调用它的方式——前面加了 new,引擎就会走另一套流程:创建新对象、绑定 this、执行函数体、返回对象。

我们通常把用作构造函数的函数名首字母大写,那只是社区约定,用来提醒阅读者“别忘记加 new”。如果你用new person()这种写法,代码也能跑,只是不符合规范。真正强制“必须用 new”的,是 ES6 的 class 语法,这在后面会单独讲。

function Person(name, age) { this.name = name; this.age = age; } const p1 = new Person('张三', 28);

请注意,Person这个函数里没有return,也没有显式创建对象,是 new 帮我们完成了这些工作。这也是很多人第一次看到构造函数时最想不通的地方:明明函数体里只写了一行this.name = name,怎么 new 完之后就拿到一个包含 name 和 age 的对象?答案在 new 的流程里。

1.2 原型:挂载在构造函数身上的“公共模板”

每个函数在创建时,都会自动获得一个prototype属性(箭头函数除外),这个属性指向一个对象。这个对象默认带一个constructor字段,指回函数本身。

说到这很多人容易绕晕,因为prototype这个名字太有迷惑性了。请记住这句话:函数的prototype是“用它 new 出来的实例”的原型,而不是函数自己的原型。函数自己的原型是另外一个东西,叫__proto__(内部是 [[Prototype]]),后面会展开。

原型的主要作用是共享。把方法挂到prototype上之后,所有 new 出来的实例都能顺着关系找到这个方法,而且每个实例不各自持有一份拷贝,大家共用同一个函数。这就解决了两个问题:一是内存浪费,二是后续扩展能力。

1.3 原型链:由proto串起来的一条查找路径

有了prototype,再看__proto__。每个对象(包括函数对象)都有一个内部指针,指向“创造它的函数”的prototype。这是原书里最核心的那个公式:

实例的__proto__=== 构造它的函数的prototype

举个例子。p1.__proto__ === Person.prototype成立。那Person.prototype也是一个对象,它的__proto__又指向谁?指向Object.prototype。而Object.prototype.__proto__是null,链到这里就断了。

原型链上的每一步都是一个“我这边没有,就去上面找”的过程。你访问一个对象的某个属性时,引擎先查对象自身,没有就沿__proto__指针往上翻一级,再没有就继续翻,直到整条链走完。这有点像家族遗产:儿子没有的财产去问老子,老子没有的去问爷爷,爷爷也没有那就只能返回 undefined 了。

2. new 到底做了什么:手写一个 new

2.1 引擎执行 new 的完整流程

不少人能背出“new 会创建一个新对象”这句话,但完整流程往往说不全。一个标准的 new 调用,引擎会依次做这些事:

  1. 创建一个全新的空对象 obj;
  2. 把 obj 的内部原型指向构造函数的prototype,也就是让obj.__proto__ === Constructor.prototype;
  3. 执行构造函数,并把 this 绑定到这个 obj 上;
  4. 如果构造函数显式返回了一个对象(包含函数),那么 new 的结果是那个对象;否则返回第一步创建的这个 obj。

很多旧教程只写前三步,把第四步吞了。可一旦忽略了第四步,后人写代码时就容易踩到“构造函数里返回对象”的坑。为了把整个流程刻进脑子里,建议自己手写一个 fakeNew,一遍就懂。

function fakeNew(Constructor, ...args) { // 第一步 + 第二步:创建新对象,并把它连接到构造函数的 prototype 上 const obj = Object.create(Constructor.prototype); // 第三步:执行构造函数,把 this 指向新手对象 const result = Constructor.apply(obj, args); // 第四步:处理返回值 const isReturnObject = result !== null && (typeof result === 'object' || typeof result === 'function'); return isReturnObject ? result : obj; }

这里面Object.create(Constructor.prototype)是 ES5 提供的原生方法,作用就是创建一个新对象,并把它的__proto__指向参数里的对象。如果你不想用 Object.create,也可以手动完成:

function fakeNew2(Constructor, ...args) { const obj = {}; Object.setPrototypeOf(obj, Constructor.prototype); const result = Constructor.apply(obj, args); const isReturnObject = result !== null && (typeof result === 'object' || typeof result === 'function'); return isReturnObject ? result : obj; }

第二版更适合理解本质:造一个空对象,然后把它的“父级”设为构造函数的 prototype,再执行构造函数。最终拿到手的是什么,取决于构造函数有没有 return 引用类型。

2.2 构造函数里的 return:一个容易被忽略的坑

很多新手以为构造函数里不能写 return,其实写了也不报错,但结果会不同。通常来说,如果构造函数里 return 的是一个原始类型的值,比如数字、字符串,new 的结果不会被影响,引擎还是把第一步创建的 obj 还给你。代码里写return只是为了提前结束函数体。

但如果 return 的是对象或函数,事情就变了,new 的结果直接变成你 return 的那个对象。你 this 上绑了多少属性都白搭,最终拿到的对象和你构造函数里写的 this 没关系了。

我在真实项目里见过一次这样的 bug。某个基础库的作者在构造函数里写了个return { custom: true },本意是为了覆盖默认返回,结果所有通过new Base()创建的实例,instanceof Base都是 false。因为 instance 判断的是原型链上有没有Base.prototype,而返回的那个对象压根没连接到Base.prototype。

function User() { this.name = '张三'; return { fake: true }; } const u = new User(); console.log(u.name); // undefined console.log(u.fake); // true console.log(u instanceof User); // false

所以我的建议是:除非你对语言特性极其熟悉,并且有明确的设计意图,否则不要在构造函数里返回对象。这会让代码的语义变得混乱。

2.3 忘了 new 会怎样

构造函数最忌讳的就是忘记 new。非严格模式下,普通调用Person()时,函数内部的 this 指向全局对象(浏览器里是 window),于是this.name = name等于给全局对象挂了个属性,代码不会报错,但你会莫名涂污染全局变量。

严格模式下情况更严酷。ES module 和'use strict'下,普通调用时 this 是 undefined,等函数体执行到this.name = name这句时浏览器直接抛出一个TypeError: Cannot set properties of undefined。因为 undefined 上不能挂属性。

'use strict'; function Person(name) { this.name = name; // TypeError: Cannot set properties of undefined } Person('张三');

如果你写的是工具库,希望强制调用者使用 new,可以借助new.target做一道检查。new.target 在普通函数被 new 调用时指向函数本身,没加 new 调用时是 undefined:

function Person(name) { if (new.target === undefined) { throw new Error('Person 必须使用 new 调用'); } this.name = name; }

class 内部其实就内置了类似的检查,所以用 class 定义的构造函数无论如何都不能当成普通函数调用,它会直接抛错。

3. 原型链到底怎么走:查找规则与遮蔽

3.1 记忆锚点:实例.proto等于构造函数.prototype

整条原型链最关键的公式,前面已经出现过一次,这里再强调一遍:

p.__proto__ === Person.prototype

只要记住这一条,其他都能推导出来。比如Person.prototype.constructor === Person,又因为Person.prototype本身也是对象,它的__proto__指向Object.prototype,所以顺着链往上走,最终到达null。

表达式值说明
p.__proto__Person.prototype实例连向构造它的函数的 prototype
Person.prototype.constructorPerson原型默认带 constructor 指回函数
Person.prototype.__proto__Object.prototype函数的 prototype 也是一个普通对象
Object.prototype.__proto__null原型链终点

这一串关系一定要亲手在浏览器控制台里敲一遍。打开 DevTools 敲一个const p = new Person(),然后依次展开p.__proto__、Person.prototype、Person.prototype.__proto__,看到它们是同一个对象的不同引用时,你会瞬间通透。

原型链带来最直接的价值是属性共享。假设你要创建 1000 个弹窗实例,每个弹窗都有 show 方法。这个方法如果写在构造函数内部,1000 个实例就会各自生成一个函数,内存里躺着 1000 份功能完全相同的代码。挂在 prototype 上,1000 个实例共享一个 show,内存开销小得多,而且以后想给所有实例打补丁,只需要改一处。

function Modal(title) { this.title = title; this.element = document.createElement('div'); } Modal.prototype.show = function () { this.element.style.display = 'block'; };

对实例而言,show 不是它自己的属性,它是沿__proto__找到的。这也解释了为什么修改Modal.prototype.show之后,所有新老实例的行为会立刻同步变化——它们又不持有副本,访问时永远走的是这条链。

3.2 属性遮蔽:找到了就不继续往上走

原型链的查找规则是“就近原则”。实例自身存在同名属性,引擎就直接返回实例上的值,根本不会再去原型上找。这个过程叫 shadowing,中文一般翻译成遮蔽。

function Person() {} Person.prototype.getType = function () { return 'person'; }; const p = new Person(); p.getType = function () { return 'custom'; }; console.log(p.getType()); // custom

如果删掉 p 自己的 getType:

delete p.getType; console.log(p.getType()); // person

一旦自身属性被删除,引擎又找不到它了,于是下降一层去原型里找。这个机制在调试时非常有用:看到某个实例的方法行为诡异,可以先把实例自身属性 delete 掉,看它是不是回到了原型方法的默认行为。

判断属性是实例自身还是来自原型,用hasOwnProperty。这个方法本身也在 Object.prototype 上,但它已经不是重点,重点是你知道链上所有对象都有hasOwnProperty可用。

console.log(p.hasOwnProperty('getType')); // false

还有一个相关的点:for...in会把原型链上可枚举的属性一起遍历出来,而Object.keys只列出自身属性。ES5 以前写遍历经常要在循环里套一层hasOwnProperty过滤,现在有了Object.keys和数组遍历方法,很多场景都不需要手动过滤了。但这个差异在面试或代码审查时依然高频出现。

3.3 链的尽头:为什么是 null

原型链走到Object.prototype就差不多完了——Object.prototype是所有普通对象默认的祖先,它的__proto__是null。既然吐 null,说明这个对象没有“创造它的函数”,或者说它本身就是 Language 最底层的存在。

JavaScript 里绝大部分对象都能调用toString、hasOwnProperty、valueOf这些方法,就是因为它们在 Object.prototype 上。你创建了一个对象,哪怕是个空对象字面量,它的原型链里必然挂着 Object.prototype 这一环,于是这些方法天然可用。

如果不想让对象继承 Object.prototype,可以显式指定原型为 null:

const bareObj = Object.create(null); console.log(bareObj.toString); // undefined

这种“裸对象”通常用来做纯字典,不会有任何原型副作用,也正因为没有toString等方法,用起来时要注意判断方式。这一点后面第 5 章有专门一节展开。

4. 继承的几种经典写法与 class 语法糖

4.1 原型链继承:最直接,坑也最多

ES5 时代,继承主要靠操作原型链。最粗暴的写法是这样:

function Parent() { this.list = [1, 2]; } Parent.prototype.getList = function () { return this.list; }; function Child(name) { this.name = name; } Child.prototype = new Parent(); const c1 = new Child('a'); const c2 = new Child('b'); c1.list.push(3); console.log(c2.list); // [1, 2, 3]

c2 的 list 被污染了,这是原型链继承最大的硬伤。原因很简单:Child.prototype被整体替换成了new Parent()这个实例,于是 c1 和 c2 的__proto__都指向这个同一个 Parent 实例对象。它们在自身找不到 list,就到公共原型上找到了同一个数组,改一个等于改所有。

除此之外,这种写法还有一个问题:无法给 Parent 传参。Parent 的构造函数在new Parent()那一刻已经执行,子类实例真正创建时,你没有任何机会把参数传给 Parent。

所以原型链继承往往只作为概念演示,实际项目里很少直接单用它。

4.2 借用构造函数与组合继承

为了解决属性独立的问题,可以把 Parent 当作普通函数,在 Child 里 call 一下:

function Parent(name) { this.name = name; this.list = []; } Parent.prototype.getName = function () { return this.name; }; function Child(name, age) { Parent.call(this, name); // 每个子实例都初始化一份自己的 list this.age = age; } // 方法还是要走原型链 Child.prototype = new Parent();

这种模式叫组合继承:用 call 借用构造函数解决属性共享问题,用原型链继承解决方法复用问题。但它有个缺陷——Parent构造函数被调用了两次。一次是Parent.call(this, name),一次是new Parent()生成 Child.prototype。第二次调用产生的 name、list 属性放在 Child.prototype 上,但子类实例根本不会访问到它们,属于白干。

更优的解法是寄生组合继承:

function inherit(Child, Parent) { Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child; } inherit(Child, Parent);

Object.create(Parent.prototype)创建了一个新对象,它顺利连接到了 Parent.prototype,但不会去执行 Parent 构造函数,所以不会产生多余的实例属性。之后再把 constructor 指回 Child。这就是 ES5 时代公认最优的继承方式,后来 ES6 的 class 底层也跑不出来这个套路。

如果你想看见 Object.create 的真面目,它其实内部就是借了个临时构造函数:

function objectCreate(proto) { function F() {} F.prototype = proto; return new F(); }

一个空函数 F,把 F.prototype 指向目标原型,然后 new 一下。依赖的无非还是那条老朋友:实例.__proto__ === 构造函数.prototype。

4.3 class 与 extends:语法糖,但也是底层同样的链

ES6 的 class 让写法好看许多,但底层仍然是原型链。一个 class 里的普通方法会被挂到类.prototype上,静态方法会被挂到类构造函数自己身上。

class Parent { constructor(name) { this.name = name; } getName() { return this.name; } static make() { return new Parent('默认'); } } class Child extends Parent { constructor(name, age) { super(name); // 本质上是 Parent.call(this, name) this.age = age; } }

注意 class 比 ES5 多了一个机制:静态方法也是可以继承的,也就是说Child.make()能正常工作。原因是引擎在创建子类构造函数时,会把子类构造函数的__proto__指向父类构造函数。这就等于,函数对象之间也存在一条原型链,子类构造函数自己没有静态方法,就沿这条链去父类构造函数上找。

super 的机制略微复杂:在子类构造函数里调用 super 之前,还不能使用 this,因为引擎此刻还没确定 this 的真实原型归属。这也是为什么你写 class extends 时必须先调用 super,更何况 JavaScript 引擎会严格检查这一点,漏了就直接抛错。整个 extends 的转译结果,就是寄生组合继承。

对比一下三种写法的继承效果:

方式属性共享问题能否传参是否多余执行父构造推荐度
原型链继承有否是不推荐
组合继承无是是可用
寄生组合继承无是否推荐
class extends无是否日常首选

5. Function、Object 和原型链的微妙关系

5.1 Function.prototype 的自指现象

这一节是很多 JS 学习者最容易懵的地方。脚本一旦有人盯着Function.__proto__ === Function.prototype发呆,说明他正在被“鸡生蛋蛋生鸡”问题困住。

先拉一组关系表:

表达式值
Object.__proto__Function.prototype
Function.__proto__Function.prototype
Function.prototype.__proto__Object.prototype
Object.prototype.__proto__null

Function是一个函数对象,函数对象也是“由 Function 构造”的,所以它的__proto__指向Function.prototype。Object也是一个函数,所以Object.__proto__同样指向Function.prototype。而Function.prototype本身是一个函数(它就是那个“空函数”),函数的内部原型又指向Object.prototype,于是整张网就串了起来。

至于“谁先出现”这类问题,不需要在 JS 层面钻牛角尖:Object 和 Function 是引擎启动时就内置的原生对象,它们的内部实现处于更底层,JS 语言规范只说好了它们之间的关系,让 JS 代码可以一致性访问。你只要把那张表记下来,面试基本题和手写题都能应付。

5.2 instanceof 到底在查什么

obj instanceof Constructor的语义是:Constructor.prototype 是否出现在 obj 的原型链上。它并不关心 obj 是不是真的由 Constructor new 出来的。哪怕你是用 Object.create 手工串联出来的对象,只要链上带上了这个 prototype,instanceof 就会返回 true。

手写一遍就清楚了:

function instanceOf(left, right) { const proto = right.prototype; let current = Object.getPrototypeOf(left); while (current !== null) { if (current === proto) return true; current = Object.getPrototypeOf(current); } return false; }

等待循环退出的条件就是当前原型为 null,又一次印证了原型链的终点。

instanceof 也不是坚不可摧的。ES6 之后,构造函数的Symbol.hasInstance这个静态方法定义了instanceof的默认行为,而且允许自定义。比如可以手动改写:

class MyArray { static [Symbol.hasInstance](instance) { return Array.isArray(instance); } } console.log([] instanceof MyArray); // true

这只是一个有趣的冷知识,生产环境基本不鼓励这么玩。但了解它,你就知道 instanceof 不是单纯的“类型判断”,它背后是一次原型链遍历加上一个可插拔的钩子。

5.3 没有原型的对象:Object.create(null)

上一节讲过Object.create(null)会得到一个裸对象。这种对象连__proto__都没有,原型链的第一级就是 null。它在项目里最常见的用途是做纯字典或 Map 的替代品。

实际踩坑的地方在于,裸对象没有hasOwnProperty、没有toString。你如果习惯写map.hasOwnProperty(key)就会直接报错。安全写法是把 Object.prototype 上的方法拿出来借用:

const map = Object.create(null); map.title = '文章'; // 正确 Object.prototype.hasOwnProperty.call(map, 'title'); // 不要这样写,会直接抛错 // map.hasOwnProperty('title');

为什么有些公共库特别爱用裸对象?因为普通对象字面量继承自 Object.prototype,会让它的原型链上带着__proto__、constructor等一堆属性。如果拿客户端传来的 JSON 数据当非正则遍历对象使用,某些恶意 key 可能触发原型链属性覆盖,带来安全隐患。裸对象从根上切断了这些风险。

6. 实战中容易翻车的地方

6.1 整体重写 prototype 丢掉了 constructor

我自己刚接触原型那两年,干过一件错事:为了给某个旧组件扩展方法,直接整个替换了它的 prototype。

function Modal(title) { this.title = title; } Modal.prototype.show = function () { /* ... */ }; Modal.prototype.destroy = function () { /* ... */ }; // 手滑了,直接整体重写 Modal.prototype = { show: function () { console.log('show'); }, destroy: function () { console.log('destroy'); } };

问题有两处。第一,旧实例的__proto__还指向旧的 Modal.prototype,新实例则指向新对象,新旧实例行为不一致;第二,新 prototype 对象没有 constructor 字段,new Modal().constructor会沿着链找到Object,而不是 Modal。团队里如果有人拿constructor做类型判断,就会得到一堆 undefined 或错误结果。

正确姿势是只改单个方法,别整对象替换:

Modal.prototype.show = function () { console.log('show with log'); };

如果真有必要整体重写,补上 constructor:

Modal.prototype = { constructor: Modal, show: function () { /* ... */ } };

6.2 扩展内置原型前,想想后果

给Array.prototype或Object.prototype加方法是新手阶段特别爱干的事,因为真的很方便。比如给数组加一个contains:

Array.prototype.contains = function (item) { return this.indexOf(item) !== -1; };

这个方法本身没什么问题,但如果某个依赖库内部用for...in遍历数组,它会把数组所有的可枚举属性连方法一起遍历出来,导致出现完全无头绪的 bug。即使不依赖 for...in,将来团队里另一个同事也往 Array.prototype 加同名但语义不同的方法,你们的代码会神仙打架。

在这方面,我踩过的坑是某个老旧的第三方库遍历配置数组时,多处理了几个“多余”的方法,表现出的现象是表格多渲染出几行空数据。排查了半天才发现是原型扩展的锅。

如果实在要扩展,尽量用Object.defineProperty把属性设为不可枚举,或者直接用 Symbol 作为方法名:

Object.defineProperty(Array.prototype, 'contains', { value: function (item) { return this.indexOf(item) !== -1; }, enumerable: false, configurable: true, writable: true });

但说到底,我的建议是不要改内置原型。如果只是你自己项目内部用,写成一个工具函数放在模块里,收益和风险不成比例。

6.3 顺着原型链排查一个线上 bug

有一次线上详情页报item.show is not a function。我第一反应是找 item 是通过哪个工厂函数创建的,再去翻那个工厂函数所属类的方法定义。结果发现 show 明明定义在基类的 prototype 上。

之所以拿不到,是因为某个后期维护者在扩展子类时,把子类的 prototype 整体替换了一份新的对象。新对象的原型链没有接上基类的 prototype,基类提供的 show 自然就不在 item 的查找链路上了。整个排查过程靠的就是三个动作:

先console.dir(item)看它原型链的展开形态;再Object.getPrototypeOf(item)手动拿到它的上一级原型;最后对比item.__proto__ === 某构造函数.prototype这个公式是否成立。

如果两三层找不到你期待的方法,大概就是某个环节断了链。这时候要去看是不是有代码整体重写过 prototype,或者中间某个原型的__proto__被篡改过。

这段经验后来被我总结成一个小技巧:遇到任何“对象方法丢失”的报错,第一步不是打开源码,而是把对象打出来看原型链。很多时候,问题根源根本不在业务代码里,而在某段看似无关的继承改写里。

最后分享一个对我自己帮助很大的心得——理解原型链最好的方法不是死背关系图,而是把 fakeNew、Object.create、寄生组合继承这三段代码各手写三遍。第一遍照葫芦画瓢,第二遍边写边说每行在做什么,第三遍合上资料直接默写。写完你会发现自己对 JavaScript 对象模型的直觉上了一个台阶。遇到再刁钻的原型链问题,临时创建两个对象、几行代码试一下,答案往往自己就浮出来了。

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

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

立即咨询