1. 程式人生 > >理解運用JS的閉包、高階函數、柯裏化

理解運用JS的閉包、高階函數、柯裏化

完成 代碼塊 sel 函數 itl onclick eof 全局變量 時間

一、閉包

1. 閉包的概念

閉包與執行上下文、環境、作用域息息相關

執行上下文

執行上下文是用於跟蹤運行時代碼求值的一個規範設備,從邏輯上講,執行上下文是用執行上下文棧(棧、調用棧)來維護的。

代碼有幾種類型:全局代碼、函數代碼、eval代碼和模塊代碼;每種代碼都是在其執行上下文中求值。

當函數被調用時,就創建了一個新的執行上下文,並被壓到棧中 - 此時,它變成一個活動的執行上下文。當函數返回時,此上下文被從棧中彈出

技術分享圖片
function recursive(flag) {
 
  // Exit condition.
  if (flag === 2) {
    return;
  }
 
  // Call recursively.
  recursive(++flag);
}
 
// Go.
recursive(0);
技術分享圖片

調用另一個上下文的上下文被稱為調用者(caller)。被調用的上下文相應地被稱為被調用者(callee),在這段代碼中,recursive 既是調用者,又是被調用者

對應的執行上下文棧

技術分享圖片

通常,一個上下文的代碼會一直運行到結束。然而在異步處理的 Generator中,是特殊的。

一個Generator函數可能會掛起其正在執行的上下文,並在結束前將其從棧中刪除。一旦Generator再次激活,它上下文就被恢復,並再次壓入棧中

技術分享圖片
function *g() {
    yield 1;
    yield 2;        
}

var f = g();

f.next();

f.next();
技術分享圖片

yield 語句將值返回給調用者,並彈出上下文。而在調用 next 時,同一個上下文被再次壓入棧中,並恢復

環境

每個執行上下文都有一個相關聯的詞法環境

可以把詞法環境定義為一個在作用域中的變量、函數和類的倉庫,每個環境有一個對可選的父環境的引用

比如這段代碼中的全局上下文與foo函數的上下文對應的環境

技術分享圖片
let x = 10;
let y = 20;
 
function foo(z) {
  let x = 100;
  return x + y + z;
}
 
foo(30); // 150
技術分享圖片

技術分享圖片

作用域

當一個執行上下文被創建時,就與一個特定的作用域(代碼域 realm)關聯起來。這個作用域為該上下文提供全局環境(此“全局”並非常規意義上的全局,只是一種提供上下文棧調用的意思)

技術分享圖片

靜態作用域

如果一個語言只通過查找源代碼,就可以判斷綁定在哪個環境中解析,那麽該語言就實現了靜態作用域。所以,一般也可稱作詞法作用域。

在環境中引用函數,同時改函數也引用著環境。靜態作用域是通過捕獲函數創建所在的環境來實現的。

技術分享圖片

如圖,全局環境引用了foo函數,foo函數也引用著全局環境

自由變量

一個既不是函數的形參,也不是函數的局部變量的變量

技術分享圖片
function testFn() {
 
  var localVar = 10;
 
  function innerFn(innerParam) {
    alert(innerParam + localVar);
  }
 
  return innerFn;
}
技術分享圖片

對於innerFn 函數來說,localVar 就屬於自由變量

閉包

閉包是代碼塊和創建該代碼塊的上下文中數據的組合,是函數捕獲它被定義時所在的環境(閉合環境)。

在JS中,函數是屬於一等公民(first-class)的,一般來說代碼塊即是函數的意思(暫不考慮ES6的特殊情況)

所以,閉包並不僅是一個函數,它是一個環境,這個環境中保存了一些相關的數據及指針引用。

理論上來說,所有的函數都是閉包。

因為它們都在創建的時候就將上層上下文的數據保存起來了。哪怕是簡單的全局變量也是如此,因為函數中訪問全局變量就相當於是在訪問自由變量,這個時候使用的是最外層的作用域

而從實現的角度上看,並不完全遵循理論,但也又兩點依據,符合其一即可稱作閉包

在代碼中引用了自由變量

使創建它的上下文已經銷毀,它仍然存在(比如,內部函數從父函數中返回)

更多相關概念可以查看 這個系列

