JavaScript 定義類的最佳寫法——完整支持面向對象(封裝、繼承、多態),兼容所有瀏覽器,支持用JSDuck生成文檔


作者: zyl910

一、緣由

由於在ES6之前,JavaScript中沒有定義類(class)語法。導致大家用各種五花八門的辦法來定義類,代碼風格不統一。而且對於模擬面向對象的三大支柱“封裝”、“繼承”、“多態”,更是有許多專門的深度研究,實現辦法更加復雜,不利於JavaScript新手使用。
於是我將這些優秀方法提煉總結,化繁為簡。目標是——就算是JavaScript新手,只要有一點的面向對象編程經驗(即有Java、C#等面向對象編程經驗),也能按照本文的辦法,輕松的在JavaScript中定義類,完整的使用封裝、繼承、多態等特性來組織代碼。

其次,該方案還有這些優點——

  • 兼容所有的瀏覽器。目前ES6尚未普及,很多瀏覽器尚不支持。而本方法因其采用了簡單的語法(應該是在ES3的范圍內),故兼容目前所有的瀏覽器。實測在 IE5~11、Edge、Chrome、Firefox等瀏覽器中均測試通過。
  • 兼容命名空間方案(JavaScript 實現命名空間(namespace)的最佳方案 ),便於管理大型代碼。
  • 使用起來與其他面向對象編程語言非常相似。僅是在定義時的寫法稍有區別。
  • 支持用JSDuck生成文檔。且JSDuck能完美的識別本文所介紹的面向對象特性,生成有用的文檔。便於理解,提高可維護性。
  • 兼容ES6。因ES6的class關鍵字實際上只是一個語法糖,它內部仍是靠prototype(原型)機制。
  • 有利於代碼風格統一。

二、定義類的基本寫法

2.1 使用構造函數法來定義類

(在ES6以前)JavaScript推薦使用構造函數法來定義類。具體來說,就是寫一個構造函數(constructor),然后用new 關鍵字對該類來構造對象實例。

例如現在需要定義一個 PersonInfo(個人信息)類,它有name(姓名)、gender(性別)字段。便這樣定義類——

function PersonInfo() {
    // 自身的實例字段.
    this.name = "";    // 姓名.
    this.gender = 0;    // 性別. 0未知, 1男, 2女.
}

隨后便可用 new 關鍵字來創建對象,可以使用“對象.屬性”的語法來訪問那些在構造函數中定義的實例字段了。例如——

var p = new PersonInfo();
p.name = "Zhang San";    // 張三.
alert(p.name);

2.2 編寫方法

定義類之后,便可為它編寫方法了。具體辦法是在構造函數的原型中增加函數。例如我們給 PersonInfo 類增加一個 getHello 方法 取得歡迎文本。

function PersonInfo() {
    this.name = "";    // 姓名.
    this.gender = 0;    // 性別. 0未知, 1男, 2女.
}

PersonInfo.prototype.getHello = function() {
    var rt = "Hello " + this.name;
    return rt;
};

隨后便可以使用“對象.方法(參數...)”的語法來調用方法了。

var p = new PersonInfo();
p.name = "Zhang San";    // 張三.
alert(p.getHello());

2.3 增加addlog函數簡化測試

目前是用alert彈出對話框來顯示處理結果的。該辦法對於以后測試不利,會導致需要連續點多次確定等麻煩。
故還是寫一個addlog(追加日志)的函數比較好,在textarea中顯示測試結果。

網頁中增加textarea控件——

輸出:<br/>
<textarea id="txtresult" rows="12" style="width:95%" readonly ></textarea>

然后我們的測試函數可改成這樣——

function doTest() {
    var txtresult = document.getElementById("txtresult");
    txtresult.value = "";
    // do
    var p = new PersonInfo();
    p.name = "Zhang San";    // 張三.
    addlog(p.getHello());
}

2.4 小結

以上便是普通JavaScript教程中所講的定義類的辦法。該辦法能組織實例字段,能編寫方法,能都滿足很多簡單的需求了。

但該辦法的缺點也很明顯——

  • 所有內容都定義在全局變量空間。可能會造成全局名稱污染,不利於大型項目。
  • 封裝性差。所有成員都暴露在對象實例里或是原型(prototype)里,所有人都能訪問,一不小心可能會弄亂數據。
  • 不支持繼承。JavaScript沒有繼承語法,無法直接定義子類。
  • 不支持多態。由於無法實現繼承,導致它不支持多態。

下列章節將解決這些問題。

三、基本寫法的改進

3.1 使用命名空間來避免全局名稱污染

為了避免全局名稱污染,可使用命名空間(namespace)機制。
雖然JavaScript沒有命名空間的語法,但可以通過一些辦法模擬。詳見 JavaScript 實現命名空間(namespace)的最佳方案 [] 。
其機制很簡單,就是定義一個 Object 變量作為命名空間。然后便可將 類的構造函數 綁定到該命名空間中,隨后便可按原來的辦法給類再綁定方法。
例如將PersonInfo類放到jsnamespace命名空間中,可這樣做——

var jsnamespace = window.jsnamespace || {};

jsnamespace.PersonInfo = function() {
    this.name = "";    // 姓名.
    this.gender = 0;    // 性別. 0未知, 1男, 2女.
}

jsnamespace.PersonInfo.prototype.getHello = function() {
    var rt = "Hello " + this.name;
    return rt;
};

隨后便可使用該類了,注意需要寫全命名空間。

var p = new jsnamespace.PersonInfo();
p.name = "Zhang San";    // 張三.
addlog(p.getHello());

3.2 改進構造函數

3.2.1 構造函數參數

一般來說,可以通過構造函數參數的辦法,來簡化對象的創建、賦值。

jsnamespace.PersonInfo = function(name, gender) {
    this.name = name;    // 姓名.
    this.gender = gender;    // 性別. 0未知, 1男, 2女.
}

可這樣使用——

var p = new jsnamespace.PersonInfo("Zhang San", 1);    // 張三, 男.
addlog(p.getHello());

該做法有2點不足——

  1. 隨着以后對該類的改進,可能會增加更多的實例字段,那可能會導致參數列表的頻繁改動。
  2. 非常依賴參數順序,萬一傳參時某個參數的順序寫錯,便會引發數據問題。

3.2.2 拷貝構造函數

為了解決上述的2點不足,且為了方便對象復制。故推薦使用“拷貝構造函數”這種構造函數寫法。
具體做法是,構造函數只用一個 Object 型的參數。

jsnamespace.PersonInfo = function(cfg) {
    cfg = cfg || {};    // 當沒傳cfg參數時,將它當作空對象。
    this.name = cfg["name"] || "";    // 姓名.
    this.gender = cfg["gender"] || 0;    // 性別. 0未知, 1男, 2女.
}

可這樣使用——

var p = new jsnamespace.PersonInfo({"name": "Zhang San"});    // 張三, 男.
addlog(p.getHello());

注意上述例子中沒傳 gender 參數。因構造函數中的 this.gender = cfg["gender"] || 0 語句,故 gender 屬性會賦值為默認值0。

另外,拷貝構造函數更適合於在繼承的場合下使用,詳見后面的章節。

3.3 使用JSDuck文檔注釋來改進代碼的可讀性

對於大型代碼來說,即使寫了注釋,閱讀代碼也是非常費神的。
這時可編寫文檔注釋,然后用工具將其生成為參考文檔。有組織的文檔,比代碼更易讀。且有了文檔注釋后,代碼也更易讀懂了。
且文檔注釋的一些標記能進一步加強代碼的可讀性。例如(ES6之前的)JavaScript沒有class關鍵字,用構造函數法定義類與普通函數差異不大,分辨、搜索起來有一些麻煩。而文檔注釋一般提供了@class 關鍵字來表示類。

對於JavaScript來說,個人覺得最好用的文檔注釋工具是JSDuck。
將上面的代碼加上JSDuck風格的文檔注釋,則變成在這樣——

/** @class
* JavaScript的命名空間.
* @abstract
*/
var jsnamespace = window.jsnamespace || {};

/** @class
* 個人信息. 構造函數法的類.
*/
jsnamespace.PersonInfo = function(cfg) {
    cfg = cfg || {};
    /** @property {String} 姓名. */
    this.name = cfg["name"] || "";
    /** @property {Number} 性別. 0未知, 1男, 2女. */
    this.gender = cfg["gender"] || 0;
};

/**
* 取得歡迎字符串.
*
* @return  {String}    返回歡迎字符串.
*/
jsnamespace.PersonInfo.prototype.getHello = function() {
    var rt = "Hello " + this.name;
    return rt;
};

JSDuck文檔注釋標記說明——

  • @class: 表示這是一個類。
  • @abstract: 該類(或方法)是抽象的。由於JSDuck沒有命名空間的關鍵字,於是習慣上用 @class @abstract 組合表示命名空間。
  • @property: 屬性。其格式為“@property {類型} 說明”。
  • @cfg: 構造函數cfg中的參數。其格式為“@cfg {類型} 說明”。一般情況下,cfg參數與公開的屬性(@property)相同,這時只用 @property 就行了,個人覺得不用寫 @cfg 了。
  • @return: 返回值。其格式為“@return {類型} 說明”。

若想知道JSDuck的文檔注釋的寫法的更多說明,可參考其官網wiki ( https://github.com/senchalabs/jsduck/wiki ),或是查看網上教程 (詳見“參考文獻”)。

對於其生成的文檔,詳見“8.2 用JSDuck生成文檔”。

3.4 枚舉

之前對於性別,是直接用數值代碼來表示。數值代碼的可讀性差,且不易維護,很多編程語言有“定義枚舉”語法來解決該問題。
雖然JavaScript沒有“定義枚舉”語法,但是可以通過一些辦法來模擬。例如可以定義一個 Object變量,其中的字段就是各種枚舉值。因(ES6之前的)JavaScript沒有常量關鍵字(const),為了區分只讀的枚舉值與普通字段,建議使用大寫字母來命名枚舉值。
並且JSDuck有定義枚舉的標注—— @enum

現在便可在 jsnamespace命名空間中 定義一個名為 GenderCode 的枚舉了——

/** @enum
* 性別代碼. 枚舉類.
*/
jsnamespace.GenderCode = {
    /** 未知 */
    "UNKNOWN": 0,
    /** 男 */
    "MALE": 1,
    /** 女 */
    "FEMALE": 2
};

隨后我們可以改進構造函數,使用枚舉值。

jsnamespace.PersonInfo = function(cfg) {
    cfg = cfg || {};
    /** @property {String} 姓名. */
    this.name = cfg["name"] || "";
    /** @property {jsnamespace.GenderCode} 性別. */
    this.gender = cfg["gender"] || jsnamespace.GenderCode.UNKNOWN;
};

使用了枚舉值之后,代碼可讀性、可維護性增加了很多。且JSDuck文檔能將 gender 的類型作為鏈接,方便查看。

隨后在使用時,也應該堅持用枚舉值——

var p = new jsnamespace.PersonInfo({"name": "Zhang San", "gender": jsnamespace.GenderCode.MALE});    // 張三, 男.
addlog(p.getHello());

3.4.1 應用:將稱謂文本加到歡迎字符串中

有了性別代碼枚舉后,便可考慮將稱謂文本加到歡迎字符串中,使歡迎文本更有意義。
具體辦法是可以寫一個getAppellation方法計算稱謂,然后在getHello中調用該方法拼接歡迎文本。

/**
* 取得稱謂.
*
* @return  {String}    返回稱謂字符串.
*/
jsnamespace.PersonInfo.prototype.getAppellation = function() {
    var rt = "";
    if (jsnamespace.GenderCode.MALE == this.gender) {
        rt = "Mr.";
    } else if (jsnamespace.GenderCode.FEMALE == this.gender) {
        rt = "Ms.";
    }
    return rt;
};

/**
* 取得歡迎字符串.
*
* @return  {String}    返回歡迎字符串.
*/
jsnamespace.PersonInfo.prototype.getHello = function() {
    var rt = "Hello " + this.getAppellation() + " " + this.name;
    return rt;
};

隨后改進一下測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.PersonInfo({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE});    // 李四.
    addlog(p1.getHello());
    addlog(p2.getHello());

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si

四、封裝

封裝(encapsulation):將程序按照一定的邏輯分成多個互相協作的部分,並對外界提供穩定的部分(暴露穩定部分),而將改變部分隱藏起來,外界只能通過暴露的部分向這個對象發送操作請求從而享受對象提供的服務,而不必管對象內部是如何運行的。

封裝性體現在2個方面——

  1. 對外隱藏實現細節,只能用專門約定的界面方法去操作。
  2. 對內能實現變量的共享,用於實現一些復雜邏輯。

4.1 私有靜態變量

在 JavaScript 中,可以使用立即執行函數(Immediately-Invoked Function Expression, IIFE)來隱藏私有變量。該辦法也很適合用在對象的封裝性上。

例如若想將之前 getHello 中的 "Hello"放到一個內部的私有變量中(歡迎單詞 m_WordHello),可以這樣寫——

/** @class
* JavaScript的命名空間.
* @abstract
*/
var jsnamespace = window.jsnamespace || {};

/** @class
* 個人信息. 構造函數法的類.
*/
jsnamespace.PersonInfo = function(cfg) {
    cfg = cfg || {};
    /** @property {String} 姓名. */
    this.name = cfg["name"] || "";
    /** @property {Number} 性別. 0未知, 1男, 2女. */
    this.gender = cfg["gender"] || 0;
};
(function(){
    /**
    * 歡迎單詞.
    * @static @private
    */
    var m_WordHello = "Hello";

    /**
    * 取得稱謂.
    *
    * @return  {String}    返回稱謂字符串.
    */
    jsnamespace.PersonInfo.prototype.getAppellation = function() {
        var rt = "";
        if (jsnamespace.GenderCode.MALE == this.gender) {
            rt = "Mr.";
        } else if (jsnamespace.GenderCode.FEMALE == this.gender) {
            rt = "Ms.";
        }
        return rt;
    };

    /**
    * 取得歡迎字符串.
    *
    * @return  {String}    返回歡迎字符串.
    */
    jsnamespace.PersonInfo.prototype.getHello = function() {
        var rt = m_WordHello + " " + this.getAppellation() + " " + this.name;
        return rt;
    };

})();

即將私有變量與prototype方法綁定代碼都放到立即執行函數中了。該寫法的優點有——

  1. 實現了封裝性。私有變量(如m_WordHello)只能在這個立即執行函數的內部使用,不會暴露到外部。
  2. 實現了變量共享。使getHello方法能訪問到私有靜態變量m_WordHello。
  3. 同一個類的方法定義都寫在一個大括號中、使用同一層縮進,可提高代碼的可讀性。且有利於編輯器的代碼折疊功能。

按照面向對象編程的定義,m_WordHello實際上是一個靜態私有變量。故在它的文檔注釋中加上“@static @private”標記。
對於私有成員命名,建議使用“m_”前綴。這樣能與公開成員區分開,提高代碼的可讀性。

JSDuck文檔注釋標記說明——

  • @static: 靜態。
  • @private: 私有。

對於JSDuck生成的文檔,注意它默認是不顯示私有級別的。可點擊“Show”,在下拉菜單中勾選“Private”,便可顯示私有成員。

4.2 私有靜態函數

有些時候我們重構代碼時,會將一些責任移到私有靜態函數,使主要邏輯更短更易讀。另外還可將各方法之間的重復代碼移到私有靜態函數中,避免重復。
例如可重構 getAppellation ,將計算稱謂文本的責任,移到一個 m_getAppellationText 函數中。

(function(){

    /**
    * 取得稱謂文本.
    *
    * @param {jsnamespace.GenderCode}    gender    性別.
    * @return  {String}    返回稱謂字符串.
    * @static @private
    */
    var m_getAppellationText = function(gender) {
        var rt = "";
        if (jsnamespace.GenderCode.MALE == gender) {
            rt = "Mr.";
        } else if (jsnamespace.GenderCode.FEMALE == gender) {
            rt = "Ms.";
        }
        return rt;
    };

    /**
    * 取得稱謂.
    *
    * @return  {String}    返回稱謂字符串.
    */
    jsnamespace.PersonInfo.prototype.getAppellation = function() {
        var rt = m_getAppellationText(this.gender);
        return rt;
    };
})();

JSDuck文檔注釋標記說明——

  • @param: 參數說明。其格式為“@param {類型} 參數名 說明”。

將代碼改成這樣后,原先的測試代碼依然能正常工作。

注意m_getAppellationText是將一個函數表達式賦值給它,而沒有使用函數聲明。這樣做有3個好處——

  1. 可讀性高。若是立即執行函數里再套一個函數聲明,有可能看不太明白代碼運行順序的脈絡,可能不少JavaScript新手會覺得很暈。但像這樣寫成“函數表達式賦值給變量”,可簡單的看成代碼順序運行,只是做了變量綁定、方法綁定操作,很容易理解。
  2. 有了函數變量后,便於以后做一些用到函數變量的工作。例如可考慮將m_getAppellationText函數變量傳給其他地方。

4.3 公開靜態成員

靜態成員是屬於整個類的而不是某個對象實例的。故有些時候,是需要將靜態成員公開給外部使用的。
對於大多數的面向對象編程語言,可使用“類.成員”的語法,來使用靜態成員。故我們也應該兼容該語法。
對於JavaScript來說,類的構造函數也是一個 Function,Function也是一種Object,並且Object可隨時在它上面增加字段或函數。即,在構造函數上增加字段或函數,就是給類綁定公開的靜態屬性、靜態方法。

4.3.1 公開靜態方法

例如對於上面的m_WordHello,可提供一套get/set方法(getWordHello、setWordHello),使外部能夠讀寫該值。

(function(){

    /**
    * 歡迎單詞.
    * @static @private
    */
    var m_WordHello = "Hello";

    // -- static method --
    /** 取得歡迎單詞.
    *
    * @return  {String}    返回歡迎單詞.
    * @static
    */
    jsnamespace.PersonInfo.getWordHello = function() {
        return m_WordHello;
    };
    /** 設置歡迎單詞.
    *
    * @param {String}    v    歡迎單詞.
    * @static
    */
    jsnamespace.PersonInfo.setWordHello = function(v) {
        m_WordHello = v;
    };

})();

隨后改進一下測試代碼,將歡迎單詞換為Welcome——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.PersonInfo({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE});    // 李四.
    addlog(p1.getHello());
    addlog(p2.getHello());
    jsnamespace.PersonInfo.setWordHello("Welcome");
    addlog(p1.getHello());
    addlog(p2.getHello());

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si
Welcome Mr. Zhang San
Welcome Ms. Li Si

4.3.2 公開靜態屬性

雖然可通過“給構造函數這個對象增加字段”的辦法來模擬靜態成員屬性,但是在一般情況並不推薦這樣做。因為JavaScript中沒有對屬性進行讀寫控制的語法,故一般情況下建議參考上一節的辦法,做一對 get/set 方法。
除非是無需讀寫控制的字段,才可考慮“直接給構造函數增加字段”的辦法。

4.4 私有實例成員

JavaScript中無法實現實例字段、對象方法(綁定到prototype的函數)的private封裝。
有一種變通策略,就是給這些私有實例字段、對象方法加上“m_”前綴,提醒它們是私有的,外部不要訪問。

由於這些實例字段、對象方法在業務上不應訪問,但語法上能夠訪問(且很多時候,子類需要訪問它們,后面的章節會詳述)。故我建議給它們的JSDuck文檔注釋中加上 @protected 標記。這樣還有助於在JSDuck生成的文檔中用“Show”篩選可見性。

五、繼承

繼承(inherit)也稱為派生(extend),在UML里稱為泛化(generalization)。繼承關系中,被繼承的稱為父類(或基類),從父類繼承而得的被稱為子類(或派生類)。繼承是保持對象差異性的同時共享對象相似性的復用。能夠被繼承的類總是含有並只含有它所抽象的那一類事務的共同特點。繼承提供了實現復用,只要從一個類繼承,我們就擁有了這個類的所有行為。語義上的“繼承”表示“是一種(is-a)”的關系。

5.1 轉發構造函數,繼承實例字段

在JavaScript中,可以使用call或apply方法實現“用指定對象來調用某個方法”的辦法。call、apply對構造函數也是有效的,故可以用他們來實現構造函數轉發功能,即在子類的構造函數中去調父類的構造函數,使其構造好父類的實例字段。

例如需要新建一個Employee(雇員信息)類,它繼承自PersonInfo(個人信息)類,它多了個 email 參數。便可這樣定義該類(的構造函數)——

jsnamespace.Employee = function(cfg) {
    cfg = cfg || {};
    jsnamespace.PersonInfo.call(this, (PersonInfo));
    // 自身的實例字段.
    /** @property {String} 電子郵箱. */
    this.email = cfg["email"] || "";
};

對上面代碼的解釋——

  1. 對 cfg 變量進行規范化。
  2. 使用call調用父類的構造函數(PersonInfo),這樣它便會給this對象 增加父類的實例字段(name、gender)。
  3. 父類(PersonInfo)構造函數調用完成后,便可添加自己(Employee)的實例變量(email)了。

這里便可看出“拷貝構造函數”寫法的優點——

  1. 只有一個 cfg 參數,故可以很簡單的通過 call 調父類。就算使用多層繼承,也一樣簡單,各個類只轉發它父類的構造函數就行。
  2. 能很方便的將 cfg 參數傳遞給父類,使父類也能用到參數來初始化變量。

測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});    // 李四.

