ES6函式引數預設值作用域的模擬原理實現與個人的一些推測
一、函式引數預設值中模糊的獨立作用域
我在ES6入門學習函式拓展這一篇部落格中有記錄,當函式的引數使用預設值時,引數會在初始化過程中產生一個獨立的作用域,初始化完成作用域會消失;如果不使用引數預設值,不會產生這個作用域;產生疑問是因為這段程式碼:
var x = 1; function foo(x, y = function () {x = 2;}) { var x = 3; y(); console.log(x); }; foo();//3 foo(4);//3 console.log(x);//1
老實說,關於這個獨立作用域的描述十分抽象,當我的同事對於這問題述向我提出疑問時,我發現確實不能很好的給他解釋這個問題,原因很簡單,我也似懂非懂。對此我做了一些測試,並嘗試去模擬實現這個作用域,便於說服同事以及我自己。
為什麼var x=3始終輸出3,為什麼去掉var後始終輸出2,這個獨立的作用域到底是怎麼回事?
如果你對於這個問題了如指掌,相關筆試題輕鬆解答,這篇文章就不那麼重要了;但如果你對這個作用域跟我一樣有一些疑慮,那可以跟著我的思路來理一理,那麼本文開始。
二、ES6帶來的塊級作用域
在改寫這段程式碼前,有必要先把塊級作用域說清楚。
我們都知道,在ES6之前JavaScript只存在全域性作用域與函式作用域這兩類,更有趣的是當我們使用var去宣告一個變數或者一個函式,本質上是在往window物件上新增屬性:
var name = "聽風是風"; var age = 26; window.name; //'聽風是風' window.age; //26
這自然是不太好的做法,我們本想宣告幾個變數,結果原本乾淨的window物件被弄的一團糟,為了讓變數宣告與window物件不再有牽連,也是彌補變數提升等一些缺陷,ES6正式引入了let宣告。
delete window.name; let name = "聽風是風"; window.name; //undefined
let還帶來了一個比較重要的概念,塊級作用域,當我們在一個花括號中使用let去宣告一個變數,這個花括號就是一個塊級作用域,塊級作用域外無權訪問這個變數。
{ let x = 1; } console.log(x)//報錯,x未宣告
當你在這個塊級作用域外層再次宣告x時,外層作用域中的x與塊級作用域中的x就是不同的兩個x了,互不影響:
let x = 2; { let x = 1; console.log(x); //1 } console.log(x) //2 var y = 1; { let y = 2 } console.log(y) //1
但你不可以在同層作用域中使用let宣告一個變數後再次var 或者再次let相同變數:
let x = 1; var x; //報錯,x已宣告 let y = 1; let y; //報錯,y已宣告 var z = 1; let z; //報錯,z已宣告
塊級作用域依舊存在作用域鏈,並不是說你變成了塊級作用域就六親不認了,誰也別想用我塊級裡面的變數:
{ //父作用域 let x = 1; let y = 1; { //子作用域 console.log(x); //1 x = 2; let y = 2; console.log(y); //2 } console.log(x); //2 console.log(y);//1 }
上述程式碼中子作用域中沒let x,父作用域還是允許子作用域中訪問修改自己的x;父子作用域中都let y,那兩個作用域中的y就是完全不相關的變數。
最後一點,很多概念都說,外(上)層作用域是無權訪問塊級作用域的變數,這句話其實有歧義,準確來說,是無權訪問塊級作用域中使用了let的變數,我的同事就誤會了這點:
{ let x = 1; var y = 2; z = 3; } console.log(y);//2 console.log(z);//3 console.log(x);//報錯,x未定義
let x確實產生了一個塊級作用域,但你只能限制外層訪問產生塊級作用域的x,我y用的var,z直接就全域性,你們抓週樹人跟我魯迅有什麼關係?這點千萬要理解清楚。
介紹let可能花了點時間,明明是介紹函式引數預設值的作用域,怎麼聊到let了。這是因為我在給同事說我的推測時,我發現他對於let存在部分誤解,所以在理解我的思路上也花了一些時間。
三、關於函式引數預設值獨立作用域的推測與我的程式碼模擬思路
1.改寫函式引數
我們都知道,函式的引數其實等同於在函式內部聲明瞭一個區域性變數,只是這個變數在函式呼叫時能與傳遞的引數一一對應進行賦值:
function fn(x) { console.log(x); }; fn(1); //等用於 function fn() { //函式內部聲明瞭一個變數,傳遞的值會賦予給它 var x = 1; }; fn()
所以第一步,我將文章開頭那段程式碼中的函式進行改寫,將形參改寫進函式內部:
function foo() { var x; var y = function () { x = 2; }; var x = 3; y(); console.log(x); };
2.模擬形參的獨立作用域
改寫後有個問題,此時形參與函式內部程式碼處於同一層作用域,這與我們得知的概念不太相符,概念傳達的意思是,函式引數使用預設值,會擁有獨立的作用域,所以我們用一個花括號將函式內程式碼隔離起來:
function foo() { var x; var y = function () { x = 2; }; { var x = 3; y(); console.log(x); } };
其次,由文章開頭的程式碼結果我們已經得知,var x =3這一行程式碼,如果帶了var ,函式體內x變數就與引數內的x互不影響了,永遠輸出3;如果把var去掉呢,就能繼承並修改引數中的變數x了,此時x始終輸出2,這個效果可以自己複製文章開頭的原始碼測試。
我在上文介紹let塊級作用域時有提到塊級作用域也是有作用域鏈的;父子塊級作用域,如果子作用域自己let一個父作用域已宣告的變數,那麼兩者就互不影響,如果子不宣告這個變數,還是可以繼承使用和修改父作用域的此變數。這個情況不就是示例程式碼的除去var和不除去var效果嗎,只是我們還缺個塊級作用域才能滿足這個條件,所以我將var x =3前面的var修改成了let,整個程式碼修改完畢:
function foo() { //父作用域 var x; var y = function () { x = 2; }; { // 子塊級作用域 let x = 3; y(); console.log(x); } };
你肯定要問,我為什麼要把var改為let?並不是我根據結論強行倒推理,我在斷點時發現了一個問題,帶var的情況:
注意觀察右邊Scope的變化,當斷點跑到var x = 3時,顯示在block(塊級作用域)下x是undefined,然後被賦值成了3,最後斷點跑到console時,也是輸出了block作用域下的x,而且在block作用域和local作用域中分別存在2個變數x,如下圖:
函式內部明明沒用let,也就是說,函式執行時,隱性建立了一個塊級作用域包裹住了函式體內程式碼。當我把var去掉時,再看截圖:
可以看到,當去掉var時,整個程式碼執行完,全程都不存在block作用域,而且從頭到尾都只有local作用域下的一個x。
由此我推斷var是產生塊級作用域的原因,所以將x變數前的var改為了let。
3.模擬程式碼測試階段:
我們最終修改後的程式碼就是這樣:
var x = 1; function foo() { var x; var y = function () { x = 2; }; { let x = 3; y(); console.log(x); } }; foo(); //3 foo(4); //3 console.log(x); //1
帶var分別輸出3 3 1,我們把var 改成了let,也是輸出3 3 1。去var輸出2 2 1,我們把let去掉也是輸出2 2 1,效果一模一樣。
我們對比了修改前後,程式碼執行時scope的變化,是一模一樣的,可以說模擬還算成功。
4.最終模擬版本
然後我又發現了一個改寫的大問題:
function fn(x=x){ }; fn();//報錯
這段程式碼是會報錯的,它會提示你,x未宣告就使用了,這是let宣告常見的錯誤。但是如果按照我前面說的將形參移到函式體內用var宣告,那就不會報錯了:
function fn(){ var x = x; }; fn()//不報錯 function fn(){ let x = x; }; fn()//報錯
所以我上面的初始程式碼改寫後的最終版本是這樣:
var x = 1; function foo() { let x; let y = function () { x = 2; }; { let x = 3; y(); console.log(x); } }; foo(); //3 foo(4); //3 console.log(x); //1
這是執行效果圖,仔細觀察可以發現scope變化以及執行結果與沒改之前一樣,只是我覺得這樣改寫更為嚴謹。
四、最終結論與個人推測
所以我得到的最終結論是,並不是函式形參使用了預設值會產生獨立的作用域,而是函式形參使用了預設值時,會讓函式體內的var宣告隱性產生一個塊級作用域,從而變相導致了函式引數所在作用域被隔離。不使用引數預設值或函式體內不使用var宣告不會產生此作用域。
我的改寫模擬思路是這樣:
第一步,形參如果用了預設值,將形參移到函式體內並用let宣告它們;
第二步,如果此時沒報錯,再用花括號將原本的函式體程式碼包裹起來,再將花括號中的var宣告修改成let宣告。
function fn(x, y = x) { let x = 1; console.log(x); }; //第一步: function fn() { let x; let y = x; let x = 1; console.log(x); };
比如上述這段程式碼,形參移動到函式體內其實你就已經會報錯了,x變數被反覆申明瞭,所以就沒必要再用花括號包裹執行體程式碼了。
我大概總結出了以下幾個規律(可以按照我的思路改寫,方便理解):
1.當函式形參聲明瞭x,函式體內不能使用let再次宣告x,否則會報錯,原因參照函式改寫步驟1。
var x = 1; function fn(x){ let x =1;//報錯 }; fn();
2.當函式形參聲明瞭x,函式體內再次使用var宣告x時,函式體內會隱性建立一個塊級作用域,這個作用域會包裹執行體程式碼,也變相導致引數有了一個獨立的作用域,此時兩個x互不影響,原因參照函式改寫步驟2。
function fn(x =1){ var x =2; console.log(x);//2 }; fn();
3.當函式形參聲明瞭x,函式體內未使用var或者let去宣告x,函式體內可以直接修改和使用引數x的,此時共用的是同一個變數x,塊級作用域也存在作用域鏈。
var x =2; function fn(y = x){ x =3; console.log(y);//2 }; fn(); x//3
4.當函式形參未宣告x,但是引數內又有引數預設值使用了x,此時會從全域性作用域繼承x。
var x = 1; function fn(y=x){ console.log(y);//1 }; fn();
那麼到這裡,我大概模擬了函式引數預設值時產生獨立作用域的過程,同時按照我的理解去解釋了它。也許我的推測與底層程式碼實現有所偏差,但是這個模擬過程能夠很直觀的去推測正確的執行結果。
我寫這篇文章也是為了兩個目的,第一如果在面試中遇到,我能更好的解釋它,而不是似懂非懂;其次,在日常開發中使用函式引數預設值時,我能更清晰的寫出符合我預期結果的程式碼,此時的你應該也能做到這兩點了。
本文中所有的程式碼都是可測的,若有問題,或者更好的推測歡迎留言討論。
那麼就寫到這裡了,端午節快