你的JavaScript程式碼都經歷了什麼

AlenQi發表於2018-08-20

從語言型別說起

要知道你寫的程式碼接下來是交給誰的,先要明白解釋型語言和編譯型語言。

解釋型語言:這種型別的程式語言,會將程式碼一句一句直接執行,不需要像編譯語言(Compiled language)一樣,經過編譯器先行編譯為機器碼,之後再執行。這種程式語言需要利用直譯器,在執行期,動態將程式碼逐句解釋(interpret)為機器碼,或是已經預先編譯為機器碼的的子程式,之後再執行。

編譯型語言:是一種以編譯器來實現的程式語言。它不像解釋型語言一樣,由直譯器將程式碼一句一句執行,而是以編譯器,先將程式碼編譯為機器碼,再加以執行。理論上,任何程式語言都可以是編譯式,或直譯式的。它們之間的區別,僅與程式的應用有關。

那麼,JavaScript就是典型的解釋型語言,那麼要執行JavaScript程式就必須要有響應的執行環境,也就是要通過JavaScript引擎解析執行JS程式碼。JavaScript引擎的基本工作是把開發人員寫的JavaScript程式碼轉換成高效、優化的程式碼,這樣就可以通過瀏覽器進行解釋甚至嵌入到應用中。比如著名的V8引擎。

JavaScript的解析過程分為兩個階段:預編譯期(預處理)執行期。在預編譯期,JavaScript直譯器完成對JavaScript程式碼的預處理,轉換為位元組碼。執行期間,JavaScript直譯器把位元組碼轉換成二進位制碼,按照順序執行。

預編譯期:

正常的編譯型語言編譯期,其過程可分為6步:詞法分析、語法分析、語義分析、原始碼優化、程式碼生成、目的碼優化。對於JavaScript來說,通過詞法分析和語法分析得到語法樹後,就會進入到執行期,執行程式碼。

詞法分析:在詞法分析階段,JavaScript直譯器先把程式碼的字元流轉換為記號流,例如:

a = (b -c)
複製程式碼

轉換為記號流:

NAME "a"  
EQUALS  
OPEN_PARENTHESIS  
NAME "b"  
MINUS  
NAME "c"  
CLOSE_PARENTHESIS  
SEMICOLON 
複製程式碼

詞法分析階段可以實現的是:1、去掉註釋,生成文件;2、記錄錯誤資訊;3、完成預處理

語法分析:

語法分析階段就是把詞法分析階段產生的記號,生成語法樹,即把從程式中收集的資訊儲存到資料結構中,資料結構在此處為兩種:1、符號表:記錄變數、函式、類;2、語法樹:程式結構的樹形表示,將此樹形結構生成中間程式碼。例如:

 if(typeof a == "undefined" ) { 
    a = 0
 } else { 
    a = a
 } 
 alert(a)
複製程式碼

生成的語法樹為:

你的JavaScript程式碼都經歷了什麼

當構建語法樹的過程中,無法構造,則報出語法錯誤,並結束整個程式碼塊的解析。 詞法分析和語法分析階段是交錯進行的,每取一個詞法記號,就送入語法分析器進行分析。 詞法、語法分析是有規則的,其中ECMAScript262這份文件,就是對JavaScript這門語言定義了一整套完整的標準。語法分析就依靠這套標準,當然也有不按照標準來實現的,比如IE的JS引擎。這也是為什麼JavaScript會有相容性的問題。

執行期

經過編譯階段的準備,程式碼在記憶體中已經構建成語法樹,JavaScript引擎會根據這個語法樹結構邊解釋邊執行。解釋過程中,引擎嚴格按照作用域機制執行。JavaScript採用的詞法作用域,簡單說就是變數和函式的作用域在定義時決定,取決於原始碼結構。

var value = 1;
function foo() {
    console.log(value);
}
function bar() {
    var value = 2;
    foo();
}
bar();
複製程式碼

就像這段程式碼,並不會像動態作用域一樣,輸出2. 引擎解釋執行每個函式時,先建立一個執行環境,在這個環境中建立一個呼叫物件,這個物件記憶體儲著當前域中所有區域性變數、引數、巢狀函式、引用函式和父級列表。呼叫物件宣告週期與函式一致,當函式呼叫完畢且沒有外部引用的情況下,被垃圾回收機制回收。

同時直譯器通過作用域鏈把多個巢狀的作用域串在一起,並藉助這個鏈,由內而外查詢變數值,直到全域性物件,如果沒有找到,返回"undefined"。作用域鏈,是由當前環境與上層環境的一系列變數物件組成,它保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問。

閉包

在執行環境建立的過程中,會有一個特殊的情況——閉包。它由兩部分組成。執行上下文(代號A),以及在該執行上下文中建立的函式(代號B)。當B執行時,如果訪問了A中變數物件中的值,那麼閉包就會產生。在大多數理解中,包括許多著名的書籍,文章裡都以函式B的名字代指這裡生成的閉包。而在chrome中,則以執行上下文A的函式名代指閉包。

function A() {
    var a = 20;
    var b = 30;
    function B() {
        return a + b;
    }
    return B;
}
var B = A();
B();
複製程式碼