2. 閉包的特性

  • 函數嵌套函數
  • 函數內部可以引用外部的參數和變量
  • 參數和變量不會被垃圾回收機制回收

一般來說,閉包形式上來說有嵌套的函數,其可引用外部的參數和變量(自由變量),且在其上下文銷毀之後,仍然存在(不會被垃圾回收機制回收)

3. 閉包的優點

  • 使一個變量長期駐紮在內存中
  • 避免全局變量的汙染
  • 作為私有成員的存在

按照特性,閉包有著對應的優點

比如創建一個計數器,常規來說我們可以使用類

技術分享圖片
function Couter() {
    this.num = 0;
}

Couter.prototype = {
    constructor: Couter,
    
    // 增
    up: function() {
        this.num++;
    },
    
    // 減
    down: function() {
        this.num--;
    },
    
    // 獲取
    getNum: function() {
        console.log(this.num);
    }
};

var c1 = new Couter();
c1.up();
c1.up();
c1.getNum(); // 2

var c2 = new Couter();
c2.down();
c2.down();
c2.getNum(); // -2
技術分享圖片

這挺好的,我們也可以用閉包的方式來實現

技術分享圖片
function couter() {
    var num = 0;

    return {
        // 增
        up: function() {
            num++;
        },
        // 減
        down: function() {
            num--;
        },
        // 獲取
        getNum: function() {
            console.log(num);
        }
    };
}

var c1 = couter();
c1.up();
c1.up();
c1.getNum(); // 2

var c2 = couter();
c2.down();
c2.down();
c2.getNum(); // -2
技術分享圖片

可以看到,雖然couter函數的上下文被銷毀了,num仍保存在內存中

在很多設計模式中,閉包都充當著很重要的角色,

4. 閉包的缺點

閉包的缺點,更多地是在內存性能的方面。

由於變量長期駐紮在內存中,在復雜程序中可能會出現內存不足,但這也不算非常嚴重,我們需要在內存使用與開發方式上做好取舍。在不需要的時候清理掉變量

在某些時候(對象與DOM存在互相引用,GC使用引用計數法)會造成內存泄漏,要記得在退出函數前清理變量

技術分享圖片
window.onload = function() {
     var elem = document.querySelector(‘.txt‘);
     
     // elem的onclick指向了匿名函數,匿名函數的閉包也引用著elem
     elem.onclick = function() {
          console.log(this.innerHTML);
     };

     // 清理
     elem = null;
};    
技術分享圖片

內存泄漏相關的東西,這裏就不多說了,之後再整理一篇

除此之外,由於閉包中的變量可以在函數外部進行修改(通過暴露出去的接口方法),所有不經意間也內部的變量會被修改,所以也要註意

5. 閉包的運用

閉包有很廣泛的使用場景

常見的一個問題是,這段代碼輸出什麽

技術分享圖片
var func = [];

for (var i = 0; i < 5; ++i) {
    func[i] = function() {
        console.log(i);
    }
}

func[3](); // 5
技術分享圖片

由於作用域的關系,最終輸出了5

稍作修改,可以使用匿名函數立即執行與閉包的方式,可輸出正確的結果

技術分享圖片
for (var i = 0; i < 5; ++i) {
    (function(i) {
        func[i] = function() {
            console.log(i);
        }
     })(i);  
}

func[3](); // 3


for (var i = 0; i < 5; ++i) {
    (function() {
        var n = i;
        func[i] = function() {
            console.log(n);
        }
     })();  
}

func[3](); // 3


for (var i = 0; i < 5; ++i) {
    func[i] = (function(i) {
        return function() {
            console.log(i);
        }
    })(i);
}

func[3](); // 3
技術分享圖片

二、高階函數

高階函數(high-order function 簡稱:HOF),咋一聽起來那麽高級,滿足了以下兩點就可以稱作高階函數了

  • 函數可以作為參數被傳遞
  • 函數可以作為返回值輸出

在維基中的定義是

  • 接受一個或多個函數作為輸入
  • 輸出一個函數

可以將高階函數理解為函數之上的函數,它很常用,比如常見的

var getData = function(url, callback) {
    $.get(url, function(data){
        callback(data);
    });
}

或者在眾多閉包的場景中都使用到

比如 防抖函數(debounce)與節流函數(throttle)

