1. 程式人生 > >ES6函式引數預設值作用域的模擬原理實現與個人的一些推測

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();

那麼到這裡,我大概模擬了函式引數預設值時產生獨立作用域的過程,同時按照我的理解去解釋了它。也許我的推測與底層程式碼實現有所偏差,但是這個模擬過程能夠很直觀的去推測正確的執行結果。

我寫這篇文章也是為了兩個目的,第一如果在面試中遇到,我能更好的解釋它,而不是似懂非懂;其次,在日常開發中使用函式引數預設值時,我能更清晰的寫出符合我預期結果的程式碼,此時的你應該也能做到這兩點了。

本文中所有的程式碼都是可測的,若有問題,或者更好的推測歡迎留言討論。

那麼就寫到這裡了,端午節快