FE.ES-理解ECMA Javascript作用域

seasonley發表於2019-02-16

本文僅整理自己所學做為筆記,如有錯誤請指正。

作用域

作用域是一套規則,用於確定在何處以及如何查詢變數(識別符號)。如果查詢的目的是對變數進行賦值,那麼就會使用 LHS 查詢;如果目的是獲取變數的值,就會使用 RHS 查詢。賦值操作符會導致 LHS 查詢。 = 操作符或呼叫函式時傳入引數的操作都會導致關聯作用域的賦值操作。

JavaScript 引擎首先會在程式碼執行前對其進行編譯,在這個過程中,像 var a = 2 這樣的宣告會被分解成兩個獨立的步驟:

  1. 首先, var a 在其作用域中宣告新變數。這會在最開始的階段,也就是程式碼執行前進行。
  2. 接下來, a = 2 會查詢(LHS 查詢)變數 a 並對其進行賦值。

LHS 和 RHS 查詢都會在當前執行作用域中開始,如果有需要(也就是說它們沒有找到所需的識別符號),就會向上級作用域繼續查詢目標識別符號,這樣每次上升一級作用域(一層樓),最後抵達全域性作用域(頂層),無論找到或沒找到都將停止。

不成功的 RHS 引用會導致丟擲 ReferenceError 異常。不成功的 LHS 引用會導致自動隱式地建立一個全域性變數(非嚴格模式下),該變數使用 LHS 引用的目標作為識別符號,或者丟擲 ReferenceError 異常(嚴格模式下)。

詞法作用域

詞法作用域意味著作用域是由書寫程式碼時函式宣告的位置來決定的。編譯的詞法分析階段基本能夠知道全部識別符號在哪裡以及是如何宣告的,從而能夠預測在執行過程中如何對它們進行查詢。

JavaScript 中有兩個機制可以“欺騙”詞法作用域: eval(..) 和 with 。前者可以對一段包含一個或多個宣告的“程式碼”字串進行演算,並藉此來修改已經存在的詞法作用域(在執行時)。後者本質上是通過將一個物件的引用當作作用域來處理,將物件的屬性當作作用域中的識別符號來處理,從而建立了一個新的詞法作用域(同樣是在執行時)。

這兩個機制的副作用是引擎無法在編譯時對作用域查詢進行優化,因為引擎只能謹慎地認為這樣的優化是無效的。使用這其中任何一個機制都將導致程式碼執行變慢。不要使用它們。

因為 JavaScript 採用的是詞法作用域,函式的作用域在函式定義的時候就決定了。

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();//local scope
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();//local scope
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

var foo = checkscope();
foo();//local scope

函式表示式和函式宣告

函式宣告:function 函式名稱 (引數:可選){ 函式體 }
函式表示式:function 函式名稱(可選)(引數:可選){ 函式體 }

辨別:

  • 如果不宣告函式名稱,它肯定是表示式。
  • 如果function foo(){}是作為賦值表示式的一部分的話,那它就是一個函式表示式,如果function foo(){}被包含在一個函式體內,或者位於程式的最頂部的話,那它就是一個函式宣告。
  • 被括號括住的(function foo(){}),他是表示式的原因是因為括號 ()是一個分組操作符,它的內部只能包含表示式
  function foo(){} // 宣告,因為它是程式的一部分

  (function(){
    function bar(){} // 宣告,因為它是函式體的一部分
  })();

  var bar = function foo(){}; // 表示式,因為它是賦值表示式的一部分
  new function bar(){}; // 表示式,因為它是new表示式
  (function foo(){}); // 表示式:包含在分組操作符內
  
  try {
    (var x = 5); // 分組操作符,只能包含表示式而不能包含語句:這裡的var就是語句
  } catch(err) {
    // SyntaxError
  }
  • 函式宣告在條件語句內雖然可以用,但是沒有被標準化,最好使用函式表示式
  • 函式宣告會覆蓋變數宣告,但不會覆蓋變數賦值
function value(){
    return 1;
}
var value;
alert(typeof value);    //"function"

函式作用域和塊作用域

函式是 JavaScript 中最常見的作用域單元。本質上,宣告在一個函式內部的變數或函式會在所處的作用域中“隱藏”起來,這是有意為之的良好軟體的設計原則。但函式不是唯一的作用域單元。

塊作用域指的是變數和函式不僅可以屬於所處的作用域,也可以屬於某個程式碼塊(通常指 { .. } 內部)。

從 ES3 開始, try/catch 結構在 catch 分句中具有塊作用域。

在 ES6 中引入了 let 關鍵字( var 關鍵字的表親),用來在任意程式碼塊中宣告變數。
if(..) { let a = 2; } 會宣告一個劫持了 if 的 { .. } 塊的變數,並且將變數新增到這個塊中。

有些人認為塊作用域不應該完全作為函式作用域的替代方案。兩種功能應該同時存在,開發者可以並且也應該根據需要選擇使用何種作用域,創造可讀、可維護的優良程式碼。

提升

我們習慣將 var a = 2; 看作一個宣告,而實際上 JavaScript 引擎並不這麼認為。它將 var a和 a = 2 當作兩個單獨的宣告,第一個是編譯階段的任務,而第二個則是執行階段的任務。

這意味著無論作用域中的宣告出現在什麼地方,都將在程式碼本身被執行前首先進行處理。可以將這個過程形象地想象成所有的宣告(變數和函式)都會被“移動”到各自作用域的最頂端,這個過程被稱為提升。

宣告本身會被提升,而包括函式表示式的賦值在內的賦值操作並不會提升。
要注意避免重複宣告,特別是當普通的 var 宣告和函式宣告混合在一起的時候,否則會引
起很多危險的問題!

