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
ECMA-262-3 in detail. Chapter 6. Closures.