徹底搞懂Javascript閉包,柯里化,手寫代碼!

本文總結了 javascript 中函數的常見知識點,包含了基礎概念,閉包,this 指向問題,高階函數,柯里化等,手寫代碼那部分也是滿滿的乾貨,無論您是想複習準備面試,還是想深入瞭解原理,本文都應該有你想看的點,總之還是值得一看的。

老規矩,先上思維導圖。

什麼是函數

一般來說,一個函數是可以通過外部代碼調用的一個 “子程序”(或在遞歸的情況下由內部函數調用)。像程序本身一樣,一個函數由稱爲函數體的一系列語句組成。值可以傳遞給一個函數,函數將返回一個值。

函數首先是一個對象,並且在 javascript 中,函數是一等對象(first-class object)。函數可以被執行(callable,擁有內部屬性 [[Call]]),這是函數的本質特性。除此之外,函數可以賦值給變量,也可以作爲函數參數,還可以作爲另一個函數的返回值。

函數基本概念

函數名

函數名是函數的標識,如果一個函數不是匿名函數,它應該被賦予函數名。

下面是一些不成文的約定,不成文代表它不必遵守,但是我們按照這樣的約定來執行,會讓開發變得更有效率。

函數參數

形參

形參是函數定義時約定的參數列表,由一對圓括號()包裹。

在 MDN 上有看到,一個函數最多可以有255個參數。

然而形參太多時,使用者總是容易在引用時出錯。所以對於數量較多的形參,一般推薦把所有參數作爲屬性或方法整合到一個對象中,各個參數作爲這個對象的屬性或方法來使用。舉個例子,微信小程序的提供的 API 基本上是這種調用形式。

wx.redirectTo(Object object)

調用示例如下:

wx.redirectTo({
  url: '/article/detail?id=1',
  success: function() {},
  fail: function() {}
})

形參的數量可以由函數的length屬性獲得,如下所示。

function test(a, b, c) {}
test.length; // 3

實參

實參是調用函數時傳入的,實參的值在函數執行前被確定。

javascript 在函數定義時並不會約定參數的數據類型。如果你期望函數調用時傳入正確的數據類型,你必須在函數體中對入參進行數據類型判斷。

function add(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error("參數必須是數字類型")
  }
}

好在 Typescript 提供了數據類型檢查的能力,這一定程度上防止了意外情況的發生。

實參的數量可以通過函數中arguments對象的length屬性獲得,如下所示。

實參數量不一定與形參數量一致。

function test(a, b, c) {
  var argLength = arguments.length;
  return argLength;
}
test(1, 2); // 2

默認參數

函數參數的默認值是undefined,如果你不傳入實參,那麼實際上在函數執行過程中,相應參數的值是undefined

ES6 也支持在函數聲明時設置參數的默認值。

function add(a, b = 2) {
    return a + b;
}
add(1); // 3

在上面的add函數中,參數b被指定了默認值2。所以,即便你不傳第二個參數b,也能得到一個預期的結果。

假設一個函數有多個參數,我們希望不給中間的某個參數傳值,那麼這個參數值必須顯示地指定爲undefined,否則我們期望傳給後面的參數的值會被傳到中間的這個參數。

function printUserInfo(name, age = 18, gender) {
  console.log(`姓名:${name},年齡:${age},性別:${gender}`);
}
// 正確地使用
printUserInfo('Bob', undefined, 'male');
// 錯誤,'male'被錯誤地傳給了age參數
printUserInfo('Bob', 'male');

PS:注意,如果你希望使用參數的默認值,請一定傳undefined,而不是null

當然,我們也可以在函數體中判斷參數的數據類型,防止參數被誤用。

function printUserInfo(name, age = 18, gender) {
  if (typeof arguments[1] === 'string') {
    age = 18;
    gender = arguments[1];
  }
  console.log(`姓名:${name},年齡:${age},性別:${gender}`);
}

printUserInfo('bob', 'male'); // 姓名:bob,年齡:18,性別:male

這樣一來,函數的邏輯也不會亂。

剩餘參數

剩餘參數語法允許我們將一個不定數量的參數表示爲一個數組。

剩餘參數通過剩餘語法...將多個參數聚合成一個數組。

function add(a, ...args) {
  return args.reduce((prev, curr) => {
    return prev + curr
  }, a)
}

剩餘參數和arguments對象之間的區別主要有三個:

剩餘語法和展開運算符看起來很相似,然而從功能上來說,是完全相反的。

