JavaScript必須要掌握的知識-作用域

智雲程式設計發表於2019-07-10

JavaScript編譯過程

在學習作用域之前先簡單瞭解一下JavaScript的編譯、執行過程。

JavaScript被稱之為解釋性語言,與Java等這類編譯語言區別在於:JavaScript程式碼寫好了就可以直接立即執行,Java則需要相對較長時間的編譯過程才可生成可執行的機器碼。

但其實JavaScript也是有編譯過程的,JavaScript使用的是一種即時編譯的方式(JIT)。 JIT會把JavaScript程式碼中會多次執行的程式碼給編譯器編譯,生成編譯後的程式碼並儲存起來,在下次使用時使用編譯好的程式碼。這其實是JavaScript執行環境採用的一種最佳化解決方案。 如果不這麼做,大量重複的程式碼都會在執行前重複編譯,這將極大的影響效能與執行效率。

JavaScript引擎也會對JavaScript程式碼在執行前進去預編譯,在預編譯的過程中會定義一套規則用來儲存變數,物件,函式等,方便在之後的執行呼叫。這套規則就是 作用域

JavaScript引擎在編譯過種中要對程式碼進行詞法分析、語法分析、程式碼生成、效能最佳化等等一系列工作。JIT就是這一過程中用來最佳化的一部分。

var a = 1;

var a = 1;  這行程式碼在執行前編譯器都會做哪些事情?

編譯器會把這行程式碼分成  var a  和  a = 1  ,兩個部分。

  1. 首先編譯器會在相同作用域內查詢是否已經存在一個叫  a  的變數,如是存在,編譯器會忽略宣告 a ,繼續下一步編譯;如果不存在,則在當前作用域宣告一個變數,命名為 a
  2. 然後編譯器會為引擎生成執行時的程式碼,這些程式碼中包含處理 a = 1 的部分,引擎在處理 a = 1 的時候,同樣也會查詢作用域中是否存在 a 變數(會逐級向上一個作用域查詢), 存在則賦值為2,不存在則丟擲異常(嚴格模式下,如非嚴格模式則會隱式建立一個全域性變數 a LHS )。

LHS查詢 & RHS查詢

LHS 和 RHS 的含義是“賦值操作的左側與右側”,不過要注意並不單指“=”和左側與右側。 賦值操作還有其它的形式,因此可以理解為:LHS-賦值操作的目標是誰? RHS-誰是賦值操作的源頭。

a = 1;  是對 a  LHS查詢,a是賦值操作的目標,為a賦值為1. 如LHS查詢失敗,非嚴格模式下會隱式建立一個全域性變數,嚴格模式下會丟擲 ReferenceError: a is not defined ;

console.log(a)  是對 a  RHS查詢,a是賦值的源頭;如果在作用域鏈中沒有查詢到 a ,同樣也會丟擲 ReferenceError: a is not defined ;

作用域鏈

作用域是儲存變數的一套規則,當程式碼執行時可能並不只是在一個作用域查詢變數。 當一個作用域中包含另一個作用域的時候,就會存在作用域巢狀的情況。所以當內部的作用域無法找到某個變數的時候,引擎會在當前作用域的外層巢狀中繼續查詢;直到查到變數或者達到最外層的作用域為止。這就是 作用域連結

var name = "rewa"; 
function sayHi(){
    console.log("hello,"+name);
}
sayHi(); // hello,rewa

如上述程式碼, sayHi 函式作用域中並沒有變數 name ;卻能正常引用。就是因為引擎在上一層作用域找到並使用了變數 name ;

var name = "rewa"; 
function sayHi(){
    var name = "fang"; // 新增的程式碼
    console.log("hello,"+name);
}
sayHi(); // hello,fang

sayHi 作用域中已經找到變數 name 時,引擎會停止向上層作用域查詢,這叫作“遮蔽效應”,內部變數遮蔽外部作用域變數。

詞法作用域

作用域有兩種主要的工作模型。一種是最為最為普遍的,被大多數程式語言採用的 詞法作用域 ; 還有一種叫 動態作用域

詞法作用域就是在寫程式碼時將變數和塊作用域寫在哪裡作用域就在哪裡,定義在詞法階段的作用域。JavaScript就是採用的詞法作用域。

詞法:就是組成程式碼塊的字串。比如:

var a = 1;

這行程式碼中, var a = 2 ;  還有這中間的 空格  都是詞法單元。

