iT邦幫忙

0

【You Don't Know JS: Scope & Closures】Chapter 1 筆記

WM 2019-02-27 22:59:542082 瀏覽
  • 分享至 

  • xImage
  •  

Scope

幾乎所有的程式語言都能設變數並且儲存值,之後我們可以從變數取值或是修改變數的值,這種利用變數儲存值,可供我們使用的機制,讓程式語言的執行過程中,保留了某種狀態。

當使用了變數之後,問題是要去哪找這變數呢?

所以就必須定義一組規範,讓我們在需要的時候,可以找到變數,而所謂的規範可稱之為:範疇

Compiler

一般認為JavaScript是「dynamic(動態)」或「interpreted(直譯)」式的程式語言,但本書作者認為JavaScript是屬於「compiled(編譯)」式。

原文:

It may be self-evident, or it may be surprising, depending on your level of interaction with various languages, but despite the fact that JavaScript falls under the general category of "dynamic" or "interpreted" languages, it is in fact a compiled language.

傳統的編譯器處理程式原始碼(source code)會經歷3個步驟:

  1. Tokenizing / Lexing
    將字串拆解成tokens,例如:var a=2;會被拆解成數個tokens:var,a,=,2,;
  2. Parsing
    將Tokenizing/Lexing過程中,所拆解出來的tokens轉換成
    抽象語法樹(Abstract Syntax Tree,AST)。
    AST是一個樹狀結構,最頂層的節點為VariableDeclaration,表示宣告關鍵字var,帶有兩個子結點:
    1. Identifier:表示宣告的變數a
    2. AssignmentExpression:賦值表達式,帶有一個子結點NumericLiteral,表示值為2
  3. Code-Generation
    將Parsing過程產生的AST轉換成機器指令(machine instructions),會在記憶體建立變數a,並賦值2

JavaScript程式碼在被執行之前,會先以非常短的時間,微秒(microseconds )或更少,進行編譯(compiled)。

JavaScript會使用JITs,lazy compile或是hot re-compile等各種方式,來達到提高效能。

JavaScript會在原始碼(source code)即將被執行之前,完成編譯,並馬上執行。

Understanding Scope

在理解範疇的過程中,有3個主要角色:

  1. Engine
    負責整個編譯流程,並執行程式碼。
  2. Compiler
    負責執行Parsing與Code-Generation階段。
  3. Scope
    建立一份由宣告的變數(identifier)所組成的清單,並且規範清單中的變數,是否可由目前正在執行的程式碼存取。

Back & Forth

var a = 2;這段程式碼,一般會以敘述句(statement)來看待,但實際的狀況是,Engine會將之拆成2個部分,分別由Engine以及Compiler處理。

首先,Compiler先進行語意分析,將之拆解為tokens,之後,再轉換為"AST" (Abstract Syntax Tree).

接下來的步驟,我們可能會認為,在記憶體配置一個空間給變數a,再賦值2,但實際上並不是如此。

Compiler會如此處理:

  • 遇到var a,Compiler會跟Scope做確認,看看a是否存在於特定的範疇集合中。若存在,Compiler會忽略該次宣告,並往下一步執行。若無,Compiler會要求Scope在範疇集合中宣告a
  • Compiler會產生Engine即將要執行的程式碼,以處理a = 2這個賦值運算式。Engine會詢問Scope在它的範疇集合中是否有a。若有,就取用。若無,則到他處尋找。
  • 如果Engine有找到a,就會賦值2,若最終都沒找到,則會報錯。

Compiler

當Engine執行上述Compiler在第二步驟(Parsing)所產生的程式碼,它會向Scope詢問,a是否已經被宣告了。
不過,Engine所執行的查詢種類,會影響到查詢的結果。

在這個案例中,Engine會執行一種名叫LHS的查詢,另一種查詢則是叫RHS
LHS:Left-hand Side
RHS:Right-hand Side

非明確的判斷LHS或RHS是以指定運算子(assignment operator)「=」為依據:
若變數在「=」的左邊,是LHS,右邊,則是RHS

但以上述的準則來判斷,容易產生誤解,為何如此一說?

以LHS來說,首先會找到位於左邊的變數,再進行賦值的動作。

但以RHS來說,並不表示變數非得在的右邊不可,真正的涵義應該是,變數不在「=」的右邊。
換個角度,我們可以解讀為,取出該變數的值。

console.log(a);這邊並沒有賦值給a,而是要取出a的值,所以這個值會被傳入console.log( )之中

那LHS呢?

a = 2;就像剛剛說的,首先會先找到a,在這個運算式中,a是什麼值並不重要,我們的主要目的,是要把2這個值,賦值給a。

不管是LHS或RHS,都不要聚焦在字面上的意義(Left/Right-hand Side),應該要理解的是
誰是目標「who's the target of the assignment(LHS)」。
誰是來源「who's the source of the assignment(RHS)」。

我們來看看這個範例:

function foo(a) {
    console.log(a); // 2
}
foo(2);

呼叫foo(2);,意思就是,對foo執行RHS查詢,稍早已經定義foo為一個函式,所以我們會找到foo的值,並執行該函式。

