深入解析JavaScript原型链:从构造函数到Class的继承实现

📅 发布时间:2026/8/29 5:53:26
深入解析JavaScript原型链:从构造函数到Class的继承实现 1. 从“对象”说起为什么需要原型如果你写过几行JavaScript代码那么“对象”这个概念你一定不陌生。无论是用字面量{}创建的一个简单配置对象还是通过new Date()实例化的一个日期对象它们都是JavaScript世界里的核心居民。但你是否想过当你创建一个对象时它凭空就拥有了toString()、hasOwnProperty()这些方法吗这些方法是从哪里来的这就是原型机制要回答的第一个问题共享行为与属性。想象一下你是一家汽车制造厂的设计师。你不会为每一辆下线的汽车都单独设计并安装一套全新的轮胎、发动机和方向盘。相反你会先设计一个“汽车蓝图”蓝图上定义了所有汽车都应该有的公共部件如四个轮子、一个方向盘和公共功能如启动、刹车。每一辆具体的汽车都是根据这份蓝图制造出来的“实例”它们天然就拥有了蓝图定义的这些公共特性。在JavaScript中这个“蓝图”就是原型Prototype。而“根据蓝图制造”的机制以及蓝图之间可能存在的引用关系比如“电动汽车蓝图”继承了“汽车蓝图”就构成了原型链Prototype Chain。理解原型与原型链绝不是为了应付面试题。它是理解JavaScript这门语言“面向对象”实现方式的核心是读懂各种库和框架源码的基石更是你写出高效、优雅代码的关键。很多看似诡异的语言特性比如为什么[] instanceof Array返回true其根源都在于此。接下来我们就从最基础的构造函数和new操作符开始一步步拆解这个核心机制。2. 构造函数、new 与 prototype 属性的三角关系要理解原型必须先搞清楚new操作符背后发生了什么以及prototype这个每个函数都有的特殊属性扮演了什么角色。2.1 一个典型的构造函数我们先定义一个简单的构造函数Personfunction Person(name, age) { this.name name; this.age age; this.sayHello function() { console.log(Hello, Im ${this.name}); }; } const alice new Person(Alice, 25); const bob new Person(Bob, 30); alice.sayHello(); // Hello, Im Alice bob.sayHello(); // Hello, Im Bob这段代码工作正常但存在一个严重的性能问题。sayHello方法被定义在了构造函数内部这意味着每次执行new Person(...)时都会在内存中创建一个全新的sayHello函数并分别赋值给alice.sayHello和bob.sayHello。如果有成千上万个Person实例就会创建成千上万个功能完全相同的函数这无疑是巨大的浪费。这里就是一个常见的“坑点”在构造函数内部直接定义方法。虽然功能上没问题但在需要创建大量实例的场景下会显著增加内存消耗和初始化时间。2.2prototype属性的登场为了解决上述问题JavaScript 为每个函数都设计了一个名为prototype的属性注意只有函数才有这个属性。这个属性是一个对象我们称之为“原型对象”。它的核心作用就是让所有由该函数作为构造函数创建的实例共享这个原型对象上的属性和方法。我们把代码改造一下function Person(name, age) { this.name name; this.age age; } // 将方法定义在构造函数的 prototype 上 Person.prototype.sayHello function() { console.log(Hello, Im ${this.name}); }; const alice new Person(Alice, 25); const bob new Person(Bob, 30); alice.sayHello(); // Hello, Im Alice bob.sayHello(); // Hello, Im Bob console.log(alice.sayHello bob.sayHello); // true!现在sayHello方法只存在于Person.prototype这一个对象上。alice和bob实例本身并没有sayHello属性但它们却可以调用它。关键就在于new操作符建立的神秘链接。2.3new操作符的四步魔法当你执行new Person(...)时JavaScript 引擎在幕后默默地做了四件事创建一个新的空对象。我们可以想象为const obj {}。将这个新对象的内部[[Prototype]]链接指向构造函数的prototype属性。这是最关键的一步。这个内部链接在很多浏览器里可以通过__proto__属性访问但这并非标准后面会详述。此时obj.__proto__ Person.prototype。将构造函数内部的this绑定到这个新创建的对象并执行构造函数。于是this.name和this.age就被赋值给了这个新对象。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。经过这四步alice这个对象就诞生了。它拥有自己的属性name和age同时通过内部的[[Prototype]]链接关联到了Person.prototype这个原型对象。当你访问alice.sayHello时JavaScript 引擎会先在alice对象自身查找是否有sayHello属性。如果没有它不会就此放弃而是沿着alice.__proto__即Person.prototype这条链继续向上查找。结果在Person.prototype上找到了于是成功调用。这个过程就是原型链查找。bob实例的查找过程完全一样它们共享了Person.prototype上的同一个sayHello函数完美解决了内存浪费的问题。2.4 一张图理清关系我们可以用下面这个简单的结构来可视化它们之间的关系alice (实例) bob (实例) { { name: Alice, name: Bob, age: 25, age: 30, __proto__: ---------------- Person.prototype ---------------- __proto__: } } | | v { sayHello: function() {...} }这个Person.prototype对象本身也是一个对象它也有自己的__proto__指向更上一层的原型这就构成了链。我们稍后会深入这条链的顶端。3. 深入原型链__proto__、Object.getPrototypeOf与instanceof理解了构造函数、实例和prototype的基本关系后我们需要更深入地探索连接它们的纽带以及如何利用这条链进行类型判断。3.1__proto__历史的产物与非标准的访问器在上面的例子和图示中我们反复使用了__proto__来指代对象内部的[[Prototype]]链接。这里必须澄清一个重要事实__proto__不是 ECMAScript 语言标准。它最初是某些浏览器如 Firefox引入的后来被广泛模仿但直到 ES2015 才被“追认”为附录中的可选特性目的是为了兼容旧代码。在实际开发中不建议直接读写__proto__。一是因为兼容性问题二是因为直接修改一个对象的原型__proto__是一项非常重量级且影响性能的操作可能导致优化失效V8 引擎就强烈不建议这样做。那么标准的方式是什么获取对象的原型使用Object.getPrototypeOf(obj)。设置对象的原型非常罕见需谨慎使用Object.setPrototypeOf(obj, prototype)。或者在创建对象时使用Object.create(proto)。让我们用标准方法验证之前的例子function Person(name) { this.name name; } Person.prototype.sayHello function() {}; const alice new Person(Alice); // 正确的方式获取原型 console.log(Object.getPrototypeOf(alice) Person.prototype); // true // 旧的非标准方式知其然即可 console.log(alice.__proto__ Person.prototype); // true (在支持的环境下) // 正确的方式设置原型谨慎使用 const newProto { shout: function() { console.log(HELLO!); } }; Object.setPrototypeOf(alice, newProto); alice.shout(); // HELLO! // 此时 alice 再也找不到 sayHello 方法了因为原型链被改了 // alice.sayHello(); // TypeError: alice.sayHello is not a function3.2 原型链的尽头Object.prototype与null每个原型对象自己也是一个对象那么它也有自己的原型。这条链的终点在哪里对于Person.prototype这个对象它是由Object构造函数创建的准确说是new Object()的语法糖。因此Person.prototype的原型指向Object.prototype。console.log(Object.getPrototypeOf(Person.prototype) Object.prototype); // true而Object.prototype是这个链条的顶端吗还不是。Object.prototype本身也是一个对象它的原型指向null。null在这里表示“没有对象”即原型链的终点。console.log(Object.getPrototypeOf(Object.prototype)); // null所以一条完整的原型链看起来是这样的alice-Person.prototype-Object.prototype-null当你访问alice.toString()时引擎会沿着这条链查找alice自身没有 -Person.prototype上也没有 -Object.prototype上有toString方法于是调用成功。这就是为什么所有普通对象都能调用toString、hasOwnProperty等方法的原因它们都继承自Object.prototype。3.3instanceof操作符的工作原理instanceof是判断对象与构造函数关系的操作符。它的工作原理就是沿着对象的原型链向上查找。obj instanceof Constructor的检查过程是检查Constructor.prototype是否出现在obj的原型链上。console.log(alice instanceof Person); // true // 因为Object.getPrototypeOf(alice) Person.prototype console.log(alice instanceof Object); // true // 因为Person.prototype 在 alice 的原型链上 // 而 Object.prototype 在 Person.prototype 的原型链上。 // 所以 Object.prototype 也在 alice 的原型链上。 console.log(Person.prototype instanceof Object); // true console.log(Person instanceof Function); // true (因为 Function.prototype 在 Person 的原型链上) console.log(Function instanceof Object); // true这里有一个经典的“坑”const arr []; console.log(arr instanceof Array); // true console.log(arr instanceof Object); // true // 但 instanceof 对于原始值无效 console.log(hello instanceof String); // false console.log(123 instanceof Number); // false // 因为 hello 和 123 是原始类型不是对象没有原型链。所以instanceof并非万能。对于数组类型判断更推荐使用Array.isArray()方法。4. 继承的实现基于原型链的经典模式与ES6 Class原型链的核心应用之一就是实现继承。JavaScript 中没有“类”的经典继承概念而是通过原型链来实现对象之间的继承关系。4.1 组合继承最经典的继承模式在 ES6 的class语法出现之前组合继承是使用最广泛、认可度最高的继承模式。它结合了构造函数继承继承实例属性和原型链继承继承原型方法的优点。假设我们有一个Animal父类和一个Dog子类// 父类构造函数 function Animal(name) { this.name name; this.colors [black, white]; } // 父类原型方法 Animal.prototype.sayName function() { console.log(My name is ${this.name}); }; // 子类构造函数 function Dog(name, breed) { // 1. 构造函数继承调用父类构造函数继承实例属性 Animal.call(this, name); // 关键在新创建的 Dog 实例上执行 Animal 初始化逻辑 this.breed breed; } // 2. 原型链继承建立 Dog 与 Animal 的原型关系 // 错误做法Dog.prototype Animal.prototype; (这会导致修改子类原型影响父类) // 正确做法创建一个以 Animal.prototype 为原型的中间对象 Dog.prototype Object.create(Animal.prototype); // 修正因为重写 prototype 而丢失的 constructor 属性 Dog.prototype.constructor Dog; // 子类原型方法 Dog.prototype.bark function() { console.log(Woof!); }; // 测试 const myDog new Dog(Buddy, Golden Retriever); myDog.sayName(); // My name is Buddy (来自父类原型链) myDog.bark(); // Woof! (来自子类原型) console.log(myDog.colors); // [black, white] (来自父类实例属性) myDog.colors.push(brown); console.log(myDog.colors); // [black, white, brown] const anotherDog new Dog(Max, Poodle); console.log(anotherDog.colors); // [black, white] (实例属性独立不受影响)组合继承的关键点解析Animal.call(this, name)在子类构造函数中调用父类构造函数并将子类实例的this传入。这确保了每个Dog实例都拥有自己的name和colors属性副本解决了引用类型属性被所有实例共享的问题这是纯原型链继承的缺陷。Dog.prototype Object.create(Animal.prototype)使用Object.create创建一个新对象这个新对象的原型 (__proto__) 指向Animal.prototype然后将这个新对象赋值给Dog.prototype。这建立了正确的原型链myDog.__proto__-Dog.prototype-Animal.prototype。同时子类原型方法的添加如bark是在这个新对象上进行的不会污染父类的原型。Dog.prototype.constructor Dog因为Dog.prototype被整个替换了其原有的constructor属性原本指向Dog就丢失了转而指向Animal。手动将其修正为Dog保持语义的正确性。组合继承的缺点是会调用两次父类构造函数一次在创建子类原型时Object.create内部可能涉及另一次在子类构造函数内部。但这在大多数情况下是可以接受的。4.2 ES6 Class语法糖与原型链的本质ES6 引入了class语法让面向对象编程的写法更加清晰直观。但必须明白class只是一个语法糖其底层实现依然基于原型链。用class重写上面的例子class Animal { constructor(name) { this.name name; this.colors [black, white]; } sayName() { console.log(My name is ${this.name}); } } class Dog extends Animal { constructor(name, breed) { super(name); // 相当于 Animal.call(this, name) this.breed breed; } bark() { console.log(Woof!); } } const myDog new Dog(Buddy, Golden Retriever); myDog.sayName(); // My name is Buddy myDog.bark(); // Woof!代码简洁明了。extends关键字自动帮我们处理了原型链的链接类似于Object.createsuper()调用则对应了在子类构造函数中调用父类构造函数。我们可以用原型相关的方法来验证其本质console.log(typeof Dog); // function (类本质是函数) console.log(Dog.prototype.sayName); // function sayName() {...} (方法在原型上) console.log(Object.getPrototypeOf(Dog.prototype) Animal.prototype); // true (正确的原型链) console.log(myDog instanceof Dog); // true console.log(myDog instanceof Animal); // true重要心得学习class时一定要时刻在脑中将其翻译成原型链的模式来理解。这能帮助你理解super关键字在方法中的行为、静态方法的继承等复杂情况。class没有引入新的继承模型只是让旧的模型更好用。5. 原型相关的核心API与操作在实际开发中我们除了理解概念还需要掌握一系列与原型相关的 API它们是我们检查和操作原型链的工具。5.1 属性访问与屏蔽规则当通过对象访问一个属性时如obj.propertyJavaScript 引擎会遵循一套明确的规则这套规则是理解许多行为的基础检查自身属性首先在对象自身属性中查找如果找到则返回。原型链查找如果自身没有则沿着原型链 ([[Prototype]]) 向上查找直到找到该属性或到达链的尽头 (null)。属性屏蔽如果对象自身和原型链上都存在同名属性那么自身的属性会“屏蔽”掉原型链上的属性。这就是“属性覆盖”。const proto { value: 42 }; const obj Object.create(proto); // obj.__proto__ proto console.log(obj.value); // 42 (来自原型链) obj.value 100; // 在 obj 自身上创建 value 属性 console.log(obj.value); // 100 (自身属性屏蔽了原型属性) console.log(proto.value); // 42 (原型属性未被修改) delete obj.value; // 删除自身属性 console.log(obj.value); // 42 (屏蔽解除再次从原型链找到)注意obj.value这样的操作实际上会触发obj.value obj.value 1。它会先读取原型链上的值42然后给对象自身赋值43从而产生屏蔽而不会修改原型上的值。5.2Object.create()以指定原型创建对象Object.create(proto)是创建对象并直接指定其原型的标准方法。它是理解原型继承的利器。const animal { eat() { console.log(${this.name} eats.); } }; const rabbit Object.create(animal); rabbit.name White Rabbit; rabbit.eat(); // White Rabbit eats. console.log(Object.getPrototypeOf(rabbit) animal); // trueObject.create(null)可以创建一个真正的空对象它没有原型__proto__为undefined或指向null因此不继承任何Object.prototype的方法如toString,hasOwnProperty。这在需要纯粹的数据字典或担心属性名冲突时非常有用。const dict Object.create(null); dict.key value; console.log(dict.toString); // undefined // dict 非常干净适合做键值对存储5.3Object.getPrototypeOf()与Object.setPrototypeOf()这两个是操作对象原型的标准“读”和“写”接口。Object.getPrototypeOf(obj)安全可靠地获取对象的原型。Object.setPrototypeOf(obj, prototype)设置对象的原型。性能警告现代 JavaScript 引擎会对对象的“隐藏类”进行大量优化而修改对象的原型是一个破坏性操作会严重影响性能。仅在绝对必要时使用且避免在性能关键的代码或循环中调用。5.4hasOwnProperty()与in操作符如何区分一个属性是对象自身的还是从原型链继承的obj.hasOwnProperty(prop)如果prop是obj自身的属性非继承则返回true。prop in obj如果prop能在obj的原型链上被访问到无论是自身还是继承则返回true。function Foo() {} Foo.prototype.bar prototype bar; const obj new Foo(); obj.own own property; console.log(obj.hasOwnProperty(own)); // true console.log(obj.hasOwnProperty(bar)); // false (bar 来自原型) console.log(own in obj); // true console.log(bar in obj); // true一个陷阱如果一个对象是通过Object.create(null)创建的它就没有hasOwnProperty方法。此时可以借用Object.prototype.hasOwnPropertyObject.prototype.hasOwnProperty.call(obj, prop)。5.5Object.keys()、for...in与原型链遍历对象属性时原型链上的属性是否会被枚举Object.keys(obj)返回一个数组包含对象自身所有可枚举属性的键名。不包含原型链上的属性。for...in循环遍历对象自身以及原型链上所有可枚举的属性除了 Symbol 属性。通常需要配合hasOwnProperty过滤。const proto { inherited: from proto }; const obj Object.create(proto, { ownEnum: { value: own enum, enumerable: true }, ownNonEnum: { value: own non-enum, enumerable: false } }); console.log(Object.keys(obj)); // [ownEnum] for (let key in obj) { console.log(key); // 输出 ownEnum, inherited }6. 实战中的原型应用与高级模式理解了基本原理和API后我们来看看原型在实际开发中的一些高级应用模式和需要注意的“坑”。6.1 原型式继承与Object.create()的妙用在不需要构建完整的构造函数体系时可以使用纯粹的“原型式继承”。这是道格拉斯·克罗克福德推广的一种模式其核心就是Object.create()。// 一个基础对象作为“原型模板” const personTemplate { greet() { console.log(Hi, Im ${this.name}); }, init(name) { this.name name; return this; } // 一个简单的初始化方法 }; // 创建新对象并继承模板 const john Object.create(personTemplate).init(John); const jane Object.create(personTemplate).init(Jane); john.greet(); // Hi, Im John jane.greet(); // Hi, Im Jane // 可以继续扩展 const programmer Object.create(personTemplate); programmer.code function() { console.log(${this.name} is coding...); }; const alice Object.create(programmer).init(Alice); alice.greet(); // Hi, Im Alice alice.code(); // Alice is coding...这种模式在创建大量共享行为但结构简单的对象时非常轻量灵活。6.2 混入Mixin模式组合优于继承经典的继承是“是一个is-a”的关系。但很多时候我们更需要“有一个has-a”或“能做什么can-do”的关系。这时混入模式就非常有用。我们可以利用原型或对象赋值将多个对象的属性/方法混合到一个对象中。// 定义一些可复用的“能力”对象 const canEat { eat(food) { console.log(${this.name} eats ${food}.); } }; const canWalk { walk() { console.log(${this.name} walks.); } }; const canSwim { swim() { console.log(${this.name} swims.); } }; // 混入辅助函数 function mixin(target, ...sources) { Object.assign(target, ...sources); // 或者使用原型链混入 sources.forEach(source { // Object.getOwnPropertyNames(source).forEach(prop { // target[prop] source[prop]; // }); // }); } // 构造对象 function Duck(name) { this.name name; } mixin(Duck.prototype, canEat, canWalk, canSwim); const donald new Duck(Donald); donald.eat(corn); // Donald eats corn. donald.swim(); // Donald swims.ES6 以后我们还可以使用Object.assign直接合并到原型上或者使用类的装饰器Decorator来实现更优雅的混入。混入模式避免了深层次的、僵化的原型链让代码更灵活、更易维护。6.3 性能考量与原型链查找原型链查找是一个递归的过程虽然引擎有优化但过深的原型链仍然会影响性能。一般来说3-4层的原型链是合理的但应避免创建非常长的原型链。另一个重要的性能提示是在频繁执行的代码如循环体内部中尽量避免访问可能来自原型链深层的属性。可以将其缓存到局部变量中。// 不佳的写法 for (let i 0; i hugeArray.length; i) { // .length 是数组自身的没问题 const item hugeArray[i]; console.log(item.someDeepProperty); // someDeepProperty 可能在很深的原型链上 } // 更好的写法如果 someDeepProperty 是只读的 const someDeepProperty obj.someDeepProperty; // 提前取出 for (let i 0; i hugeArray.length; i) { const item hugeArray[i]; console.log(someDeepProperty); // 直接使用缓存的值 } // 或者如果该属性在每个对象上都相同考虑将其定义在更靠近实例的原型上。6.4 原型污染及其防范原型是一个共享空间如果不加注意地修改内置对象的原型会造成“原型污染”这是一个严重的安全和稳定性问题。// 危险操作绝对不要在生产环境中这样做 Object.prototype.myCustomFunction function() { console.log(polluted!); }; const obj {}; obj.myCustomFunction(); // polluted! 所有对象都“被”拥有了这个方法 Array.prototype.customMap function() { /* 非标准实现 */ }; // 可能与其他库冲突为什么这是危险的破坏封装你的修改会影响整个运行环境中的所有代码包括第三方库。命名冲突你的属性名可能与未来 JavaScript 语言标准或其它库新增的属性名冲突。性能影响修改全局原型会影响引擎的优化。for...in循环出问题污染的属性会被枚举出来除非你将其设为不可枚举 (enumerable: false)。安全准则永远不要修改你不拥有的对象的原型尤其是Object.prototype,Array.prototype,Function.prototype等内置对象。如果确实需要扩展功能考虑使用函数包装或组合模式。使用Object.defineProperty并设置enumerable: false可以稍微降低污染的影响但依然不推荐。理解原型与原型链就像是拿到了JavaScript对象系统的设计图纸。从new和prototype的协作到原型链的查找机制再到基于原型的继承实现这套体系虽然初看有些绕但一旦掌握你就会对JavaScript的面向对象编程有豁然开朗的感觉。无论是阅读历史代码还是使用现代框架它们大量运用了这些概念这份理解都将让你更加得心应手。记住class是糖但原型是根。