JS作用域

什么是作用域

  • 對變量值的存儲、訪問、修改帶給程序狀態(tài)的改變
    編程語言的基本功能之一是能夠存儲變量當(dāng)中的值,在之后對這個值進(jìn)行訪問或修改。事實上,正是這種存儲和訪問變量值的能力將狀態(tài)帶給了程序。若沒有這個狀態(tài)的概念,程序雖然能執(zhí)行簡單的任務(wù),但會受到高度限制。

  • 作用域是存放與訪問變量的一套設(shè)計規(guī)范
    變量存儲在哪里呢?程序需要時又是如何找到它們的呢?這些問題說明需要一套設(shè)計良好的規(guī)則來存儲變量,并便捷地尋找到這些變量。這套規(guī)則便是作用域。

編譯原理

傳統(tǒng)編譯語言的流程中,程序源代碼在執(zhí)行前會經(jīng)歷三個步驟,它們統(tǒng)稱為“編譯”。

  • 分詞/詞法分析(Tokenizing/Lexing)
    詞法分析階段會將由字符組成的字符串分解成有意義的代碼塊,這些代碼塊又稱為詞法單元(token)。
// 詞法分析時被分解為詞法單元:var、a、=、2、;
// 空格是否被當(dāng)做詞法單元,取決于空格在語言中是否具有意義。
var a = 2;

分詞(tokenizing)和詞法分析(lexing)之間的區(qū)別非常微妙,差異在于詞法單元的識別是通過有狀態(tài)還是無狀態(tài)的方式進(jìn)行的。若詞法單元生成器在判斷a是一個獨立的詞法單元還是其他詞法單元的一部分時,調(diào)用的是有狀態(tài)的解析規(guī)則,那么這個過程就被稱為詞法分析。

  • 解析/語法分析(Parsing)
    語法分析時將詞法單元流(數(shù)組)轉(zhuǎn)換成一個由元素逐級嵌套所組成的代表程序語法結(jié)構(gòu)的樹,即抽象語法樹(AST, Abstract Syntax Tree)。
// var a = 2; 的抽象語法樹
VariableDeclaration 頂級節(jié)點
|- Identifier 其值為a
|- AssignmentExpression
  |- NumericLiteral 其值為2
  • 代碼生成
    代碼生成是將AST轉(zhuǎn)換為可執(zhí)行代碼的過程,此過程與語言、目標(biāo)平臺等信息相關(guān)。
// 某方法將它的AST轉(zhuǎn)化為一組機(jī)器指令,用來創(chuàng)建一個叫做a的變量并將一個值存儲在a中。
var a = 2;

瀏覽器

瀏覽器的核心是渲染引擎、JS引擎(JS解釋器)

  • 渲染引擎,將網(wǎng)頁代碼渲染為用戶視覺上可感知的平面文檔。
  • JS引擎,讀取網(wǎng)頁中JS代碼并對其處理。

渲染引擎

瀏覽器內(nèi)核即網(wǎng)頁瀏覽器排版引擎(LayoutEngine/Rending Engine),又稱為頁面渲染引擎或模板引擎,負(fù)責(zé)獲取頁面內(nèi)容(如HTML、XML、圖片等)、整理消息(如加入CSS等)、計算網(wǎng)頁顯示方式,然后輸出至顯示器或打印機(jī)。網(wǎng)頁瀏覽器、電子郵件客戶端等終端則根據(jù)表示性標(biāo)記語言(Presentational Markup)來顯示內(nèi)容的應(yīng)用程序都需要排版引擎。

瀏覽器典型內(nèi)核:

  • Microsoft Internet Explorer 的 Trident
  • Mozilla Firefox 的 Gecko
  • Google Chrome 的 Blink
  • Apple Safari 的 Webkit

其實一個完整的瀏覽器不會只有一個排版引擎,還有自己的界面框架與其他功能支持,排版引擎本身不可能實現(xiàn)瀏覽器的所有功能。

網(wǎng)頁渲染

渲染引擎處理網(wǎng)頁的流程:

  • 解析代碼,將HTML代碼解析為DOM,將CSS代碼解析為CSSOM(CSS Object Model)。
  • 對象合成,將DOM和CSSOM合成一顆渲染樹(Render Tree)。
  • 布局,計算出渲染樹的布局
  • 繪制,將渲染樹繪制到屏幕
HTML的DOM樹

JS引擎