剩餘語法 (Rest syntax) 看起來和展開語法完全相同,不同點在於, 剩餘參數用於解構數組和對象。從某種意義上說,剩餘語法與展開語法是相反的:展開語法將數組展開爲其中的各個元素,而剩餘語法則是將多個元素收集起來並“凝聚” 爲單個元素。

arguments

函數的實際參數會被保存在一個類數組對象arguments中。

類數組(ArrayLike)對象具備一個非負的length屬性,並且可以通過從0開始的索引去訪問元素,讓人看起來覺得就像是數組,比如NodeList,但是類數組默認沒有數組的那些內置方法,比如pushpopforEachmap

我們可以試試,隨便找一個網站,在控制檯輸入:

var linkList = document.querySelectorAll('a')

會得到一個NodeList,我們也可以通過數字下標去訪問其中的元素,比如linkList[0]

但是NodeList不是數組,它是類數組。

Array.isArray(linkList); // false

回到主題,arguments也是類數組,argumentslength由實參的數量決定,而不是由形參的數量決定。

function add(a, b) {
  console.log(arguments.length);
  return a + b;
}
add(1, 2, 3, 4);
// 這裏打印的是4,而不是2

arguments也是一個和嚴格模式有關聯的對象。

function test(obj) {
  arguments[0] = '傳入的實參是一個對象,但是被我變成字符串了'
  console.log(obj)
}
test({name: 'jack'})
// 這裏打印的是字符串,而不是對象

函數體

函數體(FunctionBody)是函數的主體,其中的函數代碼 (function code) 由一對花括號{}包裹。函數體可以爲空,也可以由任意條 javascript 語句組成。

函數的調用形式

大體來說,函數的調用形式分爲以下四種:

作爲普通函數

函數作爲普通函數被調用,這是函數調用的常用形式。

function add(a, b) {
  return a + b;
}
add(); // 調用add函數

作爲普通函數調用時,如果在非嚴格模式下,函數執行時,this指向全局對象,對於瀏覽器而言則是window對象;如果在嚴格模式下,this的值則是undefined

作爲對象的方法

函數也可以作爲對象的成員,這種情況下,該函數通常被稱爲對象方法。當函數作爲對象的方法被調用時,this指向該對象,此時便可以通過this訪問對象的其他成員變量或方法。

var counter = {
  num: 0,
  increase: function() {
    this.num++;
  }
}
counter.increase();

作爲構造函數

函數配合new關鍵字使用時就成了構造函數。構造函數用於實例化對象,構造函數的執行過程大致如下:

  1. 首先創建一個新對象,這個新對象的 __proto__屬性指向構造函數的 prototype屬性。

  2. 此時構造函數的 this指向這個新對象。

  3. 執行構造函數中的代碼,一般是通過 this給新對象添加新的成員屬性或方法。

  4. 最後返回這個新對象。

實例化對象也可以通過一些技巧來簡化,比如在構造函數中顯示地return另一個對象,jQuery 很巧妙地利用了這一點。具體分析詳見面試官真的會問:new 的實現以及無 new 實例化。

通過 call, apply 調用

applycall是函數對象的原型方法,掛載於Function.prototype。利用這兩個方法,我們可以顯示地綁定一個this作爲調用上下文,同時也可以設置函數調用時的參數。

applycall的區別在於:提供參數的形式不同,apply方法接受的是一個參數數組,call方法接受的是參數列表。

someFunc.call(obj, 1, 2, 3)
someFunc.apply(obj, [1, 2, 3])

注意,在非嚴格模式下使用call或者apply時,如果第一個參數被指定爲nullundefined,那麼函數執行時的this指向全局對象(瀏覽器環境中是window);如果第一個參數被指定爲原始值,該原始值會被包裝。這部分內容在下文中的手寫代碼會再次講到。

call是用來實現繼承的重要方法。在子類構造函數中,通過call來調用父類構造函數,以使對象實例獲得來自父類構造函數的屬性或方法。

function Father() {
  this.nationality = 'Han';
};
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {
  Father.call(this);
};
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
child.nationality; // "Han"

call, apply, bind

callapplybind都可以綁定this,區別在於:applycall是綁定this後直接調用該函數,而bind會返回一個新的函數,並不直接調用,可以由程序員決定調用的時機。

bind的語法形式如下:

function.bind(thisArg[, arg1[, arg2[, ...]]])

bindarg1, arg2, ...是給新函數預置好的參數(預置參數是可選的)。當然新函數在執行時也可以繼續追加參數。

手寫 call, apply, bind