表明現在已成功的繼承了實例字段。

5.2 綁定原型鏈,繼承方法

剛才僅是繼承了實例字段,還缺方法的繼承。這時得使用JavaScript的原型鏈機制。

5.2.1 定義extend函數

由於JavaScript原型鏈機制不太容易理解,這里直接給出了封裝好的函數,重點講解怎么使用。若對原理感興趣,可看“參考文獻”中的文章。

/** 繼承. 即設置好 Child 的原型為 Parent的原型實例,並設置 uber 屬性.
* 
*  @param    {Function}    Child    子類(構造函數).
*  @param    {Function}    Parent    父類(構造函數).
*    @static
*/
jsnamespace.extend = function(Child, Parent) {
    var F = function(){};
    F.prototype = Parent.prototype;
    Child.prototype = new F();
    Child.prototype.constructor = Child;
    Child.uber = Parent.prototype;
};

因為我們已經使用了命名空間機制,故可將該函數放到jsnamespace命名空間中。

5.2.2 使用extend

有了extend函數后,便可以用它來給子類繼承方法了。
例如讓Employee繼承父類PersonInfo的方法,便只寫這一行語句就行了——

jsnamespace.extend(jsnamespace.Employee, jsnamespace.PersonInfo);

測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});    // 李四.
    addlog(p1.getHello());
    addlog(p2.getHello());

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si