JS引擎是用來執(zhí)行JS代碼的,JS引擎的快慢對網(wǎng)頁速度有著重大影響。簡單來說,JS解析引擎是能夠“讀懂JS代碼并準(zhǔn)確地給出代碼運行結(jié)果的一段程序?!薄?/p>

對于靜態(tài)語言而言,如C、C++、Java等,處理上述事情的叫做“編譯器(compiler)”,對于動態(tài)的JS語言而言則叫做“解釋器(interpreter)”。編譯器與解釋器的區(qū)別在于“編譯器將源代碼編譯成另一種代碼(如機(jī)器碼或字節(jié)碼等),而解釋器是直接解析并將代碼運行結(jié)果輸出?!?。例如Firebug的console就是一個JS的解釋器。

典型JS引擎:

  • Microsoft IE 的 Chakra,Chakra由Opera公司編寫。
  • Mozilla Firefox 的 SpiderMonkey,第一款JS引擎。
  • Google Chrome 的 V8,C/C++編寫。

既然JS引擎是一段程序,JS代碼也是程序,那么如何讓程序去讀懂程序呢?那就需要定義規(guī)則,而ECMAScript就是專門定義規(guī)則的,JS引擎根據(jù)ECMAScript定義的標(biāo)準(zhǔn)規(guī)則去解析JS代碼。簡單來說,ECMAScript定義語言的標(biāo)準(zhǔn),JS引擎根據(jù)標(biāo)準(zhǔn)的規(guī)則去實現(xiàn)。

JS引擎與ECMAScript

JS雖被歸類為“動態(tài)”或“解釋執(zhí)行”的語言,實際上它是一門編程語言。由于JS引擎可按需創(chuàng)建并存儲變量,盡管JS引擎進(jìn)行編譯的步驟和傳統(tǒng)編譯語言相似,不同的是JS不是提前編譯的,而且編譯結(jié)果也不能在分布式系統(tǒng)中進(jìn)行移植。

JS引擎不會有大量時間進(jìn)行優(yōu)化,與其他語言不同的是JS的編譯過程不是發(fā)生在構(gòu)建之前的。大部分情況下編譯發(fā)生在代碼執(zhí)行前幾微秒。在作用域背后,JS引擎用盡各種辦法來保證性能最佳。如使用JIT進(jìn)行延遲編譯甚至實施重編譯。

// 任何JS代碼片段在執(zhí)行前需編譯,JS編譯器先對代碼進(jìn)行編譯,然后做好執(zhí)行的準(zhǔn)備。
var a  = 2;

理解作用域

要理解JS的工作原理,你需要像JS引擎一樣思考,從它的角度提出問題并回答。

演員表

  • JS引擎:主角,從頭到尾負(fù)責(zé)整個JS程序的編譯及執(zhí)行過程。
  • 編譯器:JS引擎的好友之一,負(fù)責(zé)語法分析及代碼生成等臟累活。
  • 作用域:JS引擎的好友之一,負(fù)責(zé)收集并維護(hù)由所有聲明的標(biāo)識符(變量)組成的一系列查詢,并實施一套非常嚴(yán)格的規(guī)則,確定當(dāng)前執(zhí)行的代碼對這些標(biāo)識符的訪問權(quán)限。

場景

var a = 2;

對話

  • 我們:這是一句聲明
  • JS引擎:錯,這完全是兩個不同的聲明,一個由編譯器在編譯時處理,一個則由我JS引擎在運行時處理。

流程

  • 編譯器:將代碼分解為詞法單元,將詞法單元解析為樹結(jié)構(gòu)。不過代碼生成時對代碼的處理方式會與預(yù)期有所不同。
var a = 2;

“為一個變量分配內(nèi)存,將其命名為a,然后將值2保存到這個變量中?!边@樣理解對嗎?

事實上編譯器是這樣處理的:

  1. 遇到var a,編譯器會詢問作用域是否已經(jīng)有一個該名稱的變量存在于同一個作用域的集合中。若有編譯器會忽略該聲明并繼續(xù)進(jìn)行編譯,否則它會要求作用域在當(dāng)前作用域的集合中聲明一個全新的變量并命名為a。
  2. 編譯器為JS引擎生成運行時所需代碼,這些代碼被用來處理a = 2這個賦值操作。JS引擎運行時會首先詢問作用域,在當(dāng)前作用域集合中是否存在一個叫做a的變量,若有JS引擎就會使用此變量,否則JS引擎會繼續(xù)查找該變量。若JS引擎最終找到a變量就會將2賦值給它,否則JS引擎就會舉手示意并拋出一個異常。

