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

来源:https://www.cnblogs.com/imwtr/archive/2018/08/05/9426400.html
-Advertisement-
Play Games

JS的閉包,是一個談論得比較多的話題了,不過細細想來,有些人還是理不清閉包的概念定義以及相關的特性。 這裡就整理一些,做個總結。 一、閉包 1. 閉包的概念 閉包與執行上下文、環境、作用域息息相關 執行上下文 執行上下文是用於跟蹤運行時代碼求值的一個規範設備,從邏輯上講,執行上下文是用執行上下文棧( ...


JS的閉包,是一個談論得比較多的話題了,不過細細想來,有些人還是理不清閉包的概念定義以及相關的特性。

這裡就整理一些,做個總結。

 

一、閉包

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.


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 第一次安裝wamp之後,所有服務可以正常使用,但是重啟之後wamp的圖標就變成黃色的了,重裝了也這樣 查看一下錯誤日誌: 日誌顯示的錯誤是這樣的: 日誌提示可能是3306埠被占用的錯誤,那來看一下是哪個程式占用了3306埠: windows下運行cmd ,輸入 可以看到是pid為2092這個程式 ...
  • 簡單複習下node,不過很多重要的知識點是圖,文字無法展示出來。 1.Node的特點 非同步I/O 事件與回調函數 單線程 跨平臺(libuv) 2.Node的應用場景 I/O密集型(事件迴圈、非同步I/O) CPU密集型(可以採用子進程) 3.CommonJS的模塊規範 模塊引用 模塊定義 模塊標識 ...
  • 1. ES5實現 父類: 子類繼承父類: 2. ES6實現 父類: 子類繼承: ...
  • ​ 昨晚睡前打開了半個月沒打開的知乎, 看到了一個專欄文章 "再見jquery,我的老朋友" ,突然想到之前github傳出,github已經徹底刪除jquery,這似乎標志著前端已經完全進入了一個新的時代,js從最開始的小丑語言,現在已經成為一個非常優秀的編程語言,開發者的目光從相容性一步步的轉向 ...
  • JQuery_1_選擇器 什麼是JQuery JQuery實際上就是一個javascript文件,它是一堆由javascript寫好的工具程式,一個被封裝好的js工具庫。它在js基礎之上做了很多的優化,讓網頁腳本的編寫過程更加的便捷高效。 JQuery有很多的版本,但各版本之間的差異都不是太大,基礎 ...
  • Vuex Vuex是專為Vue.js應用程式開發的狀態管理模式。它採用集中式存儲管理應用的所有組件的狀態,並以相應的規則保證狀態以一種可預測的方式發生變化。 應用場景 對於深層嵌套組件,依靠props進行父子組件的傳遞顯得太過臃腫,而且難以維護。而vuex的出現就是為瞭解決數據傳遞的問題。Vuex作 ...
  • 一、什麼是Sass Sass (Syntactically Awesome StyleSheets)是css的一個擴展開發工具,它允許你使用變數、條件語句等,使開發更簡單可維護。這裡是官方文檔。 二、基本語法 1)變數 sass的變數名必須是一個$符號開頭,後面緊跟變數名。 特殊變數:如果變數嵌套在 ...
  • javascript javascript概念 簡稱js,它可以針對網頁上面的元素,讓其動態的產生變化。 javascript的使用範圍 javascript是由瀏覽器來執行的。包含在網頁中被瀏覽器連同html、css一同解析並執行,它的運行必須依附於網頁與瀏覽器,而不能夠直接被執行。 javasc ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...