5.3 改進子類的JSDuck文檔注釋,申明繼承關系

做好剛才的2步后(構造函數轉發、使用extend),雖然JavaScript中已經能完整的使用繼承功能了。但對於JSDuck文檔注釋來說,還需要手工加上@extends標記,使JSDuck了解它們的繼承關系。
語法很簡單,“@extends 父類的類名(構造函數名)”,放在類(@class)的文檔注釋就行。
代碼如下——

/** @class
* 雇員信息. 構造函數法的類.
*
* @extends jsnamespace.PersonInfo
*/
jsnamespace.Employee = function(cfg) {
    cfg = cfg || {};
    jsnamespace.PersonInfo.call(this, cfg);
    // 自身的實例字段.
    /** @property {String} 電子郵箱. */
    this.email = cfg["email"] || "";
};
jsnamespace.extend(jsnamespace.Employee, jsnamespace.PersonInfo);

5.4 多層繼承

現在來做一個綜合練習吧,測試一下多層繼承。具體來說,即新增一個 Staff(職員信息)類,讓它繼承自 Employee(雇員信息)類,形成“Staff->Employee->PersonInfo”的繼承關系。
Staff(職員信息)類還多了一個duty(職務稱號)屬性。

代碼如下——

/** @class
* 職員信息. 構造函數法的類.
*
* @extends jsnamespace.Employee
*/
jsnamespace.Staff = function(cfg) {
    cfg = cfg || {};
    jsnamespace.Employee.call(this, cfg);
    // 自身的實例字段.
    /** @property {String} 職務稱號. */
    this.duty = cfg["duty"] || "";
};
jsnamespace.extend(jsnamespace.Staff, jsnamespace.Employee);