Debounce

防抖,指的是無論某個動作被連續觸發多少次,直到這個連續動作停止後,才會被當作一次來執行

比如一個輸入框接受用戶不斷輸入,輸入結束後才開始搜索

以頁面滾動作為例子,可以定義一個防抖函數,接受一個自定義的 delay值,作為判斷停止的時間標識

技術分享圖片
// 函數防抖,頻繁操作中不處理,直到操作完成之後(再過 delay 的時間)才一次性處理
function debounce(fn, delay) {
    delay = delay || 200;
    
    var timer = null;

    return function() {
        var arg = arguments;
          
        // 每次操作時,清除上次的定時器
        clearTimeout(timer);
        timer = null;
        
        // 定義新的定時器,一段時間後進行操作
        timer = setTimeout(function() {
            fn.apply(this, arg);
        }, delay);
    }
};

var count = 0;

window.onscroll = debounce(function(e) {
    console.log(e.type, ++count); // scroll
}, 500);
技術分享圖片

滾動頁面,可以看到只有在滾動結束後才執行

技術分享圖片

Throttle

節流,指的是無論某個動作被連續觸發多少次,在定義的一段時間之內,它僅能夠觸發一次

比如resize和scroll時間頻繁觸發的操作,如果都接受了處理,可能會影響性能,需要進行節流控制

以頁面滾動作為例子,可以定義一個節流函數,接受一個自定義的 delay值,作為判斷停止的時間標識

需要註意的兩點

要設置一個初始的標識,防止一開始處理就被執行了,同時在最後一次處理之後,也需要重新置位

也要設置定時器處理,防止兩次動作未到delay值,最後一組動作觸發不了

技術分享圖片
// 函數節流,頻繁操作中間隔 delay 的時間才處理一次
function throttle(fn, delay) {
    delay = delay || 200;
    
    var timer = null;
    // 每次滾動初始的標識
    var timestamp = 0;

    return function() {
        var arg = arguments;
        var now = Date.now();
        
        // 設置開始時間
        if (timestamp === 0) {
            timestamp = now;
        }
        
        clearTimeout(timer);
        timer = null;
        
        // 已經到了delay的一段時間,進行處理
        if (now - timestamp >= delay) {
            fn.apply(this, arg);
            timestamp = now;
        }
        // 添加定時器,確保最後一次的操作也能處理
        else {
            timer = setTimeout(function() {
                fn.apply(this, arg);
                // 恢復標識
                timestamp = 0;
            }, delay);
        }
    }
};

var count = 0;

window.onscroll = throttle(function(e) {
    console.log(e.type, ++count); // scroll
}, 500);
技術分享圖片

技術分享圖片

三、柯裏化

柯裏化(Currying),又稱為部分求值,是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回一個新的函數的技術,新函數接受余下參數並返回運算結果。

比較經典的例子是

實現累加 add(1)(2)(3)(4)

第一種方法即是使用回調嵌套

技術分享圖片
function add(a) {
    // 瘋狂的回調
    return function(b) {
        return function(c) {
            return function(d) {
                   // return a + b + c + d;
                   return [a, b, c, d].reduce(function(v1, v2) {
                       return v1 + v2;
                   });
            }
        }
    }
}

console.log(add(1)(2)(3)(4)); // 10
技術分享圖片

既不優雅也不好擴展

修改兩下,讓它支持不定的參數數量

技術分享圖片
function add() {
    var args = [].slice.call(arguments);
    
    // 用以存儲更新參數數組
    function adder() {
        var arg = [].slice.call(arguments);

        args = args.concat(arg);
        
        // 每次調用,都返回自身,取值時可以通過內部的toString取到值
        return adder;
    }
    
    // 指定 toString的值,用以隱示取值計算
    adder.toString = function() {
        return args.reduce(function(v1, v2) {
            return v1 + v2;
        });
    };

    return adder;
}


console.log(add(1, 2), add(1, 2)(3), add(1)(2)(3)(4)); // 3 6 10
技術分享圖片

上面這段代碼,就能夠實現了這個“柯裏化”

需要註意的兩個點是

  • arguments並不是真正的數組,所以不能使用數組的原生方法(如 slice)
  • 在取值時,會進行隱示的求值,即先通過內部的toString()進行取值,再通過 valueOf()進行取值,valueOf優先級更高,我們可以進行覆蓋初始的方法

