JavaScript 原型/原型链

热身

先来看一道题:

请你编写一个函数,检查给定的值是否是给定类或超类的实例。

可以传递给函数的数据类型没有限制。例如,值或类可能是 undefined 。

Leetcode:2618 检查是否类的对象实例

你可能会很轻松的用 instanceof 写出来:obj instanceof class 这种形式的判断。

class 是 es6 引入的新的一个关键字,他可以让我们在 JavaScript 中更加容易使用面向对象的思维去编写代码,让对象,原型这些东西更加清晰。

同样的,引入 instanceof 则是方便我们去理解,不必写一套复杂的代码来判断实例和类的关系。

那么,class 这个关键字帮我们做了什么?而 instanceof 又帮我们简化了什么?

原型

首先依旧是来讲那些前人已经说烂了的东西,原型。

原型设计模式

原型首先是面向对象编程中的一个概念,是一种设计模式,他的核心思想是,​通过共享属性和方法来实现对象之间的代码重用。说实话,这个听着令人感觉像是在讲类继承,目前的编程语言多是通过类继承的方式支持面向对象的,而我们所使用的 JavaScript 则是通过原型链来支持的面向对象的[1],且按下不表。

我们不妨先给原型下一个定义:原型就是一个用于创建对象的模板,他定义了一系列的属性和方法,基于原型创建的对象之间共享一些属性和方法。

根据定义,我们写出的 JavaScript 的实现代码应该是这样的:

typescript 复制代码
const boxPrototype = {
  value: 1,
  getValue() {
    return this.value;
  }
}

const box1 = {};
const box2 = {};
const box3 = {};
Object.assign(box1, boxPrototype);
Object.assign(box2, boxPrototype);
box2.value = 2;
Object.assign(box3, boxPrototype);
box3.value = 3;

box1.getValue === boxPrototype.getValue; // true
box2.getValue === boxPrototype.getValue; // true
box3.getValue === boxPrototype.getValue; // true
  1. 我们定义了一个原型 boxPrototype
  2. 基于原型 boxPrototype 创建(即拷贝、复制)了三个对象
  3. 三个对象之间各自有自己的 value 值,但是引用都是的 boxPrototypegetValue 函数,即共享了方法。

原型设计模式最主要的优点是减少了对象创建的时间和成本,通过拷贝原型对象来创建新的对象,避免重复使用构造函数初始化的操作,提高了创建对象的效率。

在不同的编程语言中,原型设计模式的实现均有差异(深拷贝、浅拷贝以及语言特性),在 JavaScript 自然也是可以有不一样的实现。

JavaScript 中的原型

先给出答案:​原型实际就是一个对象

我们在上文的原型设计模式中接触到了原型对象 和​克隆对象,并给出了 JavaScript 中的一种实现,但是我们不难发现,在这样的实现下,原型对象和克隆对象是比较独立的两个对象(除了函数能够共享,其他属性互相之间是无法访问到的)。

想要被访问到也不是不可以,只要我们保存原型对象的链接即可,同时为了避免混乱,我们为其制定了标准:

遵循 ECMAScript 标准,符号 someObject.[[Prototype]]​ 用于标识 someObject​ 的原型。内部插槽 [[Prototype]]​ 可以通过 Object.getPrototypeOf()​ 和 Object.setPrototypeOf()​ 函数来访问。这个等同于 JavaScript 的非标准但被许多 JavaScript 引擎实现的属性 __proto__​ 访问器。为在保持简洁的同时避免混淆,在我们的符号中会避免使用 obj.__proto__​,而是使用 obj.[[Prototype]]​ 作为代替。其对应于 Object.getPrototypeOf(obj) [1]。

规范很好理解,规范中声明的 [[Prototype]]​ 可以认为是一个对象的私有属性,想要对其操作,那么就需要实现对应的 get/set 方法。__proto__ 则是以前没有一套行业执行规范时,各个厂家自己实现的一套逻辑。

值得注意的是,JS 函数中的 prototype​ 属性和 [[Prototype]] 还有一定的差别,不可以混淆。

原型链

在使用原型创建对象的时候,每个对象都有一个指向原型的链接,这个一条链就被称为原型链。

有了这条链接,我们便可以使通过原型创建出来的对象可以访问到原型上的方法和属性,也就是说,当我们访问一个对象的属性时,如果这个对象本身不存在该属性,那么就会去原型上查找,其访问顺序应当是:当前对象 -> 原型 -> 原型的原型 -> ...... -> 最初的原型。null 标志着原型链的终结,当某个对象的原型指向 null 时,便不再继续向上搜索。