測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});    // 李四.
    var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"});    // 王五.
    addlog(p1.getHello());
    addlog(p2.getHello());
    addlog(p3.getHello());

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si
Hello Mr. Wang Wu

5.5 instanceof

JavaScript有個instanceof運算符,可用來判斷對象的類型。本文的介紹的繼承方案,是支持的instanceof運算符。包括在使用多層繼承時。

測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});    // 李四.
    var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"});    // 王五.
    addlog(p1.getHello());
    addlog(p2.getHello());
    addlog(p3.getHello());
    // instanceof.
    addlog("// instanceof");
    addlog("p1 instanceof jsnamespace.PersonInfo: " + (p1 instanceof jsnamespace.PersonInfo) );
    addlog("p1 instanceof jsnamespace.Employee: " + (p1 instanceof jsnamespace.Employee) );
    addlog("p1 instanceof jsnamespace.Staff: " + (p1 instanceof jsnamespace.Staff) );
    addlog("p2 instanceof jsnamespace.PersonInfo: " + (p2 instanceof jsnamespace.PersonInfo) );
    addlog("p2 instanceof jsnamespace.Employee: " + (p2 instanceof jsnamespace.Employee) );
    addlog("p2 instanceof jsnamespace.Staff: " + (p2 instanceof jsnamespace.Staff) );
    addlog("p3 instanceof jsnamespace.PersonInfo: " + (p3 instanceof jsnamespace.PersonInfo) );
    addlog("p3 instanceof jsnamespace.Employee: " + (p3 instanceof jsnamespace.Employee) );
    addlog("p3 instanceof jsnamespace.Staff: " + (p3 instanceof jsnamespace.Staff) );

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si
Hello Mr. Wang Wu
// instanceof
p1 instanceof jsnamespace.PersonInfo: true
p1 instanceof jsnamespace.Employee: false
p1 instanceof jsnamespace.Staff: false
p2 instanceof jsnamespace.PersonInfo: true
p2 instanceof jsnamespace.Employee: true
p2 instanceof jsnamespace.Staff: false
p3 instanceof jsnamespace.PersonInfo: true
p3 instanceof jsnamespace.Employee: true
p3 instanceof jsnamespace.Staff: true

