1. 程式人生 > 其它 >你真的懂 JavaScript 閉包與高階函式嗎?

你真的懂 JavaScript 閉包與高階函式嗎?

技術標籤:jsjavascriptjavalambdaweb

「JavaScript 中,函式是一等公民」,在各種書籍和文章中我們總能看到這句話。

既然有一等,那麼當然也有次等了。

如果公民分等級,一等公民什麼都可以做,次等公民這不能做那不能做。JavaScript的函式也是物件,可以有屬性,可以賦值給一個變數,可以放在數組裡作為元素,可以作為其他物件的屬性,什麼都可以做,別的物件能做的它能做,別的物件不能做的它也能做。這不就是一等公民的地位嘛。
— 程墨Morgan

所以它的含義是:函式和其他普通物件一樣,其上有屬性也有方法,普通物件能做的,函式也可以做。

正因為在 JavaScript 中的極大自由,函式被賦予了卓越的表達力和靈活性,但是也產生了很多讓人抓耳撓腮的問題。本文我們就一起討論一下最常遇見的兩個與函式密切相關的概念:閉包和高階函式。這兩個概念在之後設計模式的文章中也會經常碰見。

注意: 本文屬於基礎篇,如果你已經對本文相關知識點已經很瞭解了,那麼可以跳過本文。如果你不夠了解,或者瞭解的還不完整,那麼可以通過本文來複習一下 ~

1. 閉包

1.1 什麼是閉包

當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函式是在當前詞法作用域之外執行。

我們首先來看一個閉包的例子:

function foo() {
    var a = 2
    
    function bar() {
        console.log(a)
    }
    
    return bar
}

var baz = foo()

baz()            // 輸出: 2

foo 函式傳遞出了一個函式 bar,傳遞出來的 bar 被賦值給 baz 並呼叫,雖然這時 baz 是在 foo 作用域外執行的,但 baz 在呼叫的時候可以訪問到前面的 bar 函式所在的 foo 的內部作用域。

由於 bar 宣告在 foo 函式內部,bar 擁有涵蓋 foo 內部作用域的閉包,使得 foo 的內部作用域一直存活不被回收。一般來說,函式在執行完後其整個內部作用域都會被銷燬,因為 JavaScript 的 GC(Garbage Collection)垃圾回收機制會自動回收不再使用的記憶體空間。但是閉包會阻止某些 GC,比如本例中 foo() 執行完,因為返回的 bar 函式依然持有其所在作用域的引用,所以其內部作用域不會被回收。

注意: 如果不是必須使用閉包,那麼儘量避免建立它,因為閉包在處理速度和記憶體消耗方面對效能具有負面影響。

1.2 利用閉包實現結果快取(備忘模式)

備忘模式就是應用閉包的特點的一個典型應用。比如有個函式:

function add(a) {
    return a + 1;
}

多次執行 add() 時,每次得到的結果都是重新計算得到的,如果是開銷很大的計算操作的話就比較消耗效能了,這裡可以對已經計算過的輸入做一個快取。

所以這裡可以利用閉包的特點來實現一個簡單的快取,在函式內部用一個物件儲存輸入的引數,如果下次再輸入相同的引數,那就比較一下物件的屬性,如果有快取,就直接把值從這個物件裡面取出來。

/* 備忘函式 */
function memorize(fn) {
    var cache = {}
    return function() {
        var args = Array.prototype.slice.call(arguments)
        var key = JSON.stringify(args)
        return cache[key] || (cache[key] = fn.apply(fn, args))
    }
}

/* 複雜計算函式 */
function add(a) {
    return a + 1
}

var adder = memorize(add)

adder(1)            // 輸出: 2    當前: cache: { '[1]': 2 }
adder(1)            // 輸出: 2    當前: cache: { '[1]': 2 }
adder(2)            // 輸出: 3    當前: cache: { '[1]': 2, '[2]': 3 }

使用 ES6 的方式會更優雅一些:

/* 備忘函式 */
function memorize(fn) {
    const cache = {}
    return function(...args) {
        const key = JSON.stringify(args)
        return cache[key] || (cache[key] = fn.apply(fn, args))
    }
}

/* 複雜計算函式 */
function add(a) {
    return a + 1
}

const adder = memorize(add)

adder(1)            // 輸出: 2    當前: cache: { '[1]': 2 }
adder(1)            // 輸出: 2    當前: cache: { '[1]': 2 }
adder(2)            // 輸出: 3    當前: cache: { '[1]': 2, '[2]': 3 }

稍微解釋一下:

