原型链(Prototype Chain)与 Class:吃透 JS 对象继承的核心

原型链是 JavaScript 面向对象的 “底层逻辑”—— 它让对象能 “继承” 其他对象的属性和方法,而 ES6 的 class 只是这套逻辑的 “语法糖”。今天从原型链的本质讲到 Class 的应用,帮你彻底搞懂 JS 继承到底是怎么回事。

一、原型链:JS 对象继承的 “底层骨架”

1.1 什么是原型链?

简单说,原型链是 JS 实现继承的核心机制:每个对象都有一个隐藏的 “原型”([[Prototype]],可通过 Object.getPrototypeOf() 访问),这个原型本身也是一个对象,它也有自己的原型 —— 这样层层向上,就形成了一条 “原型链”。

当你访问一个对象的属性时,如果当前对象没有这个属性,JS 会自动沿着原型链向上查找,直到找到属性或走到链的尽头(null)。

看个直观的例子:

1
2
3
4
5
6
7
8
9
10
11
// 父对象:动物,有一个共享属性 eats
const animal = { eats: true };

// 子对象:兔子,有自己的属性 jumps
const rabbit = { jumps: true };

// 把 rabbit 的原型设为 animal(让兔子继承动物的属性)
Object.setPrototypeOf(rabbit, animal);

// 现在兔子能访问动物的 eats 属性了
console.log(rabbit.eats); // true(从原型链上找到的)

1.2 原型链的三个核心概念

要理解原型链,必须分清这三个容易混淆的概念:

  1. prototype:只有函数才有这个属性,它是一个对象,存储着 “该函数创建的实例要继承的属性和方法”(比如 Person.prototype 里的方法,会被所有 new Person() 出来的实例继承)。
  2. [[Prototype]]:所有对象(包括函数)都有的隐藏属性,指向该对象的 “原型对象”(可通过 Object.getPrototypeOf(obj) 访问,旧版的 __proto__ 已不推荐使用)。
  3. constructor:原型对象上的属性,指向 “创建该原型对应的实例的构造函数”(比如 Person.prototype.constructor === Person)。

它们的关系可以用一张图表示:

二、原型链是怎么 “造” 出来的?

2.1 用构造函数创建对象:原型链的常见来源

我们平时用 new 构造函数() 创建对象时,原型链会自动生成。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 定义构造函数(用来创建“人”实例)
function Person(name) {
// 实例独有的属性:每个实例的 name 都不同
this.name = name;
}

// 2. 在构造函数的 prototype 上定义共享方法(所有实例共用)
Person.prototype.sayHello = function () {
console.log(`你好,我是${this.name}`);
};

// 3. 用 new 创建实例
const alice = new Person('Alice');

// 验证原型关系
console.log(Object.getPrototypeOf(alice) === Person.prototype); // true(实例的原型是构造函数的 prototype)
console.log(Person.prototype.constructor === Person); // true(原型的构造函数指向原函数)

2.2 new 操作符的底层逻辑

你可能好奇:new 到底做了什么,能让实例和原型链关联起来?其实它只干了四件事:

  1. 创建一个空对象({});
  2. 把这个空对象的原型,设为构造函数的 prototype
  3. 执行构造函数,把 this 指向这个空对象(给对象加属性);
  4. 如果构造函数没有返回其他对象,就返回这个新对象(否则返回构造函数的返回值)。

我们可以手动模拟一个 new 操作符,更直观地看到这个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function myNew(constructor, ...args) {
// 1. 创建空对象,并把它的原型设为构造函数的 prototype
const obj = Object.create(constructor.prototype);

// 2. 执行构造函数,this 指向新对象
const result = constructor.apply(obj, args);

// 3. 返回结果:如果构造函数返回了对象,就用它;否则用新对象
return result instanceof Object ? result : obj;
}

// 用自定义的 myNew 创建实例,和原生 new 效果一样
const bob = myNew(Person, 'Bob');
bob.sayHello(); // 你好,我是Bob

三、原型链的 “查找规则”:属性是怎么找到的?

3.1 属性查找的完整流程

