js執行機制及非同步程式設計(一)
相信大家在面試的過程中經常遇到檢視執行順序的問題,如setTimeout,promise,async await等等,各種組合,是不是感覺頭都要暈掉了,其實這些問題最終還是考察大家對js的執行機制是否掌握牢固,對promise,async的原理是否掌握,萬變不離其宗,這次就來徹底搞懂它。
1 js引擎的執行原理
js引擎也是程式,是屬於瀏覽器的一部分,由瀏覽器廠商自行開發。從頭到尾負責整個JavaScript程式的編譯及執行過程
瀏覽器在渲染的過程中,首先按順序載入由<script>標籤分割的js程式碼塊,載入js程式碼塊完畢後,需要js引擎進行解析。無論是外部指令碼檔案(不非同步載入)還是內部指令碼程式碼塊,都是一樣的原理,並且都在同一個全域性作用域中。JavaScript被歸類為“動態”或“解釋執行”語言,所以它無需提前編譯,而是由直譯器實時執行
js引擎執行過程分為三個階段:
- JS的解釋階段
- JS的預處理(編譯)階段及執行階段
1.1 JS的解釋階段
js指令碼程式碼塊載入完畢後,會首先JS的解釋階段。該階段主要過程如下:
- 詞法分析——這個過程會將由字元組成的字串分解成(對程式語言來說)有意義的程式碼塊,這些程式碼塊被稱為詞法單元(token)
- 語法分析——這個過程是將詞法單元流(陣列)轉化成抽象語法樹(Abstract Syntax Tree)
- 使用翻譯器(translator),將程式碼轉為位元組碼(bytecode)
- 使用位元組碼直譯器(bytecode interpreter),將位元組碼轉為機器碼
最終計算機執行的就是機器碼。
為了提高執行速度,現代瀏覽器一般採用即時編譯(JIT-Just In Time compiler)
即位元組碼只在執行時編譯,用到哪一行就編譯哪一行,並且把編譯結果快取(inline cache)
這樣整個程式的執行速度能得到顯著提升。
而且,不同瀏覽器策略可能還不同,有的瀏覽器就省略了位元組碼的翻譯步驟,直接轉為機器碼(如chrome的v8)
1.2 JS的預處理(編譯)階段及執行階段
這裡我理解為js為解釋型語言,由直譯器實時執行,通俗的說就是預處理完之後馬上執行,一邊編譯一邊執行
1.2.1 js的執行環境主要有三種:
- 全域性環境
- 函式環境
- eval(不建議使用,會有安全,效能問題)
1.2.2 以下段例子說明js的預編譯與執行過程
function bar() {
var B_context = "Bar EC";
function foo() {
var f_context = "foo EC";
}
foo()
}
bar()
這段函式經過詞法解析,語法解析階段之後,就開始進入預編譯並執行,如下:
- 首先,進入全域性環境,就會先進行預處理,然建立全域性上下文執行環境(Global ExecutionContext),會對var宣告的變數和函式宣告進行預處理,window物件就是全域性執行上下文的變數物件,所有的變數和函式都是window物件的屬性方法。所以函式宣告提前和變數宣告提升是在建立變數物件中進行的,且函式宣告優先順序高於變數宣告。然後推入stack棧中。預處完成之後,開始執行js
- 當執行bar()時,就會進入bar函式執行環境,就會先進行預處理,建立bar函式執行上下文(bar Execution Context),推入stack棧中,預處理完後,開始執行foo()
- 在bar函式內部呼叫foo函式,則再進入foo函式執行環境,建立foo函式執行上下文(foo Execution Context),推入stack棧中
- 此刻棧底是全域性執行上下文(Global Execution Context),棧頂是foo函式執行上下文(foo Execution Context),如上圖,由於foo函式內部沒有再呼叫其他函式,那麼則開始出棧
- foo函式執行完畢後,棧頂foo函式執行上下文(foo Execution Context)首先出棧
- bar函式執行完畢,bar函式執行上下文(bar Execution Context)出棧
- Global Execution Context則在瀏覽器或者該標籤頁關閉時出棧。
1.2.3 執行上下文
分析一段簡單的程式碼,幫助我們理解建立執行上下文的過程,如下:
function fun(a, b) {
var num = 1;
function test() {
console.log(num)
}
}
fun(2, 3)
這裡我們在全域性環境呼叫fun函式,建立fun執行上下文,這裡為了方便大家理解,暫時不講解作用域鏈以及this指向,如下:
funEC = {
//變數物件
VO: {
//arguments物件
arguments: {
a: undefined,
b: undefined,
length: 2
},
//test函式
test: <test reference>,
//num變數
num: undefined
},
//作用域鏈
scopeChain:[],
//this指向
this: window
}
- funEC表示fun函式的執行上下文(fun Execution Context簡寫為funEC)
- funE的變數物件中arguments屬性,上面的寫法僅為了方便大家理解,但是在瀏覽器中展示是以類陣列的方式展示的
- <test reference>表示test函式在堆記憶體地址的引用
注:建立變數物件發生在預編譯階段,但尚未進入執行階段,該變數物件都是不能訪問的,因為此時的變數物件中的變數屬性尚未賦值,值仍為undefined,只有進入執行階段,變數物件中的變數屬性進行賦值後,變數物件(Variable
Object)轉為活動物件(Active Object)後,才能進行訪問,這個過程就是VO –> AO過程。
建立作用域鏈
作用域鏈由當前執行環境的變數物件(未進入執行階段前)與上層環境的一系列活動物件組成,它保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問。
理清作用域鏈可以幫助我們理解js很多問題包括閉包問題等,下面我們結合一個簡單的例子來理解作用域鏈,如下:
var num = 30;
function test() {
var a = 10;
function innerTest() {
var b = 20;
return a + b
}
innerTest()
}
test()
在上面的例子中,當執行到呼叫innerTest函式,進入innerTest函式環境。全域性執行上下文和test函式執行上下文已進入執行階段,innerTest函式執行上下文在預編譯階段建立變數物件,所以他們的活動物件和變數物件分別是AO(global),AO(test)和VO(innerTest),而innerTest的作用域鏈由當前執行環境的變數物件(未進入執行階段前)與上層環境的一系列活動物件組成,如下:
innerTestEC = {
//變數物件
VO: {b: undefined},
//作用域鏈
scopeChain: [VO(innerTest), AO(test), AO(global)],
//this指向
this: window
}
在這裡我們順便思考一下,什麼是閉包?
我們先看下面一個簡單例子,如下:
function foo() {
var num = 20;
function bar() {
var result = num + 20;
return result
}
bar()
}
foo()
我這裡直接以瀏覽器解析,以瀏覽器理解的閉包為準來分析閉包,如下圖:
如上圖所示,chrome瀏覽器理解閉包是foo,那麼按瀏覽器的標準是如何定義閉包的,我總結為三點:
- 在函式內部定義新函式
- 新函式訪問外層函式的區域性變數,即訪問外層函式環境的活動物件屬性
- 新函式執行,建立新的函式執行上下文,外層函式即為閉包
確定this指向
在全域性環境下,全域性執行上下文中變數物件的this屬性指向為window;函式環境下的this指向卻較為靈活,需根據執行環境和執行方法確定