編譯器在編譯階段生成代碼,JS執(zhí)行代碼時會通過查找變量a來判斷它是否已經(jīng)聲明過。查找的過程由作用域進(jìn)行協(xié)助,但是JS引擎執(zhí)行怎樣的查找方式會影響最終的查詢結(jié)果。

變量賦值操作會執(zhí)行兩個動作,首先編譯器會在當(dāng)前作用域中聲明一個變量,若之前未聲明,然后在運行時JS引擎會在作用域中查找該變量,若能夠找到就會對其賦值。

LHS與RHS

var a = 2;// JS引擎會為變量a進(jìn)行LHS左側(cè)查詢

JS引擎會為變量a進(jìn)行LHS查詢(賦值操作的左側(cè)),另外一種查詢類型叫做RHS(賦值操作的 右側(cè))。LHS查詢指的是當(dāng)變量出現(xiàn)在賦值操作的左側(cè)時進(jìn)行LHS查詢,出現(xiàn)在右側(cè)時進(jìn)行RHS查詢。準(zhǔn)確來講,RHS查詢與簡單地查詢某個變量的值別無二致,而LHS查詢則是試圖找到變量的容器本身,從而可以對其賦值。從這個角度來講,RHS并不是真正意義上的“賦值操作的右側(cè)”,而是“非左側(cè)”??蓪HS理解為Retrieve His Source Value即獲取到它的源值,這意味著“得到某某的值”。

LHS和RHS的含義是“賦值操作的左側(cè)或右側(cè)”,并不意味著就是“=賦值操作符的左側(cè)或右側(cè)”,賦值操作還有其他幾種形式。因此在概念上最好將其理解為:

  • LSH “賦值操作的目標(biāo)是誰”
  • RHS “誰是賦值操作的源頭”
function log(msg){
  console.log(msg); // 1
}
log(1); // log(1)函數(shù)調(diào)用需要對log進(jìn)行右側(cè)引用RHS,意味著“去查詢到log的值,并它給我?!?
// 被忽略卻非常重要的細(xì)節(jié):
// 代碼中隱式地 msg=1 操作可能很容易被忽略。
// 這個操作發(fā)生在1被當(dāng)做參數(shù)傳遞給log()函數(shù)時,1會被分配給參數(shù)msg。
// 為了給參數(shù)msg隱式地分配值,需要進(jìn)行一次LHS查詢。

作用域嵌套

作用域使根據(jù)名稱查找變量的一套法則,實際情況中,通常需要同時顧及幾個作用域。

當(dāng)一個塊或函數(shù)嵌套在另一個塊或函數(shù)中時,就發(fā)生了作用域的嵌套。因此,在當(dāng)前作用域中無法找到某變量時,JS引擎就會在外層嵌套的作用域中繼續(xù)查找,直到找到該變量或抵達(dá)最外層的作用域(全局作用域)為止。

function add(n){
  console.log(n+m);
}
var m= 10; // 對m進(jìn)行的RHS引用無法在函數(shù)add內(nèi)部完成,但可在上一級作用域中完成。
add(1);// 11
-----------------------------------------------------------------------------------------------------------------
JS引擎:add的作用域兄弟啊,你見過m嗎?我需要對它進(jìn)行RHS引用?。?作用域:走開,聽都沒聽過...
JS引擎:add的上級作用域啊,咦?大兄弟,有眼不識泰山,原來你是全局作用域大哥,太好了。你見過m嗎?我需要對它進(jìn)行RHS引用。
作用域:當(dāng)然了,給你吧!

為了將作用域處理的過程可視化,你在腦中想想如下這個高大的建筑物,這個建筑代表程序中的嵌套作用域鏈。第一層代表當(dāng)前的執(zhí)行作用域,也就是你所處的位置,建筑的頂層代表全局作用域。

LHS和RHS引用都會在當(dāng)前樓層進(jìn)行查找,如果沒有找到,就會坐電梯前往上一層樓,如果還是沒有找到就繼續(xù)向上,以此類推。一旦抵達(dá)頂層(全局作用域),可能找到了你所需的變量,也可能也沒找到,但無論如何查找過程都將停止。

把作用域鏈比喻成一個建筑

為什么區(qū)分LHS和RHS是一件重要的事情呢?

