JavaScript基礎系列—執行環境與作用域鏈

NO IMAGE
1 Star2 Stars3 Stars4 Stars5 Stars 給文章打分!
Loading...

問題

今天看筆記發現自己之前記了一個關於同名識別符號優先順序的內容,具體是下面這樣的:

  • 形參優先順序高於當前函式名,低於內部函式名
  • 形參優先順序高於arguments
  • 形參優先順序高於只宣告卻未賦值的區域性變數,但是低於宣告且賦值的區域性變數
  • 函式和變數都會宣告提升,函式名和變數名同名時,函式名的優先順序要高。執行程式碼時,同名函式會覆蓋只宣告卻未賦值的變數,但是它不能覆蓋宣告且賦值的變數
  • 區域性變數也會宣告提升,可以先使用後宣告,不影響外部同名變數

然後我就想,為什麼會有這樣的優先順序呢,規定的?但是好像沒有這個規定,於是開始查閱資料,就有了下文

初識Execution Context

Execution ContextJavascript中一個抽象概念,它定義了變數或函式有權訪問的其他資料,決定了它們各自的行為。為了便於理解,我們可以近似將其等同於執行當前程式碼的環境,JavaScript的可執行程式碼包括

  • 全域性程式碼
  • 函式程式碼
  • eval()程式碼

每當執行流轉到這些可執行程式碼時,就會“新建”一個Execution Context並進入該Execution Context

clipboard.png

在上圖中,共有4個Execution Context,其中有一個是Global Execution Context(有且僅有一個),還有三個Function Execution Context

再識Execution Context Stack

瀏覽器中的JavaScript直譯器是單執行緒的,每次建立並進入一個新的Execution Context時,這個Execution Context就會被推(push)進一個環境棧中,這個棧稱為Execution Context Stack,噹噹前Execution Context的程式碼執行完之後,棧又會將其彈(pop)出,並銷燬這個Execution Context,儲存在其中的變數及函式定義也隨之被銷燬,然後把控制權返回給之前的Execution ContextGlobal Execution Context例外,它要等到應用程式退出後 —— 如關閉網頁或瀏覽器 —— 才會被銷燬)

JavaScript的執行流就是由這個機制控制的,以下面的程式碼為例說明:

var sayHello = 'Hello';
function name(){
var fisrtName = 'Cao',
lastName = 'Cshine';
function getFirstName(){
return fisrtName;
}
function getLatName(){
return lastName;
}
console.log(sayHello   getFirstName()   ' '   getLastName());
}
name();

clipboard.png

  • 當瀏覽器第一次載入script的時候,預設會進入Global Execution Context,所以Global Execution Context永遠是在棧的最下面。
  • 然後遇到函式呼叫name(),此時新建並進入Function Execution Context nameFunction Execution Context name入棧;
  • 繼續執行遇到函式呼叫getFirstName(),於是新建並進入Function Execution Context getFirstNameFunction Execution Context getFirstName入棧,由於該函式內部不會再新建其他Execution Context,所以直接執行完畢,然後出棧,控制權交給Function Execution Context name
  • 再往下執行遇到函式呼叫getLastName(),於是新建並進入Function Execution Context getLastNameFunction Execution Context getLastName入棧,由於該函式內部不會再新建其他Execution Context,所以直接執行完畢,然後出棧,控制權交給Function Execution Context name
  • 執行完console後,函式name也執行完畢,於是出棧,控制權交給Function Execution Context name,至此棧中又只有Global Execution Context
  • 關於Execution Context Stack有5個關鍵點:

    • 單執行緒
    • 同步執行(非非同步)
    • 1個Global Execution Context
    • 無限制的函式Function Execution Context
    • 每個函式呼叫都會建立新的Execution Context,即使是自己呼叫自己,如下面的程式碼:

      (function foo(i) {
      if (i === 3) {
      return;
      }
      else {
      foo(  i);
      }
      }(0));

      Execution Context Stack的情況如下圖所示:

      clipboard.png

親密接觸Execution Context