5.5.1 在瀏覽器中查看繼承樹

在瀏覽器中按F12打開開發者工具,在JavaScript代碼中下斷點,便可在旁邊的變量面板中查看對象變量的詳情。例如可看到對象變量的繼承樹(其實JavaScript的標准術語叫“原型鏈”)。

繼承樹

可看到——

  • 觀察 [prototype],可看到 p3 的類型是jsnamespace.Staff,其繼承關系為“jsnamespace.Staff -> jsnamespace.Employee -> jsnamespace.PersonInfo -> object”。
  • p3成功的繼承了其所有父類的實例字段。

六、多態

多態(polymorphism)是“允許用戶將父對象設置成為一個或更多的它的子對象相等的技術,賦值后,基類對象就可以根據當前賦值給它的派生類對象的特性以不同的方式運作”(Charlie Calvert)。多態擴大了對象的適應性,改變了對象單一繼承的關系。多態是行為的抽象,它使得同名方法可以有不同的響應方式,我們可以通過名字調用某一方法而無需知道哪種實現將被執行,甚至無需知道執行這個實現的對象類型。

6.1 覆寫(override)

多態性中的最重要的,是覆寫(override)機制,它允許子類修改父類。即在父類中定義方法,然后子類覆寫同名方法。這樣在調用該名字的方法時,不同的對象運行的是各自子類的邏輯。

