閉包是javascript中一個十分常見但又很難掌握的概念,無論是自己寫代碼還是閱讀別人的代碼都會接觸到大量閉包。之前也沒有系統學習過,最近系統地總結了一些閉包的使用方法和使用場景,這裡做個記錄,希望大家指正補充。 一、定義 《JavaScript忍者秘籍》中對於閉包的定義是這樣的: 閉包是一個函數 ...
閉包是javascript中一個十分常見但又很難掌握的概念,無論是自己寫代碼還是閱讀別人的代碼都會接觸到大量閉包。之前也沒有系統學習過,最近系統地總結了一些閉包的使用方法和使用場景,這裡做個記錄,希望大家指正補充。
一、定義
《JavaScript忍者秘籍》中對於閉包的定義是這樣的:
閉包是一個函數在創建時允許該自身函數訪問並操作該自身函數之外的變數時所創建的作用域。換句話說,閉包可以讓函數訪問所有的變數和函數,只要這些變數和函數存在於該函數聲明時的作用域內就行。
註意:這裡說的是創建時,而不是調用時。
二、外部操作函數私有變數
正常來講,函數可以聲明一個塊級作用域,作用域內部對外部是不可見的,如:
function P(){ var innerValue = 1 } var p = new P() console.log(p.innerValue) //輸出undefined
但是,閉包可以讓我們能夠訪問私有變數:
function P(){ var innerValue = 1 this.getValue = function(){ console.log(innerValue) } this.setValue = function(newValue){ innerValue = newValue } } var p = new P() console.log(p.getValue()) //1 p.setValue(2) console.log(p.getValue()) //2
三、只要有回調的地方就有閉包
這可能是我們在日常開發中接觸閉包最多的場景,可能有些同學還沒有意識到這就是閉包,舉個例子:
function bindEvent(name, selector) { document.getElementById(selector).addEventListener('click',function () { console.log( "Activating: " + name ); } ); } bindEvent( "Closure 1", "test1" ); bindEvent( "Closure 2", "test2" );
執行了兩次bindEvent函數後,最後傳入的name是Closure 2,為什麼點擊id為test1的按鈕輸出的不是Closure 2而是Closure 1?這當然是閉包幫我們記住了每次調用bindEvent時的入參name。
四、綁定函數上下文(bind方法的實現)
先看一段代碼:
HTML: <button id="test1">click1</button> Js: var elem = document.getElementById('test1') var aHello = { name : "hello", showName : function(){ console.log(this.name); } } elem.onclick = aHello.showName
web前端/H5/javascript學習群:250777811
歡迎關註此公眾號→【web前端EDU】跟大佬一起學前端!歡迎大家留言討論一起轉發
當點擊按鈕時會有什麼現象呢?會輸出“hello”嗎?結果是會輸出something,但是輸出的不是“hello”,而是空。為什麼呢?顯然是“this.name”的this搞的鬼,原來當我們綁定事件後觸發這個事件,瀏覽器會自動把函數調用上下文切換到目標元素(本例中是id為test1的button元素)。所以this是指向button按鈕的,並不是aHello 對象,所以沒有輸出“hello”。
那麼我們如何將代碼改成我們想要的樣子呢?
1. 最常用的方式就是用一個匿名函數將showName包裝一下:
elem.onclick = function(){ aHello.showName() }
通過這樣使aHello來調用showName,這樣this就指向aHello了。
2. 使用bind函數來改變上下文
elem.onclick = aHello.showName.bind(aHello)
強行把this指向aHello對象,再點擊按鈕,就能正常輸出“hello”了。是不是很神奇?那麼如果讓你來實現bind函數,怎麼寫呢?我簡單寫了一個:
Function.prototype.bind = function(){ var fun = this; //指向aHello.showName函數 var obj = Array.prototype.slice.call(arguments).shift(); //這裡沒有處理多個參數,假設只有一個參數 return function(){ fun.apply(obj) } }
核心代碼是使用apply方法來改變this的指向,通過閉包來記住調用bind函數的函數,還有bind函數的入參。
五、函數柯里化
有同學可能會問柯里化是什麼?先看一個例子:
假如有一個求和函數:
function add(a,b){ return a + b } console.log(add(1,2)) //3
如果是柯里化的寫法:
function add(a){ return function(b){ return a+b } } console.log(add(1)(2)) //3
來看百度百科中柯里化的定義:
把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受餘下的參數且返回結果的新函數的技術。
通俗來講,柯里化也叫部分求值(不會立刻求值,而是到了需要的時候再去求值),是一個延遲計算的過程。之所以能延遲,少不了閉包來記錄參數。來看一個更有深度的例子,這是社區中某大神丟出的一個問題:
完成plus函數,通過全部的測試用例
function plus(n){}
module.exports = plus
var assert = require('assert') var plus = require('../lib/assign-4') describe('閉包應用',function(){ it('plus(0) === 0',function(){ assert.equal(0,plus(0).sum()) }) it('plus(1)(1)(2)(3)(5) === 12',function(){ assert.equal(12,plus(1)(1)(2)(3)(5).sum()) }) it('plus(1)(4)(2)(3) === 10',function(){ assert.equal(10,plus(1)(4)(2)(3).sum()) }) it('方法引用',function(){ var plus2 = plus(1)(1) assert.equal(12,plus2(1)(4)(2)(3).sum()) }) })
整理思路時考慮到以下幾點:
1. plus()()這種調用方式意味著plus函數的返回值一定是個函數,而且由於後面括弧的個數並沒有限制,想到plus函數是在遞歸調用自己。
2. plus所有的入參都應該保存起來,可以建一個數組來保存,而這個數組是要放在閉包中的。
3. plus()().sum(),sum的調用形式意味著sum應該是plus的一個屬性,而且最終的求和計算是sum來完成的
基於這幾點,我寫了一個plus函數:
var plus1 = function(){ var arr = [] var f = function(){ f.sum = function(){ return arr.reduce(function(total, curvalue){ return total + curvalue }, 0) } Array.prototype.push.apply(arr, Array.prototype.slice.call(arguments)) return arguments.callee } return f } var plus = plus1()
六、緩存記憶功能
有些函數的操作可能比較費時,比如做複雜計算。這時就需要用緩存來提高運行效率,降低運行環境壓力。以前我通常的做法是直接搞個全局對象,然後以鍵值對的形式將函數的入參和結果存到這個對象中,如果函數的入參在該對象中能查到,那就根據鍵讀出值返回就好,不用重新計算。
這種全局對象的搞法肯定不具有通用性,所以我們想到使用閉包,來看一個《JavaScript忍者秘籍》中的例子:
Function.prototype.memoized = function(key){ this._values = this._values || {} //this指向function(num){...}函數 return this._values[key] !== undefined ? this._values[key] : this._values[key] = this.apply(this, arguments); } Function.prototype.memoize = function(){ var fn = this; //this指向function(num){...}函數 return function(){ return fn.memoized.apply(fn, arguments) } } var isPrime = (function(num){ console.log("沒有緩存") var prime = num != 1;//1不是質數 for(var i = 2;i < num; i++){ if(num % i == 0){ prime = false; break; } } return prime }).memoize()
測試執行:
console.log(isPrime(5))
console.log(isPrime(5))
輸出:
沒有緩存
true
true
該例子巧妙地利用閉包將緩存存在計算函數的一個屬性中,而且實現了緩存函數與計算函數的解耦,使得緩存函數具有通用性。
七、即時函數IIFE
先來看代碼:
var p = (function(){ var a = 0 return function(){ console.log(++a) } })() p() //1 p() //2 p() //3
web前端/H5/javascript學習群:250777811
歡迎關註此公眾號→【web前端EDU】跟大佬一起學前端!歡迎大家留言討論一起轉發
有了IIFE和閉包,這種功能再也不需要全局變數了。所以,IIFE的一個作用就是創建一個獨立的、臨時的作用域,這也是後面要說的模塊化實現的基礎。
再來看一個基本所有前端都遇到過的面試題:
for (var i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, 1000 ); }
大家都知道這段代碼會在1s後列印5個6,為什麼會這樣呢?因為timer中每次列印的i和for迴圈裡面的i是同一個變數,所以當1s後要列印時,迴圈早已跑完,i的值定格在6,故列印5個6。
那麼,怎麼輸出1,2,3,4,5呢?
答案就是使用IIFE:
for (var j=1; j<=5; j++) { (function(n){ setTimeout(function timer() { console.log( n ); }, 1000 ) })(j) }
通過在for迴圈中加入即時函數,我們可以將正確的值傳給即時函數(也就是內部函數的閉包),在for迴圈每次迭代的作用域中,j變數都會被重新定義,從而給timer的閉包傳入我們期望的值。
當然,在ES6的時代,大可不必這麼麻煩,上代碼:
for (let i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, 1000 ); }
問題解決!具體原因,大家請自行百度…
八、模塊機制
先看一個最簡單的函數實現模塊封裝的例子:
function CoolModule() { var something = "cool"; var another = [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( " ! " ) ); } return { doSomething: doSomething, doAnother: doAnother }; } var foo = CoolModule(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
簡單分析一下,創建實例的過程就是執行構造函數的過程,執行後產生閉包,閉包使我們能達到使用模塊來封裝數據、函數的目的。再來看返回值,是不是有點“export”的意思,將函數封裝成一個對象return出來。
模塊模式的兩個必要條件:
1. 必須有外部的封閉函數, 該函數必須至少被調用一次(每次調用都會創建一個新的模塊實例)。
2. 封閉函數必須返回至少一個內部函數, 這樣內部函數才能在私有作用域中形成閉包, 並且可以訪問或者修改私有的狀態。
上面的代碼每調用一次就會創建一個實例,如果只需要一個實例,可使用單例模式:
var foo = (function CoolModule() { var something = "cool"; var another = [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( " ! " ) ); } return { doSomething: doSomething, doAnother: doAnother }; })(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
在ES6的import和export之前,大多數模塊載入庫的核心代碼基本如下:
var MyModules = (function Manager() { var modules = {}; function define(name, deps, impl) { for (var i=0; i<deps.length; i++) { deps[i] = modules[deps[i]]; } modules[name] = impl.apply( impl, deps ); } function get(name) { return modules[name]; } return { define: define, get: get }; })();
這段代碼的核心是 modules[name] = impl.apply(impl, deps)。 為了模塊的定義引入了包裝函數(可以傳入任何依賴), 並且將返回值, 也就是模塊的 API, 儲存在一個根據名字來管理的模塊列表中。
下麵展示瞭如何使用它來定義模塊:
MyModules.define( "bar", [], function() { function hello(who) { return "Let me introduce: " + who; } return { hello: hello }; }); MyModules.define( "foo", ["bar"], function(bar) { var hungry = "hippo"; function awesome() { console.log( bar.hello( hungry ).toUpperCase() ); } return { awesome: awesome }; }); var bar = MyModules.get( "bar" ); var foo = MyModules.get( "foo" ); console.log(bar.hello( "hippo" )); // Let me introduce: hippo foo.awesome(); // LET ME INTRODUCE: HIPPO
web前端/H5/javascript學習群:250777811
歡迎關註此公眾號→【web前端EDU】跟大佬一起學前端!歡迎大家留言討論一起轉發
這是一個很基礎的模擬模塊載入器的代碼,但是十分經典,完整的向我們展示了閉包在其中的作用。
小結:
以上閉包的用法都是在學習和工作中可能遇到的比較常見的用法,相信在掌握這些用法後自己對閉包的認識會上一個臺階,起碼在閱讀源碼時,對這塊不會有太多困難。
最後,有什麼問題或者不對的地方歡迎大家在評論區指正,後面我也會繼續完善此文。
參考文獻:
《你不知道的JavaScript》
《JavaScript忍者秘籍》