提到callapplybind總是無法避免手寫代碼這個話題。手寫代碼不僅僅是爲了應付面試,也是幫助我們理清思路和深入原理的一個好方法。手寫代碼一定不要抄襲,如果實在沒思路,可以參考下別人的代碼整理出思路,再自己按照思路獨立寫一遍代碼,然後驗證看看有沒有缺陷,這樣纔能有所收穫,否則忘得很快,只能短時間應付應付。

那麼如何才能順利地手寫代碼呢?首先是要清楚一段代碼的作用,可以從官方對於它的定義和描述入手,同時還要注意一些特殊情況下的處理。

就拿call來說,call是函數對象的原型方法,它的作用是綁定this和參數,並執行函數。調用形式如下:

function.call(thisArg, arg1, arg2, ...)

那麼我們慢慢來實現它,將我們要實現的函數命名爲myCall。首先myCall是一個函數,接受的第一個參數thisArg是目標函數執行時的this的值,從第二個可選參數arg1開始的其他參數將作爲目標函數執行時的實參。

這裏面有很多細節要考慮,我大致羅列了一下:

  1. 要考慮是不是嚴格模式。如果是非嚴格模式,對於 thisArg要特殊處理。

  2. 如何判斷嚴格模式?

  3. thisArg被處理後還要進行非空判斷,然後考慮是以方法的形式調用還是以普通函數的形式調用。

  4. 目標函數作爲方法調用時,如何不覆蓋對象的原有屬性?

實現代碼如下,請仔細看我寫的註釋,這是主要的思路!

// 首先apply是Function.prototype上的一個方法
Function.prototype.myCall = function() {
  // 由於目標函數的實參數量是不定的,這裏就不寫形參了
  // 實際上通過arugments對象,我們能拿到所有實參
  // 第一個參數是綁定的this
  var thisArg = arguments[0];
  // 接着要判斷是不是嚴格模式
  var isStrict = (function(){return this === undefined}())
  if (!isStrict) {
    // 如果在非嚴格模式下,thisArg的值是null或undefined,需要將thisArg置爲全局對象
    if (thisArg === null || thisArg === undefined) {
      // 獲取全局對象時兼顧瀏覽器環境和Node環境
      thisArg = (function(){return this}())
    } else {
      // 如果是其他原始值,需要通過構造函數包裝成對象
      var thisArgType = typeof thisArg
      if (thisArgType === 'number') {
       thisArg = new Number(thisArg)
      } else if (thisArgType === 'string') {
        thisArg = new String(thisArg)
      } else if (thisArgType === 'boolean') {
        thisArg = new Boolean(thisArg)
      }
    }
  }
  // 截取從索引1開始的剩餘參數
  var invokeParams = [...arguments].slice(1);
  // 接下來要調用目標函數,那麼如何獲取到目標函數呢?
  // 實際上this就是目標函數,因爲myCall是作爲一個方法被調用的,this當然指向調用對象,而這個對象就是目標函數  // 這裏做這麼一個賦值過程,是爲了讓語義更清晰一點
  var invokeFunc = this;
  // 此時如果thisArg對象仍然是null或undefined,那麼說明是在嚴格模式下,並且沒有指定第一個參數或者第一個參數的值本身就是null或undefined,此時將目標函數當成普通函數執行並返回其結果即可
  if (thisArg === null || thisArg === undefined) {
    return invokeFunc(...invokeParams)
  }
  // 否則,讓目標函數成爲thisArg對象的成員方法,然後調用它
  // 直觀上來看,可以直接把目標函數賦值給對象屬性,比如func屬性,但是可能func屬性本身就存在於thisArg對象上
  // 所以,爲了防止覆蓋掉thisArg對象的原有屬性,必須創建一個唯一的屬性名,可以用Symbol實現,如果環境不支持Symbol,可以通過uuid算法來構造一個唯一值。
  var uniquePropName = Symbol(thisArg)
  thisArg[uniquePropName] = invokeFunc
  // 返回目標函數執行的結果
  return thisArg[uniquePropName](...invokeParams)
}

寫完又思考了一陣,我突然發現有個地方考慮得有點多餘了。