先前 PersonInfo、Employee、Staff 的 getHello 方法,均是只返回 name、gender 這2個屬性的值的。但這個不太符合實際需要,因為Employee、Staff 其實增加了屬性。
例如現在想讓Employee的getHello方法還返回該類新增email字段的值。這時便可使用覆寫機制了,代碼如下——

(function(){

    jsnamespace.Employee.prototype.getHello = function() {
        var rt = jsnamespace.PersonInfo.prototype.getHello.call(this);
        rt = rt + " (" + this.email + ")";
        return rt;
    };
    
})();

注:

  • 很多情況下子類覆寫方法時,並不需要徹底重寫,而是可以先調父類的同名方法后再進行個性化處理。在JavaScript中,可以使用 call、apply 來調用父類的方法,其格式一般為“類名.prototype.方法名.call(this, 參數)”。
  • 覆寫時沒有額外特殊操作,簡單的給子類編寫方法就行。因為JavaScript是根據原型鏈來逐層查找方法的。若子類定義了方法便用子類的,否則去找父類的,找不到時報錯。
  • 根據之前的經驗(四、封裝),我們將代碼寫在了一個立即執行函數中。

測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});    // 李四.
    addlog(p1.getHello());
    addlog(p2.getHello());

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si (lisi@mail.com)

6.2 覆寫時的文檔注釋

子類覆寫方法時,一般情況是不用重新寫一遍文檔注釋的。使用 @inheritdoc 命令,可讓該方法繼承其父類的文檔注釋。

(function(){

    /** @inheritdoc */
    jsnamespace.Employee.prototype.getHello = function() {
        var rt = jsnamespace.PersonInfo.prototype.getHello.call(this);
        rt = rt + " (" + this.email + ")";
        return rt;
    };
    
})();

雖然JSDuck提供了 @override 關鍵字,但是因為JSDuck會自動識別覆寫關系,故可省略。

JSDuck所生成的文檔中有這些信息——

  • 類的說明的右上角,會顯示繼承關系。
  • “Defined By”會顯示該成員是哪個類定義的。
  • “Show”下拉菜單中有“Inherited”復選框,可用來控制是否顯示繼承的成員。
  • 左下角可切換左側樹的顯示模式——“By Package”(按包,即按命名空間)、“By Inheritance”(按繼承關系)。

6.3 多層繼承時的覆寫

多層繼承時,也可按同樣的辦法來覆寫。
例如給Staff的getHello返回信息中加上duty的內容。

(function(){

    /** @inheritdoc */
    jsnamespace.Staff.prototype.getHello = function() {
        var rt = jsnamespace.Employee.prototype.getHello.call(this);
        rt = rt + " [" + this.duty + "]";
        return rt;
    };
    
})();

