理解Underscore的設計架構

Russ_Zhong發表於2019-02-18

在一個多月的畢業設計之後,我再次開始了Underscore的原始碼閱讀學習,斷斷續續也寫了好些篇文章了,基本把一些比較重要的或者個人認為有營養的函式都解讀了一遍,所以現在學習一下Underscore的整體架構。我相信很多程式設計師都會有一個夢想,那就是可以寫一個自己的模組或者工具庫,那麼我們現在就來學習一下如果我們要寫一個自己的Underscore,我們該怎麼寫?

大致的閱讀了一下Underscore原始碼,可以發現其基本架構如下:

1 定義變數

在ES6之前,JavaScript開發者是無法通過let、const關鍵字模擬塊作用域的,只有函式內部的變數會被認為是私有變數,在外部無法訪問,所以大部分框架或者工具庫的模式都是在立即執行函式裡面定義一系列的變數,完成框架或者工具庫的構建,這樣做的好處就是程式碼不會汙染全域性作用域。Underscore也不例外,它也使用了經典的立即執行函式的模式:

(function() {
    // ...
}())
複製程式碼

此外,Underscore採用了經典的構造器模式,這使得使用者可以通過_(obj).function()的方式使用Underscore的介面,因為任意建立的Underscore物件都具有原型上的所有方法。那麼程式碼形式如下:

(function() {
    var _ = function() {
        // ...
    };
}())
複製程式碼

_是一個函式,但是在JavaScript中,函式也是一個物件,所以我們可以給_新增一系列屬性,即Underscore中的一系列公開的介面,以便可以通過_.function()的形式呼叫這些介面。程式碼形式如下:

(function() {
    var _ = function() {
        // ...
    };
    _.each = function() {
        // ...
    };
    // ...
}())
複製程式碼

_變數可以當做構造器構造一個Underscore物件,這個物件是標準化的,它具有規定的屬性,比如:_chain_wrapped以及所有Underscore的介面方法。Underscore把需要處理的引數傳遞給_建構函式,建構函式會把這個值賦給所構造物件的_wrapped屬性,這樣做的好處就是在之後以_(obj).function()形式呼叫介面時,可以直接到_wrapped屬性中尋找要處理的值。這就使得在定義_建構函式的時候,需要對傳入的引數進行包裹,此外還要防止多層包裹,以及為了防止增加new操作符,需要在內部進行物件構建,程式碼形式如下:

(function() {
    var _ = function(obj) {
        // 防止重複包裹的處理,如果obj已經是_的例項,那麼直接返回obj。
        if(obj instanceof _) {
            return obj;
        }
        // 判斷函式中this的指向,如果this不是_的例項,那麼返回構造的_例項。
        // 這裡是為了不使用new操作符構造新物件,很巧妙,因為在通過new使用建構函式時,函式中的this會指向新構造的例項。
        if(!(this instanceof _)) {
            return new _();
        }
        // 
        this._wrapped = obj;
    };
    _.each = function() {
        // ...
    };
    // ...
}())
複製程式碼

這一段的處理很關鍵也很巧妙。

2 匯出變數

既然我們是在立即執行函式內定義的變數,那麼_的生命週期也只存在於匿名函式的執行階段,一旦函式執行完畢,這個變數所儲存的資料也就被釋放掉了,所以不匯出變數的話實際上這段程式碼相當於什麼都沒做。那麼該如何匯出變數呢?我們知道函式內部可以訪問到外部的變數,所以只要把變數賦值給外部作用域或者外部作用域變數就行了。通常為了方便實用,把變數賦值給全域性作用域,不同的環境全域性作用域名稱不同,瀏覽器環境下通常為window,伺服器環境下通常為global,根據不同的使用環境需要做不同的處理,比如瀏覽器環境下程式碼形式如下:

(function() {
    var _ = function() {
        // ...
    };
    _.each = function() {
        // ...
    };
    // ...
    window._ = _;
}())
複製程式碼

這樣處理之後,在全域性作用域就可以直接通過_使用Underscore的介面了。

但是僅僅這樣處理還不夠,因為Underscore面向環境很多,針對不同的環境要做不同的處理。接下來看Underscore原始碼。

首先,Underscore通過以下程式碼根據不同的環境獲取不同的全域性作用域:

//獲取全域性物件,在瀏覽器中是self或者window,在伺服器端(Node)中是global。
//在瀏覽器控制檯中輸入self或者self.self,結果都是window。
var root = typeof self == `object` && self.self === self && self || typeof global == `object` && global.global === global && global || this || {};
root._ = _;
複製程式碼