当你访问 obj.prop 时,JS 会按以下步骤查找:

  1. 先检查 obj 自身有没有 prop(通过 obj.hasOwnProperty('prop') 可判断);
  2. 如果没有,就找 obj 的原型(Object.getPrototypeOf(obj)),检查原型有没有 prop
  3. 如果原型也没有,就找原型的原型,以此类推;
  4. 直到找到 prop 并返回,或者走到原型链尽头(null),返回 undefined

看个例子理解这个流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 定义构造函数 Animal
function Animal(name) {
this.name = name; // 实例独有的属性
}

// 2. 在 Animal.prototype 上定义共享方法
Animal.prototype.eat = function () {
console.log(`${this.name} 在吃东西`);
};

// 3. 创建实例 cat
const cat = new Animal('喵星人');

// 4. 调用 cat.eat(),查找流程:
// ① 检查 cat 自身:没有 eat 方法 → ② 查 cat 的原型(Animal.prototype)→ 找到 eat 方法,执行
cat.eat(); // 喵星人 在吃东西

3.2 原型链的终点:null

所有原型链的最终尽头都是 null—— 因为 Object.prototype 的原型就是 null,它是 “所有对象的最终原型”(除了用 Object.create(null) 创建的 “纯净对象”)。

1
2
3
4
5
6
7
const cat = new Animal('喵星人');

// 顺着原型链往上找:
console.log(Object.getPrototypeOf(cat)); // Animal.prototype(第一层)
console.log(Object.getPrototypeOf(Animal.prototype)); // Object.prototype(第二层)
console.log(Object.getPrototypeOf(Object.prototype)); // null(第三层,终点)
console.log(Object.getPrototypeOf(null)); // 报错(null 没有原型)

这也解释了为什么所有对象都能调用 toString()hasOwnProperty() 等方法 —— 这些方法其实定义在 Object.prototype 上,所有对象都能通过原型链找到它们。

四、原型链的 “动态特性”:改原型会影响实例吗?

4.1 动态修改原型:已创建的实例也会受影响

JS 的原型是 “活的”—— 即使实例已经创建,后续给原型添加的属性 / 方法,实例也能访问到:

1
2
3
4
5
6
7
8
9
10
11
function Dog() {}
// 创建实例 dog1(此时 Dog.prototype 上还没有 bark 方法)
const dog1 = new Dog();

// 后续给 Dog.prototype 添加 bark 方法
Dog.prototype.bark = function () {
console.log('汪汪!');
};

// dog1 能调用到新添加的 bark 方法
dog1.bark(); // 汪汪!

这是因为实例访问的是 “原型的引用”,而不是 “原型的副本”—— 原型变了,所有指向它的实例都会跟着变。

4.2 重写原型:旧实例不受影响

但如果是 “完全重写原型”(而不是修改原型的属性),情况就不一样了:重写后的原型是一个新对象,只有重写后创建的实例会用新原型,之前的旧实例还是指向原来的原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Cat() {}
// 旧实例:创建于原型重写前
const cat1 = new Cat();

// 完全重写 Cat.prototype(新对象)
Cat.prototype = {
meow() {
console.log('喵喵~');
},
};

// 新实例:创建于原型重写后
const cat2 = new Cat();

cat1.meow(); // 报错(cat1 的原型还是原来的空对象,没有 meow 方法)
cat2.meow(); // 喵喵~(cat2 的原型是新对象,有 meow 方法)

五、ES6 Class:原型链的 “语法糖”

5.1 Class 本质:还是原型继承

ES6 引入的 class 语法,看起来像其他语言的 “类”,但底层还是基于原型链实现的 —— 它只是把原型继承的写法变得更简洁、更易读,没有改变 JS 的底层逻辑。

比如下面两段代码,功能完全一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 写法1:ES6 Class
class Animal {
// 构造函数:对应原来的构造函数
constructor(name) {
this.name = name;
}

// 实例方法:会被添加到 Animal.prototype 上
eat() {
console.log(`${this.name} 在吃东西`);
}
}