測試代碼——

    var p1 = new jsnamespace.PersonInfo();
    p1.name = "Zhang San";    // 張三.
    p1.gender = jsnamespace.GenderCode.MALE;
    var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});    // 李四.
    var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"});    // 王五.
    addlog(p1.getHello());
    addlog(p2.getHello());
    addlog(p3.getHello());

便可看到——

Hello Mr. Zhang San
Hello Ms. Li Si (lisi@mail.com)
Hello Mr. Wang Wu (wangwu@mail.com) [主任]

6.4 接口多態的討論

接口(interface)多態也是一種常見的多態機制。但是JavaScript語法不支持接口,雖然可以用原型鏈去模擬,但會使程序變得過於復雜,恐怕會降低可讀性、可維護性。且JSDuck也不支持接口,不利於維護開發文檔。

建議采取以下策略來避免接口——

  • 簡單情況下其實並不需要使用多接口,例如只有一種可變邏輯時。可以在父類定義好方法,然后子類覆寫這些方法就行,即模板方法模式(Template Method Pattern)。
  • 有多種可變邏輯時,可使用 橋接模式(Bridge Pattern),一種可變邏輯就是一種父類(即C++術語中的“虛基類”,其等價於接口),分別由不同的子類來實現邏輯。
  • 若邏輯很復雜,且橋接模式不合適時,可使用JavaScript傳統的回調函數機制來解決問題。正好JavaScript里的函數機制很強大。

七、完整范例

7.1 JavaScript源碼

7.1.1 jsnamespace.js

/*! @file jsnamespace.js
 * 演示JavaScript中如何模擬命名空間,並支持 構造函數法、閉包法 來構造類. 還演示了 jsduck 文檔注釋.
 *
 * @author zhouyuelin
 * @version v1.0
 */

/** @class
 * JavaScript的命名空間.
 * @abstract
 */
var jsnamespace = window.jsnamespace || {};

/** 繼承. 即設置好 Child 的原型為 Parent的原型實例,並設置 uber 屬性.
 * 
 *  @param	{Function}	Child	子類(構造函數).
 *  @param	{Function}	Parent	父類(構造函數).
 *	@static
 */
jsnamespace.extend = function(Child, Parent) {
	var F = function(){};
	F.prototype = Parent.prototype;
	Child.prototype = new F();
	Child.prototype.constructor = Child;
	Child.uber = Parent.prototype;
};

// == enum ==

/** @enum
 * 性別代碼. 枚舉類.
 */
jsnamespace.GenderCode = {
	/** 未知 */
	"UNKNOWN": 0,
	/** 男 */
	"MALE": 1,
	/** 女 */
	"FEMALE": 2
};


// == PersonInfo class ==

/** @class
 * 個人信息. 構造函數法的類.
 */
jsnamespace.PersonInfo = function(cfg) {
	cfg = cfg || {};
	/** @property {String} 姓名. */
	this.name = cfg["name"] || "";
	/** @property {jsnamespace.GenderCode} 性別. */
	this.gender = cfg["gender"] || jsnamespace.GenderCode.UNKNOWN;
};
(function(){
	/**
	 * 歡迎單詞.
	 * @static @private
	 */
	var m_WordHello = "Hello";
	
	/**
	 * 取得稱謂文本.
	 *
	 * @param {jsnamespace.GenderCode}	gender	性別.
	 * @return  {String}	返回稱謂字符串.
	 * @static @private
	 */
	var m_getAppellationText = function(gender) {
		var rt = "";
		if (jsnamespace.GenderCode.MALE == gender) {
			rt = "Mr.";
		} else if (jsnamespace.GenderCode.FEMALE == gender) {
			rt = "Ms.";
		}
		return rt;
	};

	/**
	 * 取得稱謂.
	 *
	 * @return  {String}	返回稱謂字符串.
	 */
	jsnamespace.PersonInfo.prototype.getAppellation = function() {
		var rt = m_getAppellationText(this.gender);
		return rt;
	};

	/**
	 * 取得歡迎字符串.
	 *
	 * @return  {String}	返回歡迎字符串.
	 */
	jsnamespace.PersonInfo.prototype.getHello = function() {
		var rt = m_WordHello + " " + this.getAppellation() + " " + this.name;
		return rt;
	};

	// -- static method --
	/** 取得歡迎單詞.
	 *
	 * @return  {String}	返回歡迎單詞.
	 * @static
	 */
	jsnamespace.PersonInfo.getWordHello = function() {
		return m_WordHello;
	};
	/** 設置歡迎單詞.
	 *
	 * @param {String}	v	歡迎單詞.
	 * @static
	 */
	jsnamespace.PersonInfo.setWordHello = function(v) {
		m_WordHello = v;
	};
	
})();

// == Employee class ==

/** @class
 * 雇員信息. 構造函數法的類.
 *
 * @extends jsnamespace.PersonInfo
 */
jsnamespace.Employee = function(cfg) {
	cfg = cfg || {};
	jsnamespace.PersonInfo.call(this, cfg);
	// 自身的實例字段.
	/** @property {String} 電子郵箱. */
	this.email = cfg["email"] || "";
};
jsnamespace.extend(jsnamespace.Employee, jsnamespace.PersonInfo);
(function(){

	/** @inheritdoc */
	jsnamespace.Employee.prototype.getHello = function() {
		var rt = jsnamespace.PersonInfo.prototype.getHello.call(this);
		rt = rt + " (" + this.email + ")";
		return rt;
	};
	
})();

