继承与原型链(一):核心概念与纯原型链的问题

0 阅读5分钟

本文是一篇学习笔记,整合了 MDN官方文档《JavaScript 高级程序设计(第4版)》(红宝书)中关于继承与原型链的内容。我会尝试用自己的话把这块内容啃下来,如果你也正在学习,希望能对你有所帮助。

限于个人写作,文中若有疏漏,还请不吝赐教。

在开始之前,先记住三个最核心的概念:

  • prototype函数才有的属性。它指向一个对象,这个对象将被用作通过该函数创建的实例的"公共祖先"。
  • __proto__(或 Object.getPrototypeOf()):实例对象才有的属性。它指向创建这个实例的构造函数prototype 对象。
  • 原型链:当访问实例的一个属性时,如果实例本身没有,JS 就会顺着 __proto__ 一层层往上找,直到 null

⚠️ MDN 说明:本文示例中为了简洁会使用 __proto__ 字面量,实际开发中推荐使用 Object.getPrototypeOf()Object.setPrototypeOf()

关于用原型链实现"继承",各种方案有一个清晰的递进关系

  1. 纯原型链:只能继承方法,不能继承属性(属性会共享) → 不够用
  2. 盗用构造函数:能继承属性,但不能继承方法 → 也不够用
  3. 组合继承:两者都用,属性不共享、方法能共用 → 但调用了两次父类构造函数(小瑕疵)
  4. 寄生式组合继承:修复了"调用两次"的问题 → 最终完美方案,ES6 class 底层就是这个

思考注:这个递进关系是红宝书第8.3节的主线。如果你感到混乱,可以先抓住这条线,MDN的例子可以作为补充理解,不必强求一次看完。

基于原型链的继承

继承属性

JavaScript 对象是一个动态的"属性包"(称为自有属性)。每个对象都有一条指向原型对象的内部链。当试图访问对象的属性时,不仅在该对象上查找,还会在其原型、原型的原型上继续查找,直到找到匹配的属性或到达原型链的末尾(null)。

来看 MDN 提供的一个基础例子:

const o = {
  a: 1,
  b: 2,
  // __proto__ 设置了 [[Prototype]]。这里指定为另一个对象字面量。
  __proto__: {
    b: 3,
    c: 4,
  },
};

// 原型链:
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> Object.prototype ---> null

console.log(o.a); // 1 (自有属性)
console.log(o.b); // 2 (自有属性遮蔽了原型上的 b)
console.log(o.c); // 4 (原型上的属性)
console.log(o.d); // undefined (找不到)

备注:规范中使用 obj.[[Prototype]] 表示对象的原型。我们可以通过 Object.getPrototypeOf()Object.setPrototypeOf() 来访问和修改它。__proto__ 是众多 JavaScript 引擎实现的非标准访问器,日常开发中推荐使用标准方法。

不要将对象的 [[Prototype]] 与函数的 prototype 属性搞混。函数的 prototype 属性只在该函数作为构造函数时才有意义,它指向通过 new 创建的所有实例的 [[Prototype]]

再看一个原型链更长的例子:

const o = {
  a: 1,
  b: 2,
  __proto__: {
    b: 3,
    c: 4,
    __proto__: {
      d: 5,
    },
  },
};

// 原型链:
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> { d: 5 } ---> Object.prototype ---> null

console.log(o.d); // 5

我的理解:这有点像其他语言(如 Java、C++)中的继承链,子类可以访问父类的属性。但 JavaScript 的原型链更灵活、更动态,可以在运行时修改。

接下来看红宝书中关于"构造函数 + 原型链"的例子,这是在实际开发中更常见的场景:

function SuperType() {
    this.property = true;
}

SuperType.prototype.getSuperValue = function() {
    return this.property;
};

function SubType() {
    this.subproperty = false;
}

// 核心:继承 SuperType
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function() {
    return this.subproperty;
};

let instance = new SubType();
console.log(instance.getSuperValue()); // true

这里的搜索过程是这样的:

  1. 实例 instance 自身没有 getSuperValue
  2. 查找 SubType.prototype(它是 SuperType 的一个实例),也没有
  3. 继续查找 SuperType.prototype,找到了这个方法

因为 SuperType 继承了 Object,所以 instance 也可以调用 toString() 等方法。

方法继承与 this 绑定

在 JavaScript 中,方法本质上就是作为属性值的函数。继承函数时,有一个非常重要的特性:this 始终指向当前调用该方法的对象,而不是定义该方法的原型对象。

const parent = {
  value: 2,
  method() {
    return this.value + 1;
  },
};

console.log(parent.method()); // 3 (this 指向 parent)

const child = {
  __proto__: parent,
};
console.log(child.method()); // 3 (this 指向 child,child.value 来自原型)

child.value = 4; // 在 child 上添加自有属性,遮蔽了原型上的 value
console.log(child.method()); // 5 (this.value 现在是 4)

纯原型链的问题(红宝书核心观点)

原型链虽然是实现继承的强大工具,但它有两个致命问题,导致它基本不会被单独使用

问题一:引用值会被所有实例共享

function SuperType() {
    this.colors = ["red", "blue", "green"];
}

function SubType() {}

SubType.prototype = new SuperType(); // colors 变成了原型上的共享属性

let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // ["red", "blue", "green", "black"]

let instance2 = new SubType();
console.log(instance2.colors); // ["red", "blue", "green", "black"] (被污染了)

问题二:无法在实例化时给父类型的构造函数传参

因为 SubType.prototype = new SuperType()一次性的赋值,我们无法在每次创建 SubType 实例时,动态地向 SuperType 传递不同的参数。

以上是 ES5 的写法,意在说明"纯原型链"的缺陷。后面我们会看到如何通过"组合继承"来解决这些问题。

构造函数

我们先理解一下"为什么需要构造函数"。

假设我们要创建多个盒子,每个盒子都有一个 value 和一个获取该值的 getValue 方法。一种低效的做法是:

const boxes = [
  { value: 1, getValue() { return this.value; } },
  { value: 2, getValue() { return this.value; } },
  { value: 3, getValue() { return this.value; } },
];
// 问题:每个盒子都有一个相同的 getValue 函数,浪费内存

更好的做法是把 getValue 提取到原型上:

const boxPrototype = {
  getValue() {
    return this.value;
  },
};

const boxes = [
  { value: 1, __proto__: boxPrototype },
  { value: 2, __proto__: boxPrototype },
  { value: 3, __proto__: boxPrototype },
];

但手动绑定 __proto__ 还是很麻烦。构造函数的作用就是:自动为 new 创建的对象设置 [[Prototype]]

// 构造函数
function Box(value) {
  this.value = value;
}

// 在 Box.prototype 上定义方法,所有实例共享
Box.prototype.getValue = function () {
  return this.value;
};

const boxes = [new Box(1), new Box(2), new Box(3)];

用 ES6 的 class 语法糖重写:

class Box {
  constructor(value) {
    this.value = value;
  }
  getValue() {
    return this.value;
  }
}

注意class 本质上是构造函数的语法糖。你仍然可以修改 Box.prototype 来影响所有实例。理解原型机制,对于深入理解 class 至关重要。