這邊有個細節要注意,我們呼叫foo的同時,也傳入一個值給它,在這種情況之下,2會做為引數指定給參數a,所以會隱含地執行a=2;這個指定運算式,也就是LHS。

執行到console.log(a);這段,首先會執行對console物件的RHS查詢,Engine會查詢是否有console這個物件,並找出log這個方法。

所以我們再整理一下整個過程:
2傳入foo,再由console.log輸出,先對a執行LHS,再對console物件執行RHS,最後,要輸出a的值,會對a執行RHS。

關於宣告foo函式,如果使用var foo=function(a){…}的話,或許會認為這是執行LHS,就跟之前對a執行LHS一樣。

但實際上,Compiler會在code-generation這個階段,就會處理這種方式的賦值,並不會讓Engine去處理到這部分,所以如果將函示宣告視為LHS,是不恰當的。

Engine / Scope Conversation

對於上述的範例,我們做個整理:

function foo(a) {
    console.log(a); // 2
}
foo(2);
  • Engine執行foo(2);,它會對foo執行RHS查詢,並往scope尋找。
  • Compiler在code-generation這個階段已經宣告foo函式,所以能夠在scope中找到。
  • Engine必須把2當作引數傳給foo函式,首先它需要找到參數a,一樣往scope尋找。
  • Compiler在宣告foo函式的同時,一併也宣告參數a,所以能夠在scope中找到。
  • Engine找到參數a並賦值2,執行LHS。
  • Engine對console物件執行RHS查詢,並往scope尋找。
  • console物件是內建物件,所以一樣在scope中找到。
  • Engine得到console物件,找到log方法,這時它需要傳入a的值給log方法,依舊往scope尋找。
  • 經由Compiler的宣告,Engine的賦值,此時Engine可以對a執行RHS查詢,並將值傳給log方法。

進階的範例,讓我們更進一步了解LHS與RHS:

function foo(a) {
    var b = a;
    return a + b;
}
var c = foo(2);

執行LHS查詢有:

  • var c = foo(2);將foo函式賦值給c。
  • foo(2);隱含地執行a = 2
  • var b = a;將a的值賦值給b。

執行RHS查詢有:

  • foo(2);往scope查詢foo的值。
  • var b = a;賦值前,先找出a的值。
  • return a + b;找出ab的值,並回傳。

Nested Scope

所謂範疇,簡言之,就是一個可以讓我們藉由識別字名稱來找到變數的規範,但實際情況,我們所要尋找的範疇可能不只一個,範疇內部也可以包含另一個範疇,這種概念就是所謂的Nested Scope巢狀範疇。

如果Engine在目前的範疇找不到目標變數,它就會往外面一層的範疇尋找,直到找到,或是達到最外層的範疇(全域範疇)為止。

function foo(a) {
    console.log(a + b);
}
var b = 2;
foo(2); // 4

執行b的RHS查詢,無法在foo內完成,所以Engine會往foo的外部scope找。

在這個範例,外部scope是指全域範疇,並且在外部scope找到b

Errors

在變數未宣告的情況下,使用LHS與RHS會產生不同的結果。

function foo(a) {
    console.log(a + b);
    b = a;
}
foo(2);

會發生以下錯誤:
https://ithelp.ithome.com.tw/upload/images/20181117/20112573pMCd7TR7K4.png
這是因為對b使用RHS查詢,但是並沒有在所有的範疇中找到b,因為b並沒有被宣告。

如果RHS無法在範疇找到b,會丟出一個ReferenceError類型的錯誤。

但如果使用LHS查詢,一直到最外圍的範疇都沒找到目標變數的話,若不是在「嚴格模式」中,
那會在全域範疇中建立跟尋找目標同名的全域變數。

以下情況會發生TypeError錯誤:

  • 試圖將非函式的值當作函式執行。
let a = 10;
a();

https://ithelp.ithome.com.tw/upload/images/20181117/20112573P7ebpnXc9S.png

  • 存取null或undefined的特性。
let a;
a.prop;

https://ithelp.ithome.com.tw/upload/images/20181117/20112573A4Y3FMbyD9.png
ReferenceError錯誤的產生,與範疇解析的錯誤有關,表示找不到目標變數。
TypeError表示解析成功,但試圖對結果執行非法的行為。

重點整理

  • 範疇是一組規則,用來決定尋找變數(identifier)的位置及方式。
  • 尋找的目的有2個:賦值給變數或是從變數取值
  • 在範疇中搜尋,可能會發生在賦值運算式「=」 或是 將引數傳入函式中
  • 在程式碼執行之前,JavaScript Engine會先將之拆解成2個部分,以var a=2;為例:
    • var a,在目前的範疇中,宣告a
    • a=2,執行LHS查詢,在範疇中找到a之後,賦值。
  • RHS與LHS會先在目前的範疇中執行,若找不到目標變數,就會往外層範疇搜尋,直到找到或達到全域範疇為止。
  • RHS失敗會擲出ReferenceError類型的錯誤。
  • LHS失敗會在隱含地在全域範圍建立一個與目標同名的全域變數。

參考來源:
https://ithelp.ithome.com.tw/upload/images/20181117/20112573OWtzPwjWh4.jpg
WIKI 抽象語法樹

此為You Don't Know JS系列的筆記。


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言