註釋寫在了程式碼中,如果既不是瀏覽器環境也不是Node環境的話,就獲取值為this,通過this獲取全域性作用域,如果this仍然為空,就賦值給一個空的物件。感謝大神@冴羽的指教,賦值給空物件的作用是防止在開發微信小程式時報錯,因為在微信小程式這種特殊環境下,window和global都是undefined,並且強制開啟了strict模式,這時候this也是undefined(嚴格模式下禁止this指向全域性變數),所以指定一個空物件給root,防止報錯,具體參考:`this` is undefined in strict mode

這裡值得學習的地方還有作者關於賦值的寫法,十分簡潔,嘗試了一下,對於下面的寫法:

const flag = val1 && val2 && val3 || val4 && val5;
複製程式碼

程式會從左到右依次判斷val1、val2、val3的值,假設||把與運算分為許多組,那麼:

  • 一旦當前判斷組的某個值轉換為Boolean值後為false,那麼就跳轉到下一組進行判斷,直到最後一組,如果最後一組仍然有值被判斷為false,那麼為false的值被賦給flag。
  • 如果當前判斷組所有的值轉換後都為true,那麼最後一個值會被賦給flag。

比如:

const a = 1 && 2 && 3 || 2 && 3;
// a === 3
const b = 1 && false && 2 || 2 && 3;
// b === 3
const c = 1 && false && 2 || false && 2
// c === false
const d = 1 && false && 2 || 0 && 2
// d === 0
const e = 1 && false && 2 || 1 && 2
// e === 2
複製程式碼

除了要考慮給全域性作用域賦值的差異以外,還要考慮JavaScript模組化規範的差異,JavaScript模組化規範包括AMD、CMD等。

通過以下程式碼相容AMD規範:

//相容AMD規範的模組化工具,比如RequireJS。
if (typeof define == `function` && define.amd) {
	define(`underscore`, [], function () {
		return _;
	});
}
複製程式碼

如果define是一個函式並且define.amd不為null或者undefined,那就說明是在AMD規範的工作環境下,使用define函式匯出變數。

通過以下程式碼相容CommonJS規範:

//為Node環境匯出underscore,如果存在exports物件或者module.exports物件並且這兩個物件不是HTML DOM,那麼即為Node環境。
//如果不存在以上物件,把_變數賦值給全域性環境(瀏覽器環境下為window)。
if (typeof exports != `undefined` && !exports.nodeType) {
	if (typeof module != `undefined` && !module.nodeType && module.exports) {
		exports = module.exports = _;
	}
	exports._ = _;
} else {
	root._ = _;
}
複製程式碼

此外,通過以上程式碼可以支援ES6模組的import語法。具體原理參考阮一峰老師的教程:ES6 模組載入 CommonJS 模組。如果既不是AMD規範也不是CommonJS規範,那麼直接將_賦值給全域性變數。這一點可以通過將Underscore原始碼複製到瀏覽器的控制檯回車後再檢視__.prototype的值得到結論。

匯出變數之後,在外部就可以使用我們定義的介面了。

3 實現鏈式呼叫

許多出名的工具庫都會提供鏈式呼叫功能,比如jQuery的鏈式呼叫:$(`...`).css().click();,Underscore也提供了鏈式呼叫功能:_.chain(...).each().unzip();

鏈式呼叫基本都是通過返回原物件實現的,比如返回this,在Underscore中,可以通過_.chain函式開始鏈式呼叫,實現原理如下:

// Add a "chain" function. Start chaining a wrapped Underscore object.
//將傳入的物件包裝為鏈式呼叫的物件,將其標誌位置位true。
_.chain = function (obj) {
	var instance = _(obj);
	instance._chain = true;
	return instance;
};
複製程式碼

它構造一個_例項,然後將其_chain鏈式標誌位屬性值為true代表鏈式呼叫,然後返回這個例項。這樣做就是為了強制通過_().function()的方式呼叫介面,因為在_的原型上,所有介面方法與_的屬性方法有差異,_原型上的方法多了一個步驟,它會對其父物件的_chain屬性進行判斷,如果為true,那麼就繼續使用_.chain方法進行鏈式呼叫的包裝,在一部分在後續會繼續討論。

4 實現介面擴充套件

在許多出名的工具庫中,都可以實現使用者擴充套件介面,比如jQuery的$.extend$.fn.extend方法,Underscore也不例外,其_.mixin方法允許使用者擴充套件介面。

