第2集丨JavaScript 中原型链(prototype chain)与继承

对于使用过基于类的语言(如 JavaC++)的开发者来说,JavaScript 实在是有些令人困惑——JavaScript 是动态的且没有静态类型。一切都是对象(实例)或函数(构造函数),甚至函数本身也是 Function 构造函数的实例。即使是语法结构中的“类”也只是运行时的构造函数。

当谈到继承时,JavaScript 只有一种结构:对象。每个对象(object)都有一个私有属性指向另一个名为原型(prototype)的对象。原型对象也有一个自己的原型,层层向上直到一个对象的原型为 null。根据定义,null 没有原型,并作为这个原型链(prototype chain)中的最后一个环节。可以改变原型链中的任何成员,甚至可以在运行时换出原型,因此 JavaScript 中不存在静态分派的概念。

尽管这种混杂通常被认为是 JavaScript 的弱点之一,但是原型继承模型本身实际上比类式模型更强大。例如,在原型模型的基础上构建类式模型(即类的实现方式)相当简单。

尽管类现在被广泛采用并成为 JavaScript 中新的范式,但类并没有带来新的继承模式。虽然类为大部分原型的机制提供了抽象,但了解原型在底层是如何工作的仍然十分有用。

一、一些基础概念

1.1 ECMAScript 标准

遵循 ECMAScript 标准,符号 someObject.[[Prototype]] 用于标识 someObject 的原型。

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

它不应与函数的 func.prototype 属性混淆,后者指定在给定函数被用作构造函数时分配给所有对象实例的 [[Prototype]]

有几种可以指定对象的 [[Prototype]] 的方法。值得注意的是,{ __proto__: ... } 语法与 obj.__proto__ 访问器不同:前者是标准且未被弃用的

1.2 prototype和 proto

每个函数都具有prototype属性,一般来说构造函数的prototype属性才具有实际意义;而实例(对象)是不具有prototype属性的,但是其有一个内部的__proto__属性 。

  • Object是构造器;我们定义的函数也是构造器,所以他们具有prototype 属性

  • JavaScript 中的所有构造函数都有一个被称为 prototype 的特殊属性,它与 new 运算符一起使用。对原型对象的引用被复制到新实例的内部属性[[Prototype]]中。

  • 箭头函数没有默认的原型属性

function doSomething() {}
console.log(doSomething.prototype);
// 你如何声明函数并不重要;
// JavaScript 中的函数总有一个默认的原型属性——有一个例外:
// 箭头函数没有默认的原型属性:
const doSomethingFromArrowFunction = () => {};
// undefined
console.log(doSomethingFromArrowFunction.prototype); 

如上所示,doSomething() 有一个默认的 prototype 属性(正如控制台所示)。运行这段代码后,控制台应该显示一个类似于下面的对象。

{
  constructor: ƒ doSomething(),
  [[Prototype]]: {
    constructor: ƒ Object(),
    hasOwnProperty: ƒ hasOwnProperty(),
    isPrototypeOf: ƒ isPrototypeOf(),
    propertyIsEnumerable: ƒ propertyIsEnumerable(),
    toLocaleString: ƒ toLocaleString(),
    toString: ƒ toString(),
    valueOf: ƒ valueOf()
  }
}

1.3 constructor属性

  • 实例具有constructor 属性,而且其值和类的prototypeconstructor 属性是等价。即:
    Object.prototype.constructor === new Object().constructor (也就是说:实例的构造器和)

  • 构造函数也有constructor属性(因为函数本身也是一个对象),并且其值为Funciton,但是其值和(构造函数).prototype.constructor 是
    不等价的,即:(构造函数).prototype.constructor !== (构造函数).constructor //这两者是不等价的

function Person(name,age){
    this.name = name;
    this.age = age;
    this.sayName = function(){
        return this.name;
    }
}
var p1 = new Person("张三",20);
console.log(p1.constructor);    //Person(name, age)
console.log(p1.constructor === Person.prototype.constructor);   //true
console.log(Person.constructor === Person.prototype.constructor)    //false
console.log(Person.constructor === Function)    //true 即:ƒ Function() { [native code] }

1.4 函数名

函数名:就是指函数的本身的引用

  • (函数名).prototype.constructor == (函数名)

二、原型链的维护

2.1 内部原型链和构造器原型链

  • 内部原型链 和 构造器原型链:
    下图说明了构造器通过了显示的prototype属性构建了一个原型链,而对象实例也通过内部的__proto__构建了一个内部原型链。
    在这里插入图片描述

2.2 从实例回溯原型链

注意不要混淆:obj.constructor.prototype == 构造器.prototype构造器.prototype.constructor
通过 obj.constructor.prototype.__proto__.__proto__. . . === Object.prototype 为止