继承

如果你已经有了一定的编程语言的基础,那么你可能首先会联想到的是类的继承,A extends B​ 那么 A 便可以访问 A 对象中的所有属性和方法,以及从 B 中继承下来的 public 和 protected 属性和方法,而 B extends C则可以类推。

可以通过这个去理解原型链,但是要知道他们是不同的。

硬要说的话,可以说他是一个 继承链 ,同样也是做到了方法属性的复用,但不一样的是,他们无法"共享"。

举个例子:

typescript 复制代码
// 继承
class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  changeName(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
}

class Cat extends Animal {
  changeName(name: string) {
    this.name = 'cat' + name;
  }
}

const animal = new Animal("1");
console.log(animal.name); // 1
animal.changeName("111");
console.log(animal.name); // 111
const dog = new Dog("2");
dog.changeName("222");
console.log(dog.name, animal.name); // 222 111
const cat = new Cat("3");
console.log(cat.name); // 3
cat.changeName("333");
console.log(cat.name, dog.name, animal.name); // cat333 222 111

上面这段代码是比较典型的继承,Dog 和 Cat 类都继承自 Animal 类,自然的,这里 Animal 类中定义的属性 name​ 和方法 changeName​ 都被继承下来了,但不同的是,Cat 类里面我们覆写了 changeName​ 方法,所以我们在实例化这三个类后,分别调用 changeName​ 方法,并打印方法调用前后 name 属性变化的情况,不难发现,每个对象仅改变了自己的属性,并没有对其他对象造成影响(也就是说他们互相之间无法访问属性),而且即使 Dog 类中没有写任何内容,但是他继承了父类的所有内容,所以他依旧可以访问到对应的属性和方法。

原型链

原型是"共享"属性和方法,以做到代码复用:

typescript 复制代码
const anObj = {};
const anObjPrototype = { df: 1 };

Object.setPrototypeOf(anObj, anObjPrototype);

console.log(anObj.df); // 1,访问到了 anObjPrototype 的 df 属性值
anObj.df = 2;
console.log(anObj.df, anObjPrototype.df); // 2, 1,修改了 anObj 的 df 属性值,但对于其原型 anObjPrototype 没有影响,等于在 anObj 对象中创建了一个 df 属性,并赋值为 1

Object.getPrototypeOf(anObj) === anObjPrototype; // true,说明 anObj 的原型保存的是 anObjPrototype 的对象地址

这段代码非常简单,我们让 anObj​ 对象保存了原型对象 anObjPrototype​ 的地址,按照我们之前对原型链的定义,由于 anObj​ 中没有属性 df ,所以我们去他的原型上搜索,获取到值。

而我们在后面给 anObj​ 的 df 属性赋值为 2,由于其本身是没有这个属性的,所以我们这里的操作实际可以看做两步:

  1. anObj 对象中创建属性 df
  2. 为该属性赋值

而后再访问属性 df​ 时,由于已经在 anObj​ 对象中找到了对应的属性,所以就不再继续向上搜索了,即使其原型对象上存在一个相同的属性,这个就是所谓的​属性遮蔽

如果在 anObjPrototype 中也没找到的话,那就返回 undefined。

对比上面两种形式,不难看出我们之前所说的继承实际上各个实例对象之间是没有关联的,而在原型链上,对象及其原型对象是通过一个链接(或者说一个指针指向的关系)关联上的,对象可以访问到其原型上的一些属性。

一些特别的情况

查漏补缺。

我们通过字面量创建的对象,会隐式的设置其 prototype:

typescript 复制代码
// 对象字面量(没有 `__proto__` 键)自动将 `Object.prototype` 作为它们的 `[[Prototype]]`
const object = { a: 1 };
Object.getPrototypeOf(object) === Object.prototype; // true

// 数组字面量自动将 `Array.prototype` 作为它们的 `[[Prototype]]`
const array = [1, 2, 3];
Object.getPrototypeOf(array) === Array.prototype; // true

// 正则表达式字面量自动将 `RegExp.prototype` 作为它们的 `[[Prototype]]`
const regexp = /abc/;
Object.getPrototypeOf(regexp) === RegExp.prototype; // true

本段代码来源于 MDN。

func.prototype 指向的是构造函数,通过 new func() 方式创建的对象,会自动的将 func.prototype 作为自己的原型。

typescript 复制代码
function Box(name: string) {
  this.name = name;
}

