基於原型的JavaScript繼承 - 面向對象的JavaScript - JavaScript核心

源碼: https://github.com/RobinQu/Programing-In-Javascript/blob/master/chapters/JavaScript_Core/Object_Oriented_Javascript/Javascript_Prototypal_Inheritance.mdjavascript

原文: http://pij.robinqu.me/JavaScript_Core/Object_Oriented_Javascript/Javascript_Prototypal_Inheritance.htmlhtml

  • 本文須要補充更多例子
  • 本文存在批註,但該網站的Markdown編輯器不支持,因此沒法正常展現,請到原文參考。

基於原型的JavaScript繼承

坐臥不安的寫下這篇文章。用JavaScript實現繼承模型,已是很是成熟的技術,各類大牛也已經寫過各式的經驗總結和最佳實踐。在這裏,我只能就我所能,寫下我本身的思考和總結。java

在閱讀以前,咱們先假設幾個在面向對象編程中的概念是你們熟悉的:git

  • 類, Class
  • 構造函數, Constructor
  • 繼承, Inheritance
  • 實例, Instance
  • 實力化, Instantiation
  • 方法, Method
  • 多態, Polymorphism
  • 接口, Interface

因爲講解這些概念是十分複雜的,因此還請參閱其餘資料。github

瞭解原型

面向對象是當代編程的主流思想。不管是C++仍是Java,都是面向對象的。嚴格上來說,JavaScript並非面向對象的,而是「基於對象的」(Object-based),由於它的確缺少面向對象裏的不少特性,例如:編程

  • 繼承
  • 接口
  • 多態
  • ...

但再另外一方面,JavaScript是基於原型(Prototype)的對象系統。它的繼承體系,叫作原型鏈繼承。不一樣於繼承樹形式的經典對象系統,基於原型的對象系統中,對象的屬性和方法是從一個對象原型(或模板)上拷貝或代理(Delegation)的。JavaScript也不是惟一使用這種繼承方法的編程語言,其餘的例子如:瀏覽器

  • Lisp
  • Lua
  • ...

那麼,prototype在哪裏呢?app

訪問構造函數的原型

// 訪問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自己意味着superFoo這個實例有除了可以從原型上獲取屬性和方法,自己也有存儲屬性、方法的能力。咱們稱其爲own property,咱們也有很多相關的方法來操做:

    • obj.hasOwnProperty(name)
    • Object.getOwnPropertyNames(obj)
    • Object.getOwnPropertyDescriptor(obj)
  • SuperFoo.prototype

    • 回憶一下這句SuperFoo.prototype = new Foo();,也就是說SuperFoo.prototoye就是這個新建立的這個Foo類型的對象
    • 這也就解釋了爲啥咱們能訪問到Foo.prototype上的方法和屬性了
    • 也就是說,咱們要在這個新建的Foo對象的本地屬性和方法中查找
  • Foo.prototype:

    • 查找到這一次層,純粹是由於咱們制定了SuperFoo.prototype的值,回想上一條
  • Object.prototype

    • 這是該原型鏈的最後一環,由於Object.prototype這個對象的原型是null,咱們沒法繼續查找
    • 這是JavaScript中全部對象的祖先,上面定義了一個簡單對象上存在的屬性和方法,例如toString

那麼,當在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指向咱們所指望的原型,而後返回這個構造函數所建立的實例。有一些細節:

  • 咱們不喜歡直接作A.prototype = B.prototype這樣的事情,由於你對子類的修改,有可能直接影響到父類以及父類的全部實例。大多數狀況下這不是你想看到的結果
  • 新建F的實例,建立了一個本地對象,能夠持有(own)自身的屬性和方法,即可以支持以後的任意修改。回憶一下superFoo.bar方法。

若是你使用的JavaScript引擎支持Object.create,那麼一樣的事情就更簡單:

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

要注意Object.create的區別:

  • 咱們能夠建立沒有原型的對象: Object.create(null)
  • 咱們能夠配置建立的對象,參閱Object.create的文檔2
  • 咱們沒必要去運行一遍父類構造函數,這樣能夠避免不須要的反作用

函數的序列化、解義

JavaScript的函數能夠在運行時很方便的獲取其字符串表達:

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

這樣的能力其實時很強大的,你去問問Java和C++工程師該如何作到這點吧。

這意味着,咱們能夠去分析函數的字符串表達來作到:

  1. 瞭解函數的函數列表
  2. 瞭解函數體的實際內容
  3. 瞭解一個函數是否有別名
  4. ...

動態的this

JavaScript中的this是在運行時綁定的,咱們每每須要用到這個特性,例如:

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

以上這段代碼有以下細節:

  • A.methodA()運行時,其上下文對象指定的是A,因此this指向了A
  • 咱們能夠用這個來模擬「類的靜態方法或類方法」
  • 咱們可以經過這裏的this引用到類(構造函數)自己

若干版本

最簡單版本

單純實現一個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"

因爲過於簡單,我就不作講解了。

更復雜的例子

  • 咱們須要一個根對象XObject
  • 根對象有各類繼承方法,並能傳入一些子類的方法和屬性
  • 咱們要複用上個例子裏的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 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"

上面的例子中,

  • XObject是咱們對象系統的根類
  • XObject.extend能夠接受一個包含屬性和方法的對象來定義子類
  • XObject的全部子類,都沒有定義構造函數邏輯的機會!真是難以接受的:

    • 咱們偏好一個類上的init方法來初始化對象,而將構造函數自己最簡化

      • 繞開工廠方法的實現過程當中,參數傳遞如何傳遞到構造函數的問題
    • 能夠支持更多新的特性,例如super屬性、mixin特性等

總結,而後呢?

咱們解決了一部分問題,又發現了一些新問題。但本文的主要內容在這裏就結束了。一個更具實際意義的對象系統,實際隨處可見,EmberAngular中的根類。他們都有更強大的功能,例如:

  • Ember中的binding,setter、getter
  • Angular中的函數依賴注入
  • ...

可是,這些框架中對象系統的出發點都在本文所闡述的內容之中。若是做爲教學,John Resig在2008年的一篇博客中3,總結了一個現代JavaScript框架中的對象系統的雛形。我建立了docco代碼註解來當即這段代碼,本文也會結束在這段代碼的註解。

還有一些更高級的話題和技巧,會在另一篇文章中給出。

相關文章
相關標籤/搜索