Javascript编程语言

基于原型的JavaScript继承

诚惶诚恐的写下这篇文章。用JavaScript实现继承模型,已经是非常成熟的技术,各种大牛也已经写过各式的经验总结和最佳实践。在这里,我只能就我所能,写下我自己的思考和总结。

在阅读之前,我们先假设几个在面向对象编程中的概念是大家熟悉的:

由于讲解这些概念是十分复杂的,所以还请参阅其他资料。

了解原型

面向对象是当代编程的主流思想。无论是C++还是Java,都是面向对象的。严格上来讲,JavaScript并不是面向对象的,而是“基于对象的”(Object-based),因为它的确缺乏面向对象里的很多特性,例如:

但再另一方面,JavaScript是基于原型(Prototype)的对象系统。它的继承体系,叫做原型链继承。不同于继承树形式的经典对象系统,基于原型的对象系统中,对象的属性和方法是从一个对象原型(或模板)上拷贝或代理(Delegation)的。JavaScript也不是唯一使用这种继承方法的编程语言,其他的例子如:

那么,prototype在哪里呢?

访问构造函数的原型

// 访问Array的原型
Array.prototype
// 访问自定义函数Foo的原型
var Foo = function() {}
Foo.prototype

访问一个实例的原型

__proto__不是标准属性,但是被大多数浏览器支持

var a = {}
a.__proto__;

使用ES5的Object.getPrototypeOf:

Object.getPrototypeOf([]) === Array.prototype;

再来点绕弯的:

[].constructor.prototype === Array.prototype

new关键字

大多数面向对象语言,都有new关键字。他们大多和一个构造函数一起使用,能够实例化一个类。JavaScript的new关键字是异曲同工的。

等等,不是说JavaScript不支持经典继承么!的确,其实new的含义,在JavaScript中,严格意义上是有区别的。

当我们,执行

new F()

实际上是得到了一个从F.prototype继承而来的一个对象。这个说法来自Douglas的很早之前的一篇文章1。在如今,如果要理解原型继承中new的意义,还是这样理解最好。

如果我们要描述new的工作流程,一个接近的可能流程如下:

  1. 分配一个空对象
  2. 设置相关属性、方法,例如constructorF.prototype上的各式方法、属性。注意,这里执行的并不是拷贝,而是代理。后文会讲解这点。
  3. 将这个新对象作为构造函数的执行上下文(其this指向这个对象),并执行构造函数
  4. 返回这个对象

原型继承

我们来定义一个简单的“类”和它的原型:

var Foo = function() {};
Foo.prototype.bar = function() {
    console.log("haha");
};
Foo.prototype.foo = function() { console.log("foo"); };

我们在原型上定义了一个bar方法。看看我们怎么使用它:

var foo = new Foo();
foo.bar(); // =>  "haha"
foo.foo(); // => "foo"

我们要继承Foo:

var SuperFoo = function() {
    Foo.apply(this, arguments);
};
SuperFoo.prototype = new Foo();
SuperFoo.prototype.bar = function() {
    console.log("haha, haha");
};
var superFoo = new SuperFoo();
superFoo.foo(); // => "foo"
superFoo.bar(); // => "haha, haha"

注意到几个要点:

  1. SuperFoo中,我们执行了父级构造函数
  2. SuperFoo中,我们让然可以调用foo方法,即使SuperFoo上没有定义这个方法。这是继承的一种表现:我们可以访问父类的方法
  3. SuperFoo中,我们重新定义了bar方法,实现了方法的重载

我们仔细想想第二点和第三点。我们新指定的bar方法到底保存到哪里了?foo方法是如何找到的?

原型链

要回答上面的问题,必须要介绍原型链这个模型。相比树状结构的经典类型系统,原型继承采取了另一种线性模型。

当我们要在对象上查找一个属性或方法时:

  1. 在对象本身查找,如果没有找到,进行下一步
  2. 在该对象的构造函数自己的prototype对象上查找,如果没有找到进行下一步
  3. 获取该对象的构造函数的prototype对象作为当前对象;如果当前对象存在prototype,就能继续,否则不存在则查找失败,退出;在该对象上查找,如果没有找到,将前面提到的“当前对象”作为起始对象,重复步骤3

这样的递归查找终究是有终点的,因为:

Object.prototype.__proto__ === null

也就是Object构造函数上,prototype这个对象的构造函数上已经没有prototype了。

我们来看之前FooSuperFoo的例子,我们抽象出成员查找的流程如下:

superFoo本身 => SuperFoo.prototype => Foo.prototype => Object.prototype

解读原型链的查找流程:

那么,当在SuperFoo上添加bar方法呢?这时,JavaScript引擎会在SuperFoo.prototype的本地添加bar这个方法。当你再次查找bar方法时,按照我们之前说明的流程,会优先找到这个新添加的方法,而不会找到再原型链更后面的Foo.prototype.bar

也就是说,我们既没有删掉或改写原来的bar方法,也没有引入特殊的查找逻辑。

模拟更多的经典继承

基本到这里,继承的大部分原理和行为都已经介绍完毕了。但是如何将这些看似简陋的东西封装成最简单的、可重复使用的工具呢?本文的后半部分将一步一步来介绍如何编写一个大体可用的对象系统。

热身

准备几个小技巧,以便我们在后面使用。

beget

如果要以一个对象作为原型,创建一个新对象:

function beget(o) {
    function F() {}
    F.prototype = o;
    return new F();
}
var foo = beget({bar:"bar"});
foo.bar === "bar"; //true

理解这些应该困难。我们构造了一个临时构造函数,让它的prototype指向我们所期望的原型,然后返回这个构造函数所创建的实例。有一些细节:

如果你使用的JavaScript引擎支持Object.create,那么同样的事情就更简单:

Object.create({bar:"bar"});

要注意Object.create的区别:

函数的序列化、解义

JavaScript的函数可以在运行时很方便的获取其字符串表达:

var f = function(a) {console.log("a")};
f.toString(); // 'function(a) {console.log("a")};'

这样的能力其实时很强大的,你去问问Java和C++工程师该如何做到这点吧。

这意味着,我们可以去分析函数的字符串表达来做到:

  1. 了解函数的函数列表
  2. 了解函数体的实际内容
  3. 了解一个函数是否有别名

动态的this

JavaScript中的this是在运行时绑定的,我们往往需要用到这个特性,例如:

var A = function() {};
A.methodA = function() {
    console.log(this === A);
};
A.methodA();// => true

以上这段代码有如下细节:

若干版本

最简单版本

单纯实现一个extend方法:

var extend = function(Base) {
  var Class = function() {
    Base.apply(this, arguments);
  }, F;
  if(Object.create) {
    Class.prototype = Object.create(Base.prototype);
  } else {
    F = function() {};
    F.prototype = Base.prototype;
    Class.prototype = new F();
  }
  Class.prototype.constructor = Class;
  return Class;
};

var Foo = function(name) {
  this.name = name;
};
Foo.prototype.bar = function() {
  return "bar";
};

var SuperFoo = extend(Foo);
var superFoo = new SuperFoo("super");
console.log(superFoo.name);// => "super"
console.log(superFoo.bar());// => "bar"

由于过于简单,我就不做讲解了。

更复杂的例子

var extend = function(Base) {
  var Class = function() {
    Base.apply(this, arguments);
  }, F;
  if(Object.create) {
    Class.prototype = Object.create(Base.prototype);
  } else {
    F = function() {};
    F.prototype = Base.prototype;
    Class.prototype = new F();
  }
  Class.prototype.constructor = Class;
  return Class;
};

var merge = function(target, source) {
  var k;
  for(k in source) {
    if(source.hasOwnProperty(k)) {
      target[k] = source[k];
    }
  }
  return target;
};

// Base Contstructor
var XObject = function() {};

XObject.extend = function(props) {
  var Class = extend(this);
  if(props) {
    merge(Class.prototype, props);
  }

  // copy `extend`
  // should not use code like this; will throw at ES6
  // Class.extend = arguments.callee;
  Class.extend = XObject.extend;

  return Class;
};


var Foo = XObject.extend({
  bar: function() { return "bar"; },
  name: "foo"
});

var SuperFoo = Foo.extend({
  name: "superfoo",
  bar: function() { return "super bar"; }
});

var foo = new Foo();
console.log(foo.bar()); // => "bar"
console.log(foo.name); // => "foo"

var superFoo = new SuperFoo();
console.log(superFoo.name); // => "superfoo"
console.log(superFoo.bar()); // => "super bar"

上面的例子中,

总结,然后呢?

我们解决了一部分问题,又发现了一些新问题。但本文的主要内容在这里就结束了。一个更具实际意义的对象系统,实际随处可见,EmberAngular中的根类。他们都有更强大的功能,例如:

但是,这些框架中对象系统的出发点都在本文所阐述的内容之中。作为教学,John Resig在2008年的一篇博客中3总结了一个现代JavaScript框架中的对象系统的雏形。

我创建了docco代码注解来立即这段代码,本文也会结束在这段代码的注解。强力推荐大家去阅读该注解文档。

还有一些更高级的话题和技巧,会在另外一篇文章中给出。


  1. http://javascript.crockford.com/prototypal.html

  2. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/create

  3. http://ejohn.org/blog/simple-javascript-inheritance/