Box.prototype; // {constructor: f}

const box = new Box('hen');
box.prototype; // 

定义 ​:​构造函数是使用 new 调用的函数,如 new Box();

基于原型链的继承

图穷匕见,JavaScript 的继承模式是 原型继承 ,尽管现在语言已经支持了 class 关键字,即类的说法,但实际并没有改变他的继承模式。

那么什么是原型继承呢?

我们在原型链一节中讲到,JavaScript 通过原型链来实现代码的复用,同时阅读对应的示例代码可以发现,我们通过构造了一个原型链,使得对象能够访问到其原型的属性,这就是​继承了属性

继承"方法" ​,本质和继承属性一样,这时候的 属性遮蔽 我们可以则类比为 "​方法重写 ​"。但是这里有一个 JavaScript 的特别之处,也是一个难点,即 this ,当继承的函数被调用的时候,this 会指向当前对象,而不是拥有该函数的原型对象。

typescript 复制代码
const anObjPrototype = {
  a: 1,
  getValue() {
    return this.a;
  }
}

const anObj = {};
Object.setPrototypeOf(anObj, anObjPrototype);

// 修改 anObj 中属性 a 的值
anObj.a = 2;
anObj.getValue(); // 2,得到的是 anObj 自己的属性值。

运行上述代码,你会发现虽然 getValue​ 是属于原型对象 anObjPrototype​ 的,但是最终 anObj​ 调用该方法的时候得到的是对象 anObj 的属性值,而非原型对象的值。

最后

回到我们开篇的问题,class 关键字帮我们做了什么?instanceof 关键字又帮我们简化了什么?

这两个问题的答案已经十分清晰了,但也不妨在这里做个总结:

class 本质是一个语法糖,他帮我们处理绑定每个对象的原型,实现属性的共享。

typescript 复制代码
// 无 class 的写法
function Box(name: string) {
  this.name = name;
}

Box.prototype.getName = function () {
  return this.name;
}

const box1 = new Box(1);
const box2 = new Box(2);
box1.getValue === box2.getValue; // true

// 对应的 class 写法
class Box {
  constructor(name: string) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

显而易见的,class 的写法更符合我们面向对象的编程习惯,function 的写法则相对不那么直观。

instanceof 则是帮我们检查实例对象是否是某个类的对象,实际上,我们在 JavaScript 中的描述应该是,用于检测构造函数的 prototype 是否出现在了实例对象的原型链上。

typescript 复制代码
function Box() {
}
const box = new Box(); // 前文定义了使用 new 调用的函数就是构造函数

box instanceof Box; // true
Object.getPrototypeOf(box) === Box.prototype; // true

由此我们可以推导出,如果想在 JavaScript 中实现继承,我们可以构造一个很长的原型链:

typescript 复制代码
function Animal(name: string) {
  this.name = name;
}

function Cat() {}
Object.setPrototypeOf(Cat.prototype, Animal.prototype); // Cat 继承自 Animal
function Dog() {}
Object.setPrototypeOf(Dog.prototype, Animal.prototype); // Dog 继承自 Animal

代码中我们在修改了原型之后,Cat 和 Dog 就变成了 Animal 的一个子类,Animal 则作为基类存在,在 function 这种写法下,他并不是那么直观,但是,我们将其转换成 class 的写法后,一切会更加清晰易读。

不妨躬身一试。

一些思考

说 js 中类的性能比较差,但这并不是说我们一定要用以前这种比较拗口的形式去面向对象编程,这是一种取舍,让我们在代码的可读性和可维护性和性能之间权衡,我们未必需要保证我们编写的代码去追求极致的性能,但是我们需要保证我们的代码符合一定的规范,让别人维护时不至于会高兴的蹦起来去指责前人的代码。

时代总是在进步的,编译器、引擎也是在更新换代,在努力解决这些问题,有时候极致的性能就意味着厚重的技术债务,后人对你当前引以为豪的代码无从下手。

当然,以上是从业务角度出发的思考,如果你是写高性能库的,看看就好。

参考文章

1\]: [继承与原型链 - JavaScript \| MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype_chain) \[2\]: [对象原型 - 学习 Web 开发 \| MDN](https://developer.mozilla.org/zh-CN/docs/Learn/JavaScript/Objects/Object_prototypes) \[3\]: [基于原型编程 - MDN Web 文档术语表:Web 相关术语的定义 \| MDN](https://developer.mozilla.org/zh-CN/docs/Glossary/Prototype-based_programming)