// 如果在非嚴格模式下,thisArg的值是null或undefined,需要將thisArg置爲全局對象
if (thisArg === null || thisArg === undefined) {
  // 獲取全局對象時兼顧瀏覽器環境和Node環境
  thisArg = (function(){return this}())
} else {

其實這種情況下不用處理thisArg,因爲代碼執行到該函數後面部分,目標函數會被作爲普通函數執行,那麼this自然指向全局對象!所以這段代碼可以刪除了!

接着來測試一下myCall是否可靠,我寫了一個簡單的例子:

function test(a, b) {
  var args = [].slice.myCall(arguments)
  console.log(arguments, args)
}
test(1, 2)
var obj = {
  name: 'jack'
};
var name = 'global';
function getName() {
  return this.name;
}
getName();
getName.myCall(obj);

我不敢保證我寫的這個myCall方法沒有 bug,但也算是考慮了很多情況了。就算是在面試過程中,面試官主要關注的就是你的思路和考慮問題的全面性,如果寫到這個程度還不能讓面試官滿意,那也無能爲力了......

理解了手寫call之後,手寫apply也自然觸類旁通,只要注意兩點即可。

直接上代碼:

Function.prototype.myApply = function(thisArg, params) {
  var isStrict = (function(){return this === undefined}())
  if (!isStrict) {
    var thisArgType = typeof thisArg
    if (thisArgType === 'number') {
     thisArg = new Number(thisArg)
    } else if (thisArgType === 'string') {
      thisArg = new String(thisArg)
    } else if (thisArgType === 'boolean') {
      thisArg = new Boolean(thisArg)
    }
  }
  var invokeFunc = this;
  // 處理第二個參數
  var invokeParams = Array.isArray(params) ? params : [];
  if (thisArg === null || thisArg === undefined) {
    return invokeFunc(...invokeParams)
  }
  var uniquePropName = Symbol(thisArg)
  thisArg[uniquePropName] = invokeFunc
  return thisArg[uniquePropName](...invokeParams)
}

用比較常用的Math.max來測試一下:

Math.max.myApply(null, [1, 2, 4, 8]);// 結果是8

接下來就是手寫bind了,首先要明確,bindcallapply的不同點在哪裏。

好的,按照思路開始寫代碼。

Function.prototype.myBind = function() {
  // 保存要綁定的this
  var boundThis = arguments[0];
  // 獲得預置參數
  var boundParams = [].slice.call(arguments, 1);
  // 獲得綁定的目標函數
  var boundTargetFunc = this;
  if (typeof boundTargetFunc !== 'function') {
    throw new Error('綁定的目標必須是函數')
  }
  // 返回一個新的函數
  return function() {
    // 獲取執行時傳入的參數
    var restParams = [].slice.call(arguments);
    // 合併參數
    var allParams = boundParams.concat(restParams)
    // 新函數被執行時,通過執行綁定的目標函數獲得結果,並返回結果
    return boundTargetFunc.apply(boundThis, allParams)
  }
}

本來寫到這覺得已經結束了,但是翻到一些資料,都提到了手寫bind需要支持new調用。仔細一想也對,bind返回一個新的函數,這個函數被作爲構造函數使用也是很有可能的。

我首先思考的是,能不能直接判斷一個函數是不是以構造函數的形式執行的呢?如果能判斷出來,那麼問題就相對簡單了。

於是我想到構造函數中很重要的一點,那就是在構造函數中,this 指向對象實例。所以,我利用instanceof改了一版代碼出來。

Function.prototype.myBind = function() {
  var boundThis = arguments[0];
  var boundParams = [].slice.call(arguments, 1);
  var boundTargetFunc = this;
  if (typeof boundTargetFunc !== 'function') {
    throw new Error('綁定的目標必須是函數')
  }
  function fBound () {
    var restParams = [].slice.call(arguments);
    var allParams = boundParams.concat(restParams)
    // 通過instanceof判斷this是不是fBound的實例
    var isConstructor = this instanceof fBound;
    if (isConstructor) {
      // 如果是,說明是通過new調用的(這裏有bug,見下文),那麼只要把處理好的參數傳給綁定的目標函數,並通過new調用即可。
      return new boundTargetFunc(...allParams)
    } else {
      // 如果不是,說明不是通過new調用的
      return boundTargetFunc.apply(boundThis, allParams)
    }
  }
  return fBound
}

最後看了一下 MDN 提供的 bind 函數的 polyfill,發現思路有點不一樣,於是我通過一個實例進行對比。

function test() {}
var fBoundNative = test.bind()
var obj1 = new fBoundNative()
var fBoundMy = test.myBind()
var obj2 = new fBoundMy()
var fBoundMDN = test.mdnBind()
var obj3 = new fBoundMDN()

我發現我的寫法看起來竟然更像原生bind。瞬間懷疑自己,但一下子卻沒找到很明顯的 bug......

終於我還是意識到了一個很大的問題,obj1fBoundNative的實例,obj3fBoundMDN的實例,但obj2不是fBoundMy的實例(實際上obj2test的實例)。

obj1 instanceof fBoundNative; // true
obj2 instanceof fBoundMy; // false
obj3 instanceof fBoundMDN; // true

存在這個問題麻煩就大了,假設我要在fBoundMy.prototype上繼續擴展原型屬性或方法,obj2是無法繼承它們的。所以最直接有效的方法就是用繼承的方法來實現,雖然不能達到原生bind的效果,但已經夠用了。於是我參考 MDN 改了一版。

Function.prototype.myBind = function() {
  var boundTargetFunc = this;
  if (typeof boundTargetFunc !== 'function') {
    throw new Error('綁定的目標必須是函數')
  }
  var boundThis = arguments[0];
  var boundParams = [].slice.call(arguments, 1);
  function fBound () {
    var restParams = [].slice.call(arguments);
    var allParams = boundParams.concat(restParams)
    return boundTargetFunc.apply(this instanceof fBound ? this : boundThis, allParams)
  }
  fBound.prototype = Object.create(boundTargetFunc.prototype || Function.prototype)
  return fBound
}

這裏面最重要的兩點:處理好原型鏈關係,以及理解 bind 中構造實例的過程。

fBound.prototype = Object.create(boundTargetFunc.prototype || Function.prototype)

這一行代碼中用了一個||運算符,||的兩端充分考慮了myBind函數的兩種可能的調用方式。

  1. 常規的函數綁定
function test(name, age) {
  this.name = name;
  this.age = age;
}
var bound1 = test.myBind('小明')
var obj1 = new bound1(18)

這種情況把fBound.prototype的原型指向boundTargetFunc.prototype,完全符合我們的思維。

  1. 直接使用 Function.prototype.myBind
var bound2 = Function.prototype.myBind()
var obj2 = new bound2()

這相當於創建一個新的函數,綁定的目標函數是Function.prototype。這裏必然有朋友會問了,Function.prototype也是函數嗎?是的,請看!

typeof Function.prototype; // "function"

雖然我還不知道第二種調用方式存在的意義,但是存在即合理,既然存在,我們就支持它。

首先要清楚new的執行過程,如果您還不清楚這一點,可以看看我寫的這篇面試官真的會問:new 的實現以及無 new 實例化。

還是之前那句話,先要判斷是不是以構造函數的形式調用的。核心就是這:

this instanceof fBound

我們用一個例子再來分析下new的過程。

function test(name, age) {
  this.name = name;
  this.age = age;
}
var bound1 = test.myBind('小明')
var obj1 = new bound1(18)
obj1 instanceof bound1 // true
obj1 instanceof test // true
  1. 執行構造函數 bound1,實際上是執行 myBind執行後返回的新函數 fBound。首先會創建一個新對象 obj1,並且 obj1的非標準屬性 __proto__指向 bound1.prototype,其實就是 myBind執行時聲明的 fBound.prototype,而 fBound.prototype的原型指向 test.prototype。所以到這裏,原型鏈就串起來了!

  2. 執行的構造函數中, this指向這個 obj1

  3. 執行構造函數,由於 fBound是沒有實際內容的,執行構造函數本質上還是要去執行綁定的那個目標函數,本例中也就是 test。因此如果是以構造函數形式調用,我們就把實例對象作爲 this傳給 test.apply

  4. 通過執行 test,對象實例被掛載了 name和 age屬性,一個嶄新的對象就出爐了!

最後附上 Raynos 大神寫的 bind 實現,我感覺又受到了 “暴擊”!有興趣鑽研bind終極奧義的朋友請點開鏈接查看源碼!

this 指向問題

分析this的指向,首先要確定當前執行代碼的環境。

全局環境中的 this 指向

全局環境中,this 指向全局對象(視宿主環境而定,瀏覽器是 window,Node 是 global)。

函數中的 this 指向

在上文中介紹函數的調用形式時已經比較詳細地說過this指向問題了,這裏再簡單總結一下。

函數中this的指向取決於函數的調用形式,在一些情況下也受到嚴格模式的影響。

函數聲明和函數表達式

撕了這麼久代碼,讓大腦休息一會兒,先看點輕鬆點的內容。

函數聲明

函數聲明是獨立的函數語句。

function test() {}

函數聲明存在提升(Hoisting)現象,如變量提升一般,對於同名的情況,函數聲明優於變量聲明(前者覆蓋後者,我說的是聲明階段哦)。

函數表達式

函數表達式不是獨立的函數語句,常作爲表達式的一部分,比如賦值表達式。

函數表達式可以是命名的,也可以是匿名的。

// 命名函數表達式
var a = function test() {}
// 匿名函數表達式
var b = function () {}

匿名函數就是沒有函數名的函數,它不能單獨使用,只能作爲表達式的一部分使用。匿名函數常以 IIFE(立即執行函數表達式)的形式使用。

(function(){console.log("我是一個IIFE")}())

閉包

關於閉包,我已經寫了一篇超詳細的文章去分析了,是個人原創總結的乾貨,建議直接打開解讀閉包,這次從 ECMAScript 詞法環境,執行上下文說起。

PS:閱讀前,您應該對 ECMAScript5 的一些術語有一些簡單的瞭解,比如 Lexical Environment, Execution Context 等。

純函數

// 純函數
function pure(a, b) {
  return a + b;
}
// 非純函數
function impure(c) {
  return c + d
}
var d = 10;
pure(1, 2); // 3
impure(1); // 11
d = 20;
impure(1); // 21
pure(1, 2); // 3

惰性函數

相信大家在兼容事件監聽時,都寫過這樣的代碼。

function addEvent(element, type, handler) {
  if (window.addEventListener) {
    element.addEventListener(type, handler, false);
  } else if (window.attachEvent){
    element.attachEvent('on' + type, handler);
  } else {
    element['on' + type] = handler;
  }
}

仔細看下,我們會發現,每次調用addEvent,都會做一次if-else的判斷,這樣的工作顯然是重複的。這個時候就用到惰性函數了。

惰性函數表示函數執行的分支只會在函數第一次調用的時候執行。後續我們所使用的就是這個函數執行的結果。

利用惰性函數的思維,我們可以改造下上述代碼。

function addEvent(element, type, handler) {
  if (window.addEventListener) {
    addEvent = function(element, type, handler) {
      element.addEventListener(type, handler, false);
    }
  } else if (window.attachEvent){
    addEvent = function(element, type, handler) {
      element.attachEvent('on' + type, handler);
    }
  } else {
    addEvent = function(element, type, handler) {
      element['on' + type] = handler;
    }
  }
  addEvent(type, element, fun);
}

這代碼看起來有點 low,但是它確實減少了重複的判斷。在這種方式下,函數第一次執行時才確定真正的值。

我們還可以利用 IIFE 提前確定函數真正的值。

var addEvent = (function() {
  if (window.addEventListener) {
    return function(element, type, handler) {
      element.addEventListener(type, handler, false);
    }
  } else if (window.attachEvent){
    return function(element, type, handler) {
      element.attachEvent('on' + type, handler);
    }
  } else {
    return function(element, type, handler) {
      element['on' + type] = handler;
    }
  }
}())

高階函數

函數在 javascript 中是一等公民,函數可以作爲參數傳給其他函數,這讓函數的使用充滿了各種可能性。

不如來看看維基百科中高階函數(High-Order Function)的定義:

在數學和計算機科學中,高階函數是至少滿足下列一個條件的函數:

  1. 接受一個或多個函數作爲輸入

  2. 輸出一個函數

看到這,大家應該都意識到了,平時使用過很多高階函數。數組的一些高階函數使用得尤爲頻繁。

[1, 2, 3, 4].forEach(function(item, index, arr) {
  console.log(item, index, arr)
})
[1, 2, 3, 4].map(item => `小老弟${item}`)

可以發現,傳入forEachmap的就是一個函數。我們自己也可以封裝一些複用的高階函數。

我們知道Math.max可以求出參數列表中最大的值。然而很多時候,我們需要處理的數據並不是1, 2, 3, 4這麼簡單,而是對象數組。

假設有這麼一個需求,存在一個數組,數組元素都是表示人的對象,我們想從數組中選出年紀最大的人。

這個時候,就需要一個高階函數來完成。

/**
 * 根據求值條件判斷數組中最大的項
 * @param {Array} arr 數組
 * @param {String|Function} iteratee 返回一個求值表達式,可以根據對象屬性的值求出最大項,比如item.age。也可以通過自定義函數返回求值表達式。
 */function maxBy(arr, iteratee) {
    let values = [];
    if (typeof iteratee === 'string') {
        values = arr.map(item => item[iteratee]);
    } else if (typeof iteratee === 'function') {
        values = arr.map((item, index) => {
            return iteratee(item, index, arr);
        });
    }
    const maxOne = Math.max(...values);
    const maxIndex = values.findIndex(item => item === maxOne);
    return arr[maxIndex];
}

利用這個高階函數,我們就可以求出數組中年紀最大的那個人。

var list = [
  {name: '小明', age: 18},
  {name: '小紅', age: 19},
  {name: '小李', age: 20}
]// 根據age字段求出最大項,結果是小李。
var maxItem = maxBy(list, 'age');

我們甚至可以定義更復雜的求值規則,比如我們需要根據一個字符串類型的屬性來判定優先級。這個時候,就必須傳一個自定義的函數作爲參數了。

const list = [
  {name: '小明', priority: 'middle'},
  {name: '小紅', priority: 'low'},
  {name: '小李', priority: 'high'}
]
const maxItem = maxBy(list, function(item) {
  const { priority } = item
  const priorityValue = priority === 'low' ? 1 : priority === 'middle' ? 2 : priority === 'high' ? 3 : 0
  return priorityValue;
});

maxBy接受的參數最終都應該能轉化爲一個Math.max可度量的值,否則就沒有可比較性了。

要理解這樣的高階函數,我們可以認爲傳給高階函數的函數就是一箇中間件,它把數據預處理好了,然後再轉交給高階函數繼續運算。

PS:寫完這句總結,突然覺得挺有道理的,反手給自己一個贊!

柯里化

說柯里化之前,首先拋出一個疑問,如何實現一個add函數,使得這個add函數可以靈活調用和傳參,支持下面的調用示例呢?

add(1, 2, 3) // 6
add(1) // 1
add(1)(2) // 3
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2)(3)(4) // 10