當然,並不是所有類型的toString和toValue都一樣,Number、String、Date、Function 各種類型是不完全相同的,本文不展開

上面用到了call 方法,它的作用主要是更改執行的上下文,類似的還有apply,bind 等

我們可以試著自定義一個函數的 bind方法,比如

技術分享圖片
var obj = {
    num: 10,
    getNum: function(num) {
        console.log(num || this.num);
    }
};

var o = {
    num: 20
};

obj.getNum(); // 10
obj.getNum.call(o, 1000); // 1000
obj.getNum.bind(o)(20); // 20

// 自定義的 bind 綁定
Function.prototype.binder = function(context) {
    var fn = this;
    var args = [].slice.call(arguments, 1);

    return function() {
        return fn.apply(context, args);
    };
};

obj.getNum.binder(o, 100)(); // 100
技術分享圖片

上面的柯裏化還不夠完善,假如要定義一個乘法的函數,就得再寫一遍長長的代碼

需要定義一個通用currying函數,作為包裝

技術分享圖片
// 柯裏化
function curry(fn) {
    var args = [].slice.call(arguments, 1);
    
    function inner() {
        var arg = [].slice.call(arguments);

        args = args.concat(arg);
        return inner;
    }

    inner.toString = function() {
        return fn.apply(this, args);
    };

    return inner;
}

function add() {
    return [].slice.call(arguments).reduce(function(v1, v2) {
        return v1 + v2;
    });
}

function mul() {
    return [].slice.call(arguments).reduce(function(v1, v2) {
        return v1 * v2;
    });
}

var curryAdd = curry(add);
console.log(curryAdd(1)(2)(3)(4)(5)); // 15

var curryMul = curry(mul, 1);
console.log(curryMul(2, 3)(4)(5)); // 120
技術分享圖片

看起來就好多了,便於擴展

不過實際上,柯裏化的應用中,不定數量的參數場景比較少,更多的情況下的參數是固定的(常見的一般也就兩三個)

技術分享圖片
// 柯裏化
function curry(fn) {
    var args = [].slice.call(arguments, 1),
        // 函數fn的參數長度
        fnLen = fn.length;
    
    // 存儲參數數組,直到參數足夠多了,就調用
    function inner() {
        var arg = [].slice.call(arguments);

        args = args.concat(arg);

        if (args.length >= fnLen) {
            return fn.apply(this, args);
        } else {
            return inner;
        }
    }

    return inner;
}

function add(a, b, c, d) {
    return a + b + c + d;
}

function mul(a, b, c, d) {
    return a * b * c * d;
}

var curryAdd = curry(add);
console.log(curryAdd(1)(2)(3)(4)); // 10

var curryMul = curry(mul, 1);
console.log(curryMul(2, 3)(4)); // 24
技術分享圖片

上面定義的 add方法中,接受4個參數

在我們currying函數中,接受這個add方法,並記住這個方法需要接受的參數數量,存儲傳入的參數,直到符合數量要求時,便進行調用處理。

反柯裏化

反柯裏化,將柯裏化過後的函數反轉回來,由原先的接受單個參數的幾個調用轉變為接受多個參數的單個調用

一種簡單的實現方法是:將多個參數一次性傳給柯裏化的函數,因為我們的柯裏化函數本身就支持多個參數的傳入處理,反柯裏化調用時,僅使用“一次調用”即可。

結合上方的柯裏化代碼,反柯裏化代碼如下

技術分享圖片
// 反柯裏化
function uncurry(fn) {
    var args = [].slice.call(arguments, 1);

    return function() {
        var arg = [].slice.call(arguments);
        
        args = args.concat(arg);

        return fn.apply(this, args);
    }
}

var uncurryAdd = uncurry(curryAdd);
console.log(uncurryAdd(1, 2, 3, 4)); // 10

var uncurryMul = uncurry(curryMul, 2);
console.log(uncurryMul(3, 4)); // 24
技術分享圖片

參考資料:

JavaScript. The Core: 2nd Edition

JavaScript:核心 - 第二版(譯)

ECMA-262-3 in detail. Chapter 6. Closures.

理解運用JS的閉包、高階函數、柯裏化