編譯器的第一個工作就是詞法化,會把程式碼分解成一個一個詞法單元;具體編譯器在詞法化階段都做了哪些工作遵守哪些規則,根據不同程式語言而不同。JavaScript是怎麼樣的規則我特麼也不清楚,等我研究清楚了;再來做一個筆記。

簡單的說,詞法作用域就是你寫程式碼的時候,把變數 a 寫在函式 b 中,那麼編譯器編譯時 b 的作用域中就會包含有 a 變數,編譯器會保持詞法作用域不變。(也會有特殊情況)

如下程式碼:

var a = 1;
function foo(){
    var b = a + 2;
    function bar(){
        var c = b + 3;
        console.log(a,b,c)
    }
    bar();
}
foo(); // 1,3,6

這段程式碼編譯後的作用域與你編寫時的詞法作用域是一致的。

全域性作用域: 變數 a , 函式  foo

函式 foo() 建立的作用域:變數 b ,函式 bar

函式 bar() 建立的作用域:變數 c

程式碼寫在哪作用域就在哪。

瞭解詞法作用域需要注意以下幾點:

  • 無論函式在哪裡被呼叫,如何被呼叫,函式的詞法作用域都只由函式被宣告時所處的位置決定。
  • 詞法作用域查詢只會查詢一級識別符號,比如上述程式碼中的變數 a,b,c 。如果訪問 foo.bar.baz ,詞法作用域只會查詢 foo 。找到這個變數後,再訪問屬性 bar ,再到 baz
  • 存在使詞法作用域編譯後不一致的方法,但會導致效能下降。

修改詞法作用域的方法 eval & with (千萬不要這麼做)

eval

程式碼如下:

var a = 1;
function foo(str){
    eval(str);
    console.log(a);
}
foo('var a = 2;'); // 2

var a = 1;  會在函式 foo 中執行,變數 a 將包含作用域。  eval(...) 函式接受一個字串,並將字串當作程式碼執行;就相當於把程式碼寫在這個位置。

eval 在嚴格模式下會丟擲異常:

var a = 1;
function foo(str){
    "use strict"
    eval(str);
    console.log(a); // ReferenceError: a is not defined
}
foo('var a = 2;');

預設情況下,如果 eval() 中有包含宣告,就會對所處的詞法作用域進行修改;在嚴格模式下, eval() 在執行時有其自己的詞法作用域,那麼將無法修改所在的作用域,如上述程式碼。

with
var obj = {
    a:1,
    b:2,
    c:3
}
obj.a = 11;
obj.b = 22;
obj.c = 33;
// with 也可以達到同樣的效果
with(obj){
    a=111;
    b=222;
    c=333;
}
//這樣 obj 被修改為:
{   
    a:111,
    b:222,
    c:333
}

with() 接受一個引數,在這裡是 obj ;此時 with 中作用域是 obj , 可以訪問 obj 中的屬性。 這種方式賦值就變得簡潔很多。

with可以為一個物件建立一個作用域,物件的屬性會定義為這個作用域中的變數;不過 with 中的透過 var 宣告的變數並不會成為這個作用域的成員,而是被宣告到with所在的作用域中。這不正常了,程式碼使用 with 會變得很不容易控制。比如:

with(obj){
    a=111;
    b=222;
    c=333;
    d=444;
}
console.log(obj.d); // undefined
console.log(d); // 444

原來以為會新增在 obj 中的屬性 d ,卻被新增到了全域性作用域中;這就可能與開發編寫時的預期結果不符;也不符合詞法作用域的規則。

所以 eval with 都已經被禁止了,也不推薦使用。

這種不可預估詞法作用域的特性,也帶了一個嚴重的效能問題。 JavaScript引擎在編譯階段會進行效能最佳化。其中有一些最佳化依賴程式碼的詞法,對詞法進行靜態分析,並預先確定所有變數與函式的定義位置,才能在執行過程中快速找到變數。

如果引擎在程式碼中發現了 eval with ,它無法在詞法分析階段明確知道 eval(...) 接生什麼程式碼;也無法知道傳遞給with用來建立新詞法作用域的物件內容是什麼。 那麼最佳化未知的程式碼和詞法作用域是沒有意義的,引擎將放棄最佳化這一部分。

如果在程式碼中頻繁使用 eval with ,程式執行起來將會非常慢。

函式作用域

函式內部的變數和函式定義都可以封裝起來,外部無法訪問封裝在函式內部的變數識別符號。

