深入解析JavaScript原型链:从构造函数到Class的继承实现
2026/9/10 16:40:26 网站建设 项目流程

1. 从“对象”说起:为什么需要原型?

如果你写过几行JavaScript代码,那么“对象”这个概念你一定不陌生。无论是用字面量{}创建的一个简单配置对象,还是通过new Date()实例化的一个日期对象,它们都是JavaScript世界里的核心居民。但你是否想过,当你创建一个对象时,它凭空就拥有了toString()hasOwnProperty()这些方法吗?这些方法是从哪里来的?

这就是原型机制要回答的第一个问题:共享行为与属性

想象一下,你是一家汽车制造厂的设计师。你不会为每一辆下线的汽车都单独设计并安装一套全新的轮胎、发动机和方向盘。相反,你会先设计一个“汽车蓝图”,蓝图上定义了所有汽车都应该有的公共部件(如四个轮子、一个方向盘)和公共功能(如启动、刹车)。每一辆具体的汽车都是根据这份蓝图制造出来的“实例”,它们天然就拥有了蓝图定义的这些公共特性。

在JavaScript中,这个“蓝图”就是原型(Prototype)。而“根据蓝图制造”的机制,以及蓝图之间可能存在的引用关系(比如“电动汽车蓝图”继承了“汽车蓝图”),就构成了原型链(Prototype Chain)

理解原型与原型链,绝不是为了应付面试题。它是理解JavaScript这门语言“面向对象”实现方式的核心,是读懂各种库和框架源码的基石,更是你写出高效、优雅代码的关键。很多看似诡异的语言特性(比如为什么[] instanceof Array返回true),其根源都在于此。接下来,我们就从最基础的构造函数和new操作符开始,一步步拆解这个核心机制。

2. 构造函数、new 与 prototype 属性的三角关系

要理解原型,必须先搞清楚new操作符背后发生了什么,以及prototype这个每个函数都有的特殊属性扮演了什么角色。

2.1 一个典型的构造函数

我们先定义一个简单的构造函数Person

function Person(name, age) { this.name = name; this.age = age; this.sayHello = function() { console.log(`Hello, I'm ${this.name}`); }; } const alice = new Person('Alice', 25); const bob = new Person('Bob', 30); alice.sayHello(); // Hello, I'm Alice bob.sayHello(); // Hello, I'm Bob

这段代码工作正常,但存在一个严重的性能问题sayHello方法被定义在了构造函数内部,这意味着每次执行new Person(...)时,都会在内存中创建一个全新的sayHello函数,并分别赋值给alice.sayHellobob.sayHello。如果有成千上万个Person实例,就会创建成千上万个功能完全相同的函数,这无疑是巨大的浪费。

这里就是一个常见的“坑点”:在构造函数内部直接定义方法。虽然功能上没问题,但在需要创建大量实例的场景下,会显著增加内存消耗和初始化时间。

2.2prototype属性的登场

为了解决上述问题,JavaScript 为每个函数都设计了一个名为prototype的属性(注意:只有函数才有这个属性)。这个属性是一个对象,我们称之为“原型对象”。它的核心作用就是:让所有由该函数作为构造函数创建的实例,共享这个原型对象上的属性和方法。

我们把代码改造一下:

function Person(name, age) { this.name = name; this.age = age; } // 将方法定义在构造函数的 prototype 上 Person.prototype.sayHello = function() { console.log(`Hello, I'm ${this.name}`); }; const alice = new Person('Alice', 25); const bob = new Person('Bob', 30); alice.sayHello(); // Hello, I'm Alice bob.sayHello(); // Hello, I'm Bob console.log(alice.sayHello === bob.sayHello); // true!

现在,sayHello方法只存在于Person.prototype这一个对象上。alicebob实例本身并没有sayHello属性,但它们却可以调用它。关键就在于new操作符建立的神秘链接。

2.3new操作符的四步魔法

当你执行new Person(...)时,JavaScript 引擎在幕后默默地做了四件事:

  1. 创建一个新的空对象。我们可以想象为const obj = {}
  2. 将这个新对象的内部[[Prototype]]链接指向构造函数的prototype属性。这是最关键的一步。这个内部链接在很多浏览器里可以通过__proto__属性访问(但这并非标准,后面会详述)。此时,obj.__proto__ === Person.prototype
  3. 将构造函数内部的this绑定到这个新创建的对象,并执行构造函数。于是,this.namethis.age就被赋值给了这个新对象。
  4. 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象

