JavaScript-继承与原型链

145 阅读9分钟

继承与原型链

对于使用过基于类的语言(如Java或C++)的开发者们来说,JavaScript实在是有些令人困惑——JavaScript是动态的,本身不提供一个class的实现,即便是在ES2015/ES6中引入了calss关键字,但那也只是语法糖,JavaScript仍然是基于原型的。

当谈到继承时,JavaScript只有一种结构:对象。每个实例对象(object)都有一个私有属性(称之为_proto_)指向它的构造函数的原型对象(prototype)。该原型对象也有一个自己的原型对象(proto),层层向上直到一个对象的原型对象为null。根据定义,null没有原型,并作为这个原型链中的最后一个环节。几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object的实例。

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

基于原型链的继承

继承属性

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

备注:  遵循 ECMAScript 标准,someObject.[[Prototype]] 符号是用于指向 someObject 的原型。从 ECMAScript 6 开始,[[Prototype]] 可以通过 Object.getPrototypeOf() 和 Object.setPrototypeOf() 访问器来访问。这个等同于 JavaScript 的非标准但许多浏览器实现的属性 __proto__。但它不应该与构造函数 func 的 prototype 属性相混淆。被构造函数创建的实例对象的 [[Prototype]] 指向 func 的 prototype 属性。Object.prototype 属性表示 Object 的原型对象。

这里演示当尝试访问属性时会发生什么:

// 让我们从一个函数里创建一个对象p,它自身拥有属性a和b
let f = function () {
    this.a = 1;
    this.b = 2;
}
/* 
function f() {
    this.a = 1;
    this.b = 2;
}
*/
let p = new f(); // {a: 1, b: 2};

// 在f函数的原型上定义属性
f.prototype.b = 3;
f.prototype.c = 4;

/* 
不要在f函数的原型上直接定义f.prototype = {b:3,c:4};这样会直接打破原型链
p.[[Prototype]] 有属性b和c (其实就是p.__proto__或者 p.constructor.prototype)
p.[[Prototype]].[[Prototype]]是Object.prototype
最后p.[[Prototype]].[[Prototype]].[[Prototype]]是null
原型链的末尾就是null,根据定义,null就是没有[[Prototype]]
*/

// 综上,整个原型链如下:
console.log(p.a); // 1
// a 是 p 的自身属性吗? 是的,该属性的值为1

console.log(p.b); // 2
// b 是 p 的自身属性吗? 是的,该属性的值为2
// 原型上也有一个'b'属性,但是它不会被访问到。
// 这种情况被称为“属性遮蔽(property shadowing)”

console.log(p.c); // 4
// c 是 p 的自身属性吗? 不是,那看看它的原型上有没有
// c 是 p.[[Prototype]] 的属性吗? 是的,该属性的值为4

console.log(p.d); //undefined
// d 是 p 的自身属性吗? 不是,那看看它的原型上有没有
// d 是 p.[[Prototype]] 的属性吗? 不是,那再看看它的原型上有没有
// o.[[Prototype]].[[Prototype]] 为null,停止搜索
// 找不到d属性,返回undefined

给对象设置属性会创建自有属性。获取和设置行为规则的唯一例外是当继承的属性带有 getter 或 setter 时。

继承方法

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

当继承的函数被调用时,this指向的是当前继承的对象,而不是继承的函数所在的原型对象。

let p = {
    a: 2,
    m: function() {
        return this.a + 1;
    }
};

console.log(p.m()); // 3
// 当调用了p.m时,this指向了p

let o = Object.create(p);
// o是一个继承自p的对象

o.a = 4; // 创建了o的自身属性 'a'
console.log(o.m()); // 5
// 调用o.m时,this指向了o
// 又因为 o 继承了 p 的 m 函数
// 所以,此时的this.a 即 o.a,就是 o 的自身属性'a'
// 最终执行m函数 返回了 o下的a值为4, 4 + 1 返回 5

使用不同的方法来创建对象和生成原型链

使用语法结构创建的对象

let o = { a: 1 };
// o 这个对象了继承了Object.protoype 上面的所有属性
// o 自身没有名为 hasOwnProperty 的属性
// hasOwnProperty 是 Object.prototype 的属性
// 因此 o 继承了Object.prototype身上的hasOwnProperty
// Object.prototype的原型为null
// 原型链如下
// o ==> Object.prototype ==> null

let arr = [1, 2, 3];
// 数组都继承于Array.prototype
// Array.prototype身上有 indexOf,forEach等方法
// 原型链如下
// arr ==> Array.prototype ==> Object.prototype ==> null

let f = function () {
    return 2
}
// 函数都继承于Function.prototype
// Function.prototype身上有call、bind等方法
// 原型链如下
// f ==> Function.prototype ==> Object.prototype ==> null

使用构造器创建的对象

在 JavaScript 中,构造器其实就是一个普通的函数。当使用 new 操作符 来作用这个函数时,它就可以被称为构造方法(构造函数)。

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

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

var g = new Graph();
g.addVertex(130);
console.log(g); // { edges: [], vertices: [ 130 ] }
// g 是生成的对象,他的自身属性有'vertices' 和 'edges'.
// 在 g 被实例化,g.[[Prototype]]指向了Graph.prototype
// 调用了 g 的原型对象身上的addVertex方法

使用Object.create创建对象

ECMAScript 5 中引入了一个新方法:Object.create()。可以调用这个方法来创建一个新对象。新对象的原型就是调用 create 方法时传入的第一个参数:

