挂了很多面试题,却未必清楚它到底是什么
一提到 JavaScript 原型链,很多人脑海里会浮出一堆名词:__proto__、prototype、constructor、Object.create……如果再去碰一下 ES6 的 class,又是一套新的写法。但真正在项目里写代码时,这些概念真的被理解过吗?我见过不少能背出“原型链是对象之间通过 [[Prototype]] 形成的链式关系”的开发者,一旦遇到“在原型上挂方法好,还是写在构造函数里好”这类实际问题,就开始凭感觉选了。

这篇文章不打算做名词堆砌,而是想把这些概念放在同一个线性逻辑里讲清楚:从对象内部机制出发,到函数与构造过程,再到 class 语法如何“包装”了这套机制。你会发现,原型链和 class 并不对立,class 只是把原型操作的可读性提高了,但本质上仍然在玩同一套对象链接规则。
先厘清三个容易混淆的东西:[[Prototype]]、__proto__、prototype
很多混乱都源自这三个名词长得太像。先说结论:[[Prototype]] 是规范层面的内部槽,任何对象都有,它指向该对象的原型(可能是另一个对象,或者 null);__proto__ 是一个历史遗留的访问器属性,浏览器和 Node 都实现了它,用来读写 [[Prototype]];而 prototype 是函数才有的一个普通属性,它指向一个对象,这个对象会成为通过该函数构造出来的实例的 [[Prototype]]。
简单来说:__proto__ 是“对象指向其原型的门”,而 prototype 是“构造函数预留用来给实例当原型的容器”。
看一段最基本的代码:
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
console.log(this.name + ' makes a sound');
};
const dog = new Animal('Dog');
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Object.getPrototypeOf(dog) === Animal.prototype); // true
console.log(dog.constructor === Animal); // true
这里 dog 的 [[Prototype]] 指向 Animal.prototype。当访问 dog.speak 时,JS 引擎先在 dog 自身属性里找,找不到就沿着 [[Prototype]] 向上找。这就是原型链的查找原则。
函数、new 与原型链的关系
普通函数在 JavaScript 里是“双重身份”:既可以作为普通函数调用,也可以作为构造函数用 new 调用。调用 new Animal() 时,引擎大致做了四件事:
- 创建一个新对象,并把这个对象的
[[Prototype]]指向构造函数的prototype属性。 - 将构造函数内的
this绑定到这个新对象上。 - 执行构造函数体中的代码,给新对象添加属性。
- 如果构造函数显式返回了一个对象,则返回该对象;否则返回第一步创建的新对象。
关键点是第一步。你可能听过“构造函数是模板”,但更准确地说,构造函数的 prototype 才是实例心法的来源。真正给实例提供方法的,不是构造函数本身,而是它身上的 prototype 指向的那个对象。
很多团队在早期代码里习惯用“在构造函数里 this.method = function() {}”的方式来定义方法。好处是每个实例都有一份自己的方法,可以避免共享状态;坏处是每个实例都重复创建了函数,内存浪费明显,而且方法无法通过原型链传递给子类。当你的系统里存在大量实例时,这种写法的额外开销会变得肉眼可见。
class 语法:它是语法糖,但不完全是
ES6 的 class 出现后,上面那套手动设置 prototype 的过程被藏起来了。看下面这段:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(this.name + ' makes a sound');
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
speak() {
console.log(this.name + ' barks');
}
}
这段代码与前面的函数式写法在对象链接机制上等价:Dog.prototype 的 [[Prototype]] 指向 Animal.prototype,Dog 的 [[Prototype]] 指向 Animal。也就是说,class 只是语法糖,原型链的底层结构没有变化。
但它也不完全只是糖。它有几点重要差异:
class声明不会被提升(有暂时性死区)。- 类体内的方法不可枚举,而且所有方法运行在严格模式下。
- 类只能通过
new调用,不能直接作为普通函数执行,否则会抛TypeError。 - 子类中必须先调用
super()才能访问this。
这些差异并不是纯语法约束,而是为了让继承语义更安全。比如子类在没有调用 super() 之前不能操作 this,避免了父类初始化未完成就使用子类状态的问题。
一个经常被忽视的工程问题:原型链上的属性修改会波及所有实例
原型链最隐蔽的坑在于:你可以在运行时给 prototype 加属性,而且这个修改会立即影响所有实例。这在团队协作里可能引发非常难排查的 bug。
想象一个场景:某个模块中为了给所有数组追加一个自定义能力,直接修改了 Array.prototype。这个模块一旦被引入,整个项目里的数组都会“获得”这个新方法。如果该方法的名字恰好与某个第三方库里的方法重名,或者迭代行为不符合预期,那么所有使用数组的代码都可能出现异常。更麻烦的是,这类问题往往在运行时才暴露,而且排查时需要回溯“谁动了原型”。
一个务实的建议是:不要修改内置对象的原型,哪怕是 polyfill 也应该用 Object.defineProperty 去定义不可枚举的属性,并且在修改前检查是否已存在。对于业务代码,更建议用组合或者工具函数替代。
constructor 指向真的是“构造者”吗?
很多人以为 instance.constructor 一定指向创建它的函数。在简单场景下确实如此,但一旦涉及继承,这个判断很容易出错。
function Parent() {}
function Child() {}
Child.prototype = Object.create(Parent.prototype);
const child = new Child();
console.log(child.constructor === Child); // false
console.log(child.constructor === Parent); // true
因为 Child.prototype 被整体替换成了一个新对象,而这个新对象的 constructor 指向的是 Parent。如果你像上面这样重写原型,最好手动把 constructor 指回去:
Child.prototype.constructor = Child;
这个细节在类继承里被自动处理了,所以很多用习惯 class 的开发者反而对这类问题没感觉。但当你需要去维护一段老代码,或者手写原型链时,constructor 经常被人忽略,导致类型判断出现问题。
方案对比:class 继承与手写原型链,怎么选?
为了让你对这两条路径有更立体的判断,我列了一个比较表:
| 维度 | class 继承 | 手写原型链 |
|---|---|---|
| 代码可读性 | 高,结构清晰 | 低,容易写乱 |
| 底层机制透明性 | 隐藏细节,排查问题时需要解糖 | 透明,但容易误用 |
| 子类扩展灵活性 | 受限较多,如必须 super() 先调用 | 灵活,可任意重置 prototype,但易出错 |
| 性能特征 | 引擎优化友好 | 某些动态操作会阻碍优化 |
| 适用场景 | 大多数业务代码 | 框架/底层库、需要动态生长的继承体系 |
注意,这里说“class 性能更好”是基于 V8 对 class 模式的优化而观察到的普遍现象,但并不是绝对的。某些情况下手写原型链可以做到极高的动态性,例如元编程场景,但需要你对引擎的隐蔽优化有足够理解,否则容易得不偿失。
用 Object.getPrototypeOf 和 hasOwnProperty 来排查问题
在实际调试中,建议把自己从 __proto__ 里拉出来,多使用规范 API。例如判断一个属性是来自实例还是原型:
const obj = { own: 1 };
obj.__proto__.parent = 2;
console.log('own' in obj); // true
console.log('parent' in obj); // true
console.log(obj.hasOwnProperty('own')); // true
console.log(obj.hasOwnProperty('parent')); // false
in 会沿着原型链查,hasOwnProperty 只查自身。这个区别在编写工具函数时非常关键。比如遍历对象属性时,如果你用 for...in 会把原型上的可枚举属性也带出来,这时就必须配合 hasOwnProperty 做过滤。
还有一个常见误区是“Object.keys() 无论继承的属性都会返回”。这里要澄清:Object.keys() 只返回自身可枚举属性,不会包括原型链上的。但如果你访问属性值,原型链的查找机制依然参与。用 Object.getPrototypeOf 可以逐级向上检查完整链路,在调试继承关系时比 __proto__ 更规范。
原型链在工程实践中的几个建议
基于多年的工程经验,我总结了下面几条可落地的建议,你可以根据项目阶段选择性地引入:
- 默认用
class来表达继承关系,尤其是在业务代码中,让后续维护者少猜底层。 - 雷打不动用
Object.create或class extends来建立原型链,不要直接赋值Child.prototype = Parent.prototype,那样会把父子原型指向同一个对象,导致子类的修改污染父类。 - 当需要给原型加方法时,使用
Object.defineProperty定义不可枚举属性,避免出现在for...in中。 - 捕获错误时不要依赖
instanceof判断跨 realm 对象,用Object.prototype.toString或者检查特定标识属性更安全。 - 如果对原型链机制不熟悉,可以在本地写下
new的手工模拟过程,一步步打印对象的[[Prototype]],比看十篇教程有用。
另外,当你维护大型项目时,建议在关键对象上使用 Object.freeze 或 Object.seal 来防止原型被意外修改。当然这也有代价:如果某个第三方库真的需要动态扩展,你可能会因此遇到障碍。所以请评估团队的代码规范,而不是盲目套用。
本质上是同一件事
回过头来看,原型链和 class 的争论其实没啥必要。它们都在回答同一个问题:对象之间如何安全、高效地共享能力。原型链提供的是底层协议,class 只是改善了这个协议的书写体验。真正需要理解的是对象之间的链接逻辑——每个对象都有隐藏的 [[Prototype]],属性查找沿链而上,函数通过 prototype 给实例提供公共能力,而 class 只是把这些操作整理成了更清晰的形式。
当你能把“为什么 class 里定义的方法会挂在原型上”“为什么子类调用方法前要先 super()”这些细节解释清楚时,说明你已经掌握了它的本质。希望这篇文章能帮你打通从底层机制到上层语法的任督二脉,在以后写代码或排查问题时少一点犹豫,多一点笃定。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/519/