跳到主要内容

继承与原型链

  • 对于使用过基于类的语言(如 Java 或 C++)的开发者来说,JavaScript 实在是有些令人困惑!
  • JavaScript 是动态的且没有静态类型。

理解:

  • 这可能也是为什么后面会出现 TS 的原因。
  • 像 C#,Java 这种面向对象编程语言,一般是先创建一个类,在实例化一个类变成对象。这样其实也挺简单明了的。
c# 类与实例化类写法
// 定义一个类
class person{
public string name;
pbulic string age;
}
// 根据类实例化一个对象数据
person onePerson = new person({name="江山录",age=18})

// 实例化一个列表数据
List<onePerson> listPerson = new List(){}
listPerson.Add(new onePerson({id=1,name="江山录",age=18}))
listPerson.Add(new onePerson({id=2,name="江山录",age=18}))
listPerson.Add(new onePerson({id=3,name="江山录",age=18}))

// 使用时.出来即可
onePerson.name
onePerson.age

// 像这些基于类的语言,继承也很简单,就是基于类进行继承。
class newperson extends person{
...
}

一,js 继承:基于原型链的继承

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

1. 继承属性

  • JavaScript 对象是动态的属性(指其自有属性)“包”。
  • JavaScript 对象有一个指向一个原型对象的链。
  • 当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。
当尝试访问属性时会发生什么
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。
  • 给对象设置属性会创建自有属性。
  • 获取和设置行为规则的唯一例外是当它被 getter 或 setter 拦截时。

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

3. 构造函数

  • 原型的强大之处在于,如果一组属性应该出现在每一个实例上,那我们就可以重用它们——尤其是对于方法。
  • 假设我们要创建多个盒子,其中每一个盒子都是一个对象,包含一个可以通过 getValue 函数访问的值。
  • 一个简单的实现可能是:
const boxes = [
{
value: 1,
getValue() {
return this.value
},
},
{
value: 2,
getValue() {
return this.value
},
},
{
value: 3,
getValue() {
return this.value
},
},
]
  • 这是不够好的,因为每一个实例都有自己的,做相同事情的函数属性,这是冗余且不必要的。
  • 相反,我们可以将 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。这允许我们在任何实例中访问原始构造函数。

  • 备注: 如果构造函数返回非原始值,则该值将成为 new 表达式的结果。在这种情况下,[[Prototype]] 可能无法正确绑定——但在实践中应该很少发生。

上面的构造函数可以重写为类:
class Box {
constructor(value) {
this.value = value
}

// 在 Box.prototype 上创建方法
getValue() {
return this.value
}
}
  • 类是构造函数的语法糖,这意味着你仍然可以修改 Box.prototype 来改变所有实例的行为。

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

function Box(value) {
this.value = value
}
Box.prototype.getValue = function () {
return this.value
}
const box = new Box(1)

// 在创建实例后修改 Box.prototype
Box.prototype.getValue = function () {
return this.value + 1
}
box.getValue() // 2
  • 有个推论是:重新赋值 Constructor.prototype(Constructor.prototype = ...)是一个不好的主意,原因有两点:
      1. 在重新赋值之前创建的实例的 [[Prototype]] 现在引用的是与重新赋值之后创建的实例的 [[Prototype]] 不同的对象——改变一个的 [[Prototype]] 不再改变另一个的 [[Prototype]]。
      1. 除非你手动重新设置 constructor 属性,否则无法再通过 instance.constructor 追踪到构造函数,这可能会破坏用户期望的行为。一些内置操作也会读取 constructor 属性,如果没有设置,它们可能无法按预期工作。
  • Constructor.prototype 仅在构造实例时有用。
  • 它与 Constructor.[[Prototype]] 无关,后者是构造函数的自有原型,即 Function.prototype。也就是说,Object.getPrototypeOf(Constructor) === Function.prototype。

3. 字面量的隐式构造函数

  • 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

3.1 什么事字面量?

// 这是对象字面量
const object = { a: 1 }
// 这是数组字面量
const array = [1, 2, 3]
// 这是正则表达式字面量
const regexp = /abc/
  • 例如,像 map() 这样的“数组方法”只是在 Array.prototype 上定义的方法,而它们又自动在所有数组实例上可用,就是因为这个原因。

4. 错误使用案例

  • 警告: 有一个常见的错误实践(misfeature):扩展 Object.prototype 或其它内置原型。
  • 这种不良特性例子是,定义 Array.prototype.myMethod = function () {...},然后在所有数组实例上使用 myMethod。
  • 这种错误实践被称为猴子修补(monkey patching)。
  • 使用猴子修补存在向前兼容的风险,因为如果语言在未来添加了此方法但具有不同的签名,你的代码将会出错。
  • 它已经导致了类似于 SmooshGate 这样的事件,并且由于 JavaScript 致力于“不破坏 web”,因此这可能会对语言的发展造成极大的麻烦。
  • 扩展内置原型的唯一理由是向后移植新的 JavaScript 引擎的特性,比如 Array.prototype.forEach。

5. 历史原因

  • 有趣的是,由于历史原因,一些内置构造函数的 prototype 属性本身就是其自身的实例。
  • 例如,Number.prototype 是数字 0,Array.prototype 是一个空数组,RegExp.prototype 是 /(?:)/。
Number.prototype + 1 // 1
Array.prototype.map((x) => x + 1) // []
String.prototype + 'a' // "a"
RegExp.prototype.source // "(?:)"
Function.prototype() // Function.prototype 本身就是一个无操作函数
  • 然而,对于用户定义的构造函数,以及 Map 等现代的构造函数,则并非如此。