如下程式碼:

function foo(){
    var a = 1;
    function sayHi(){
        console.log('Hello!')
    }
}
console.log(a); // ReferenceError:a is not defined
sayHi(); //ReferenceError: sayHi is not defined

在函式外部訪問其內部的變數與函式會丟擲異常。

這樣函式就可以行成一個相對獨立的作用域,可以用函式來封裝一個相對獨立的功能。 把業務程式碼隱藏在函式內部實現,對外暴露介面;只要傳入不同的引數就可以輸入對應的結果。 所以很多情況下函式可以用來模擬Java語言中類的實現。

例如:

function shoot(who,score){
    //這裡面可以包含更多邏輯
    function one(){
        console.log(who + '罰籃命中!到得' +score+ '分!');
    }
    function dunk(){
        console.log(who + '扣籃,獲得' +score+ '分!');
    }
    function three(){
        console.log(who + '命中了一個' +score+ '分球!');
    }
    switch(score){
        case 1:
            one();
            break;
        case 2:
            dunk();
            break;
        case 3:
            three();
            break;
    }
}
shoot('Kobe',3); // Kobe投中了一個3分球!'
shoot('Lebron',2); // Lebron扣籃,獲得2分!' 
shoot('Shaq',1); // Shaq罰籃命中!到得1分!'

函式內部隱藏變數與函式的定義可以避免汙染全域性名稱空間;比如當全域性作用域中也有 one   dunk   three  這些函式,並且內部實現不同;程式碼邏輯就會混亂。 而在上面的程式碼中,函式中定義的函式會遮蔽外部作用域的函式定義,只會呼叫到當前函式作用域中的同名函式。

但是即使如此,大量的函式宣告同樣也會汙染全域性全名空間。 當下流行的模組化就是解決這一問題的方案之一。不過在模組化出來之前,大多數情況可以使用 立即執行函式 (IIFE)來解決。 程式碼如下:

(function(){
    var name = 'kobe';
    console.log(name);
})();

當函式執行結束後, name 變數會被垃圾回收; 且不會與外部的任何作用域產生衝突,因為整個函式都執行在一個立即執行函式中。它是一個塊作用域,且本身也沒有在作用域下建立任何識別符號。

立即執行函式也可以接受引數,用來函式內部引用:

(function(name){
    console.log(name);
})('kobe');

JavaScript中除了函式作用域,還有其它塊作用域。比如with也是塊作用域;上面有過介紹 with 。 還有一個容易被忽略的塊作用域  try/catch  。

try{
    undefined(); //丟擲異常
}
catch(err){
    console.log(err); // 正常執行
}
console.log(err); //ReferenceError: err is not defined

err 只能在 catch 中訪問,在外部的引用會丟擲異常。

對於塊作用域, ES6 中我們可以用 let 宣告實現這種需求。

if(true){
    let a = 1;
    console.log(a); //1
}
console.log(a); //ReferenceError: a is not defined

if(){}  並不是塊作用域,但上述程式碼中 let 可以讓 a 變數成為僅 if(){...} 中的變數,外部不可訪問。

這是不是像極了 try/catch  , 可 let ES6 的標準;在 ES6 之前實現類似塊作用域效果的方法可沒這麼輕鬆。 現在一般我們在編寫 ES6 程式碼,想要執行在所有瀏覽器上需要透過轉譯。而轉譯器也會把類似let的宣告,轉為  try/catch 的形式。

{
    let a = 1;
    console.log(a); // 1
}
console.log(a); //ReferenceError: a is not defined

轉為:

try{
    throw 1;
}catch(a){
    console.log(a); //1
}
console.log(a); //ReferenceError: a is not defined

還有可能轉譯為:

{
    let _a = 1;  // 把{}中的 a 轉為_a 
    console.log(_a); 
}
console.log(a);

多年程式設計經驗,月初整理了一批2019年最新WEB前端教學影片,不論是零基礎想要學習前端還是學完在工作想要提升自己,這些資料都會給你帶來幫助,從最基礎的HTML+CSS+JS【炫酷特效,遊戲,外掛封裝,設計模式】資料都有整理,幫助所有想要學好前端的同學,學習規劃、學習路線、學習資料、問題解答。只要加入WEB前端學習交流qun:767,273,102 ,即可免費獲取,學習不怕從零開始,就怕從不開始。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69901074/viewspace-2650117/,如需轉載,請註明出處,否則將追究法律責任。

相關文章