每個Execution Context在概念上可以看成由下面三者組成:

  • 變數物件(Variable object,簡稱VO
  • 作用域鏈(Scope Chain
  • this

變數物件(Variable object

該物件與Execution Context相關聯,儲存著Execution Context中定義的所有變數、函式宣告以及函式形參,這個物件我們無法訪問,但是解析器在後臺處理資料是用到它(注意函式表示式以及沒用var/let/const宣告的變數不在VO中)

Global Execution Context中的變數物件VO根據宿主環境的不同而不同,在瀏覽器中為window物件,因此所有的全域性變數和函式都是作為window物件的屬性和方法建立的。

對於Function Execution Context,變數物件VO為函式的活動物件,活動物件是在進入Function Execution Context時建立的,它通過函式的arguments屬性初始化,也就是最初只包含arguments這一個屬性。

JavaScript直譯器內部,每次呼叫Execution Context都會經歷下面兩個階段:

  • 建立階段(發生在函式呼叫時,但是內部程式碼執行前,這將解釋宣告提升現象)

    • 建立作用域鏈(作用域鏈見下文)
    • 建立變數物件VO
    • 確定this的值
  • 啟用/程式碼執行階段

    • 變數賦值、執行程式碼

其中建立階段的第二步建立變數物件VO的過程可以理解成下面這樣:

  • Global Execution Context中沒有這一步) 建立arguments物件,掃描函式的所有形參,並將形參名稱 和對應值組成的鍵值對作為變數物件VO的屬性。如果沒有傳遞對應的實參,將undefined作為對應值。如果形參名為arguments,將覆蓋arguments物件
  • 掃描Execution Context中所有的函式宣告(注意是函式宣告,函式表示式不算)

    • 將函式名和對應值(指向記憶體中該函式的引用指標)組成組成的鍵值對作為變數物件VO的屬性
    • 如果變數物件VO已經存在同名的屬性,則覆蓋這個屬性
  • 掃描Execution Context中所有的變數宣告

    • 由變數名和對應值(此時為undefined) 組成,作為變數物件的屬性
    • 如果變數名與已經宣告的形參或函式相同,此時什麼都不會發生,變數宣告不會干擾已經存在的這個同名屬性。

好~~現在我們來看程式碼捋一遍:

function foo(num) {
console.log(num);// 66
console.log(a);// undefined
console.log(b);// undefined
console.log(fc);// f function fc() {}
var a = 'hello';
var b = function fb() {};
function fc() {}
}
foo(66);
  • 當呼叫foo(66)時,建立階段時,Execution Context可以理解成下面這個樣子

    fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
    arguments: {
    0: 66,
    length: 1
    },
    num: 66,
    fc: pointer to function fc()
    a: undefined,
    b: undefined
    },
    this: { ... }
    }
  • 當建立階段完成以後,執行流進入函式內部,啟用執行階段,然後程式碼完成執行,Execution Context可以理解成下面這個樣子:

    fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
    arguments: {
    0: 66,
    length: 1
    },
    num: 66,
    fc: pointer to function fc()
    a: 'hello',
    b: pointer to function fb()
    },
    this: { ... }
    }