Map.prototype.get(1)
// TypeError: get method called on incompatible Map.prototype

6. 构建更长的继承链

  • Constructor.prototype 属性将成为构造函数实例的 [[Prototype]],包括 Constructor.prototype 自身的 [[Prototype]]。
  • 默认情况下,Constructor.prototype 是一个普通对象——即 Object.getPrototypeOf(Constructor.prototype) === Object.prototype。
  • 唯一的例外是 Object.prototype 本身,其 [[Prototype]] 是 null——即 Object.getPrototypeOf(Object.prototype) === null。
  • 因此,一个典型的构造函数将构建以下原型链:
function Constructor() {}
const obj = new Constructor()
// obj ---> Constructor.prototype ---> Object.prototype ---> null
  • 要构建更长的原型链,我们可用通过 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
  • 你可能还会看到一些使用 Object.create() 来构建继承链的旧代码。
  • 然而,因为这会重新为 prototype 属性赋值并删除 constructor 属性,所以更容易出错,而且如果构造函数还没有创建任何实例,性能提升可能并不明显。

7. 检查原型:更深入的研究

  • 在 JavaScript 中,函数可以拥有属性。但箭头函数是没有原型的。
  • 所有函数都有一个名为 prototype 的特殊属性。
  • 请注意,下面的代码是独立的(出于严谨,假设页面没有其他的 JavaScript 代码)。
function doSomething() {}
console.log(doSomething.prototype)
// 你如何声明函数并不重要;
// JavaScript 中的函数总有一个默认的
// 原型属性——有一个例外:
// 箭头函数没有默认的原型属性:
const doSomethingFromArrowFunction = () => {}
console.log(doSomethingFromArrowFunction.prototype)

8. 使用不同的方法来创建对象和改变原型链

  • 我们碰到过很多创建对象和改变其原型链的方法。
  • 我们将系统地总结不同的方法,并比较每种方法的优缺点。
使用语法结构创建对象
const o = { a: 1 }
// 新创建的对象 o 以 Object.prototype 作为它的 [[Prototype]]
// Object.prototype 的原型为 null。
// o ---> Object.prototype ---> null

const b = ['yo', 'whadup', '?']
// 数组继承了 Array.prototype(具有 indexOf、forEach 等方法)
// 其原型链如下所示:
// b ---> Array.prototype ---> Object.prototype ---> null

function f() {
return 2
}
// 函数继承了 Function.prototype(具有 call、bind 等方法)
// f ---> Function.prototype ---> Object.prototype ---> null

const p = { b: 2, __proto__: o }
// 可以通过 __proto__ 字面量属性将新创建对象的
// [[Prototype]] 指向另一个对象。
// (不要与 Object.prototype.__proto__ 访问器混淆)
// p ---> o ---> Object.prototype ---> null

9. 性能

  • 原型链上较深层的属性的查找时间可能会对性能产生负面影响,这在性能至关重要的代码中可能会非常明显。

  • 此外,尝试访问不存在的属性始终会遍历整个原型链。

  • 在遍历对象的属性时,原型链中的每个可枚举属性都将被枚举。

  • 要检查对象是否具有在其自身上定义的属性,而不是在其原型链上的某个地方,则有必要使用 hasOwnProperty 或 Object.hasOwn 方法。

  • 除 [[Prototype]] 为 null 的对象外,所有对象都从 Object.prototype 继承 hasOwnProperty——除非它已经在原型链的更深处被覆盖。

  • 我们将使用上面的图示例代码来说明它,具体如下:

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

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

10. 结论

  • 对于 Java 或 C++ 的开发者来说,JavaScript 可能有点令人困惑,因为它是完全动态、完全是在执行期间确定的,而且根本没有静态类型。

  • 一切都是对象(实例)或函数(构造函数),甚至函数本身也是 Function 构造函数的实例。即使是语法结构中的“类”也只是运行时的构造函数。

  • JavaScript 中的所有构造函数都有一个被称为 prototype 的特殊属性,它与 new 运算符一起使用。

  • 对原型对象的引用被复制到新实例的内部属性 [[Prototype]] 中。

  • 例如,当你执行 const a1 = new A() 时,JavaScript(在内存中创建对象之后,为其定义 this 并执行 A() 之前)设置 a1.[[Prototype]] = A.prototype。

  • 然后,当你访问实例的属性时,JavaScript 首先检查它们是否直接存在于该对象上,如果不存在,则在 [[Prototype]] 中查找。

  • 会递归查询 [[Prototype]],即 a1.doSomething、Object.getPrototypeOf(a1).doSomething、Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething,以此类推,直至找到或 Object.getPrototypeOf 返回 null。这意味着在 prototype 上定义的所有属性实际上都由所有实例共享,并且甚至可以更改 prototype 的部分内容,使得更改被应用到所有现有的实例中。

  • 在上面的示例中,如果你执行 const a1 = new A(); const a2 = new A();,

  • 那么 a1.doSomething 实际上会引用 Object.getPrototypeOf(a1).doSomething——这与你定义的 A.prototype.doSomething 相同,

  • 即 Object.getPrototypeOf(a1).doSomething === Object.getPrototypeOf(a2).doSomething === A.prototype.doSomething。

  • 了解原型继承模型是使用它编写复杂代码的重要基础。

  • 此外,要注意代码中原型链的长度,在必要时可以将其分解,以避免潜在的性能问题。

  • 此外,除非是为了与新的 JavaScript 特性兼容,否则永远不应扩展原生原型。