Underscore.js是很有名的一個工具庫,我也經常用他來處理物件,陣列等,本文會深入解析Underscore原始碼架構,跟大家一起學習下他原始碼的亮點,然後模仿他寫一個簡單的架子來加深理解。他的原始碼通讀下來,我覺得他的亮點主要有如下幾點:
- 不需要new的建構函式
- 同時支援靜態方法呼叫和例項方法呼叫
- 支援鏈式呼叫
本文的例子已經上傳到GitHub,同一個repo下還有我全部的博文和例子,求個star:
外層是一個自執行函式
Underscore外層就是一個自執行函式,在自執行函式裡面將_
掛載到了window上。這是很多第三方庫慣用的套路。如果你還不知道怎麼入手看原始碼,不知道入口在哪裡,或者看不懂他的外層結構,請看從架構入手輕鬆讀懂框架原始碼:以jQuery,Zepto,Vue和lodash-es為例,這篇文章詳細講解了怎麼入手看原始碼。本文主要講解Underscore原始碼架構裡面的亮點,怎麼入手就不再贅述了。
不用new的建構函式
我們在使用第三方庫的時候,經常需要先拿一個他們的例項,有些庫需要用new來顯式的呼叫,比如原生的Promise,有些庫不需要new也可以拿到例項物件,比如jQuery。不用new就返回一個例項原生JS肯定是不支援的,有這些特性的庫都是自己封裝了一層的。不同的庫在封裝的時候也有不同的思路,下面我們來講講其中兩種方案。
jQuery的方案
之前我在另一篇文章從架構入手輕鬆讀懂框架原始碼:以jQuery,Zepto,Vue和lodash-es為例中詳細講解了jQuery是怎麼實現不用new就返回一個例項的。另外還模仿jQuery的這種方案實現了我自己的一個工具庫:學以致用:手把手教你擼一個工具庫並打包釋出,順便解決JS小數計算不准問題。這裡貼一段我工具庫文章的程式碼簡單回顧下這種方案:
// 首先建立一個fc的函式,我們最終要返回的其實就是一個fc的例項
// 但是我們又不想讓使用者new,那麼麻煩
// 所以我們要在建構函式裡面給他new好這個例項,直接返回
function FractionCalculator(numStr, denominator) {
// 我們new的其實是fc.fn.init
return new FractionCalculator.fn.init(numStr, denominator);
}
// fc.fn其實就是fc的原型,算是個簡寫,所有例項都會擁有這上面的方法
FractionCalculator.fn = FractionCalculator.prototype = {};
// 這個其實才是真正的建構函式,這個建構函式也很簡單,就是將傳入的引數轉化為分數
// 然後將轉化的分數掛載到this上,這裡的this其實就是返回的例項
FractionCalculator.fn.init = function(numStr, denominator) {
this.fraction = FractionCalculator.getFraction(numStr, denominator);
};
// 前面new的是init,其實返回的是init的例項
// 為了讓返回的例項能夠訪問到fc的方法,將init的原型指向fc的原型
FractionCalculator.fn.init.prototype = FractionCalculator.fn;
// 呼叫的時候就不用new了,直接呼叫就行
FractionCalculator();
複製程式碼
Underscore的方案
jQuery的方案是在建構函式裡面new了另外一個物件,然後將這個物件的原型指向jQuery的原型,以便返回的例項能夠訪問jQuery的例項方法。目的是能夠達到的,但是方案顯得比較冗長,Underscore的方案就簡潔多了:
function _(){
if(!(this instanceof _)) {
return new _();
}
}
// 呼叫的時候直接_()就可以拿到例項物件
const instance = _();
console.log(instance);
複製程式碼
上面程式碼的輸出是:
可以看到constructor指向的是_()
,說明這真的是一個_的例項,我們來分析下程式碼執行流程:
- 呼叫
_()
,裡面的this指向外層的作用域,我們這裡是window,因為window不是_的例項,會走到if裡面去。關於this指向,如果你還不是很明白,請看這篇文章。- if裡面會呼叫
new _()
,這會拿到一個例項物件,並將這個物件return
出去。new _()
也會調到_()
方法,但是因為使用new呼叫,裡面的this指向的就是new出來的例項,所以if進不去,執行結束。
Underscore巧妙應用了this的指向,通過檢測this的指向來判斷你是new呼叫的還是普通呼叫的,如果是普通呼叫就幫你new一下再返回。
同時支援靜態方法和例項方法
用過Underscore的朋友應該有注意到,對於同一個方法來說,Underscore既支援作為靜態方法呼叫,也支援作為例項方法呼叫,下面是官方的例子:
_.map([1, 2, 3], function(n){ return n * 2; }); // map作為靜態方法呼叫
_([1, 2, 3]).map(function(n){ return n * 2; }); // map作為例項方法呼叫
複製程式碼
當我們把方法作為靜態方法呼叫的時候,需要處理的資料就是第一個引數;當把他作為例項方法呼叫的時候,待處理資料是作為引數傳給建構函式的。下面我們來講講這是怎麼實現的。
其實最簡單的方法就是寫兩個函式,一個是靜態方法,一個是例項方法。但是如果我們這樣做了,這兩個函式內部處理的邏輯其實是高度相似的,可能只是引數稍微有點不同而已。這肯定不是一個優雅的程式設計師應該做的。Underscore給出的方法就是所有方法先寫成靜態方法,然後用一個統一的函式來將所有的靜態方法掛載到原型上,讓他成為一個例項方法。我們試著一步一步的來實現下。
先寫一個靜態方法
我們先來寫一個簡單的map方法,將它掛載到_上成為靜態方法:
_.map = function(array, callback) {
var result = [];
var length = array.length;
for(var i = 0; i< length; i++) {
var res = callback(array[i]);
result[i] = res;
}
return result;
}
複製程式碼
這個方法寫完其實就可以直接用了,用上面那個例子呼叫如下:
對映成例項方法
在Underscore裡面是用一個mixin
方法來將靜態方法對映到原型上的,mixin
方法接收一個物件作為引數,然後將這個物件上的方法全部複製到原型上。具體流程如下:
- 取出引數裡面的函式屬性,將其塞入一個陣列
- 遍歷這個陣列,將裡面的每個項設定到原型上
- 設定原型的時候注意處理下例項方法和靜態方法的引數
下面來看看程式碼:
_.mixin = function(obj) {
// 遍歷obj裡面的函式屬性
_.each(_.functions(obj), function(item){
// 取出每個函式
var func = obj[item];
// 在原型上設定一個同名函式
_.prototype[item] = function() {
// 注意這裡,例項方法待處理資料是建構函式接收的引數,改造建構函式的程式碼在後面
// 這裡將資料取出來作為靜態方法的第一個引數
var value = this._wrapped;
var args = [value];
// 將資料和其他引數放到一個陣列裡面,作為靜態方法的引數
Array.prototype.push.apply(args, arguments);
// 用處理好的引數來呼叫靜態方法
var res = func.apply(this, args);
// 將結果返回
return res;
}
});
}
// 上面的mixin寫好後不要忘了呼叫一下,將_自己作為引數傳進去
_.mixin(_);
// 建構函式需要接收處理的資料
// 並將它掛載到this上,這裡的this是例項物件
function _(value){
if(!(this instanceof _)) {
return new _(value);
}
this._wrapped = value;
}
複製程式碼
上面的_.mixin(_);
呼叫之後就會將_
上的靜態方法全部對映到原型上,這樣_()
返回的例項也有了所有的靜態方法,這就讓_
支援了兩種呼叫方式。可能有朋友注意到,我們上面的程式碼還有each
和functions
兩個輔助方法,我們也來實現下這兩個方法:
// functions就是取出物件上所有函式的名字,塞到一個陣列裡面返回
_.functions = function(obj){
var result = [];
for(var key in obj) {
if(typeof obj[key] === 'function'){
result.push(key);
}
}
return result;
}
// each就是對一個陣列進行遍歷,每個都執行下callback
_.each = function(array, callback){
var length = array.length;
for(var i = 0; i < length; i++) {
callback(array[i]);
}
}
複製程式碼
mixin順便支援外掛
Underscore的mixin
不僅讓他支援了靜態和例項方法兩種呼叫方式,同時因為他自己也是_
的一個靜態方法,我們也是可以拿來用的。官方支援自定義外掛就是用的這個方法,下面是官方例子:
_.mixin({
capitalize: function(string) {
return string.charAt(0).toUpperCase() + string.substring(1).toLowerCase();
}
});
_("fabio").capitalize(); // Fabio
複製程式碼
其實我們前面寫的那個mixin
方法已經支援將自定義方法作為例項方法了,但是還差一點,還差靜態方法,所以我們再加一行程式碼,同時將接收到的引數賦值給_
就行了:
_.mixin = function(obj) {
_.each(_.functions(obj), function(item){
var func = obj[item];
// 注意這裡,我們同時將這個方法賦值給_作為靜態方法,這下就完全支援自定義外掛了
_[item] = func;
_.prototype[item] = function() {
var value = this.value;
var args = [value];
Array.prototype.push.apply(args, arguments);
var res = func.apply(this, args);
return res;
}
});
}
複製程式碼
支援鏈式呼叫
鏈式呼叫也很常見,比如jQuery的點點點,我在另一篇文章學以致用:手把手教你擼一個工具庫並打包釋出,順便解決JS小數計算不准問題詳細講解過這種例項方法的鏈式呼叫怎麼實現,關鍵是每個例項方法計算完成後都返回當前例項,對於例項方法來說,當前例項就是this。這種方式也適用於Underscore,但是Underscore因為自身需求和API結構的原因,他的鏈式呼叫需要支援更多場景:
- Underscore的例項方法還支援直接呼叫返回結果,不能簡單的返回例項
- Underscore的靜態方法也要支援鏈式呼叫
例項方法支援鏈式呼叫
我們一步一步來,先來解決例項方法支援鏈式呼叫的問題,我們前面已經實現了將靜態方法對映成例項方法,前面實現的例項方法的返回值就是靜態方法的返回值。為了實現鏈式呼叫,我們還需要例項方法計算完後還能夠返回當前例項(也就是this),所以我們需要一個依據來判斷應該返回計算結果還是當前例項。這個依據在Underscore裡面是要使用者給的,也就是顯式呼叫chain
方法。依據我們的分析,chain
應該很簡單,給一個依據來判斷例項方法應該返回啥,也就是給當前例項設定一個標誌位:
_.chain = function() {
this._chain = true;
return this;
}
複製程式碼
chain
就是這麼簡單,兩行程式碼,然後我們的例項方法裡面根據_chain來判斷返回計算結果還是當前例項:
_.mixin = function(obj) {
_.each(_.functions(obj), function(item){
var func = obj[item];
_[item] = func;
_.prototype[item] = function() {
var value = this._wrapped;
var args = [value];
Array.prototype.push.apply(args, arguments);
var res = func.apply(this, args);
// 檢查鏈式呼叫標記,如果是鏈式呼叫
// 將資料掛載到例項上,返回例項
var isChain = this._chain;
if(isChain) {
// 注意如果方法是chain本身,不要更新_wrapped,不然_wrapped會被改為chain的返回值,也就是一個例項
// 這裡有點醜,後面優化
if(item !== 'chain') {
this._wrapped = res;
}
return this;
}
return res;
}
});
}
複製程式碼
我們再來寫個unique
方法來驗證下鏈式呼叫:
_.unique = function(array){
var result = [];
var length = array.length;
for(var i = 0; i < length; i++) {
if(result.indexOf(array[i]) === -1){
result.push(array[i]);
}
}
return result;
}
複製程式碼
試下鏈式呼叫:
我們發現結果是對的,但是輸出的是一個例項,不是我們想要的,所以我們還要一個方法來輸出真正的計算結果,這個方法只能掛在原型上,不能寫成靜態方法,不然還會走到我們的mixin,會返回例項:
_.prototype.value = function() {
return this._wrapped;
}
複製程式碼
再來試一下呢:
靜態方法支援鏈式呼叫
靜態方法也要支援鏈式呼叫,我們必須要讓他的返回值也能夠訪問到例項方法才行。一般情況下靜態方法的返回值是不能返回例項的,但是我們現在已經有了chain
方法,我們直接讓這個方法構造一個_
例項返回就行了,上面的例項方法支援鏈式呼叫是利用了現成的例項,返回的this,但是如果chain
返回一個新例項,也是相容上面的,於是chain
改為:
_.chain = function(obj) {
var instance = _(obj);
instance._chain = true;
return instance;
}
複製程式碼
這樣我們的靜態方法chain
也可以鏈式呼叫了,資料跟其他靜態方法一樣作為引數傳給chain
:
優化程式碼
到這裡我們的功能基本實現了,但是mixin
函式還有需要優化的地方:
-
var res = func.apply(this, args);
這裡的this指向的是當前例項,但是一個方法作為靜態方法呼叫時,比如_.map()
,方法裡面的this指向的是_
,所以這裡應該改成_
。之前這裡傳this是因為chain
裡面操作的是this,現在已經改成新建例項,就不用傳this,所以改為正確的_
。 -
對
item
進行了特異性判斷,前面之所以這麼做,也是因為chain
裡面操作的是this,所以在apply裡面其實已經設定了this._chain
為true,所以會走到if裡面去,現在新建例項了,走到apply的時候,設定的其實是res._chain
,所以不會進到if,要調下一個例項方法的時候,this._chain
才會是true,所以這個if可以直接去掉了。_.mixin = function(obj) { _.each(_.functions(obj), function(item){ var func = obj[item]; _[item] = func; _.prototype[item] = function() { var value = this._wrapped; var args = [value]; Array.prototype.push.apply(args, arguments); var res = func.apply(_, args); var isChain = this._chain; if(isChain) { // if(item !== 'chain') { this._wrapped = res; // } return this; } return res; } }); } 複製程式碼
-
Underscore裡面還將
isChain
的判斷單獨提成了一個方法,我這裡沒這麼做了,放在一起看著還直觀點。
總結
本文主要講解了Underscore原始碼的架構,並自己實現了一個簡單的架子,部分變數名字和方法的具體實現可能不一樣,但是原理是一樣的。通過搭建這個簡單的架子,其實我們學會了:
- 不用new構造例項物件
mixin
怎麼擴充套件靜態方法到原型上- 通過顯式的呼叫
chain
來支援靜態方法和例項方法的鏈式呼叫
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
作者博文GitHub專案地址: github.com/dennis-jian…
作者掘金文章彙總:juejin.im/post/5e3ffc…