要解答這樣的疑問,還是要先明白什麼是柯里化。

在計算機科學中,柯里化(Currying)是把接受多個參數的函數變換成接受一個單一參數 (最初函數的第一個參數) 的函數,並且返回接受餘下的參數且返回結果的新函數的技術。

這段解釋看着還是挺懵逼的,不如舉個例子:

本來有這麼一個求和函數dynamicAdd(),接受任意個參數。

function dynamicAdd() {
  return [...arguments].reduce((prev, curr) => {
    return prev + curr
  }, 0)
}

現在需要通過柯里化把它變成一個新的函數,這個新的函數預置了第一個參數,並且可以在調用時繼續傳入剩餘參數。

看到這,我覺得有點似曾相識,預置參數的特性與bind很相像。那麼我們不如用bind的思路來實現。

function curry(fn, firstArg) {
  // 返回一個新函數
  return function() {
    // 新函數調用時會繼續傳參
    var restArgs = [].slice.call(arguments)
    // 參數合併,通過apply調用原函數
    return fn.apply(this, [firstArg, ...restArgs])
  }
}

接着我們通過一些例子來感受一下柯里化。

// 柯里化,預置參數10
var add10 = curry(dynamicAdd, 10)
add10(5); // 15
// 柯里化,預置參數20
var add20 = curry(dynamicAdd, 20);
add20(5); // 25
// 也可以對一個已經柯里化的函數add10繼續柯里化,此時預置參數10即可
var anotherAdd20 = curry(add10, 10);
anotherAdd20(5); // 25