请注意:左边是prototype 属性,右边是__proto__ 属性,他们都指向原型对象,并且
原型对象是一个实例,所以原型对象本身也有原型即有__proto__ 属性。这样我们可以
一层一层的找到他们的父类,直到顶级类的Object.prototype 为止。

在这里插入图片描述

2.3 修正原型指向

MyObjectEx.prototype = new MyObject();
//MyObjectEx.prototype.constructor =  MyObjectEx;	//加上这句话,修正构造器指向自身

但是这有个问题:由于丢弃掉了原型的constructor属性,因此事实上也就切断了与原型父类的关系,如下图,这个时候,通过constructor属性就不知道其父类是谁了。但是为什么他依然可以继承父类,是因为还有内部的__proto__,他依然记得。

因此,可靠的原型的回溯必须要通过__proto__属性,因为根据prototypeconstructor来是不可靠的,我们必须要维护正确的原型链才行,而实际的过程中,我们随时可以修改prototype.constructor的值。

简单的来说,内部原型链是JavaScript的原型继承机制所需。而通过prototypeconstructor所维护的构造器原型链,则是用户代码需要回溯时才需要的。如果用户无需回溯,那么不维护这个“原型链”,也没有关系。

在这里插入图片描述

三、基于原型链的继承

3.1 继承属性

JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。

例如,当你执行 const a1 = new A() 时,JavaScript(在内存中创建对象之后,为其定义 this 并执行 A() 之前)设置 a1.[[Prototype]] = A.prototype。然后,当你访问实例的属性时,JavaScript 首先检查它们是否直接存在于该对象上,如果不存在,则在 [[Prototype]] 中查找。会递归查询 [[Prototype]],即 a1.doSomethingObject.getPrototypeOf(a1).doSomethingObject.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething,以此类推,直至找到或 Object.getPrototypeOf 返回 null。这意味着在 prototype 上定义的所有属性实际上都由所有实例共享,并且甚至可以更改 prototype 的部分内容,使得更改被应用到所有现有的实例中。

下面以具体代码来进行说明

  • 注意:自有属性属性遮蔽 这两个概念
const o = {
  a: 1,
  b: 2,
  // __proto__ 设置了 [[Prototype]]。它在这里被指定为另一个对象字面量。
  __proto__: {
    b: 3,
    c: 4,
  },
};

// o.[[Prototype]] 具有属性 b 和 c。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype(我们会在下文解释其含义)。
// 最后,o.[[Prototype]].[[Prototype]].[[Prototype]] 是 null。
// 这是原型链的末尾,值为 null,
// 根据定义,其没有 [[Prototype]]。
// 因此,完整的原型链看起来像这样:
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> Object.prototype ---> null

console.log(o.a); // 1
// o 上有自有属性“a”吗?有,且其值为 1。

console.log(o.b); // 2
// o 上有自有属性“b”吗?有,且其值为 2。
// 原型也有“b”属性,但其没有被访问。
// 这被称为属性遮蔽(Property Shadowing)

console.log(o.c); // 4
// o 上有自有属性“c”吗?没有,检查其原型。
// o.[[Prototype]] 上有自有属性“c”吗?有,其值为 4。

console.log(o.d); // undefined
// o 上有自有属性“d”吗?没有,检查其原型。
// o.[[Prototype]] 上有自有属性“d”吗?没有,检查其原型。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype 且
// 其默认没有“d”属性,检查其原型。
// o.[[Prototype]].[[Prototype]].[[Prototype]] 为 null,停止搜索,
// 未找到该属性,返回 undefined。

3.2 继承“方法”

JavaScript 并没有其他基于类的语言所定义的“方法”。在 JavaScript 中,任何函数都被可以添加到对象上作为其属性。函数的继承与其他属性的继承没有差别,包括上面的“属性遮蔽”(这种情况相当于其他语言的方法重写)。

当继承的函数被调用时,this 值指向的是当前继承的对象,而不是拥有该函数属性的原型对象。

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

console.log(parent.method()); // 3
// 当调用 parent.method 时,“this”指向了 parent

// child 是一个继承了 parent 的对象
const child = {
  __proto__: parent,
};
console.log(child.method()); // 3
// 调用 child.method 时,“this”指向了 child。
// 又因为 child 继承的是 parent 的方法,
// 首先在 child 上寻找“value”属性。但由于 child 本身
// 没有名为“value”的自有属性,该属性会在
// [[Prototype]] 上被找到,即 parent.value。