備忘函式中用 JSON.stringify 把傳給 adder 函式的引數序列化成字串,把它當做 cache 的索引,將 add 函式執行的結果當做索引的值傳遞給 cache,這樣 adder 執行的時候如果傳遞的引數之前傳遞過,那麼就返回快取好的計算結果,不用再計算了,如果傳遞的引數沒計算過,則計算並快取 fn.apply(fn, args),再返回計算的結果。

當然這裡的實現如果要實際應用的話,還需要繼續改進一下,比如:

  1. 快取不可以永遠擴張下去,這樣太耗費記憶體資源,我們可以只快取最新傳入的 n 個;

  2. 在瀏覽器中使用的時候,我們可以藉助瀏覽器的持久化手段,來進行快取的持久化,比如 cookie、localStorage 等;

這裡的複雜計算函式可以是過去的某個狀態,比如對某個目標的操作,這樣把過去的狀態快取起來,方便地進行狀態回退。

複雜計算函式也可以是一個返回時間比較慢的非同步操作,這樣如果把結果快取起來,下次就可以直接從本地獲取,而不是重新進行非同步請求。

注意: cache 不可以是 Map,因為 Map 的鍵是使用 === 比較的,因此當傳入引用型別值作為鍵時,雖然它們看上去是相等的,但實際並不是,比如 [1]!==[1],所以還是被存為不同的鍵。

//  X 錯誤示範
function memorize(fn) {        
  const cache = new Map()
  return function(...args) {
    return cache.get(args) || cache.set(args, fn.apply(fn, args)).get(args)
  }
}

function add(a) {
  return a + 1
}

const adder = memorize(add)

adder(1)    // 2    cache: { [ 1 ] => 2 }
adder(1)    // 2    cache: { [ 1 ] => 2, [ 1 ] => 2 }
adder(2)    // 3    cache: { [ 1 ] => 2, [ 1 ] => 2, [ 2 ] => 3 }

2. 高階函式

高階函式就是輸入引數裡有函式,或者輸出是函式的函式。

2.1 函式作為引數

如果你用過 setTimeoutsetInterval、ajax 請求,那麼你已經用過高階函數了,這是我們最常看到的場景:回撥函式,因為它將函式作為引數傳遞給另一個函式。

比如 ajax 請求中,我們通常使用回撥函式來定義請求成功或者失敗時的操作邏輯:

$.ajax("/request/url", function(result){
    console.log("請求成功!")
})

在 Array、Object、String 等等基本物件的原型上有很多操作方法,可以接受回撥函式來方便地進行物件操作。這裡舉一個很常用的 Array.prototype.filter() 方法,這個方法返回一個新建立的陣列,包含所有回撥函式執行後返回 true 或真值的陣列元素。

var words = ['spray', 'limit', 'elite', 'exuberant', 'destruction', 'present'];

var result = words.filter(function(word) {
    return word.length > 6
})       // 輸出: ["exuberant", "destruction", "present"]

回撥函式還有一個應用就是鉤子,如果你用過 Vue 或者 React 等框架,那麼你應該對鉤子很熟悉了,它的形式是這樣的:

function foo(callback) {
    // ... 一些操作
    callback()
}

2.2 函式作為返回值

另一個經常看到的高階函式的場景是在一個函式內部輸出另一個函式,比如:

function foo() {
    return function bar() {}
}

主要是利用閉包來保持著作用域:

function add() {
    var num = 0
    return function(a) {
        return num = num + a
    }
}
var adder = add()

adder(1)     // 輸出: 1
adder(2)     // 輸出: 3

1. 柯里化

柯里化(Currying),又稱部分求值(Partial Evaluation),是把接受多個引數的原函式變換成接受一個單一引數(原函式的第一個引數)的函式,並且返回一個新函式,新函式能夠接受餘下的引數,最後返回同原函式一樣的結果。

核心思想是把多引數傳入的函式拆成單(或部分)引數函式,內部再返回呼叫下一個單(或部分)引數函式,依次處理剩餘的引數。

柯里化有3個常見作用:

  1. 引數複用

  2. 提前返回

  3. 延遲計算/執行

先來看看柯里化的通用實現:

// ES5 方式
function currying(fn) {
    var rest1 = Array.prototype.slice.call(arguments)
    rest1.shift()
    return function() {
        var rest2 = Array.prototype.slice.call(arguments)
        return fn.apply(null, rest1.concat(rest2))
    }
}

// ES6 方式
function currying(fn, ...rest1) {
  return function(...rest2) {
    return fn.apply(null, rest1.concat(rest2))
  }
}

用它將一個 sayHello 函式柯里化試試:

// 接上面
function sayHello(name, age, fruit) {
  console.log(console.log(`我叫 ${name},我 ${age} 歲了, 我喜歡吃 ${fruit}`))
}

var curryingShowMsg1 = currying(sayHello, '小明')
curryingShowMsg1(22, '蘋果')           // 輸出: 我叫 小明,我 22 歲了, 我喜歡吃 蘋果