// 写法2:ES5 原型继承(和上面完全等价)
function Animal(name) {
this.name = name;
}

Animal.prototype.eat = function () {
console.log(`${this.name} 在吃东西`);
};

5.2 Class 继承:extends 怎么工作?

Class 的 extends 关键字,本质是帮我们自动搭建了原型链。比如让 Rabbit 继承 Animal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 子类 Rabbit 继承父类 Animal
class Rabbit extends Animal {
constructor(name, speed) {
// super():调用父类的 constructor,相当于 Animal.call(this, name)
super(name);
// 子类独有的属性
this.speed = speed;
}

// 子类独有的方法(添加到 Rabbit.prototype 上)
jump() {
console.log(`${this.name} 跳得很快,速度${this.speed}`);
}
}

// 创建子类实例
const bunny = new Rabbit('小兔', 10);

// 验证继承关系
console.log(bunny instanceof Rabbit); // true(是 Rabbit 的实例)
console.log(bunny instanceof Animal); // true(也是 Animal 的实例,因为继承)
console.log(bunny instanceof Object); // true(最终继承自 Object)

// 调用继承的方法和自己的方法
bunny.eat(); // 小兔 在吃东西(继承自 Animal)
bunny.jump(); // 小兔 跳得很快,速度10(自己的方法)

extends 做的核心事情:把 Rabbit.prototype 的原型,设为 Animal.prototype,从而搭建起 “bunnyRabbit.prototypeAnimal.prototypeObject.prototypenull” 的原型链。

六、原型链的实际用途:这些场景会用到

6.1 共享方法:节省内存

如果多个实例需要用同一个方法,把方法放在原型上(而不是每个实例都定义一次),能大幅节省内存 —— 因为所有实例共享同一个方法引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function User(name) {
this.name = name; // 每个实例独有的属性
}

// 所有 User 实例共享 sayHi 方法
User.prototype.sayHi = function () {
console.log(`你好,${this.name}`);
};

const user1 = new User('Alice');
const user2 = new User('Bob');

// 验证:两个实例的 sayHi 是同一个函数
console.log(user1.sayHi === user2.sayHi); // true

如果把 sayHi 写在构造函数里(this.sayHi = function() {}),每个实例都会有一个独立的函数副本,内存占用会翻倍。

6.2 实现自定义继承(ES5 写法)

在 Class 出现前,我们用原型链手动实现继承。比如让 Circle 继承 Shape

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 父类:图形
function Shape(color) {
this.color = color;
}

// 父类的共享方法
Shape.prototype.getColor = function () {
return this.color;
};

// 子类:圆形
function Circle(radius, color) {
// 1. 调用父类构造函数,继承父类的属性(color)
Shape.call(this, color);
// 2. 子类独有的属性
this.radius = radius;
}

// 3. 搭建原型链:让 Circle.prototype 继承 Shape.prototype
Circle.prototype = Object.create(Shape.prototype);
// 4. 修复 constructor 指向(因为上面一步把 Circle.prototype 换成了新对象,constructor 会指向 Shape)
Circle.prototype.constructor = Circle;

// 5. 子类的共享方法
Circle.prototype.getArea = function () {
return Math.PI * this.radius ** 2;
};

// 使用子类
const redCircle = new Circle(5, 'red');
console.log(redCircle.getColor()); // red(继承自 Shape)
console.log(redCircle.getArea()); // 78.539...(自己的方法)

6.3 扩展内置对象(谨慎使用)

我们可以给内置对象的原型添加方法,让所有该类型的对象都能使用。比如给数组加一个 sum 方法:

1
2
3
4
5
6
7
// 给 Array.prototype 加 sum 方法,所有数组都能调用
Array.prototype.sum = function () {
return this.reduce((total, num) => total + num, 0);
};

const numbers = [1, 2, 3, 4];
console.log(numbers.sum()); // 10

⚠️ 注意:扩展内置原型有风险!可能和其他库的方法重名(比如别人也给数组加了 sum 方法),导致代码冲突。非必要不推荐用。

注意: 扩展内置原型有风险!可能和其他库的方法重名(比如别人也给数组加了 `sum` 方法),导致代码冲突。非必要不推荐用。