var a = { a: 1 };
// a ==> Object.prototype ==> null

var b = Object.create(a);
// b ==> a ==> Object.prototype ==> null
console.log(b.a); // 继承而来

var c = Object.create(b);
// c ==> b ==> a ==> Object.prototype ==> null

var d = Object.create(null);
// d ==> null
console.log(d.hasOwnProperty)// undefined,因为d没有继承Object.prototype

使用class关键字创建的对象

ECMAScript6 引入了一套新的关键字用来实现 class。使用基于类语言的开发人员会对这些结构感到熟悉,但它们是不同的。JavaScript 仍然基于原型。这些新的关键字包括 classconstructorstaticextends 和 super

"use strict";

class Polygon {
    constructor(height,width) {
        this.height = height;
        this.width = width;
    }
};

class Square extends Polygon {
    constructor(sideLength) {
        super(sideLength,sideLength);
    }
    get area() {
        return this.height * this.width;
    }
    set sideLength(newLength) {
        this.height = newLength;
        this.width = newLength;
    }
}

var square = new Square(2);
console.log(square); // { height: 2, width: 2, area: 4 }
// Square 使用 extends 继承了 Polygon 从而拥有了 height和width 属性

性能

在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。

遍历对象的属性时,原型链上的每个可枚举属性都会被枚举出来。要检查对象是否具有自己定义的属性,而不是其原型链上的某个属性,则必须使用所有对象从 Object.prototype 继承的 hasOwnProperty (en-US) 方法。下面给出一个具体的例子来说明它:

// 以下代码引用 - 使用构造器创建的对象
console.log(g.hasOwnProperty('vertices'));
// true

console.log(g.hasOwnProperty('nope'));
// false

console.log(g.hasOwnProperty('addVertex'));
// false

console.log(g.__proto__.hasOwnProperty('addVertex'));
// true

hasOwnProperty (en-US) 是 JavaScript 中唯一一个处理属性并且不会遍历原型链的方法。(译者注:原文如此。另一种这样的方法:Object.keys()

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

错误实践:扩展原生对象的原型

经常使用的一个错误实践是扩展Object.prototype 或其他内置原型。这种技术被称为猴子补丁并且会破坏封装。尽管一些流行的框架(如 Prototype.js)在使用该技术,但仍然没有足够好的理由使用附加的非标准方法来混入内置原型。

扩展内置原型的唯一理由是支持 JavaScript 引擎的新特性,如 Array.forEach

prototype 和 Object.getPrototypeOf

对于从 Java 或 C++ 转过来的开发人员来说,JavaScript 会有点让人困惑,因为它完全是动态的,都是运行时,而且不存在类(class)。所有的都是实例(对象)。即使我们模拟出的“类”,也只是一个函数对象。

你可能已经注意到我们的 function A 有一个叫做 prototype 的特殊属性。该特殊属性可与 JavaScript 的 new 操作符一起使用。对原型对象的引用被复制到新实例的内部 [[Prototype]] 属性。例如,当执行 var a1 = new A(); 时,JavaScript(在内存中创建对象之后,和在运行函数 A() 把 this 指向对象之前)设置 a1.[[Prototype]] = A.prototype;。然后当您访问实例的属性时,JavaScript 首先会检查它们是否直接存在于该对象上,如果不存在,则会 [[Prototype]] 中查找。这意味着你在 prototype 中定义的所有内容都可以由所有实例有效地共享,你甚至可以稍后更改部分 prototype,并在所有现有实例中显示更改(如果有必要的话)。

当你执行:

var a = new Foo();

JavaScript实际上执行的是:

var a = new Object();
a.__proto__ = Foo.prototype;
Foo.call(a)

(或者类似上面这样的),然后,当你执行:

a.someProp;

它检查 a 是否具有 someProp 属性。如果没有,它会查找 Object.getPrototypeOf(a).someProp,如果仍旧没有,它会继续查找 Object.getPrototypeOf(Object.getPrototypeOf(a)).someProp

结论

在使用原型继承编写复杂代码之前,理解原型继承模型是至关重要的。此外,请注意代码中原型链的长度,并在必要时将其分解,以避免可能的性能问题。此外,原生原型不应该被扩展,除非它是为了与新的 JavaScript 特性兼容。

示例

B 继承自 A

function A(a){
  this.varA = a;
}

// 以上函数 A 的定义中,既然 A.prototype.varA 总是会被 this.varA 遮蔽,
// 那么将 varA 加入到原型(prototype)中的目的是什么?
A.prototype = {
  varA : null,
/*
既然它没有任何作用,干嘛不将 varA 从原型(prototype)去掉 ?
也许作为一种在隐藏类中优化分配空间的考虑 ?
https://developers.google.com/speed/articles/optimizing-javascript
如果 varA 并不是在每个实例中都被初始化,那这样做将是有效果的。
*/
  doSomething : function(){
    // ...
  }
}

function B(a, b){
  A.call(this, a);
  this.varB = b;
}
B.prototype = Object.create(A.prototype, {
  varB : {
    value: null,
    enumerable: true,
    configurable: true,
    writable: true
  },
  doSomething : {
    value: function(){ // override
      A.prototype.doSomething.apply(this, arguments);
      // call super
      // ...
    },
    enumerable: true,
    configurable: true,
    writable: true
  }
});
B.prototype.constructor = B;

var b = new B();
b.doSomething();

最重要的部分是:

  • 类型被定义在 .prototype 中
  • 用 Object.create() 来继承