轉載於: https://www.cnblogs.com/echolun/p/10584703.html
一、一個簡單的for循環問題與我思考后產生的問題
還是這段代碼,分別用var與let去聲明變量,得到的卻是完全不同的結果,為什么?如果讓你把這個東西清晰的講給別人聽,怎么去描述呢?
//使用var聲明,得到3個3 var a = []; for (var i = 0; i < 3; i++) { a[i] = function () { console.log(i); }; } a[0](); //3 a[1](); //3 a[2](); //3 //使用let聲明,得到0,1,2 var a = []; for (let i = 0; i < 3; i++) { a[i] = function () { console.log(i); }; } a[0](); //0 a[1](); //1 a[2](); //2
再弄懂這個問題前,我們得知道for循環是怎么執行的。首先,對於一個for循環,設置循環變量的地方是一個父作用域,而循環體代碼在一個子作用域內;別忘了for循環還有條件判斷,與循環變量的自增。
for循序的執行順序是這樣的:設置循環變量(var i = 0) ==》循環判斷(i<3) ==》滿足執行循環體 ==》循環變量自增(i++)
我們按照這個邏輯改寫上面的for循環,以第一個var聲明為例,結合父子作用域的特點,上面的代碼可以理解為:
{ //我是父作用域 var i = 0; if (0 < 3) { a[0] = function () { //我是子作用域 console.log(i); }; }; i++; //為1 if (1 < 3) { a[1] = function () { console.log(i); }; }; i++; //為2 if (2 < 3) { a[2] = function () { console.log(i); }; }; i++; //為3 // 跳出循環 } //調用N次指向都是最終的3 a[0](); //3 a[1](); //3 a[2](); //3
那么我們此時模擬的步驟代碼中的聲明方式var修改為let,執行代碼,發現輸出的還是3個3!WTF???
按照模糊的理解,當for循環使用let時產生了塊級作用域,每次循環塊級作用域中的 i 都相互獨立,並不像var那樣全程共用了一個。
但是有個問題,子作用域中並沒有let,何來的塊級作用域,整個循環也就父作用域中使用了一次let i = 0;子作用域哪里來的塊級作用域?
請教了下百度的同學,談到了會不會是循環變量不止聲明了一次,其實自己也考慮到了這個問題,for循環會不會因為使用let而改變了我們常規理解的執行順序,自己又在子作用域用了let從而創造了塊級作用域?抱着僥幸的心理還是打斷點測試了一下:
可以看到,使用let還是一樣,聲明只有一次,之后就在后三個步驟中來回跳動了。
二、一個額外問題的暗示
如果說,在使用let的情況下產生了塊級作用域,每次循環的i都是獨立的一份,並不共用,那有個問題,第二次循環 i++ 自增時又是怎么知道上一個塊級作用域中的 i 是多少的。這里得到的解釋是從阮一峰ES6入門獲取的。
JavaScript 引擎內部會記住上一輪循環的值,初始化本輪的變量
i
時,就在上一輪循環的基礎上進行計算。
那這就是JS引擎底層實現的問題了,我還真沒法用自己的代碼去模擬去實現,我們分別截圖var與let斷點情況下作用域的分布。
首先是var聲明時,當函數執行時,只能在全局作用域中找到已被修改的變量i,此時已被修改為3
而當我們使用let聲明時,子作用域本沒有使用let,不應該是是塊級作用域,但斷點顯示卻是一個block作用域,而且可以確定的是整個for循環let i只聲明了一次,但產生了三個塊級作用域,每個作用域中的 i 均不相同。
那在子作用域中,我並沒有使用let,這個塊級作用域哪里開的,從JS引擎記錄 i 的變換進行循環自增而我們卻無法感知一樣,我猜測,JS引擎在let的情況下,每次循環自己都創建了一個塊級作用域並塞到了for循環里(畢竟子作用域里沒用let),所以才有了三次循環三個獨立的塊級作用域以及三個獨立的 i。
這也只是我的猜測了,可能不對,如果有人能從JS底層實現給我解釋就更好了。
PS:2019.4.19更新
之前對於for循環中使用let的步驟拆分推斷,我們得到了兩個結論以及一個猜想:
結論1:在for循環中使用let的情況下,由於塊級作用域的影響,導致每次迭代過程中的 i 都是獨立的存在。
結論2:既然說每次迭代的i都是獨立的存在,那i自增又是怎么知道上次迭代的i是多少?這里通過ES6提到的,我們知道是js引擎底層進行了記憶。
猜測1:由於整個for循環的執行體中並沒有使用let,但是執行中每次都產生了塊級作用域,我猜想是由底層代碼創建並塞給for執行體中。
由於寫這篇博客的時候順便給同事講了let相關知識,同事今天也正好看了模擬底層實現的代碼,這個做個補充:
還是上面的例子,我們在let情況下對for循環步驟拆分,代碼如下:
var a = []; { //我是父作用域 let i = 0; if (i < 3) { //這一步模擬底層實現 let k = i; a[k] = function () { //我是子作用域 console.log(k); }; }; i++; //為1 if (i < 3) { let k = i; a[k] = function () { console.log(k); }; }; i++; //為2 if (i < 3) { let k = i; a[k] = function () { console.log(k); }; }; i++; //為3 // 跳出循環 } a[0](); //0 a[1](); //1 a[2](); //2
上述代碼中,每次迭代新增了let k = i這一步,且這一步由底層代碼實現,我們看不到;
這一行代碼起到兩個作用,第一是產生了塊級作用域,解釋了這個塊級作用域是怎么來的,由於塊級的作用,導致3個k互不影響。
第二是通過賦值的行為讓3個k都訪問外部作用域的i,讓三個k建立了聯系,這也解釋了自增時怎么知道上一步是多少。
這篇文章有點鑽牛角尖了,不過有個問題在心頭不解決是真的難受,大概如此了。
PS:2019.11.28更新
謝謝博友 coltfoal 在基本數據類型與引用數據的概念上提供了一個有趣的例子,代碼如下,猜猜輸出什么:
var a = [] for (let y = {i: 0}; y.i < 3; y.i++) { a[y.i] = function () { console.log(y.i); }; }; a[0](); a[1](); a[2]();
你一定會好奇,為什么輸出的是3個3,不是說let會創建一個塊級作用域嗎,我們還是一樣的改成寫模擬代碼,如下:
var a = []; { //我是父作用域 let y = { i: 0 }; if (y.i < 3) { //這一步模擬底層實現 let k = y; a[k.i] = function () { //我是子作用域 console.log(k.i); }; }; y.i++; //為1 if (y.i < 3) { let k = y; a[k.i] = function () { console.log(k.i); }; }; y.i++; //為2 if (y.i < 3) { let k = y; a[k.i] = function () { console.log(k.i); }; }; y.i++; //為3 // 跳出循環 } a[0](); //3 a[1](); //3 a[2](); //3
注意,在模擬代碼中為let k = y而非let k = y.i。我們始終使用let聲明一個新變量用於保存for循環中的初始變量y,以達到創建塊級作用域的目的,即使y是一個對象。
那為什么有了塊級作用域,最終結果還是相同呢,這就涉及了深/淺拷貝的問題。由於y屬於引用數據類型,let k = y 本質上是保存了變量 y 指向值的引用地址,當循環完畢時,y中的 i 已自增為3。
變量k因為塊級作用域的原因雖然也是三個不同的k,但不巧的是大家保存的是同一個引用地址,所以輸出都是3了。
我們再次改寫代碼,說說會輸出什么:
var a = [] var b = {i:0}; for (let y = b.i; y < 3; y++) { a[y] = function () { console.log(y); }; }; a[0](); a[1](); a[2]();
對深/淺拷貝有疑問可以閱讀博主這篇博客 深拷貝與淺拷貝的區別,實現深拷貝的幾種方法
若對JavaScript中基本數據類型,引用數據類型的存儲有興趣,可以閱讀 JS 從內存空間談到垃圾回收機制 這篇博客。