var curryingShowMsg2 = currying(sayHello, '小衰', 20)
curryingShowMsg2('西瓜')               // 輸出: 我叫 小衰,我 20 歲了, 我喜歡吃 西瓜

更高階的用法參見:JavaScript 函數語言程式設計技巧 - 柯里化

2. 反柯里化

柯里化是固定部分引數,返回一個接受剩餘引數的函式,也稱為部分計算函式,目的是為了縮小適用範圍,建立一個針對性更強的函式。核心思想是把多引數傳入的函式拆成單引數(或部分)函式,內部再返回呼叫下一個單引數(或部分)函式,依次處理剩餘的引數。

反柯里化,從字面講,意義和用法跟函式柯里化相比正好相反,擴大適用範圍,建立一個應用範圍更廣的函式。使本來只有特定物件才適用的方法,擴充套件到更多的物件。

先來看看反柯里化的通用實現吧~

// ES5 方式
Function.prototype.unCurrying = function() {
  var self = this
  return function() {
    var rest = Array.prototype.slice.call(arguments)
    return Function.prototype.call.apply(self, rest)
  }
}

// ES6 方式
Function.prototype.unCurrying = function() {
  const self = this
  return function(...rest) {
    return Function.prototype.call.apply(self, rest)
  }
}

如果你覺得把函式放在 Function 的原型上不太好,也可以這樣:

// ES5 方式
function unCurrying(fn) {
  return function (tar) {
    var rest = Array.prototype.slice.call(arguments)
    rest.shift()
    return fn.apply(tar, rest)
  }
}

// ES6 方式
function unCurrying(fn) {
  return function(tar, ...argu) {
    return fn.apply(tar, argu)
  }
}

下面簡單試用一下反柯里化通用實現,我們將 Array 上的 push 方法借出來給 arguments 這樣的類陣列增加一個元素:

// 接上面
var push = unCurrying(Array.prototype.push)

function execPush() {
  push(arguments, 4)
  console.log(arguments)
}

execPush(1, 2, 3)    // 輸出: [1, 2, 3, 4]

簡單說,函式柯里化就是對高階函式的降階處理,縮小適用範圍,建立一個針對性更強的函式。

function(arg1, arg2)              // => function(arg1)(arg2)
function(arg1, arg2, arg3)        // => function(arg1)(arg2)(arg3)
function(arg1, arg2, arg3, arg4)  // => function(arg1)(arg2)(arg3)(arg4)
function(arg1, arg2, ..., argn)   // => function(arg1)(arg2)…(argn)

而反柯里化就是反過來,增加適用範圍,讓方法使用場景更大。使用反柯里化, 可以把原生方法借出來,讓任何物件擁有原生物件的方法。

obj.func(arg1, arg2)        // => func(obj, arg1, arg2)

可以這樣理解柯里化和反柯里化的區別:

  1. 柯里化是在運算前提前傳參,可以傳遞多個引數;

  2. 反柯里化是延遲傳參,在運算時把原來已經固定的引數或者 this 上下文等當作引數延遲到未來傳遞。

更高階的用法參見:JavaScript 函數語言程式設計技巧 - 反柯里化

3. 偏函式

偏函式是建立一個呼叫另外一個部分(引數或變數已預製的函式)的函式,函式可以根據傳入的引數來生成一個真正執行的函式。其本身不包括我們真正需要的邏輯程式碼,只是根據傳入的引數返回其他的函式,返回的函式中才有真正的處理邏輯比如:

var isType = function(type) {
  return function(obj) {
    return Object.prototype.toString.call(obj) === `[object ${type}]`
  }
}

var isString = isType('String')
var isFunction = isType('Function')

這樣就用偏函式快速建立了一組判斷物件型別的方法~

偏函式和柯里化的區別:

  1. 柯里化是把一個接受 n 個引數的函式,由原本的一次性傳遞所有引數並執行變成了可以分多次接受引數再執行,例如:add = (x, y, z) => x + y + zcurryAdd = x => y => z => x + y + z

  2. 偏函式固定了函式的某個部分,通過傳入的引數或者方法返回一個新的函式來接受剩餘的引數,數量可能是一個也可能是多個;

當一個柯里化函式只接受兩次引數時,比如 curry()(),這時的柯里化函式和偏函式概念類似,可以認為偏函式是柯里化函式的退化版。

最後

如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:

  1. 點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)

  2. 歡迎加我微信「qianyu443033099」拉你進技術群,長期交流學習...

  3. 關注公眾號「前端下午茶」,持續為你推送精選好文,也可以加我為好友,隨時聊騷。

點個在看支援我吧,轉發就更好了