JavaScript 原型链的终极理解:从 [[Prototype]] 到 class 语法的本质统一

深入剖析 JavaScript 原型链的底层机制,从 [[Prototype]]、__proto__ 到 class 语法糖,理清原型继承的本质,并给出工程实践中的避坑指南与方案选型建议。

挂了很多面试题,却未必清楚它到底是什么

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

AI technology illustration

这篇文章不打算做名词堆砌,而是想把这些概念放在同一个线性逻辑里讲清楚:从对象内部机制出发,到函数与构造过程,再到 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() 时,引擎大致做了四件事:

  1. 创建一个新对象,并把这个对象的 [[Prototype]] 指向构造函数的 prototype 属性。
  2. 将构造函数内的 this 绑定到这个新对象上。
  3. 执行构造函数体中的代码,给新对象添加属性。
  4. 如果构造函数显式返回了一个对象,则返回该对象;否则返回第一步创建的新对象。

关键点是第一步。你可能听过“构造函数是模板”,但更准确地说,构造函数的 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.prototypeDog[[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__ 更规范。

原型链在工程实践中的几个建议

基于多年的工程经验,我总结了下面几条可落地的建议,你可以根据项目阶段选择性地引入:

  1. 默认用 class 来表达继承关系,尤其是在业务代码中,让后续维护者少猜底层。
  2. 雷打不动用 Object.createclass extends 来建立原型链,不要直接赋值 Child.prototype = Parent.prototype,那样会把父子原型指向同一个对象,导致子类的修改污染父类。
  3. 当需要给原型加方法时,使用 Object.defineProperty 定义不可枚举属性,避免出现在 for...in 中。
  4. 捕获错误时不要依赖 instanceof 判断跨 realm 对象,用 Object.prototype.toString 或者检查特定标识属性更安全。
  5. 如果对原型链机制不熟悉,可以在本地写下 new 的手工模拟过程,一步步打印对象的 [[Prototype]],比看十篇教程有用。

另外,当你维护大型项目时,建议在关键对象上使用 Object.freezeObject.seal 来防止原型被意外修改。当然这也有代价:如果某个第三方库真的需要动态扩展,你可能会因此遇到障碍。所以请评估团队的代码规范,而不是盲目套用。

本质上是同一件事

回过头来看,原型链和 class 的争论其实没啥必要。它们都在回答同一个问题:对象之间如何安全、高效地共享能力。原型链提供的是底层协议,class 只是改善了这个协议的书写体验。真正需要理解的是对象之间的链接逻辑——每个对象都有隐藏的 [[Prototype]],属性查找沿链而上,函数通过 prototype 给实例提供公共能力,而 class 只是把这些操作整理成了更清晰的形式。

当你能把“为什么 class 里定义的方法会挂在原型上”“为什么子类调用方法前要先 super()”这些细节解释清楚时,说明你已经掌握了它的本质。希望这篇文章能帮你打通从底层机制到上层语法的任督二脉,在以后写代码或排查问题时少一点犹豫,多一点笃定。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/519/

(0)
上一篇 2026年8月28日
下一篇 2026年8月28日

相关推荐