可以發現,柯里化是在一個函數的基礎上進行變換,得到一個新的預置了參數的函數。最後在調用新函數時,實際上還是會調用柯里化前的原函數。

並且柯里化得到的新函數可以繼續被柯里化,這看起來有點像俄羅斯套娃的感覺。

實際使用時也會出現柯里化的變體,不侷限於只預置一個參數。

function curry(fn) {
  // 保存預置參數
  var presetArgs = [].slice.call(arguments, 1)
  // 返回一個新函數
  return function() {
    // 新函數調用時會繼續傳參
    var restArgs = [].slice.call(arguments)
    // 參數合併,通過apply調用原函數
    return fn.apply(this, [...presetArgs, ...restArgs])
  }
}

其實Function.protoype.bind就是一個柯里化的實現。不僅如此,很多流行的庫都大量使用了柯里化的思想。

實際應用中,被柯里化的原函數的參數可能是定長的,也可能是不定長的。

參數定長的柯里化

假設存在一個原函數fnfn接受三個參數abc,那麼函數fn最多被柯里化三次(有效地綁定參數算一次)。

function fn(a, b, c) {
  return a + b + c
}
var c1 = curry(fn, 1);
var c2 = curry(c1, 2);
var c3 = curry(c2, 3);
c3(); // 6
// 再次柯里化也沒有意義,原函數只需要三個參數
var c4 = curry(c3, 4);
c4();