七、原型链的坑:这些问题要注意

7.1 原型污染:修改内置原型影响所有对象

如果恶意代码(或不小心)修改了 Object.prototype,所有对象都会受到影响 —— 这就是 “原型污染”。比如:

1
2
3
4
5
6
7
8
// 不小心给 Object.prototype 加了一个 hack 方法
Object.prototype.hack = function () {
console.log('所有对象都会有这个方法!');
};

// 即使是新建的空对象,也会有 hack 方法
const emptyObj = {};
emptyObj.hack(); // 所有对象都会有这个方法!

解决方案:用 Object.create(null) 创建 “纯净对象”—— 这种对象没有原型([[Prototype]]null),不会继承 Object.prototype 的属性,也就不会被污染:

1
2
const pureObj = Object.create(null);
console.log(pureObj.hack); // undefined(不受原型污染影响)

7.2 实例属性 “遮蔽” 原型属性

如果实例有一个和原型同名的属性,实例属性会 “覆盖” 原型属性(这叫 “属性遮蔽”):

1
2
3
4
5
6
7
8
9
10
11
12
function Person() {}
// 原型上的 name 属性
Person.prototype.name = '原型默认名';

// 实例上的 name 属性(和原型同名)
const person = new Person();
person.name = '实例自定义名';

// 访问时会优先用实例的属性
console.log(person.name); // 实例自定义名
// 要访问原型的属性,需要手动找原型
console.log(Object.getPrototypeOf(person).name); // 原型默认名

7.3 循环引用:导致栈溢出

如果两个构造函数的原型互相指向对方,会形成 “循环原型链”,创建实例时会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function A() {}
function B() {}

// A 的原型指向 B 的实例
A.prototype = new B();
// B 的原型指向 A 的实例 → 循环引用!
B.prototype = new A();

// 尝试创建实例:会触发无限递归,栈溢出
try {
const a = new A();
} catch (e) {
console.error(e.message); // Maximum call stack size exceeded
}

八、原型链最佳实践:写代码更安全

  1. 优先用 Class 语法classextends 比 ES5 手动改原型更清晰,不容易出错,比如:

    1
    2
    class Animal {}
    class Rabbit extends Animal {}
  2. 不用 __proto__,用标准方法操作原型

    • 查原型:Object.getPrototypeOf(obj)(替代 obj.__proto__
    • 改原型:Object.setPrototypeOf(obj, newProto)(替代 obj.__proto__ = newProto
    • 创建带原型的对象:Object.create(proto)(比 new 更灵活)
  3. 属性和方法分开放:构造函数里定义 “实例独有的属性”,原型上定义 “所有实例共享的方法”—— 符合内存高效利用的原则:

    1
    2
    3
    4
    function Person(name) {
    this.name = name; // 实例独有属性
    }
    Person.prototype.sayName = function () {}; // 共享方法
  4. 避免修改内置原型:除非有绝对必要,否则不要给 Array.prototypeObject.prototype 等加方法,防止冲突。

最后小测验

看看你有没有掌握原型链的核心逻辑,下面代码会输出什么?

1
2
3
4
5
6
function Foo() {}
const f1 = new Foo();

console.log(f1 instanceof Object);
console.log(Foo.prototype instanceof Object);
console.log(Object.getPrototypeOf(f1) === Foo.prototype);

答案truetruetrue

  • f1Foo 的实例,Foo.prototype 继承自 Object.prototype,所以 f1 instanceof Objecttrue
  • Foo.prototype 是一个普通对象,继承自 Object.prototype,所以 Foo.prototype instanceof Objecttrue
  • new Foo() 创建的实例,原型就是 Foo.prototype,所以第三个判断为 true

原型链虽然是 JS 的 “底层概念”,但理解它能帮你避开很多隐藏的坑(比如原型污染、属性遮蔽),也能让你更懂 Class 的本质 —— 不是 “新的继承方式”,只是原型链的优雅包装。掌握这些,你对 JS 面向对象的理解会更上一层楼~