child.value = 4; // 在 child,将“value”属性赋值为 4。
// 这会遮蔽 parent 上的“value”属性。
// child 对象现在看起来是这样的:
// { value: 4, __proto__: { value: 2, method: [Function] } }
console.log(child.method()); // 5
// 因为 child 现在拥有“value”属性,“this.value”现在表示
// child.value

四、构造函数

原型的强大之处在于,如果一组属性应该出现在每一个实例上,那我们就可以重用它们——尤其是对于方法。

4.1 案例

:假设我们要创建多个盒子,其中每一个盒子都是一个对象,包含一个可以通过 getValue 函数访问的值。

一个简单的实现

const boxes = [
  { value: 1, getValue() { return this.value; } },
  { value: 2, getValue() { return this.value; } },
  { value: 3, getValue() { return this.value; } },
];

这是不够好的,因为每一个实例都有自己的,做相同事情的函数属性,这是冗余且不必要的

手动创建__proto__

相反,我们可以将 getValue 移动到所有盒子的 [[Prototype]] 上:

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

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

这样,所有盒子的 getValue 方法都会引用相同的函数,降低了内存使用率。但是,手动绑定每个对象创建的 proto 仍旧非常不方便

构造函数方式

这时,我们就可以使用构造函数,它会自动为每个构造的对象设置 [[Prototype]]。构造函数是使用 new 调用的函数。

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

// 使用 Box() 构造函数创建的所有盒子都将具有的属性
Box.prototype.getValue = function () {
  return this.value;
};

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

  • 我们说 new Box(1) 是通过 Box 构造函数创建的一个实例。Box.prototype 与我们之前创建的 boxPrototype 并无太大区别——它只是一个普通的对象。

  • 通过构造函数创建的每一个实例都会自动将构造函数的 prototype 属性作为其 [[Prototype]]。即,Object.getPrototypeOf(new Box()) === Box.prototype

  • Constructor.prototype 默认具有一个自有属性:constructor,它引用了构造函数本身。即,Box.prototype.constructor === Box。这允许我们在任何实例中访问原始构造函数

4.2 类语法

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

  // 在 Box.prototype 上创建方法
  getValue() {
    return this.value;
  }
}

4.3 修改prototype

因为 Box.prototype 引用了(作为所有实例的 [[Prototype]] 的)相同的对象,所以我们可以通过改变 Box.prototype 来改变所有实例的行为。

function Box(value) {
    this.value = value;
}
Box.prototype.getValue = function () {
return this.value;
};
const box = new Box(1);
console.log(box.getValue()); // 1
// 在创建实例后修改 Box.prototype
Box.prototype.getValue = function () {
return this.value + 1;
};
console.log(box.getValue()); // 2

推论:重新赋值 Constructor.prototype(Constructor.prototype = ...)是一个不好的主意,原因有两点:

  • 在重新赋值之前创建的实例的 [[Prototype]] 现在引用的是与重新赋值之后创建的实例的 [[Prototype]] 不同的对象——改变一个的 [[Prototype]] 不再改变另一个的 [[Prototype]]
  • 除非你手动重新设置 constructor 属性,否则无法再通过 instance.constructor 追踪到构造函数,这可能会破坏用户期望的行为。一些内置操作也会读取 constructor 属性,如果没有设置,它们可能无法按预期工作。

Constructor.prototype 仅在构造实例时有用。它与 Constructor.[[Prototype]] 无关,后者是构造函数的自有原型,即 Function.prototype。也就是说,Object.getPrototypeOf(Constructor) === Function.prototype

4.4 字面量的隐式构造函数

JavaScript 中的一些字面量语法会创建隐式设置 [[Prototype]] 的实例。例如:

// 对象字面量(没有 `__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

五. 构建更长的继承链

Constructor.prototype 属性将成为构造函数实例的 [[Prototype]]

默认情况下,Constructor.prototype 是一个普通对象——即 Object.getPrototypeOf(Constructor.prototype) === Object.prototype

唯一的例外是 Object.prototype 本身,其 [[Prototype]] null——即 Object.getPrototypeOf(Object.prototype) === null

5.1 典型的原型链

因此,一个典型的构造函数将构建以下原型链:

function Constructor() {}

const obj = new Constructor();
// obj ---> Constructor.prototype ---> Object.prototype ---> null

5.2 Object.setPrototypeOf()

要构建更长的原型链,我们可用通过 Object.setPrototypeOf() 函数设置 Constructor.prototype[[Prototype]]

function Base() {}
function Derived() {}
// 将 `Derived.prototype` 的 `[[Prototype]]`
// 设置为 `Base.prototype`
Object.setPrototypeOf(Derived.prototype, Base.prototype);

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

在类的术语中,这等同于使用 extends 语法。