也就是說,我們可以通過柯里化緩存的參數數量,來判斷是否到達了執行時機。那麼我們就得到了一個柯里化的通用模式。

function curry(fn) {
  // 獲取原函數的參數長度
  const argLen = fn.length;
  // 保存預置參數
  const presetArgs = [].slice.call(arguments, 1)
  // 返回一個新函數
  return function() {
    // 新函數調用時會繼續傳參
    const restArgs = [].slice.call(arguments)
    const allArgs = [...presetArgs, ...restArgs]
    if (allArgs.length >= argLen) {
      // 如果參數夠了,就執行原函數
      return fn.apply(this, allArgs)
    } else {
      // 否則繼續柯里化
      return curry.call(null, fn, ...allArgs)
    }
  }
}

這樣一來,我們的寫法就可以支持以下形式。

function fn(a, b, c) {
  return a + b + c;
}
var curried = curry(fn);
curried(1, 2, 3); // 6
curried(1, 2)(3); // 6
curried(1)(2, 3); // 6
curried(1)(2)(3); // 6
curried(7)(8)(9); // 24

參數不定長的柯里化

解決了上面的問題,我們難免會問自己,假設原函數的參數不定長呢,這種情況如何柯里化?

首先,我們需要理解參數不定長是指函數聲明時不約定具體的參數,而在函數體中通過arguments獲取實參,然後進行運算。就像下面這種。

