前言
在其他编程语言中类很常见,但在javascript中基本上没有类的概念,只有对象,javascript是基于原型的语言,javascript中对象是通过原型链来实现继承的。在ES6中javascript才正式引入了类的概念,但本质上还是基于原型链来实现继承的。
什么是原型链
原型与原型链
- 要明白什么原型链,首先要知道什么是原型,每一个 JavaScript 对象在创建时,都会关联另一个对象,这个被关联的对象就是它的原型。对象可以共享其原型上的所有属性和方法。
- 每一个对象都有一个隐式的属性
[[Prototype]](在代码中通常写作__proto__),指向它的原型对象。原型对象也有自己的原型,它的原型也是一个对象,所以也有__proto__,指向更上一级的原型,这样就形成了一个链条,这个链条就是原型链。
原型链访问
当你访问一个对象的属性或方法时,JS 引擎会遵循以下查找规则:
- 先在当前对象自身寻找。
- 如果找不到,就沿着
__proto__去它的原型对象里找。 - 如果还找不到,就继续沿着原型的
__proto__向上查找。 - 这一条由
__proto__串联起来的查找路径,就叫做原型链(Prototype Chain)。
沿着原型链一直往上找,最终都会到达 Object.prototype。而Object.prototype.__proto__ 的值是null。null意味着到达了链条的尽头,如果走到这里还没找到属性,系统就会返回 undefined。
原型链的示例
我们可以以数组为例来说明:
let arr = [1, 2, 3];
console.log(arr.__proto__); // Array.prototype
arr.push(4); // 在arr这个对象中找不到push方法,就会沿着原型链去Array.prototype中找,找到了就执行
console.log(arr.__proto__.__proto__); // Object.prototype
arr.toString(); // 在arr这个对象中找不到toString方法,就会沿着原型链去Array.prototype中找,找不到就继续去Object.prototype中找,找到了就执行
console.log(arr.__proto__.__proto__.__proto__); // null
其图示大致如下:
[ arr 实例 ]
│ 自身属性: 0: 1, 1: 2, 2: 3, length: 3
▼ __proto__
[ Array.prototype ]
│ 共享方法: push(), pop(), map(), slice()...
▼ __proto__
[ Object.prototype ]
│ 通用方法: toString(), valueOf(), hasOwnProperty()...
▼ __proto__
null <-- 【原型链终点】
prototype和__proto__的区别
prototype是函数的一个属性,它指向一个对象,这个对象包含了所有通过该构造函数创建的实例可以共享的属性和方法。__proto__是每个对象都有的一个隐式属性,它指向该对象的原型,也就是创建该对象的构造函数的prototype属性。
简单来说,prototype是给别人用的模版,只有函数才有,而__proto__是给自己用的指针,所有对象都有。
当创建实例的时候,实例的 __proto__ 会指向构造函数的 prototype,从而实现属性和方法的共享。即实例对象.__proto__ === 构造函数.prototype。
在下文的示例中我会详细说明。
如何用原型链实现类的操作
在其他编程语言中类有多种操作,比如定义类、创建实例、实现继承、多态等,在javascript中这些操作都是可以通过原型链来实现的。
定义类并绑定方法、创建实例
通过构造函数的方式定义类
现在javasscript类的定义方式有两种,一种是ES6之后的的class关键字,一种是ES6之前的构造函数方式,下面我们以构造函数的方式来定义一个类,这样比较方便说明底层原理。
function Person(name,age) {
this.name = name;
this.age = age;
}
// 通过原型链给Person类添加方法
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}, I am ${this.age} years old.`);
}
// 创建实例
let person1 = new Person("Alice", 25);
let person2 = new Person("Bob", 30);
// 调用实例方法
person1.sayHello(); // Hello, my name is Alice, I am 25 years old.
person2.sayHello(); // Hello, my name is Bob, I am 30 years old.
我们可以看到,sayHello 方法是通过 Person.prototype 添加的,这样所有通过 Person 构造函数创建的实例都可以共享这个方法。当我new一个实例时,实例的 __proto__ 会指向 Person.prototype,从而实现方法的共享.new方法的实现大致如下:
// 模拟 new 的核心原理
function myNew(Constructor, ...args) {
// 1. 创建一个空对象
const obj = {};
// 2. 将空对象的 __proto__ 指向构造函数的 prototype
// 这样新对象就能顺着原型链找到 Animal.prototype 上的方法
Object.setPrototypeOf(obj, Constructor.prototype);
// 3. 将构造函数的 this 绑定到新对象并执行(继承属性)
const result = Constructor.call(obj, ...args);
// 4. 如果构造函数返回了对象则返回该对象,否则返回新创建的对象
return typeof result === 'object' && result !== null ? result : obj;
}
关系图示大致如下:
[ Person 构造函数 ]
│
├─────────────────────────┐
│ (.prototype) │ (new 出来的实例)
▼ ▼
[ Person.prototype ] ◄─────── [ person1 实例 ]
{ sayHi: function } (. __proto__ )
│
│ (. __proto__ )
▼
[ Object.prototype ]
│
│ (. __proto__ )
▼
null (原型链终点)
- Person 函数有一个 prototype 属性,指向一个包含 sayHi 的原型对象。
- person1 实例有一个 proto 属性,精准指向了这个 Person.prototype 原型对象。
- 当你访问 person1.sayHi 时,由于 person1 本身没有这个方法,JavaScript 引擎就会通过 person1.proto 顺藤摸瓜去 Person.prototype 里查找。
通过ES6的class关键字定义类
使用ES6的class关键字定义类也是类似的,ES6的class关键字只是语法糖,本质上还是通过原型链来实现继承的,底层原理并不会改变。
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log(`Hello, my name is ${this.name}, I am ${this.age} years old.`);
}
}
// 创建实例完全一样
let person1 = new Person("Alice", 25);
let person2 = new Person("Bob", 30);
// 调用实例方法也没有区别
person1.sayHello(); // Hello, my name is Alice, I am 25 years old.
person2.sayHello(); // Hello, my name is Bob, I am 30 years old.
实现继承
在其他编程语言中类的继承是很常见的操作,在javascript中实现类的继承有多种方式:
通过原型链实现继承
通过原型链继承是最能体现原型链原理的一种方式,主要原理是直接将子类的原型指向父类的实例。
function Person(name, age) {
this.name = name;
this.age = age;
this.skills = ['JavaScript', 'HTML', 'CSS'];
}
Person.prototype.getName = function() {
return this.name;
}
function Child() {}
Child.prototype = new Person();
const child1 = new Child();
const child2 = new Child();
child1.skills.push('React');
console.log(child1.skills); // ['JavaScript', 'HTML', 'CSS', 'React']
child2.skills.push('Vue');
console.log(child2.skills); // ['JavaScript', 'HTML', 'CSS', 'React', 'Vue']
原型链路径如下:
[ Child 构造函数 ]
│
├─────────────────────────┐
│ (.prototype) │ (new 出来的实例)
▼ ▼
[ Person 实例 ] ◄─────── [ child1 实例 ]
{ name: undefined, age: undefined, skills: ['JavaScript', 'HTML', 'CSS'] } (. __proto__ )
│
│ (. __proto__ )
▼
[ Person.prototype ]
{ getName: function } (. __proto__ )
│
│ (. __proto__ )
▼
[ Object.prototype ]
│
│ (. __proto__ )
▼
null (原型链终点)
这里其实就是简单的把Child的原型指向了Person的实例,这样Child就继承了Person的属性和方法,子类实例可以访问父类原型上的方法。但这样做的缺点很明显:
- 因为是完全通过原型链来实现属性和方法的传递,父类构造函数中的引用类型属性会被所有子类实例共享,修改一个会影响其他实例。
- 创建子类实例时,无法向父类构造函数传参,因为父类的构造函数在配置
Child.prototype = new Person(...)时就已经执行了,后续调用new Child(...)无法把参数动态传给父类。 这样的缺陷使得原型链继承在实际开发中很少使用,基本不推荐使用这种方式来实现继承。
借用构造函数实现继承
主要原理是在子类构造函数内部通过 call() 或 apply() 方法调用父类构造函数。
function Person(name, age) {
this.name = name;
this.age = age;
this.skills = ['JavaScript', 'HTML', 'CSS'];
}
Person.prototype.getName = function() {
return this.name;
}
function Child(name, age) {
Person.call(this, name, age); // 借用构造函数
// 这里也可以添加子类特有的属性
}
// 创建实例
const child1 = new Child('Alice', 25);
const child2 = new Child('Bob', 30);
// 修改子类实例的引用类型属性
child1.skills.push('React'); // 只会影响child1实例
console.log(child1.skills); // ['JavaScript', 'HTML', 'CSS', 'React']
console.log(child2.skills); // ['JavaScript', 'HTML', 'CSS']
// 调用父类方法会报错,因为父类方法在原型上,子类实例无法访问
console.log(child1.getName()); // TypeError: child1.getName is not a function
- 为什么在
Child里可以直接调用Person.call?
- 因为在 JavaScript 中,函数本身也是一个对象。
Person是我声明的一个函数(对象)。在 JavaScript 的顶层设计中,所有函数对象的__proto__都指向Function.prototype。 - call、apply、bind 这三个方法,就挂载在
Function.prototype原型对象上。 所以,无论在代码的任何地方,只要Person这个函数存在,你就可以直接通过Person.call(...)去调用它。这与继承关系、原型链怎么改没有任何关系。
Person.call(this, name, age)底层到底在干什么?
- 这里的
call并不是在做什么“类的继承运算”,它只是在强行改变函数执行时的 this 指向:当我new Child("Tom")时,JavaScript 会在内存中创建一个全新的空对象,并把这个空对象赋给Child构造函数内部的 this。 - 紧接着执行到Child构造函数内部的
Person.call(this, name, age): 它把Child内部的这个 this(新创建的对象)作为参数传给了Person函数。它让Person函数里的代码跑了一遍。在Person内部执行this.name = name和this.age = age时,实际上是在直接给Child的新实例 添加name和age属性。
简单的说:Person.call(this, name, age) 就相当于把 Person 构造函数里的代码借用过来,在当前子类实例上现场铺设了传入的属性。且由于只是借用了构造函数而没有创建新的原型链,这些属性都直接存放在子类实例自身,所以每个子类实例都有自己的引用类型属性,互不影响。
借用构造函数继承解决了原型链继承的两个缺点:
- 每个子类实例都有自己的引用类型属性,互不影响。
- 可以向父类构造函数传参,动态初始化子类实例。
但是借用构造函数继承也有缺点:
- 子类实例无法访问父类原型上的方法,因为父类方法在原型上,子类实例无法访问。
- 每个子类实例都会创建一份父类构造函数的副本,无法实现方法的复用,增加了内存开销
组合继承
结合原型链继承和借用构造函数继承的优点:使用构造函数继承属性,使用原型链继承方法。
function Person(name, age) {
this.name = name;
this.age = age;
this.skills = ['JavaScript', 'HTML', 'CSS'];
}
Person.prototype.getName = function() {
return this.name;
}
function Child(name, age, type) {
Person.call(this, name, age); // 借用构造函数继承属性,此处调用了父类的构造函数
this.type = type; // 子类特有属性
}
// 通过原型链继承方法
Child.prototype = new Person(); // 原型链继承方法,此处调用了父类的构造函数
// 修复constructor(构造函数)指向
Child.prototype.constructor = Child; // 关键在这里
// 创建实例
const child1 = new Child('Alice', 25);
const child2 = new Child('Bob', 30, 'child');
// 修改子类实例的引用类型属性
child1.skills.push('React'); // 只会影响child1实例
console.log(child1.skills); // ['JavaScript', 'HTML', 'CSS', 'React']
console.log(child2.skills); // ['JavaScript', 'HTML', 'CSS']
// 调用父类方法
console.log(child1.getName()); // Alice
// 调用子类特有属性
console.log(child1.type); // undefined
console.log(child2.type); // child
我们可以来解释一下为什么组合继承可以解决前两种继承方式的缺点:
- 通过
Person.call(this, name, age),每个子类实例都有自己的引用类型属性,互不影响。 - 通过
Child.prototype = new Person(),子类实例可以访问父类原型上的方法,实现了方法的复用。 - 通过
Child.prototype.constructor = Child,修复了子类原型的 constructor 指向,确保子类实例的 constructor 指向正确。
整体流程图大致为:
========================================================================================
【第 1 次调用 Person 构造函数】
触发时刻:代码定义继承关系时,执行 Child.prototype = new Person()
作用阶段:搭建原型链基础
产生效果:在 Child.prototype 上生成了“冗余遮蔽属性” (name: undefined, skills: [...])
========================================================================================
│
▼
【 实例化过程: new Child('Alice', 25) 】
│
========================================================================================
【第 2 次调用 Person 构造函数】
触发时刻:执行 new Child() 实例化时
触发代码:Child 内部的 Person.call(child1, 'Alice', 25)
作用阶段:继承私有属性
产生效果:把 name, age, skills 直接挂载到 child1 实例自身,实现属性隔离与传参
========================================================================================
│
┌─────────────────────────────┴─────────────────────────────┐
│ │
▼ (来自第2次调用 Person.call) ▼ (子类自定义赋值)
{ name, age, skills } { type }
│ │
└─────────────────────────────┬─────────────────────────────┘
│ 挂载到实例自身
▼
[ child1 实例 ]
│
├── 自身私有属性: {
│ name: 'Alice', // 由【第 2 次调用 Person】生成
│ age: 25, // 由【第 2 次调用 Person】生成
│ skills: [...], // 由【第 2 次调用 Person】生成 (私有独立数组)
│ type: undefined // 由 Child 自身代码逻辑生成
│ }
│
└── __proto__
│
▼
[ Child.prototype ] <─── (实质是【第 1 次调用 new Person()】生成的实例)
│
├── 自身属性: constructor -> 指向 Child (由 Child.prototype.constructor = Child 修复)
├── 冗余遮蔽属性: { name: undefined, age: undefined, skills: [...] } <── (【第 1 次调用】留下的冗余)
│
└── __proto__
│
▼
[ Person.prototype ] <==================================================== 共享方法匹配点
│ │
├── 共享方法: getName() ◄───【动态查找】执行 child1.getName() 锁定的节点 ──────┘
├── 属性: constructor -> 指向 Person 构造函数
│
└── __proto__
│
▼
[ Object.prototype ] ───► __proto__ ───► null (原型链终点)
我们可以发现,虽然组合继承解决了前两种继承方式的缺点,但它也有一个小问题:在创建子类实例时,父类构造函数会被调用两次(一次在 Parent.call(),一次在 new Parent()),会导致子类原型上存在一份多余的父类属性,这很明显会增加性能开销。
寄生组合式继承
组合式继承是js中实现继承最经典的方式,但是它仍然存在问题,即父类构造函数会被调用两次。为了解决这个问题,我们可以使用寄生组合式继承。
了解寄生组合式继承之前,我们需要先了解寄生式继承,要了解寄生式继承,我们可以先了解一下原型式继承,原型式继承是通过创建一个中间函数来实现的,寄生式继承是在原型式继承的基础上进行扩展的。
function createObj(o) {
function F() {}
F.prototype = o;
return new F();
}
// 创建一个新的对象,继承自传入的对象 o,new F()后F.__proto__ --> o
const person = { name: 'Tom', friends: ['Bob', 'Alice'] };
const p1 = createObj(person);
p1.friends.push('John');
console.log(p1.friends); // ['Bob', 'Alice', 'John']
const p2 = createObj(person);
console.log(p2.friends); // ['Bob', 'Alice', 'John'] (引用属性依然共享)
这其实和原型链继承有相似之处,只不过不需要构造函数,直接创建一个中间函数来实现继承,本质也是通过原型链实现的。它也和原型链继承一样存在引用类型属性共享的问题。ES5官方将这个逻辑标准化,变成了原生的 Object.create() 方法。
const person = { name: 'Tom', friends: ['Bob', 'Alice'] };
const p1 = Object.create(person); // 使用Object.create()创建一个新的对象,继承自传入的对象 person
p1.friends.push('John');
console.log(p1.friends); // ['Bob', 'Alice', 'John']
const p2 = Object.create(person);
console.log(p2.friends); // ['Bob', 'Alice', 'John'] (引用属性依然共享)
原型链继承适用于不需要单独定义构造函数,仅需让对象之间共享信息的场景,缺点是引用类型属性会被所有实例共享,无法实现属性隔离。寄生式继承在原型式继承的基础上进行了扩展,解决了引用类型属性共享的问题。
function createAnother(original) {
const clone = Object.create(original); // 浅拷贝
clone.sayHi = function() { // 增强对象
console.log('hi');
};
return clone;
}
// 创建一个新的对象,继承自传入的对象 original,并增强了一个 sayHi 方法
const person = { name: 'Tom', friends: ['Bob', 'Alice'] };
const p1 = createAnother(person);
p1.sayHi(); // hi
p1.friends.push('John');
console.log(p1.friends); // ['Bob', 'Alice', 'John']
寄生式继承的核心思想是拿来一个现成的模板对象,用原型式继承把它克隆一份,然后在克隆出来的对象上‘寄生’一些新的方法或属性,最后把增强后的对象返回,这样可以实现给对象添加新的功能,同时又不会影响原来的对象。但这样依然存在很大问题:
- 实例方法无法复用(性能损耗): 在工厂函数内部添加的方法(如 clone.sayHi = function...),在每一次调用 createAnother() 创建新实例时,都会在内存中重新创建一份新的函数对象。这失去了原型继承“方法共享复用”的初衷。
- 引用类型属性依旧共享: 原型模板(original)上的数组、对象等引用类型数据,在各个实例间依然会相互污染。
为了解决寄生式继承的缺点,寄生组合式继承应运而生。寄生组合式继承结合了组合继承和寄生式继承的优点,避免了父类构造函数被调用两次的问题,同时实现了方法的复用。
function Person(name, age) {
this.name = name;
this.age = age;
this.skills = ['JavaScript', 'HTML', 'CSS'];
}
Person.prototype.getName = function() {
return this.name;
}
function Child(name, age, type) {
Person.call(this, name, age); // 借用构造函数继承属性
this.type = type; // 子类特有属性
}
// 核心:切断父类构造函数的二次执行,只继承原型
Child.prototype = Object.create(Person.prototype);
Child.prototype.constructor = Child; // 修复 constructor 指向
const child1 = new Child('Alice', 25, 'child');
const child2 = new Child('Bob', 30, 'child');
child1.skills.push('React');
console.log(child1.skills); // ['JavaScript', 'HTML', 'CSS', 'React']
console.log(child2.skills); // ['JavaScript', 'HTML', 'CSS']
console.log(child1.getName()); // Alice
console.log(child2.getName()); // Bob
很明显我们可以看出寄生式式组合继承其实只是在组合继承的基础上做了一个优化,使用 Object.create() 来创建子类的原型对象,而不是直接使用 new Person(),这样就避免了父类构造函数被调用两次的问题,同时也实现了方法的复用。为什么可以做到:
- 使用
Child.prototype = new Person()时,new关键字会在底层将一个空对象的__proto__指向Person.prototype,然后强行执行Person构造函数,把this.name、this.age、this.skills等属性挂载到这个空对象上,这导致Child.prototype上多出很多冗余私有属性。 - 使用
Child.prototype = Object.create(Person.prototype)时,Object.create()会创建一个空对象,并将其__proto__指向Person.prototype,但不会执行Person构造函数,因此不会在子类原型上产生冗余的私有属性。且原型链依然保持完整,子类实例仍然可以访问Person.prototype上的方法。 - 完整的原型链就是
Child实例.__proto__ --> Child.prototype、Child.prototype.__proto__ --> Person.prototype、Person.prototype.__proto__ --> Object.prototype.__proto__ --> null。
在ES6中引入class和extends关键字后,语法更加清晰,不过其本质仍是寄生组合式继承的语法糖,但是在其基础上做了一些优化。
class Parent {
constructor(name) {
this.name = name;
}
getName() {
console.log(this.name);
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 调用父类 constructor,必须在访问 this 之前调用
this.age = age;
}
}
const c1 = new Child('Tom', 18);
c1.getName(); // 'Tom'
其中super()的作用就是调用父类的构造函数,初始化父类的属性。extends关键字则是实现了子类原型对象的继承,底层依然是通过Object.create()来实现的。
ES6底层对继承的实现做了优化,主要体现在以下几个方面: ES6 extends 的两处关键底层升级
- 双重原型链(静态方法/属性继承) 在 ES5 的标准寄生组合继承中,只连接了原型对象(实例方法)的链条,构造函数本身是没有继承关系的。而 ES6 的 extends 一口气建立了两条原型链:
- 实例方法原型链:
Child.prototype.__proto__ === Parent.prototype(对应寄生组合继承) - 静态方法原型链:
Child.__proto__ === Parent(ES6 专属,通过 Object.setPrototypeOf(Child, Parent)实现)
这意味着在 ES6 中,父类的静态方法(static 定义的方法)能够直接被子类继承和调用:
// ES6 独有的静态继承:Child.__proto__ === Parent
Child.staticMethod(); // 成功调用 Parent 的静态方法
this实例对象的生成顺序颠倒
| 维度 | ES5 寄生组合继承 | ES6 class ... extends |
|---|---|---|
this 创建者 | 子类先创建 this | 父类先创建 this |
| 构建顺序 | 1. new Child() 先在内存中生成子类空对象 this2. 执行 Child 构造函数代码3. 在子类内部通过 Parent.call(this) 把 this 借给父类挂载属性 | 1. 执行 new Child() 进入子类构造函数2. 必须先调用 super(),由父类构造函数创建基础 this3. 父类创建完 this 后返回给子类,子类再对其进行扩展 |
this 的可使用时机 | 在 Child 构造函数第一行就可以直接访问和使用 this | 在调用 super() 之前绝对不能使用 this(否则直接报 ReferenceError) |
| 内置对象继承限制 (如 Array / Date) | 无法完美继承。 由于是子类先生成普通对象 this 再传给 Parent.call,父类的内部私有槽(如 Array 的 [[ArrayLength]])无法挂载到子类 this 上 | 可以完美继承。 由于是直接调用父类构造函数生成原始的内置 this 对象,子类实例能完全拥有内置对象的所有底层特性 |
六大继承方式演进
终于我们来到了最后,JavaScript 中传统的 6 种继承方式遵循着一套非常清晰的演进逻辑:解决上一代的致命缺点-->产生新的性能/结构问题-->融合优点-->最终达到完美形态。我们可以用一张图来总结这六种继承方式的演进过程:
1. 原型链继承
(Child.prototype = new Parent())
└── ❌ 缺点:引用属性共享污染、无法向父类动态传参
│
├── 引入 解决属性隔离与传参问题 ──► 2. 借用构造函数继承 (Parent.call(this))
│ └── ❌ 缺点:拿不到父类原型上的方法,无法复用
│ │
└───────────────── 融合两者 ──────────────┘
│
▼
3. 组合继承 (Parent.call + Child.prototype = new Parent())
└── ❌ 缺点:父类构造函数被调用 2 次,原型上留有冗余遮蔽属性
│
│ (转向:探索不依赖构造函数的继承)
▼
4. 原型式继承 (Object.create(obj))
└── ❌ 缺点:依然存在引用属性共享污染
│
├── 引入 解决对象增强/扩展问题 ──► 5. 寄生式继承 (工厂函数增强 clone)
│ └── ❌ 缺点:增强的方法无法复用 (每次 new 都重新创建)
│
└─────── 将“寄生”的思想引入到“组合继承”中 ───────┐
│
▼
6. 寄生组合式继承 【终极完美方案】
(Parent.call + Object.create(Parent.prototype))
└── 优点:只调用 1 次父类构造函数,原型纯净零冗余,属性隔离且方法共享
总结
javascript的原型链及其类与类的继承是javascript中非常重要的概念,这也是javascript区别于其他语言的重要特性,其中涉及到的内容相当多,本文只是对原型链及其类与类的继承做了一个简单的梳理,更多的使用方式或者可能遇到的问题还需要在实际开发中不断的去学习和总结。