作用域鏈(Scope Chain

當程式碼在一個Execution Context中執行時,就會建立變數物件的一個作用域鏈,作用域鏈的用途是保證對執行環境有權訪問的所有變數和函式的有序訪問

Global Execution Context中的作用域鏈只有Global Execution Context的變數物件(也就是window物件),而Function Execution Context中的作用域鏈還會有“父”Execution Context的變數物件,這裡就會要牽扯到[[Scopes]]屬性,可以將函式作用域鏈理解為—- 當前Function Execution Context的變數物件VO(也就是該函式的活動物件AO[[Scopes]],怎麼理解呢,我們繼續往下看

[[Scopes]]屬性

[[Scopes]]這個屬性與函式的作用域鏈有著密不可分的關係,JavaScript中每個函式都表示為一個函式物件,[[Scopes]]是函式物件的一個內部屬性,只有JavaScript引擎可以訪問。

結合函式的生命週期:

  • 函式定義

    • [[Scopes]]屬性在函式定義時被儲存,保持不變,直至函式被銷燬
    • [[Scopes]]屬性連結到定義該函式的作用域鏈上,所以他儲存的是所有包含該函式的 “父/祖父/曾祖父…” Execution Context的變數物件(OV),我們將其稱為所有父變數物件(All POV
    • !!!特別注意 [[Scopes]]是在定義一個函式的時候決定的
  • 函式呼叫

    • 函式呼叫時,會建立並進入一個新的Function Execution Context,根據前面討論過的呼叫Function Execution Context的兩個階段可知:先建立作用域鏈,這個建立過程會將該函式物件的[[Scopes]]屬性加入到其中
    • 然後會建立該函式的活動物件AO(作為該Function Execution Context的變數物件VO),並將建立的這個活動物件AO加到作用域鏈的最前端
    • 然後確定this的值
    • 正式執行函式內的程式碼

通過上面的過程我們大概可以理解:作用域鏈 = 當前Function Execution Context的變數物件VO(也就是該函式的活動物件AO[[Scopes]],有了這個作用域鏈, 在發生識別符號解析的時候, 就會沿著作用域鏈一級一級地搜尋識別符號,最開始是搜尋當前Function Execution Context的變數物件VO,如果沒有找到,就會根據[[Scopes]]找到父變數物件,然後繼續搜尋該父變數物件中是否有該識別符號;如果仍沒有找到,便會找到祖父變數物件並搜尋其中是否有該識別符號;如此一級級的搜尋,直至找到識別符號為止(如果直到最後也找不到,一般會報未定義的錯誤)

現在再結合例子來捋一遍:

var a = 10;
function foo(d) {
var b = 20;
function bar() {
var c = 30;
console.log(a    b   c   d); // 110
//這裡可以訪問a,b,c,d
}
//這裡可以訪問a,b,d 但是不能訪問c
bar();
}
//這裡只能訪問a
foo(50);
  • 當瀏覽器第一次載入script的時候,預設會進入Global Execution Context的建立階段

    • 建立Scope Chain(作用域鏈)
    • 建立變數物件,此處為window物件。然後會掃描所有的全域性函式宣告,再掃描全域性變數宣告。之後該變數物件會加到Scope Chain
    • 確定this的值
    • 此時Global Execution Context可以表示為:

      globalEC = {
      scopeChain: {
      pointer to globalEC.VO
      },
      VO: {
      a: undefined,
      foo: pointer to function foo(),
      (其他window屬性)
      },
      this: { ... }
      }
  • 接著進入Global Execution Context的執行階段

    • 遇到賦值語句var a = 10,於是globalEC.VO.a = 10

      globalEC = {
      scopeChain: {
      pointer to globalEC.VO
      },
      VO: {
      a: 10,
      foo: pointer to function foo(),
      (其他window屬性)
      },
      this: { ... }
      }
    • 遇到foo函式定義語句,進入foo函式的定義階段,foo[[Scopes]]屬性被確定

      foo.[[Scopes]] = {
      pointer to globalEC.VO
      }
    • 遇到foo(50)呼叫語句,進入foo函式呼叫階段,此時進入Function Execution Context foo的建立階段

      • 建立Scope Chain(作用域鏈)
      • 建立變數物件,此處為foo的活動物件。先建立arguments物件,然後掃描函式的所有形參,之後會掃描foo函式內所有的函式宣告,再掃描foo函式內的變數宣告。之後該變數物件會加到Scope Chain
      • 確定this的值
      • 此時Function Execution Context foo可以表示為

        fooEC = {
        scopeChain: {
        pointer to fooEC.VO,
        foo.[[Scopes]]
        },
        VO: {
        arguments: {
        0: 66,
        length: 1
        },
        b: undefined,
        d: 50,
        bar: pointer to function bar(),
        },
        this: { ... }
        }
    • 接著進入Function Execution Context foo的執行階段

      • 遇到賦值語句var b = 20;,於是fooEC .VO.b = 20

        fooEC = {
        scopeChain: {
        pointer to fooEC.VO,
        foo.[[Scopes]]
        },
        VO: {
        arguments: {
        0: 66,
        length: 1
        },
        b: 20,
        d: 50,
        bar: pointer to function bar(),
        },
        this: { ... }
        }
      • 遇到bar函式定義語句,進入bar函式的定義階段,bar[[Scopes]]`屬性被確定

        bar.[[Scopes]] = {
        pointer to fooEC.VO,
        pointer to globalEC.VO
        }
      • 遇到bar()呼叫語句,進入bar函式呼叫階段,此時進入Function Execution Context bar的建立階段

        • 建立Scope Chain(作用域鏈)
        • 建立變數物件,此處為bar的活動物件。先建立arguments物件,然後掃描函式的所有形參,之後會掃描foo函式內所有的函式宣告,再掃描bar函式內的變數宣告。之後該變數物件會加到Scope Chain
        • 確定this的值
        • 此時Function Execution Context bar可以表示為

          barEC = {
          scopeChain: {
          pointer to barEC.VO,
          bar.[[Scopes]]
          },
          VO: {
          arguments: {
          length: 0
          },
          c: undefined
          },
          this: { ... }
          }
      • 接著進入Function Execution Context bar的執行階段

        • 遇到賦值語句var c = 30,於是barEC.VO.c = 30

          barEC = {
          scopeChain: {
          pointer to barEC.VO,
          bar.[[Scopes]]
          },
          VO: {
          arguments: {
          length: 0
          },
          c: 30
          },
          this: { ... }
          }
        • 遇到列印語句console.log(a b c d);,需要訪問變數a,b,c,d

          • 通過bar.[[Scopes]].globalEC.VO.a訪問得到a=10
          • 通過bar.[[Scopes]].fooEC.VO.b,bar.[[Scopes]].fooEC.VO.d訪問得到b=20,d=50
          • 通過barEC.VO.c訪問得到c=30
          • 通過運算得出結果110
      • bar函式執行完畢,Function Execution Context bar銷燬,變數c也隨之銷燬
    • foo函式執行完畢,Function Execution Context foo銷燬,b,d,bar也隨之銷燬
  • 所有程式碼執行完畢,等到該網頁被關閉或者瀏覽器被關閉,Global Execution Context才銷燬,a,foo才會銷燬

通過上面的例子,相信對Execution Context和作用域鏈的理解也更清楚了,下面簡單總結一下作用域鏈:

  • 作用域鏈的前端始終是當前執行的程式碼所在Execution Context的變數物件;
  • 下一個變數物件來自其包含Execution Context,以此類推;
  • 最後一個變數物件始終是Global Execution Context的變數物件;
  • 內部Execution Context可通過作用域鏈訪問外部Execution Context反之不可以
  • 識別符號解析是沿著作用域鏈一級一級地搜尋識別符號的過程。搜尋過程始終從作用域鏈的前端開始,然後逐級的向後回溯,直到找到識別符號為止(如果找不到,通常會導致錯誤);
  • 作用域鏈的本質是一個指向變數物件的指標列表,只引用而不實際包含變數物件。

延長作用域鏈

下面兩種語句可以在作用域鏈的前端臨時增加一個變數物件以延長作用域鏈,該變數物件會在程式碼執行後被移除

  • try-catch語句的catch
    建立一個新的變數物件,其中包含的是被丟擲的錯誤物件的宣告
  • with語句
    將指定的物件新增到作用域鏈中

    function buildUrl(){
    var qs = "?debug=true";
    with(location){
    var url = href   qs;
    }
    //console.log(href) 將會報href is not defined的錯誤,因為with語句執行完with建立的變數物件就被移除了
    return url;
    }

    with語句接收window.location物件,因此其變數物件就包含了window.location物件的所有屬性,而這個變數物件被新增到作用域鏈的前端。所以在with語句裡面使用href相當於window.location.href

解答問題

現在我們來解答最開始的優先順序問題

  • 形參優先順序高於當前函式名,低於內部函式名

    function fn(fn){
    console.log(fn);// cc
    }
    fn('cc');

    函式fn屬於Global Execution Context,而形參fn屬於Function Execution Context fn,此時作用域的前端是Function Execution Context fn的變數物件,所以console.log(fn)為形參的值

    function fa(fb){
    console.log(fb);// ƒ fb(){}
    function fb(){}
    console.log(fb);// ƒ fb(){}
    }
    fa('aaa');

    呼叫fa函式時,進入Function Execution Context fa的建立階段,根據前面所說的變數物件建立過程:

    先建立arguments物件,然後掃描函式的所有形參,之後會掃描函式內所有的函式宣告,再掃描函式內的變數宣告;
    掃描函式宣告時,如果變數物件VO中已經存在同名的屬性,則覆蓋這個屬性

    我們可以得到fa的變數物件表示為:

    fa.VO = {
    arguments: {
    0:'aaa',
    length: 1
    },
    fb: pointer to function fb(),
    }

    所以console.log(fb)得到的是fa.VO.fb的值ƒ fb(){}

  • 形參優先順序高於arguments

    function fn(aa){
    console.log(arguments);// Arguments ["hello world"]
    }
    fn('hello world');
    function fn(arguments){
    console.log(arguments);// hello world
    }
    fn('hello world');

    呼叫fn函式時,進入Function Execution Context fn的建立階段,根據前面所說的變數物件建立過程:

    先建立arguments物件,然後掃描函式的所有形參,之後會掃描函式內所有的函式宣告,再掃描函式內的變數宣告;
    先建立arguments物件,後掃描函式形參,如果形參名為arguments,將會覆蓋arguments物件

    所以當形參名為arguments時,console.log(arguments)為形參的值hello world

  • 形參優先順序高於只宣告卻未賦值的區域性變數,但是低於宣告且賦值的區域性變數

    function fa(aa){
    console.log(aa);//aaaaa
    var aa;
    console.log(aa);//aaaaa
    }
    fa('aaaaa');

    呼叫fa函式時,進入Function Execution Context fa的建立階段,根據前面所說的變數物件建立過程:

    先建立arguments物件,然後掃描函式的所有形參,之後會掃描函式內所有的函式宣告,再掃描函式內的變數宣告;
    掃描函式內的變數宣告時,如果變數名與已經宣告的形參或函式相同,此時什麼都不會發生,變數宣告不會干擾已經存在的這個同名屬性

    所以建立階段之後Function Execution Context fa的變數物件表示為:

    fa.VO = {
    arguments: {
    0:'aaaaa',
    length: 1
    },
    aa:'aaaaa',
    }

    之後進入Function Execution Context fa的執行階段:console.log(aa);列印出fa.VO.aa(形參aa)的值aaaaa;由於var aa;僅宣告而未賦值,所以不會改變fa.VO.aa的值,所以下一個console.log(aa);列印出的仍然是fa.VO.aa(形參aa)的值aaaaa

    function fb(bb){
    console.log(bb);//bbbbb
    var bb = 'BBBBB';
    console.log(bb);//BBBBB
    }
    fb('bbbbb');

    呼叫fb函式時,進入Function Execution Context fb的建立階段,根據前面所說的變數物件建立過程:

    先建立arguments物件,然後掃描函式的所有形參,之後會掃描函式內所有的函式宣告,再掃描函式內的變數宣告;
    掃描函式內的變數宣告時,如果變數名與已經宣告的形參或函式相同,此時什麼都不會發生,變數宣告不會干擾已經存在的這個同名屬性

    所以建立階段之後Function Execution Context fb的變數物件表示為:

    fb.VO = {
    arguments: {
    0:'bbbbb',
    length: 1
    },
    bb:'bbbbb',
    }

    之後進入Function Execution Context fb的執行階段:console.log(bb);列印出fb.VO.bb(形參bb)的值’bbbbb’;遇到var bb = 'BBBBB';fb.VO.bb的值將被賦為BBBBB,所以下一個console.log(bb);列印出fb.VO.bb(區域性變數bb)的值BBBBB

  • 函式和變數都會宣告提升,函式名和變數名同名時,函式名的優先順序要高。

    console.log(cc);//ƒ cc(){}
    var cc = 1;
    function cc(){}

    根據Global Execution Context的建立階段中建立變數物件的過程:是先掃描函式宣告,再掃描變數宣告,且變數宣告不會影響已存在的同名屬性。所以在遇到var cc = 1;這個宣告語句之前,global.VO.ccƒ cc(){}

  • 執行程式碼時,同名函式會覆蓋只宣告卻未賦值的變數,但是它不能覆蓋宣告且賦值的變數

    var cc = 1;
    var dd;
    function cc(){}
    function dd(){}
    console.log(cc);//1
    console.log(dd);//ƒ dd(){}

    Global Execution Context的建立階段之後,Global Execution Context的變數物件可以表示為:

    global.VO = {
    cc:pointer to function cc(),
    dd:pointer to function dd()
    }

    然後進入Global Execution Context的執行階段,遇到var cc = 1;這個宣告賦值語句後, global.VO.cc將被賦值為1;然後再遇到var dd這個宣告語句,由於僅宣告未賦值,所以不改變global.VO.dd的值;所以console.log(cc);列印出1console.log(dd);列印出ƒ dd(){}

  • 區域性變數也會宣告提升,可以先使用後宣告,不影響外部同名變數

每個Execution Context都會有變數建立這個過程,所以會有宣告提升;根據作用域鏈,如果區域性變數與外部變數同名,那麼最先找到的是區域性變數,影響不到外部同名變數

相關資料

JavaScript基礎系列—變數及其值型別
Understanding Scope in JavaScript
What is the Execution Context & Stack in JavaScript?
深入探討JavaScript的執行環境和棧
作用域原理
JavaScript執行環境 變數物件 作用域鏈 閉包

相關文章

IOS開發 最新文章