class Base {}
class Derived extends Base {}

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

5.3 Object.create()

你可能还会看到一些使用 Object.create() 来构建继承链的旧代码。然而,因为这会重新为 prototype 属性赋值并删除 constructor 属性,所以更容易出错,而且如果构造函数还没有创建任何实例,性能提升可能并不明显。所以尽量不要使用底下代码

function Base() {}
function Derived() {}
// 将 `Derived.prototype` 重新赋值为 `Base.prototype`,
// 以作为其 `[[Prototype]]` 的新对象
// 请不要这样做——使用 Object.setPrototypeOf 来修改它
Derived.prototype = Object.create(Base.prototype);  // 

// 修改之前是ƒ Derived() {},修改之后ƒ Base() {}
console.log(Derived.prototype.constructor)  //ƒ Base() {}

允许使用 Object.create(null) 创建没有原型的对象

六、实例对象原型链

function doSomething() {}
doSomething.prototype.foo = "bar"; // 向原型上添加一个属性
const doSomeInstancing = new doSomething();
doSomeInstancing.prop = "some value"; // 向该对象添加一个属性
console.log(doSomeInstancing);

这会产生类似于下面的输出:

{
  prop: "some value",
  [[Prototype]]: {
    foo: "bar",
    constructor: ƒ doSomething(),
    [[Prototype]]: {
      constructor: ƒ Object(),
      hasOwnProperty: ƒ hasOwnProperty(),
      isPrototypeOf: ƒ isPrototypeOf(),
      propertyIsEnumerable: ƒ propertyIsEnumerable(),
      toLocaleString: ƒ toLocaleString(),
      toString: ƒ toString(),
      valueOf: ƒ valueOf()
    }
  }
}

如上所示,doSomeInstancing[[Prototype]]doSomething.prototype。但是,这是做什么的呢?当你访问 doSomeInstancing 的属性时,运行时首先会查找 doSomeInstancing 是否有该属性。

如果 doSomeInstancing 没有该属性,那么运行时会在 doSomeInstancing.[[Prototype]](也就是 doSomething.prototype)中查找该属性。如果 doSomeInstancing.[[Prototype]] 有该属性,那么就会使用 doSomeInstancing.[[Prototype]] 上的该属性。

否则,如果 doSomeInstancing.[[Prototype]] 没有该属性,那么就会在 doSomeInstancing.[[Prototype]].[[Prototype]] 中查找该属性。

默认情况下,任何函数的 prototype 属性的 [[Prototype]] 都是 Object.prototype。因此,然后会在 doSomeInstancing.[[Prototype]].[[Prototype]](也就是 doSomething.prototype.[[Prototype]](也就是 Object.prototype))上查找该属性。

如果在 doSomeInstancing.[[Prototype]].[[Prototype]] 中没有找到该属性,那么就会在 doSomeInstancing.[[Prototype]].[[Prototype]].[[Prototype]] 中查找该属性。但是,这里有一个问题:doSomeInstancing.[[Prototype]].[[Prototype]].[[Prototype]] 不存在,因为 Object.prototype.[[Prototype]]null 。然后,只有在查找完整个 [[Prototype]] 链之后,运行时才会断言该属性不存在,并得出该属性的值为 undefined

七、性能问题

原型链上较深层的属性的查找时间可能会对性能产生负面影响,这在性能至关重要的代码中可能会非常明显。此外,尝试访问不存在的属性始终会遍历整个原型链

7.1 hasOwnProperty

此外,在遍历对象的属性时,原型链中的每个可枚举属性都将被枚举。要检查对象是否具有在其自身上定义的属性,而不是在其原型链上的某个地方,则有必要使用 hasOwnPropertyObject.hasOwn 方法。除 [[Prototype]] null 的对象外,所有对象都从 Object.prototype 继承 hasOwnProperty——除非它已经在原型链的更深处被覆盖。我们将使用上面的图示例代码来说明它,具体如下:

function Graph() {
  this.vertices = [];
  this.edges = [];
}

Graph.prototype.addVertex = function (v) {
  this.vertices.push(v);
};

const g = new Graph();
// g ---> Graph.prototype ---> Object.prototype ---> null

g.hasOwnProperty("vertices"); // true
Object.hasOwn(g, "vertices"); // true

g.hasOwnProperty("nope"); // false
Object.hasOwn(g, "nope"); // false

g.hasOwnProperty("addVertex"); // false
Object.hasOwn(g, "addVertex"); // false

Object.getPrototypeOf(g).hasOwnProperty("addVertex"); // true

注意:仅检查属性是否为 undefined 是不够的。该属性很可能存在,但其值恰好设置为 undefined

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值