// == Staff class ==

/** @class
 * 職員信息. 構造函數法的類.
 *
 * @extends jsnamespace.Employee
 */
jsnamespace.Staff = function(cfg) {
	cfg = cfg || {};
	jsnamespace.Employee.call(this, cfg);
	// 自身的實例字段.
	/** @property {String} 職務稱號. */
	this.duty = cfg["duty"] || "";
};
jsnamespace.extend(jsnamespace.Staff, jsnamespace.Employee);
(function(){

	/** @inheritdoc */
	jsnamespace.Staff.prototype.getHello = function() {
		var rt = jsnamespace.Employee.prototype.getHello.call(this);
		rt = rt + " [" + this.duty + "]";
		return rt;
	};
	
})();


// == PersonInfoUtil class ==

/** @class
 * 個人信息工具. 閉包法的類.
 */
jsnamespace.PersonInfoUtil = function () {
	/**
	 * 前綴.
	 *
	 * @static @private
	 */
	var _prefix = "[show] ";
	
	return {
		/** 顯示信息.
		 *
		 * @param {jsnamespace.PersonInfo}	p	個人信息.
		 * @static
		 */
		show: function(p) {
			var s = _prefix;
			if (!!p) {
				s += p.getHello();
			}
			alert(s);
		},
		
		/** 版本號. @readonly */
		version: 0x100
	};
}();

7.1.2 jsnamespace_test.htm

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>測試JavaScript 命名空間</title>
</head>
<body>
<script type="text/javascript" src="jsnamespace.js"></script>
<script type="text/javascript" src="jsnamespace_sub.js"></script>

<script type="text/javascript">

/** 追加日志.
 *
 * @param {String}	str	日志字符串.
 */
function addlog(str) {
	var txtresult = document.getElementById("txtresult");
	txtresult.value = txtresult.value + str + "\r\n";
}

/** 測試. */
function doTest() {
	var txtresult = document.getElementById("txtresult");
	txtresult.value = "";
	// do
	//alert(jsnamespace);
	var p1 = new jsnamespace.PersonInfo();
	p1.name = "Zhang San";	// 張三.
	p1.gender = jsnamespace.GenderCode.MALE;
	var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"});	// 李四.
	var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"});	// 王五.
	addlog(p1.getHello());
	addlog(p2.getHello());
	addlog(p3.getHello());
	// setWordHello.
	addlog("// setWordHello");
	jsnamespace.PersonInfo.setWordHello("Welcome");
	addlog(p1.getHello());
	addlog(p2.getHello());
	addlog(p3.getHello());
	// instanceof.
	addlog("// instanceof");
	addlog("p1 instanceof jsnamespace.PersonInfo: " + (p1 instanceof jsnamespace.PersonInfo) );
	addlog("p1 instanceof jsnamespace.Employee: " + (p1 instanceof jsnamespace.Employee) );
	addlog("p1 instanceof jsnamespace.Staff: " + (p1 instanceof jsnamespace.Staff) );
	addlog("p2 instanceof jsnamespace.PersonInfo: " + (p2 instanceof jsnamespace.PersonInfo) );
	addlog("p2 instanceof jsnamespace.Employee: " + (p2 instanceof jsnamespace.Employee) );
	addlog("p2 instanceof jsnamespace.Staff: " + (p2 instanceof jsnamespace.Staff) );
	addlog("p3 instanceof jsnamespace.PersonInfo: " + (p3 instanceof jsnamespace.PersonInfo) );
	addlog("p3 instanceof jsnamespace.Employee: " + (p3 instanceof jsnamespace.Employee) );
	addlog("p3 instanceof jsnamespace.Staff: " + (p3 instanceof jsnamespace.Staff) );
	// PersonInfoUtil.
	//jsnamespace.PersonInfoUtil.show(p1);
	//jsnamespace.PersonInfoUtil.show(p2);
	//jsnamespace.PersonInfoUtil.show(p3);
}



</script>
<h1>測試JavaScript 命名空間</h1>

<input type="button" value="測試" OnClick="doTest();" title="doTest" />
<br/>
輸出:<br/>
<textarea id="txtresult" rows="12" style="width:95%" readonly ></textarea>

</body>
</html>

7.2 用JSDuck生成文檔

可用 jsduck命令來生成文檔。對於本文的范例代碼,可使用目錄中的“jsduck_make.bat”來生成文檔,隨后可通過 “doc”子目錄中的“index.html”查看文檔。

以下截圖,就是JSDuck根據上面的范例代碼所生成文檔。可發現它完美的識別了代碼中的類(class),正確的生成了屬性、方法等的文檔,還能清晰的查看繼承樹、方法覆蓋(override)。

八、總結

簡單來說,本文所介紹的編寫類的寫法,是分為3段來寫的——

  1. 構造函數。這是JavaScript傳統的定義類的寫法。
  2. 使用extend來構造繼承關系(原型鏈)。僅對需要用到繼承的類,才這樣做。
  3. 在立即執行函數中編寫方法及私有成員,保證封裝性。

源碼地址:

https://github.com/zyl910/test_jsduck

參考文獻

(完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM