深入解析Underscore.js原始碼架構

蔣鵬飛發表於2020-03-20

Underscore.js是很有名的一個工具庫,我也經常用他來處理物件,陣列等,本文會深入解析Underscore原始碼架構,跟大家一起學習下他原始碼的亮點,然後模仿他寫一個簡單的架子來加深理解。他的原始碼通讀下來,我覺得他的亮點主要有如下幾點:

  • 不需要new的建構函式
  • 同時支援靜態方法呼叫和例項方法呼叫
  • 支援鏈式呼叫

本文的例子已經上傳到GitHub,同一個repo下還有我全部的博文和例子,求個star:

github.com/dennis-jian…

外層是一個自執行函式

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);
複製程式碼

上面程式碼的輸出是:

image-20200318155826651

可以看到constructor指向的是_(),說明這真的是一個_的例項,我們來分析下程式碼執行流程:

  1. 呼叫_(),裡面的this指向外層的作用域,我們這裡是window,因為window不是_的例項,會走到if裡面去。關於this指向,如果你還不是很明白,請看這篇文章
  2. 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;
  }
複製程式碼

這個方法寫完其實就可以直接用了,用上面那個例子呼叫如下:

image-20200318173552217

對映成例項方法

在Underscore裡面是用一個mixin方法來將靜態方法對映到原型上的,mixin方法接收一個物件作為引數,然後將這個物件上的方法全部複製到原型上。具體流程如下:

  1. 取出引數裡面的函式屬性,將其塞入一個陣列
  2. 遍歷這個陣列,將裡面的每個項設定到原型上
  3. 設定原型的時候注意處理下例項方法和靜態方法的引數

下面來看看程式碼:

_.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(_);呼叫之後就會將_上的靜態方法全部對映到原型上,這樣_()返回的例項也有了所有的靜態方法,這就讓_支援了兩種呼叫方式。可能有朋友注意到,我們上面的程式碼還有eachfunctions兩個輔助方法,我們也來實現下這兩個方法:

// 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結構的原因,他的鏈式呼叫需要支援更多場景:

  1. Underscore的例項方法還支援直接呼叫返回結果,不能簡單的返回例項
  2. 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;
}
複製程式碼

試下鏈式呼叫:

image-20200319150724786

我們發現結果是對的,但是輸出的是一個例項,不是我們想要的,所以我們還要一個方法來輸出真正的計算結果,這個方法只能掛在原型上,不能寫成靜態方法,不然還會走到我們的mixin,會返回例項:

_.prototype.value = function() {
  return this._wrapped;
}
複製程式碼

再來試一下呢:

image-20200319151150422

靜態方法支援鏈式呼叫

靜態方法也要支援鏈式呼叫,我們必須要讓他的返回值也能夠訪問到例項方法才行。一般情況下靜態方法的返回值是不能返回例項的,但是我們現在已經有了chain方法,我們直接讓這個方法構造一個_例項返回就行了,上面的例項方法支援鏈式呼叫是利用了現成的例項,返回的this,但是如果chain返回一個新例項,也是相容上面的,於是chain改為:

_.chain = function(obj) {
  var instance = _(obj);
  instance._chain = true;
  return instance;
}
複製程式碼

這樣我們的靜態方法chain也可以鏈式呼叫了,資料跟其他靜態方法一樣作為引數傳給chain:

image-20200319151921266

優化程式碼

到這裡我們的功能基本實現了,但是mixin函式還有需要優化的地方:

image-20200319152825665

  1. var res = func.apply(this, args);這裡的this指向的是當前例項,但是一個方法作為靜態方法呼叫時,比如_.map(),方法裡面的this指向的是_,所以這裡應該改成_。之前這裡傳this是因為chain裡面操作的是this,現在已經改成新建例項,就不用傳this,所以改為正確的_

  2. 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;
        }
      });
    }
    複製程式碼
  3. Underscore裡面還將isChain的判斷單獨提成了一個方法,我這裡沒這麼做了,放在一起看著還直觀點。

總結

本文主要講解了Underscore原始碼的架構,並自己實現了一個簡單的架子,部分變數名字和方法的具體實現可能不一樣,但是原理是一樣的。通過搭建這個簡單的架子,其實我們學會了:

  1. 不用new構造例項物件
  2. mixin怎麼擴充套件靜態方法到原型上
  3. 通過顯式的呼叫chain來支援靜態方法和例項方法的鏈式呼叫

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: github.com/dennis-jian…

作者掘金文章彙總:juejin.im/post/5e3ffc…

相關文章