经过这四步,alice这个对象就诞生了。它拥有自己的属性nameage,同时通过内部的[[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.getPrototypeOfinstanceof

理解了构造函数、实例和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 function

3.2 原型链的尽头:Object.prototypenull

每个原型对象自己也是一个对象,那么它也有自己的原型。这条链的终点在哪里?

对于Person.prototype这个对象,它是由Object构造函数创建的(准确说,是new Object()的语法糖)。因此,Person.prototype的原型指向Object.prototype

console.log(Object.getPrototypeOf(Person.prototype) === Object.prototype); // true

Object.prototype是这个链条的顶端吗?还不是。Object.prototype本身也是一个对象,它的原型指向nullnull在这里表示“没有对象”,即原型链的终点。

console.log(Object.getPrototypeOf(Object.prototype)); // null

所以,一条完整的原型链看起来是这样的:alice->Person.prototype->Object.prototype->null

当你访问alice.toString()时,引擎会沿着这条链查找:alice自身没有 ->Person.prototype上也没有 ->Object.prototype上有toString方法,于是调用成功。这就是为什么所有普通对象都能调用toStringhasOwnProperty等方法的原因,它们都继承自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'] (实例属性独立,不受影响)

组合继承的关键点解析:

  1. Animal.call(this, name):在子类构造函数中调用父类构造函数,并将子类实例的this传入。这确保了每个Dog实例都拥有自己的namecolors属性副本,解决了引用类型属性被所有实例共享的问题(这是纯原型链继承的缺陷)。
  2. Dog.prototype = Object.create(Animal.prototype):使用Object.create创建一个新对象,这个新对象的原型 (__proto__) 指向Animal.prototype,然后将这个新对象赋值给Dog.prototype。这建立了正确的原型链:myDog.__proto__->Dog.prototype->Animal.prototype。同时,子类原型方法的添加(如bark)是在这个新对象上进行的,不会污染父类的原型。
  3. 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.create),super()调用则对应了在子类构造函数中调用父类构造函数。

我们可以用原型相关的方法来验证其本质:

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.property),JavaScript 引擎会遵循一套明确的规则,这套规则是理解许多行为的基础:

  1. 检查自身属性:首先在对象自身属性中查找,如果找到则返回。
  2. 原型链查找:如果自身没有,则沿着原型链 ([[Prototype]]) 向上查找,直到找到该属性或到达链的尽头 (null)。
  3. 属性屏蔽:如果对象自身和原型链上都存在同名属性,那么自身的属性会“屏蔽”掉原型链上的属性。这就是“属性覆盖”。
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); // true

Object.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):如果propobj自身的属性(非继承),则返回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, I'm ${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, I'm John jane.greet(); // Hi, I'm 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, I'm 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() { /* 非标准实现 */ }; // 可能与其他库冲突

为什么这是危险的?

  1. 破坏封装:你的修改会影响整个运行环境中的所有代码,包括第三方库。
  2. 命名冲突:你的属性名可能与未来 JavaScript 语言标准或其它库新增的属性名冲突。
  3. 性能影响:修改全局原型会影响引擎的优化。
  4. for...in循环出问题:污染的属性会被枚举出来,除非你将其设为不可枚举 (enumerable: false)。

安全准则

  • 永远不要修改你不拥有的对象的原型(尤其是Object.prototype,Array.prototype,Function.prototype等内置对象)。
  • 如果确实需要扩展功能,考虑使用函数包装或组合模式。
  • 使用Object.defineProperty并设置enumerable: false可以稍微降低污染的影响,但依然不推荐。

理解原型与原型链,就像是拿到了JavaScript对象系统的设计图纸。从newprototype的协作,到原型链的查找机制,再到基于原型的继承实现,这套体系虽然初看有些绕,但一旦掌握,你就会对JavaScript的面向对象编程有豁然开朗的感觉。无论是阅读历史代码,还是使用现代框架(它们大量运用了这些概念),这份理解都将让你更加得心应手。记住,class是糖,但原型是根。

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

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

立即咨询