因為在變量還沒聲明時,即在任何作用域中都無法找到該變量的情況下,這2種查詢的行為是不一樣的。

function handler(n){
  console.log(n+m);
  m = n;
}
------------------------------------------------------------------------------------------------------------------
第一次對m進(jìn)行RHS查詢時是無法找到該變量的。
也就是說,這是一個“未聲明”的變量,因為在任何相關(guān)的作用域中都無法找到它。
如果RHS查詢在所有嵌套的作用域中遍尋不到所需的變量
JS引擎就會拋出 ReferenceError異常,值得注意的是,ReferenceError是非常重要的異常類型。
相比之下,當(dāng)JS引擎執(zhí)行LHS查詢時,如果在頂層(全局作用域)中也無法找到目標(biāo)變量。
全局作用域中就會創(chuàng)建一個具有該名稱的變量,并將其返還給引擎,前提是程序運行在非嚴(yán)格模式下。

ES5引入了嚴(yán)格模式,同正常模式或者說寬松/懶惰模式相比,嚴(yán)格模式在行為上有很多不同。
其中一個不同的行為是嚴(yán)格模式禁止自動或隱式地創(chuàng)建全局變量。
因此,在嚴(yán)格模式中LHS查詢失敗時,并不會創(chuàng)建并返回一個全局變量。
JS引擎會拋出同RHS查詢失敗時類似的ReferenceError異常。

如果RHS查詢到一個變量,嘗試對這個變量的值進(jìn)行不合理的操作。
JS引擎會拋出另一種類型的異常,叫做TypeError。

管理作用域

作用域(scope)概念是理解JS的關(guān)鍵,不僅從性能的角度,而且從功能的角度。作用域?qū)S有許多影響,從確定哪些變量可被函數(shù)訪問,到確定this的值。JS作用域也關(guān)系到性能,但是要理解速度與作用域的關(guān)系,首先要理解作用域的工作原理。

作用域鏈和標(biāo)識符解析

每個JS函數(shù)都被表示為對象,進(jìn)一步說,它是一個函數(shù)實例。函數(shù)對象和其他對象一樣,擁有可供編程訪問的屬性和僅供JS引擎使用的內(nèi)部屬性。其中一個內(nèi)部屬性是[[Scope]],由ECMA-262標(biāo)準(zhǔn)第3版定義。

內(nèi)部屬性[[Scope]]包含了一個函數(shù)被創(chuàng)建時的作用域中對象的集合,此集合被稱為函數(shù)的作用域鏈,它決定哪些數(shù)據(jù)可由函數(shù)訪問。此函數(shù)作用域鏈中的每個對象被稱為一個可變對象,每個可變對象都以“鍵值對”的形式存在。

// 創(chuàng)建全局函數(shù)sum后,它的作用域鏈中填入一個單獨的可變對象,此全局對象代表了所有全局范圍定義的變量。
// 此全局變量包含諸如窗口、瀏覽器、文檔之類的訪問接口。
function sum(n1, n2){
  return n1 + n2;
}

當(dāng)一個函數(shù)創(chuàng)建后,它的作用域鏈被填充以對象,這些對象代表創(chuàng)建此函數(shù)的環(huán)境中可訪問的數(shù)據(jù)。

// 函數(shù)作用域鏈將會在運行時使用
var total = sum(1,2);

函數(shù)運行時會建立一個內(nèi)部對象,稱作“運行期上下文”。一個運行期上下文定義了一個函數(shù)運行時的環(huán)境。對函數(shù)的每次運行而言,每次運行期上下文都是獨一無二的,所以多次調(diào)用同一個函數(shù)就會導(dǎo)致多次創(chuàng)建運行期上下文。當(dāng)函數(shù)執(zhí)行完畢,運行期上下文就會被銷毀。

一個運行期上下文有它自己的作用域鏈,用于標(biāo)識符解析。當(dāng)運行期上下文被創(chuàng)建時,它的作用域鏈被初始化,連同運行函數(shù)的[[Scope]]屬性中所包含的對象。這些值按照它們出現(xiàn)在函數(shù)中的順序,被復(fù)制到運行期上下文的作用域鏈中。這項工作一旦完成,一個被稱作“激活對象(AO,Active Object)”的新對象就為運行期上下文創(chuàng)建好了。此激活對象作為函數(shù)執(zhí)行器的一個可變對象,包含訪問所有局部變量、命名參數(shù)、參數(shù)結(jié)合、this的接口。然后,此對象被推入作用域鏈的前端。當(dāng)作用域鏈被銷毀時,激活對象也一同被銷毀。