var a;
if (!("a" in window)) {
    a = 1;
}
alert(a);

作用域閉包

通常,程式設計師會錯誤的認為,只有匿名函式才是閉包。其實並非如此,正如我們所看到的 —— 正是因為作用域鏈,使得所有的函式都是閉包(與函式型別無關: 匿名函式,FE,NFE,FD都是閉包), 這裡只有一類函式除外,那就是通過Function構造器建立的函式,因為其[[Scope]]只包含全域性物件。 為了更好的澄清該問題,我們對ECMAScript中的閉包作兩個定義(即兩種閉包):

ECMAScript中,閉包指的是:

從理論角度:所有的函式。因為它們都在建立的時候就將上層上下文的資料儲存起來了。哪怕是簡單的全域性變數也是如此,因為函式中訪問全域性變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。
從實踐角度:以下函式才算是閉包:
即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回)
在程式碼中引用了自由變數

迴圈閉包

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
        console.log( j );
        }, j*1000 );
    })( i );
}

for (var i=1; i<=5; i++) {
    let j = i; // 是的,閉包的塊作用域!
    setTimeout( function timer() {
    console.log( j );
    }, j*1000 );
}

for (let i=1; i<=5; i++) {
    setTimeout( function timer() {
    console.log( i );
    }, i*1000 );
}
var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();//3
data[1]();//3
data[2]();//3

模組

模組有兩個主要特徵:(1)為建立內部作用域而呼叫了一個包裝函式;(2)包裝函式的返回
值必須至少包括一個對內部函式的引用,這樣就會建立涵蓋整個包裝函式內部作用域的閉
包。

現代模組機制

var MyModules = (function Manager() {
    var modules = {};
    function define(name, deps, impl) {
        for (var i=0; i<deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply( impl, deps );
    }
    function get(name) {
        return modules[name];
    }
    return {
        define: define,
        get: get
    };
})();

未來模組機制

//bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;
//foo.js
// 僅從 "bar" 模組匯入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(
        hello( hungry ).toUpperCase()
    );
}
export awesome;
baz.js
// 匯入完整的 "foo" 和 "bar" 模組
module foo from "foo";
module bar from "bar";
console.log(
    bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

塊作用域替代方案

Google 維護著一個名為 Traceur 的專案,該專案正是用來將 ES6 程式碼轉換成相容 ES6 之前的環境(大部分是 ES5,但不是全部)。TC39 委員會依賴這個工具(也有其他工具)來測試他們指定的語義化相關的功能。

{
    try {
        throw undefined;
    } catch (a) {
        a = 2;
        console.log( a );
    }
}
console.log( a )

上下文

EC(執行環境或者執行上下文,Execution Context)

EC={
    VO:{/* 函式中的arguments物件, 引數, 內部的變數以及函式宣告 */},
    this:{},
    Scope:{ /* VO以及所有父執行上下文中的VO */}
}

ECS(執行環境棧Execution Context Stack)

//ECS=[Window]
A(//ECS=[Window,A]
    B(//ECS=[Window,A,B]
        //run B 
    )
    //ECS=[Window,A]
)
//ECS=[Window]

VO(變數物件,Variable Object)

var a = 10;
function test(x) {
  var b = 20;
};
test(30);
/*
VO(globalContext)
  a: 10,
  test: 
VO(test functionContext)
  x: 30
  b: 20
*/

AO(活動物件,Active Object)

function test(a, b) {
  var c = 10;
  function d() {}
  var e = function _e() {};
  (function x() {});
} 
test(10);
/*
AO(test) = {
  a: 10,
  b: undefined,
  c: undefined,
  d: <reference to FunctionDeclaration "d">
  e: undefined
};
*/

scope chain(作用域鏈)和[[scope]]屬性

Scope = AO|VO + [[Scope]]

例子

var x = 10;
 
function foo() {
  var y = 20;
 
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
 
  bar();
}
 
foo(); // 60
  • 全域性上下文的變數物件是:
globalContext.VO === Global = {
  x: 10
  foo: <reference to function>
};
  • 在“foo”建立時,“foo”的[[scope]]屬性是:
foo.[[Scope]] = [
  globalContext.VO
];
  • 在“foo”啟用時(進入上下文),“foo”上下文的活動物件是:
fooContext.AO = {
  y: 20,
  bar: <reference to function>
};
  • “foo”上下文的作用域鏈為:
fooContext.Scope = fooContext.AO + foo.[[Scope]] // i.e.:
 
fooContext.Scope = [
  fooContext.AO,
  globalContext.VO
];
  • 內部函式“bar”建立時,其[[scope]]為:
bar.[[Scope]] = [
  fooContext.AO,
  globalContext.VO
];
  • 在“bar”啟用時,“bar”上下文的活動物件為:
barContext.AO = {
  z: 30
};
  • “bar”上下文的作用域鏈為:
barContext.Scope = barContext.AO + bar.[[Scope]] // i.e.:
 
barContext.Scope = [
  barContext.AO,
  fooContext.AO,
  globalContext.VO
];
  • 對“x”、“y”、“z”的識別符號解析如下:
- "x"
-- barContext.AO // not found
-- fooContext.AO // not found
   globalContext.VO // found - 10

- "y"
-- barContext.AO // not found
   fooContext.AO // found - 20

- "z"
   barContext.AO // found - 30

參考資料:
深入理解JavaScript系列(16):閉包(Closures)
JavaScript深入之執行上下文棧
JavaScript深入之詞法作用域和動態作用域
你不懂JS:作用域與閉包
變數物件(Variable object)
深入理解JavaScript系列(14):作用域鏈(Scope Chain)
let 是否會宣告提升?

相關文章