function dynamicAdd() {
  return [...arguments].reduce((prev, curr) => {
    return prev + curr
  }, 0)
}

回到最開始的問題,怎麼支持下面的所有調用形式?

add(1, 2, 3) // 6
add(1) // 1
add(1)(2) // 3
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2)(3)(4) // 10

思考了一陣,我發現在參數不定長的情況下,要同時支持1~N次調用還是挺難的。add(1)在一次調用後可以直接返回一個值,但它也可以作爲函數接着調用add(1)(2),甚至可以繼續add(1)(2)(3)。那麼我們實現add函數時,到底是返回一個函數,還是返回一個值呢?這讓人挺犯難的,我也不能預測這個函數將如何被調用啊。

而且我們可以拿上面的成果來驗證下:

curried(1)(2)(3)(4);

運行上面的代碼會報錯:Uncaught TypeError: curried(...)(...)(...) is not a function,因爲執行到curried(1)(2)(3),結果就不是一個函數了,而是一個值,一個值當然是不能作爲函數繼續執行的。

所以如果要支持參數不定長的場景,已經柯里化的函數在執行完畢時不能返回一個值,只能返回一個函數;同時要讓 JS 引擎在解析得到的這個結果時,能求出我們預期的值。

大家看了這個可能還是不懂,好,說人話!我們實現的curry應該滿足:

  1. 經 curry處理,得到一個新函數,這一點不變。
// curry是一個函數
var curried = curry(add);
  1. 新函數執行後仍然返回一個結果函數。
// curried10也是一個函數
var curried10 = curried(10);
var curried30 = curried10(20);
  1. 結果函數可以被 Javascript 引擎解析,得到一個預期的值。
curried10; // 10

好,關鍵點在於 3,如何讓 Javascript 引擎按我們的預期進行解析,這就回到 Javascript 基礎了。在解析一個函數的原始值時,會用到toString

我們知道,console.log(fn)可以把函數 fn 的源碼輸出,如下所示:

console.log(fn)
ƒ fn(a, b, c) {
  return a + b + c;
}

那麼我們只要重寫toString,就可以巧妙地實現我們的需求了。

function curry(fn) {
  // 保存預置參數
  const presetArgs = [].slice.call(arguments, 1)
  // 返回一個新函數
  function curried () {
    // 新函數調用時會繼續傳參
    const restArgs = [].slice.call(arguments)
    const allArgs = [...presetArgs, ...restArgs]
    return curry.call(null, fn, ...allArgs)
  }
  // 重寫toString
  curried.toString = function() {
    return fn.apply(null, presetArgs)
  }
  return curried;
}

這樣一來,魔性的add用法就都被支持了。

function dynamicAdd() {
  return [...arguments].reduce((prev, curr) => {
    return prev + curr
  }, 0)
}
var add = curry(dynamicAdd);
add(1)(2)(3)(4) // 10
add(1, 2)(3, 4)(5, 6) // 21

至於爲什麼是重寫toString,而不是重寫valueOf,這裏留個懸念,大家可以想一想,也歡迎與我交流!

柯里化總結

柯里化是一種函數式編程思想,實際上在項目中可能用得少,或者說用得不深入,但是如果你掌握了這種思想,也許在未來的某個時間點,你會用得上!

大概來說,柯里化有如下特點:

小結

本文是筆者回顧函數知識點時總結的一篇非常詳細的文章。在理解一些晦澀的知識模塊時,我加入了一些個人解讀,相信對於想要深究細節的朋友會有一些幫助。如果您覺得這篇文章有所幫助,請無情地關注點贊支持一下吧!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Q6B_gaoGD68tF8VrS4B3ug