首先有執行上下文A,在A中定義了函式B,而通過對外返回B的方式讓B得以執行。當B執行時,訪問了A內部的變數a,b。因此這個時候閉包產生。JavaScript擁有自動的垃圾回收機制,關於垃圾回收機制,有一個重要的行為,那就是,當一個值,在記憶體中失去引用時,垃圾回收機制會根據特殊的演算法找到它,並將其回收,釋放記憶體。正常來講,當A執行完畢後,生命週期結束,A函式的執行上下文就會失去引用。其佔用的記憶體空間很快就會被垃圾回收器釋放。可是B函式的存在,會阻止這一過程,使B函式常駐記憶體。

單執行緒&&事件迴圈

JavaScript的單執行緒,與它的用途有關。作為瀏覽器指令碼語言,JavaScript的主要用途是與使用者互動,以及操作DOM。這決定了它只能是單執行緒,否則會帶來很複雜的同步問題。比如,假定JavaScript同時有兩個執行緒,一個執行緒在某個DOM節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準? 所以,為了避免複雜性,從一誕生,JavaScript就是單執行緒,這已經成了這門語言的核心特徵,將來也不會改變。 為了利用多核CPU的計算能力,HTML5提出WebWorker標準,允許JavaScript指令碼建立多個執行緒,但是子執行緒完全受主執行緒控制,且不得操作DOM。所以,這個新標準並沒有改變JavaScript單執行緒的本質。單個執行緒使得執行程式碼很容易,因為你不必處理在多執行緒環境中出現的複雜場景——例如死鎖。但是在一個執行緒上執行也非常有限制。由於JavaScript、只有一個呼叫堆疊,當某段程式碼執行變慢時會發生什麼?

既然是單執行緒的,在某個特定的時刻只有特定的程式碼能夠被執行,並阻塞其它的程式碼。而瀏覽器是事件驅動的(Event driven),瀏覽器中很多行為是非同步的,會建立事件並放入執行佇列中。JavaScript引擎是單執行緒處理它的任務佇列。當非同步事件發生時,如mouse click, a timer firing, or an XMLHttpRequest completing(滑鼠點選事件發生、定時器觸發事件發生、XMLHttpRequest完成回撥觸發等),將他們放入執行佇列,等待當前程式碼執行完成再從執行佇列按序拿出事件執行。Event Loop只做一件事情,負責監聽Call Stack和Callback Queue。當Call Stack裡面的呼叫棧執行完變成空了,Event Loop就把Callback Queue裡面的第一條事件(其實就是回撥函式)放到呼叫棧中並執行它,後續不斷迴圈執行這個操作。

也就是說JS只有一個呼叫棧。呼叫棧是一種資料結構,它記錄了我們在程式中的位置。如果我們執行到一個函式,它就會將其放置到棧頂。當從這個函式返回的時候,就會將這個函式從棧頂彈出,這就是呼叫棧做的作用。棧內的任務佇列又分為macro-task(巨集任務)與micro-task(微任務),在最新標準中,它們被分別稱為task與jobs。

  • macro-task大概包括:script(整體程式碼), setTimeout, setInterval, setImmediate, I/O, UI rendering。
  • micro-task大概包括: process.nextTick, Promise, Object.observe(已廢棄), MutationObserver(html5新特性)。
  • setTimeout/Promise等我們稱之為任務源。而進入任務佇列的是他們指定的具體執行任務。
setTimeout(function() {
    console.log('xxxx'); // 這段程式碼才是進入任務佇列的任務
})
// setTimeout作為一個任務分發器,這個函式會立即執行,而它所要分發的任務,也就是它的第一個引數,才是延遲執行
複製程式碼
  • 來自不同任務源的任務會進入到不同的任務佇列。其中setTimeout與setInterval是同源的。
  • 事件迴圈的順序,決定了JavaScript程式碼的執行順序。它從script(整體程式碼)開始第一次迴圈。之後全域性上下文進入函式呼叫棧。直到呼叫棧清空(只剩全域性),然後執行所有的micro-task。當所有可執行的micro-task執行完畢之後。迴圈再次從macro-task開始,找到其中一個任務佇列執行完畢,然後再執行所有的micro-task,這樣一直迴圈下去。
  • 其中每一個任務的執行,無論是macro-task還是micro-task,都是藉助函式呼叫棧來完成。
垃圾回收

垃圾回收機制有好多種,這裡簡單說下標記清除演算法 為了決定一個物件是否被需要,這個演算法用於確定是否可以找到某個物件。 其包含以下步驟。

  1. 垃圾回收器生成一個根列表。根通常是將引用儲存在程式碼中的全域性變數。在JavaScript中,window物件是一個可以作為根的全域性變數。
  2. 所有的根都被檢查和標記成活躍的(不是垃圾),所有的子變數也被遞迴檢查。所有可能從根元素到達的都不被認為是垃圾。
  3. 所有沒有被標記成活躍的記憶體都被認為是垃圾。垃圾回收器就可以釋放記憶體並且把記憶體還給作業系統。 這個演算法可以有效的避免迴圈依賴問題。

相關文章