在函數(shù)運行過程中,每遇到一個變量,標(biāo)識符識別過程要決定從哪里獲得或存儲數(shù)據(jù)。此過程搜索運行期上下文的作用域鏈,查找同名的標(biāo)識符。搜索工作從運行函數(shù)的激活目標(biāo)之作用域鏈的前端開始。如果找到就使用這個具有指定標(biāo)識符的變量。若沒有找到,搜索工作將進(jìn)入作用域鏈的下一個對象。此過程持續(xù)運行,直到標(biāo)識符被找到,或者沒有更多對象可用于搜索,這種情況下標(biāo)識符將被認(rèn)為是未定義(undefined)的。函數(shù)運行時每個標(biāo)識符都要經(jīng)過這樣的搜索過程,正是這種搜索過程影響了性能。

標(biāo)識符識別性能

標(biāo)識符識別不是免費的,事實上沒有哪種電腦操作可以不產(chǎn)生性能開銷。在運行期上下文的作用域鏈中,一個標(biāo)識符所處的位置越深,它的讀寫速度就越慢。所以,函數(shù)中局部變量的訪問速度總是最快的,而全局變量通常是最慢的。記住,全局變量總是處于運行期上下文作用域鏈的最后一個位置,所以總是最遠(yuǎn)才能觸及的。

總的趨勢是,對現(xiàn)代瀏覽器來說,一個標(biāo)識符所處的位置越深,讀寫它的速度就越慢。采用優(yōu)化的JS引擎的瀏覽器,如Safari 4.0 訪問域外標(biāo)識符時沒有這種性能損失,而IE、Safari3.2和其他瀏覽器則由較大幅度的影響。值得注意的是,早期瀏覽器如IE6和Firefox2,有著令人難以置信的陡峭斜坡。

因此在沒有優(yōu)化JS引擎的瀏覽器中,最好盡可能使用局部變量。一個好的經(jīng)驗法則是:用局部變量存儲本地范圍之外的變量值,如果它們在函數(shù)中使用多于一次

function init(){
  var body = document.body;
  var links = document.getElementsByTagName_r('a');
  var i = 0, len = links.length;
  while(i < len){
    update(links[i++]);
  }

  document.getElementById('goBtn').onclick = function(){
    start();
  }

  body.className = "active";
}

init()函數(shù)包含三個對document的引用,document是一個全局對象。搜索此變量必須遍歷整個作用域鏈,直至最后在全局變量對象中找到它。你可以通過這種方法減輕重復(fù)的全局變量訪問對性能的影響:首先將全局變量的引用存儲在一個局部變量中,然后使用這個局部變量代替全局變量。

function init(){
  var doc = document; // 將document的引用存入局部變量doc中,性能優(yōu)化。
  var body = doc.body;
  var links = doc.getElementsByTagName_r('a');
  var i = 0, len = links.length;

  while(i < len){
    update(links[i++]);
  }

  doc.getElementById('goBtn').onclick = function(){
    start();
  }

  body.className = 'active';
}

改變作用域鏈

一般而言,一個運行期上下文的作用域鏈不會被改變。但是有兩種表達(dá)式可在運行時臨時改變運行期上下文作用域鏈。
...

動態(tài)作用域

無論是with表達(dá)式還是try-catch表達(dá)式的catch子句,以及包含()的函數(shù),都被認(rèn)為是動態(tài)作用域。一個動態(tài)作用域只因代碼運行而存在,因此無法通過靜態(tài)分析(查看源代碼結(jié)構(gòu))來確定(是否存在)。

function exec(code){
  (code);
  function subrouting(){
    return window;
  }

  var w = subrouting();
}

// test
exec('var window={};');

優(yōu)化的JS引擎,如Safari的Nitro引擎,企圖通過分析代碼來確定哪些變量應(yīng)該在任意時刻被訪問,來加快標(biāo)識符識別過程。這些引擎企圖避開傳統(tǒng)作用域鏈查找,取代以標(biāo)識符索引的方式進(jìn)行快速查找。當(dāng)涉及一個動態(tài)作用域后,這種優(yōu)化方法就不起作用了。JS引擎需要切回慢速的基于哈希表的標(biāo)識符識別方法,更像傳統(tǒng)的作用域鏈搜索。正是以為這個原因,只在絕對必要時才推薦使用動態(tài)作用域。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容