深入理解Javascript中的作用域鏈和閉包
首先我們回顧下之前一篇關(guān)于介紹數(shù)組遍歷的文章:
請先看上一篇中提到的for循環(huán)代碼:
var array = []; array.length = 10000000;//(一千萬) for(var i=0,length=array.length;i<length;i++){ array[i] = 'hi'; } var t1 = +new Date(); for(var i=0,length=array.length;i<length;i++){ } var t2 = +new Date(); console.log(t2-t1); //以下是連續(xù)5次的運(yùn)行時間 //168+158+170+159+165 = 820(ms)
我們再看下面一段代碼, 測試環(huán)境為 chrome 52.0.2743.116 (64-bit):
var t1 = +new Date(); (function(){//閉包 for(var i=0,length=array.length;i<length;i++){ //array.push(i); } })(); var t2 = +new Date(); console.log(t2-t1); //以下是連續(xù)5次的運(yùn)行時間: //8+6+8+7+6 = 35(ms)
計(jì)算一下: 820/35 = 23 效率提升大致20倍. 實(shí)際上, 在 Firefox 及 Safari 對 for有做底層優(yōu)化的情況下, 仍然有4~6倍的性能提升. 這是為什么呢?
我們注意到兩段代碼最大的區(qū)別就是, 第二段代碼使用了匿名函數(shù)包裹for循環(huán). 我們將在后面講到, 請耐心閱讀.
作用域
所謂作用域, 指的是, 變量在聲明它們的函數(shù)體以及這個函數(shù)體嵌套的任意函數(shù)體內(nèi)都是有定義的.
js中只有函數(shù)作用域
眾所周知, JS中并沒有塊作用域, 只有函數(shù)作用域. 如下:
for(var i=0;i<10;i++){ ; } console.log(i);//10 function f(){ var a = 123; } f(); console.log(a);//a is not defined
因此 js 中只有一種局部作用域, 即函數(shù)作用域.
使用 var 聲明變量
通常我們知道, js 作為一種弱類型語言, 聲明一個變量只需要var保留字, 如果在函數(shù)中不使用 var 聲明變量, 該變量將提升為全局變量, 進(jìn)而脫離函數(shù)作用域, 如下:
function f(){ b = 123; } f(); console.log(b);//123
此時相對于前面使用var聲明的 a 變量, b 變量被提升為全局變量, 在函數(shù)作用域外依然可以訪問.
既然在函數(shù)作用域內(nèi)不使用 var 聲明變量, 會將變量提升為全局變量, 那么在全局下, 不使用var, 會怎么樣呢?
//全局下不使用var聲明,該變量依然是全局變量 c = "hello scope"; console.log(c);//hello scope console.log(window.c);//hello scope //查看c變量的屬性 console.log(Object.getOwnPropertyDescriptor(window, 'c'));//Object {value: "hello scope", writable: true, enumerable: true, configurable: true} ,此時c變量可賦值,可列舉,可配置 //試著刪除c變量 delete c;//true 表示c變量被成功刪除 console.log(c);//c is not defined console.log(window.c);//undefined //使用var聲明后再刪除d變量 var d = 1; console.log(Object.getOwnPropertyDescriptor(window, 'd'));//Object {value: 1, writable: true, enumerable: true, configurable: false} ,此時d變量可賦值,可列舉,但不可配置 delete d;//false 表示d變量刪除失敗 console.log(d);//1 console.log(window.d);//1
綜上, 有如下規(guī)律:
- 不使用var保留字聲明變量, 變量提升為全局變量, 而不論變量處于哪種作用域;
- 如果不使用var聲明, 該變量便可配置, 即可被 delete 保留字刪除, 刪除后該變量便不可訪問; 如果使用var聲明, 該變量便不可配置, 即不能被 delete 保留字刪除;
- 只要是全局變量都可以直接訪問, 也可使用 “window.變量名” 來訪問, 不管該變量是不是通過var來聲明的;
JS中的作用域鏈
函數(shù)對象和其它對象一樣,擁有可以通過代碼訪問的屬性和一系列僅供JavaScript引擎訪問的內(nèi)部屬性。其中一個內(nèi)部屬性是[[Scope]],由ECMA-262標(biāo)準(zhǔn)第三版定義,該內(nèi)部屬性包含了函數(shù)被創(chuàng)建的作用域中對象的集合,這個集合被稱為函數(shù)的作用域鏈,它決定了哪些數(shù)據(jù)能被函數(shù)訪問。
我們先看一個栗子:
var e = "hello"; function f(){ e = "scope chain"; var g = = "good"; }
以上作用域鏈的圖如下所示:
函數(shù)執(zhí)行時, 在函數(shù) f 內(nèi)部會生成一個 active object 和 scope chain. JavaScript引擎內(nèi)部對象會放入 active object中, 外部的 e 變量處于scope chain的第二層, index=1, 而內(nèi)部的g變量處于scope chain的頂層, index=0, 因此訪問g變量總比訪問e變量來的快些.
閉包
聊到作用域, 就不得不說閉包, 那么, 什么是閉包?
“官方”的解釋是:閉包是一個擁有許多變量和綁定了這些變量的環(huán)境的表達(dá)式(通常是一個函數(shù)),因而這些變量也是該表達(dá)式的一部分。
這是什么意思呢, 簡單來說就是:
- 函數(shù)執(zhí)行時返回內(nèi)部私有函數(shù), 或者通過其他方式將內(nèi)部私有函數(shù)保留在外(比如說通過將其內(nèi)部私有函數(shù)的引用賦值外部變量), 從而阻止該函數(shù)內(nèi)部作用域等被執(zhí)行引擎回收.
- 在函數(shù)外部通過訪問暴露在外的函數(shù)內(nèi)部私有函數(shù), 從而具有訪問函數(shù)內(nèi)部私有作用域的效果, 就是閉包.
ES6之前, 通常我們實(shí)現(xiàn)的模塊就是利用了閉包. 閉包依賴的結(jié)構(gòu)有個鮮明的特點(diǎn), 即: 一個函數(shù)在詞法作用域之外執(zhí)行. 如下, f2是閉包的關(guān)鍵, 它的詞法作用域便是函數(shù)f的內(nèi)部私有作用域, 且它在f的作用域外部執(zhí)行.
var h = 1; function f(){ var i = 2; return function f2(){ var j = 3 + i + h; console.log(j); } } var ff = f(); ff();//6
由于定義時 f2 處于 f 的內(nèi)部, 因此 f2 內(nèi)可以訪問到 f 的內(nèi)部私有作用域, 這樣通過返回 f2 就能保證在 f 函數(shù)外部也能訪問到 i 變量.
當(dāng)f2執(zhí)行時, 變量 j 處于scope chain的 index0的位置上, 變量 i 和變量 h 分別處于 scope chain 的 index1 index2 的位置上. 因此 j 的賦值過程其實(shí)就是沿著 scope chain 第二層 第三層 依次找到 i 和 h 的值, 然后將它們和3一起求和, 最終賦值給 j .
瀏覽器沿著 scope chain 尋找變量總是需要耗費(fèi)CPU時間, 越是 scope chain 的 外層(或者離f2越遠(yuǎn)的變量), 瀏覽器查找起來越是需要時間, 因?yàn)?scope chain 需要?dú)v經(jīng)更多次遍歷. 因此全局變量(window)總是需要最多的訪問時間.
閉包內(nèi)的微觀世界
如果要更加深入的了解閉包以及函數(shù) f 和嵌套函數(shù) f2 的關(guān)系,我們需要引入另外幾個概念:函數(shù)的執(zhí)行環(huán)境(excution context)、活動對象(call object)、作用域(scope)、作用域鏈(scope chain)。以函數(shù)a從定義到執(zhí)行的過程為例闡述這幾個概念。
- 當(dāng)定義函數(shù) f 的時候, js解釋器會將函數(shù)a的作用域鏈(scope chain)設(shè)置為定義 f 時 a 所在的”環(huán)境”, 如果 f 是一個全局函數(shù),則scope chain中只有window對象。
- 當(dāng)執(zhí)行函數(shù) f 的時候, f 會進(jìn)入相應(yīng)的執(zhí)行環(huán)境(excution context).
- 在創(chuàng)建執(zhí)行環(huán)境的過程中, 首先會為 f 添加一個scope屬性, 即a的作用域, 其值就為第1步中的scope chain. 即a.scope=f 的作用域鏈.
- 然后執(zhí)行環(huán)境會創(chuàng)建一個活動對象(call object). 活動對象也是一個擁有屬性的對象, 但它不具有原型而且不能通過JavaScript代碼直接訪問. 創(chuàng)建完活動對象后, 把活動對象添加到 f 的作用域鏈的最頂端. 此時a的作用域鏈包含了兩個對象: f 的活動對象和window對象.
- 下一步是在活動對象上添加一個arguments屬性, 它保存著調(diào)用函數(shù) f 時所傳遞的參數(shù).
- 最后把所有函數(shù) f 的形參和內(nèi)部的函數(shù) f2 的引用也添加到 f 的活動對象上. 在這一步中, 完成了函數(shù) f2 的定義, 因此如同第3步, 函數(shù) f2 的作用域鏈被設(shè)置為 f2 所被定義的環(huán)境, 即 f 的作用域.
到此, 整個函數(shù) f 從定義到執(zhí)行的步驟就完成了. 此時 f 返回函數(shù) f2 的引用給 ff, 又函數(shù) f2 的作用域鏈包含了對函數(shù) f 的活動對象的引用, 也就是說 f2 可以訪問到 f 中定義的所有變量和函數(shù). 函數(shù) f2 被 ff 引用, 函數(shù) f2又依賴函數(shù) f , 因此函數(shù) f 在返回后不會被GC回收.
當(dāng)函數(shù) f2 執(zhí)行的時候亦會像以上步驟一樣. 因此, 執(zhí)行時 f2 的作用域鏈包含了3個對象: f2 的活動對象、f 的活動對象和window對象, 如下圖所示:
如圖所示, 當(dāng)在函數(shù) f2 中訪問一個變量的時候, 搜索順序是:
- 先搜索自身的活動對象, 如果存在則返回, 如果不存在將繼續(xù)搜索函數(shù) f 的活動對象, 依次查找, 直到找到為止.
- 如果函數(shù) f2 存在prototype原型對象, 則在查找完自身的活動對象后先查找自身的原型對象, 再繼續(xù)查找. 這就是Javascript中的變量查找機(jī)制.
- 如果整個作用域鏈上都無法找到, 則返回undefined.
小結(jié), 本段中提到了兩個重要的詞語: 函數(shù)的定義與執(zhí)行. 文中提到函數(shù)的作用域是在定義函數(shù)時候就已經(jīng)確定, 而不是在執(zhí)行的時候確定(參看步驟1和3).用一段代碼來說明這個問題:
function f(x) { var g = function () { return x; } return g; } var h = f(1); alert(h());
這段代碼中變量h指向了f中的那個匿名函數(shù)(由g返回).
- 假設(shè)函數(shù)h的作用域是在執(zhí)行
alert(h())
確定的, 那么此時h的作用域鏈?zhǔn)? h的活動對象->alert的活動對象->window對象. - 假設(shè)函數(shù)h的作用域是在定義時確定的, 就是說h指向的那個匿名函數(shù)在定義的時候就已經(jīng)確定了作用域. 那么在執(zhí)行的時候, h的作用域鏈為: h的活動對象->f的活動對象->window對象.
如果第一種假設(shè)成立, 那輸出值就是undefined; 如果第二種假設(shè)成立, 輸出值則為1。
運(yùn)行結(jié)果證明了第2個假設(shè)是正確的,說明函數(shù)的作用域確實(shí)是在定義這個函數(shù)的時候就已經(jīng)確定了.
閉包有可能導(dǎo)致IE瀏覽器內(nèi)存泄漏
先看一個栗子:
function f(){ var div = document.createElement("div"); div.onclick = function(){ return false; } }
上述div的click事件就是一個閉包, 由于該閉包的存在使得 f 函數(shù)內(nèi)部的 div 變量對DOM元素的引用將一直存在.
而早期IE瀏覽器( IE9之前 ) js 對象和 DOM 對象使用不同的垃圾收集方法, DOM對象使用計(jì)數(shù)垃圾回收機(jī)制, 只要匿名函數(shù)( 比如說onclick事件 )存在, DOM對象的引用便至少為1,因此它所占用的內(nèi)存就永遠(yuǎn)不會被銷毀.
有趣的是,不同的IE版本將導(dǎo)致不同的現(xiàn)象:
- 如果是IE 6, 內(nèi)存泄漏,直到關(guān)閉IE進(jìn)程為止;
- 如果是IE 7,內(nèi)存泄漏, 直到離開當(dāng)前頁面為止;
- 如果是IE 8, GC回收器回收他們的內(nèi)存,無論當(dāng)前是不是compatibility模式.
總結(jié)一下, 閉包的優(yōu)點(diǎn): 共享函數(shù)作用域, 便于開放一些接口或變量供外部使用;
注意事項(xiàng): 由于閉包可能會使得函數(shù)中變量被長期保存在內(nèi)存中, 從而大量消耗內(nèi)存, 影響頁面性能, 因此不能濫用, 并且在IE瀏覽中可能導(dǎo)致內(nèi)存泄露. 解決方法是,在退出函數(shù)之前,將不使用的局部變量全部刪除.
for循環(huán)問題分析
我們再來看看開篇的for循環(huán)問題, 增加匿名函數(shù)后, for循環(huán)內(nèi)部的變量便處于匿名函數(shù)的局部作用域下, 此時訪問 length 屬性, 或者訪問 i 屬性, 都只需要在匿名函數(shù)作用域內(nèi)查找即可, 因此查詢效率大大提升(測試數(shù)據(jù)發(fā)現(xiàn)提升有兩百多倍).
使用匿名函數(shù)后, 不止是作用域查詢更快, 作用域內(nèi)的變量還與外部隔離, 避免了像 i , length 這樣的變量對后續(xù)代碼產(chǎn)生影響. 可謂一舉兩得.
踩個作用域的坑
下面我們來踩一個作用域經(jīng)典的坑.
var div = document.getElementsByTagName("div"); for(var i=0,len=div.length;i<len;i++){ div[i].onclick = function(){ console.log(i); } }
上述代碼的本意是每次點(diǎn)擊div, 打印div的索引, 實(shí)際上打印的卻是 len 的值. 我們來分析下原因.
點(diǎn)擊div時, 將會執(zhí)行 console.log(i)
語句, 顯然 i 變量不在 click 事件的局部作用域內(nèi), 瀏覽器將沿著 scope chain 尋找 i 變量, 在 index1
的地方, 即 for循環(huán)開始的地方, 此處定義了一個 i 變量, 又 js 沒有塊作用域, 故 i 變量并不會在 for循環(huán)塊執(zhí)行完成后被銷毀,又 i的最后一次自加使得 i = len
, 于是瀏覽器在scope chain index=1
索引的地方停下來了, 返回了i的值, 即len的值.
為了解決這個問題, 我們將根據(jù)癥結(jié), 對癥下藥, 從作用域入手, 改變click事件的局部作用域, 如下:
var div = document.getElementsByTagName("div"); for(var i=0,len=div.length;i<len;i++){ (function(n){ div[n].onclick = function(){ console.log(n); } })(i); }
由于 click 事件被閉包包裹, 并且閉包自執(zhí)行, 因此閉包內(nèi) n 變量的值每次都不一樣, 點(diǎn)擊div時, 瀏覽器將沿著 scope chain 尋找 n 變量, 最終會找到閉包內(nèi)的 n 變量, 并且打印出div 的索引.
this作用域
前面我們學(xué)習(xí)了作用域鏈, 閉包等基礎(chǔ)知識, 下面我們來聊聊神秘莫測的this作用域.
熟悉OOP的開發(fā)人員都知道, this是對象實(shí)例的引用, 始終指向?qū)ο髮?shí)例. 然而 js 的世界里, this隨著它的執(zhí)行環(huán)境改變而改變, 并且它總是指向它所在方法的對象. 如下,
function f(){ alert(this); } var o = {}; o.func = f; f();//[object Window] o.func();//[object Object] console.log(f===window.f);//true
當(dāng)f單獨(dú)執(zhí)行時, 其內(nèi)部this指向window對象, 但是當(dāng)f成為o對象的屬性func時, this指向的是o對象, 又f === window.f
, 故它們實(shí)際上指向的都是this所在方法的對象.
下面我們來應(yīng)用下
Array.prototype.slice.call([1,2,3],1);//[2,3],正確用法 Array.prototype.slice([1,2,3],1);//[], 錯誤用法,此時slice內(nèi)部this仍然指向Array.prototype var slice = Array.prototype.slice; slice([1,2,3],1);//Uncaught TypeError: Array.prototype.slice called on null or undefined //此時slice內(nèi)部this指向的是window對象,離開了原來的Array.prototype對象作用域,故報錯~~
總結(jié)下, this的使用只需要注意一點(diǎn):
this 總是指向它所在方法的對象.
with語句
聊到作用域鏈就不得不說with語句了, with語句可以用來臨時改變作用域, 將語句中的對象添加到作用域的頂部.
語法: with (expression){statement}
例如:
var k = {name:"daicy"}; with(k){ console.log(name);//daicy } console.log(name);//undefined
with 語句用于對象 k, 作用域第一層為 k 對象內(nèi)部作用域, 故能直接打印出 name 的值, 在with之外的語句不受此影響.
再看一個栗子:
var l = [1,2,3]; with(l) { console.log(map(function(i){ return i*i; }));//[1,4,9] }
在這個例子中,with 語句用于數(shù)組,所以在調(diào)用 map()
方法時,解釋程序?qū)z查該方法是否是本地函數(shù)。如果不是,它將檢查偽對象 l,看它是否為該對象的方法, 又map是Array對象的方法, 數(shù)組l繼承了該方法, 故能正確執(zhí)行.
注意: with語句容易引起歧義, 由于需要強(qiáng)制改變作用域鏈, 它將帶來更多的cpu消耗, 建議慎用 with 語句.
總結(jié)
以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對大家的學(xué)習(xí)或者工作能帶來一定的幫助,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
相關(guān)文章
javascript for循環(huán)設(shè)法提高性能
讓你的for循環(huán)提升性能的寫法,需要的朋友可以參考下。2010-02-02d3.js實(shí)現(xiàn)簡單的網(wǎng)絡(luò)拓?fù)鋱D實(shí)例代碼
最近一直在學(xué)習(xí)d3.js,大家都知道d3.js是一個非常不錯的數(shù)據(jù)可視化庫,我們可以用它來做一些比較酷的東西,比如可以來顯示一些簡單的網(wǎng)絡(luò)拓?fù)鋱D,這篇文中就通過實(shí)例代碼給大家介紹了如何利用d3.js實(shí)現(xiàn)簡單的網(wǎng)絡(luò)拓?fù)鋱D,有需要的朋友們可以參考借鑒,下面來一起看看吧。2016-11-11詳解JavaScript+Canvas繪制環(huán)形進(jìn)度條
canvas可以在頁面中設(shè)定一個區(qū)域,再利用JavaScript動態(tài)地繪制圖像。本文將利用canvas繪制一個可以動的環(huán)形進(jìn)度條。文中的示例代碼講解詳細(xì),感興趣的小伙伴可以動手試一試2022-02-02js猜數(shù)字小游戲的簡單實(shí)現(xiàn)代碼
這篇文章介紹了js猜數(shù)字小游戲的簡單實(shí)現(xiàn)代碼,很好玩的游戲哦,可以看看你的智商 是否驚人額2013-07-07JavaScript代碼壓縮工具UglifyJS和Google Closure Compiler的基本用法
網(wǎng)上搜索了,目前主流的Js代碼壓縮工具主要有Uglify、YUI Compressor、Google Closure Compiler,簡單試用了UglifyJS 和Google Closure Compiler 兩種工具的基本用法,需要的朋友可以參考下2020-04-04原生JavaScript實(shí)現(xiàn)remove()和recover()功能示例
這篇文章主要介紹了原生JavaScript實(shí)現(xiàn)remove()和recover()功能,結(jié)合實(shí)例形式分析了javascript實(shí)現(xiàn)類似jQueryremove()和recover()功能的自定義函數(shù),需要的朋友可以參考下2018-07-07Javascript實(shí)現(xiàn)倒計(jì)時(防頁面刷新)實(shí)例
本文分享了Javascript實(shí)現(xiàn)倒計(jì)時并且頁面不刷新的實(shí)例,具有很好的參考價值,需要的朋友一起來看下吧2016-12-12JavaScript中String.prototype用法實(shí)例
這篇文章主要介紹了JavaScript中String.prototype用法,實(shí)例分析了prototype的功能及使用技巧,需要的朋友可以參考下2015-05-05