這裡涉及到的一個概念就是mixin設計模式,mixin設計模式是JavaScript中最常見的設計模式,可以理解為把一個物件的屬性拷貝到另外一個物件上,具體可以參考:摻雜模式(mixin)

先看Underscore中_.mixin方法的原始碼:

_.mixin = function (obj) {
	// _.functions函式用於返回一個排序後的陣列,包含所有的obj中的函式名。
	_.each(_.functions(obj), function (name) {
		// 先為_物件賦值。
		var func = _[name] = obj[name];
		// 為_的原型新增函式,以增加_(obj).mixin形式的函式呼叫方法。
		_.prototype[name] = function () {
			// this._wrapped作為第一個引數傳遞,其他使用者傳遞的引數放在後面。
			var args = [this._wrapped];
			push.apply(args, arguments);
			// 使用chainResult對運算結果進行鏈式呼叫處理,如果是鏈式呼叫就返回處理後的結果,
			// 如果不是就直接返回運算後的結果。
			return chainResult(this, func.apply(_, args));
		};
	});
	return _;
};
複製程式碼

這段程式碼很好理解,就是對於傳入的obj物件引數,將物件中的每一個函式拷貝到_物件上,同名會被覆蓋。與此同時,還會把obj引數物件中的函式對映到_物件的原型上,為什麼說是對映,因為並不是直接拷貝的,還進行了鏈式呼叫的處理,通過chainResult方法,實現了了鏈式呼叫,所以第三節中說_物件原型上的方法與_物件中的對應方法有差異,原型上的方法多了一個步驟,就是判斷是否鏈式呼叫,如果是鏈式呼叫,那麼繼續通過_.chain函式進行包裝。chainResult函式程式碼如下:

// Helper function to continue chaining intermediate results.
//返回一個鏈式呼叫的物件,通過判斷instance._chain屬性是否為true來決定是否返回鏈式物件。
var chainResult = function (instance, obj) {
	return instance._chain ? _(obj).chain() : obj;
};
複製程式碼

實現mixin函式之後,Underscore的設計者非常機智的運用了這個函式,程式碼中只可以看到為_自身定義的一系列函式,比如_.each_.map等,但看不到為_.prototype所定義的函式,為什麼還可以通過_().function()的形式呼叫介面呢?這裡就是因為作者通過_.mixin函式直接將所有_上的函式對映到了_.prototype上,在_.mixin函式定義的下方,有一句程式碼:

// Add all of the Underscore functions to the wrapper object.
_.mixin(_);
複製程式碼

這句程式碼就將所有的_上的函式對映到了_.prototype上,有點令我歎為觀止。

通過_.mixin函式,使用者可以為_擴充套件自定義的介面,下面的例子來源於中文手冊

_.mixin({
    capitalize: function(string) {
        return string.charAt(0).toUpperCase() + string.substring(1).toLowerCase();
    }
});
_("fabio").capitalize();
=> "Fabio"
複製程式碼

5 實現noConflict

在許多工具庫中,都有實現noConflict,因為在全域性作用域,變數名是獨一無二的,但是使用者可能引入多個類庫,多個類庫可能有同一個識別符號,這時就要使用noConflict實現無衝突處理。

具體做法就是先儲存原來作用域中該標誌位的資料,然後在呼叫noConflict函式時,為全域性作用域該標誌位賦值為原來的值。程式碼如下:

// Save the previous value of the `_` variable.
//儲存之前全域性物件中_屬性的值。
var previousUnderscore = root._;
// Run Underscore.js in *noConflict* mode, returning the `_` variable to its
// previous owner. Returns a reference to the Underscore object.
_.noConflict = function () {
	root._ = previousUnderscore;
	return this;
};
複製程式碼

在函式的最後,返回了Underscore物件,允許使用者使用另外的變數儲存。

6 為變數定義一系列基本屬性

作為一個物件,應該有一些基本屬性,比如toString、value等等,需要重寫這些屬性或者函式,以便使用時返回合適的資訊。此外還需要新增一些版本號啊什麼的屬性。

7 總結

做完以上所有的工作之後,一個基本的工具庫基本就搭建完成了,完成好測試、壓縮等工作之後,就可以釋出在npm上供大家下載了。想要寫一個自己的工具庫的同學可以嘗試一下。

另外如果有錯誤之處或者有補充之處的話,歡迎大家不吝賜教,一起學習,一起進步!
更多Underscore原始碼解析:GitHub

相關文章