轉載於: 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 從內存空間談到垃圾回收機制 這篇博客。

