初步理解 JavaScript 底層原理

Ozzie發表於2019-12-17

本文綱領:

  • JS解析引擎
  • JS執行過程
  • 引入:JS單執行緒
  • JS非同步執行機制
  • 任務佇列 與 事件迴圈
  • JS — 定時器

JS解析引擎

  1. 簡單說,JS 解析引擎就是:能夠“讀懂”JavaScript程式碼,並準確地給出程式碼執行結果的一段程式

  2. 瞭解過編譯原理的人都清楚,對於靜態語言(如C、Java),處理上述過程的稱為編譯器,相應地,對於JS這種動態語言則叫直譯器

  3. 簡單概括編譯器和直譯器的區別就是:

    • 編譯器是在執行程式前,將整個原始碼編譯為目的碼(如機器碼、位元組碼)之後,計算機直接再執行此目的碼即可
    • 直譯器是在執行程式時,一條一條地將原始碼解釋成機器語言來讓計算機執行,直接解析並將程式碼的執行結果輸出
  1. JS語言是由瀏覽器的解析引擎解釋的,不同的瀏覽器的解析引擎也不同,例如:

    1. Chrome :  webkit/blink :  V8
    2. FireFox : Gecko        :  SpiderMonkey
    3. Safari :  webkit       :  JavaScriptCore
    4. IE :   Trident       :  Chakra
    • 當然,JS不一定非要在瀏覽器中執行,只要有引擎即可,最典型的比如NodeJs,採用了谷歌的V8引擎,使JS完全脫離瀏覽器執行
  2. 其實,現在很難去界定JS引擎到底是直譯器還是編譯器,比如Ghrome V8,它為了提高JS的執行效能,在執行前就會先把JS編譯成本地的機器碼,然後再去執行機器碼,這樣會快很多;不過,也不需要過分強調JS引擎到底是什麼,只需要瞭解他做了什麼事情就OK

  3. JS解析引擎與ECMAScript是什麼關係?

    • 我們寫的JS程式碼是一段程式,而JS引擎也同樣是一段程式(如V8就是用C/C++編寫的),如何讓程式去讀懂程式呢?這就需要定義規則,這裡的ECMAScript就定義了一套標準的規則,而標準的JS引擎就會根據規則去實現
    • 除了標準之外,當然也有不按標準來的,如IE的JS引擎,也就是為什麼JS會有相容性問題
    • 簡單說,兩者關係為:ECMAScript定義了語言的標準,JS引擎根據它來實現。
  4. JS解析引擎與瀏覽器又是什麼關係?

    • 概括說,JS引擎大多存於瀏覽器的核心中,是瀏覽器的組成成分之一
    • 瀏覽器當然還有其他的事情,比如解析頁面,渲染頁面,Cookie管理,歷史記錄等等

JS執行過程

JS執行過程可分為兩步:(1)語言檢查;(2)執行

語法檢查

  1. 語法檢查可分為:詞法分析語法分析
  2. 詞法分析:
    • 將字元組成的字串分解成有意義的程式碼塊,這些程式碼塊被稱為詞法單元
    • 例如:會將var a = 1; 分解成:vara=1;
    • 深入瞭解詞法分析可讀此文:JS詞法分析
  3. 語法分析:
    • 會將詞法單元流轉換成一個由元素逐級巢狀所組成的代表了程式語法結構的樹
    • 例如:上面的的詞法單元流var a = 2 ; 會被轉為下方所示的AST
      在這裡插入圖片描述
    • 深入語法分析和抽象樹可讀此文:JavaScript 語法解析、AST、V8、JIT

執行階段

  1. 執行階段可分為:預編譯執行
  2. 預編譯:將生成的AST複製到當前執行的上下文中,對當前AST變數宣告函式宣告函式形參進行屬性填充
  3. 執行:逐行讀取並執行程式碼

引入:JS單執行緒

  1. 深入理解瀏覽器程式與JS執行緒及其執行機制,請參考:
    從瀏覽器多程式到JS單執行緒,JS執行機制
  2. 區分 程式與執行緒
    • 一個形象的比喻:

      程式 是一個獨立的工廠,每個工廠都有它獨立的資源,工廠之間相互獨立
      執行緒 是工廠裡的工人,工廠內有一個或多個工人 — 工人之間共享工廠的空間等資源

    • 來一套比較專業的描述:

      程式 是 CPU 資源分配的最小單位(是能擁有資源和獨立執行的最小單位,系統會給程式分配 記憶體)
      執行緒 是 CPU 排程的最小單位(執行緒是建立在程式的基礎上的一次執行單位,一個程式可以有多個執行緒)

    • 不同程式之間也可以通訊,不過代價很大
    • 現在通常說的 “單執行緒” 與 “多執行緒” 都指的是在一個程式內部的 “單” 和 “多”
      所以討論前提還得屬於一個程式才行。
  3. 瀏覽器是多程式的
    • 簡單理解:瀏覽器之所以能夠執行,是因為系統給它的程式分配了資源(cpu、記憶體),
      每開啟一個Tab頁,就建立了一個獨立的瀏覽器程式(至少最近的瀏覽器版本是這樣的)
      在這裡插入圖片描述
      • 瞭解瀏覽器是多程式後,再看看它到底包含哪些程式(此處僅簡明列舉主要程式):
        (1)Browser程式:瀏覽器的主程式(負責協調,主控),此程式只有一個
        (2)第三方外掛程式:每種型別的外掛對應一個程式,僅當使用該外掛時才建立
        (3)GPU程式:最多一個,用於3D繪製等
        (4)瀏覽器渲染程式(瀏覽器核心,Renderer程式,內部是多執行緒的):預設每個Tab頁面一個程式,互不影響
    • 注意: 在這裡瀏覽器應該也有自己的優化機制,有時候開啟多個 tab 頁後,可以在 Chrome 工作管理員中看到,有些程式被合併了。
      所以每一個 Tab 標籤對應一個程式並不一定是絕對的
  4. JS語言的一大特點就是:單執行緒;也就是說,同一時間只能做一件事情,那麼,為什麼它不能有多個執行緒呢,這樣也好提交效率啊...
    • JavaC#中的非同步均是通過多執行緒實現的,沒有迴圈佇列一說,直接在子執行緒中完成相關的操作
  5. JS的用途決定了JS必須是單執行緒的:作為瀏覽器指令碼語言,JS的主要用途是與使用者互動,以及操作DOM
    • 例如,假設有多個執行緒,一個執行緒在某DOM節點上新增內容,另一執行緒要刪除這個節點,這時瀏覽器應該以哪個執行緒為準?
    • 所以,為了避免複雜性,從一誕生,JS就是單執行緒,這已經成了這門語言的核心特徵,將來也不會改變。
    • 為了利用多核CPU的計算能力,HTML5提供了 Web Worker 標準,允許JS指令碼建立多執行緒,但是子執行緒完全受主執行緒的控制,而且不能操作DOM。所以,這個新的標準並沒有改變JS單執行緒的本質

JS非同步執行機制

  1. 同步與非同步如圖:同步與非同步
  2. 同步:
    • 若在函式返回結果時,呼叫者能夠拿到預期的結果(即函式計算的結果),那麼這個函式就是同步的,例如:
      //在函式返回時,獲得了預期值,即2的平方根
      Math.sqrt(2);
      //在函式返回時,獲得了預期的效果,即在控制檯上列印了'hello'
      console.log('hello');
    • 若函式是同步的,即使呼叫函式執行任務比較耗時,也會一直等到執行結束。如:
      function wait(){
          var time = (new Date()).getTime();  //獲取當前的unix時間戳
          while((new Date()).getTime() - time > 5000){}
          console.log('5秒過去了');
      };
      wait();
      console.log('慢死了');
      • 上面程式碼中,函式wait()是一個耗時程式,持續5秒,在它執行的這漫長的5秒中,下面的console.log()函式只能等待,這就是同步。
  3. 非同步:
    • 如果在函式返回的時候,呼叫者還不能得到預期結果,而是將來通過一定的手段得到(例如回撥函式),這就是非同步。例如ajax操作。
    • 如果函式是非同步的,發出呼叫之後,馬上返回,但是不會馬上返回預期結果。呼叫者不必主動等待,當被呼叫者得到結果之後會通過回撥函式主動通知呼叫者,例如:
      //讀取檔案
      fs.readFile('Hello.text', 'utf-8', function(err, data){
          console.log(data);
      });
      //網路請求
      var xhr = new XMLHttpRequest();
      xhr.onreadystatechange = xxx;   //新增回撥函式
      xhr.open('GET',url);
      xhr.send();
      • 上述中的讀取檔案函式readFile()和網路請求的發起函式send()都將執行耗時操作,雖然函式會立刻返回,但是不能立刻得到預期的結果,因為耗時操作交給其他執行緒執行,暫時不能獲取預期結果。
      • 而上述過程中,通過回撥函式function(err, data){ console.log(data) }onreadystatechange,在耗時操作執行完成後會把相應的結果資訊傳遞給回撥函式,通知執行JS程式碼的執行緒進行回撥

任務佇列 與 事件迴圈

  1. 瀏覽器
    • OK,現在,我們知道了:(1)JS是單執行緒的;(2)非同步的概念;
    • 現在問題來了,既然JS是單執行緒的,怎麼還會非同步呢,誰去執行非同步的那些耗時操作呢?
    • 首先,我們得清楚,JavaScript僅僅是一門語言 ,我們討論單執行緒以及多執行緒都得結合具體的執行環境。既然JS通常是在瀏覽器中執行的,那麼我們從瀏覽器角度思考一下:
      • 目前主流瀏覽器為:Chrome,Safari,FireFox,Opera(或許還應有IE)。瀏覽器的核心是多執行緒的。
      • 對於JS的宿主環境—瀏覽器,瀏覽器的核心是多執行緒的;
      • 在核心控制下各執行緒相互配合以保持同步,瀏覽器通常由以下 常駐執行緒 組成:
        (1)渲染引擎執行緒: 負責頁面的渲染
        (2)JS引擎執行緒: 負責JS的解析和執行
        (3)定時觸發器執行緒: 處理定時事件,比如setTimeout, setInterval
        (4)事件觸發執行緒: 處理DOM事件
        (5)非同步http請求執行緒: 處理http請求
        注意:渲染引擎執行緒JS引擎執行緒 是不能同時進行的,渲染引擎執行緒在執行任務時, JS引擎執行緒會被掛著,因為JS可以操作DOM,與正在渲染中的DOM可能發生矛盾
    • 既然非同步操作是靠瀏覽器中多個執行緒的合作完成的,那麼非同步的回撥函式又是怎樣執行的呢 ? 這還得從 任務佇列事件迴圈 說起
  2. 任務佇列(Task Queue):
    • JS是單執行緒的,但單執行緒就意味著:所有任務需要排隊,前一個任務結束,才會執行後一個任務。即使前一個任務耗時很長,後一個任務也必須一直等著。
    • 耗時的情況通常不是因為計算量過大使得CPU忙不過來,而是因為I/O裝置很慢,比如Ajax操作從網路中讀取資料,只能等到結果出來才能執行下一步
    • JS語言的設計者意識到,這時主執行緒完全可以不管I/O裝置,掛起處於等待中的任務,先執行排在後面的任務。等到I/O裝置返回了結果,再回過頭,把掛起的任務繼續執行下去。
    • 於是,所有的任務都分成兩種,即 同步任務非同步任務
      • 同步任務:是在主執行緒排隊執行的任務,前一個任務執行結束,才能執行後一個
      • 非同步任務:是不進入主執行緒,而進入任務佇列的任務,只有當任務佇列告知主執行緒,某個任務可以執行了,該任務才會進入主執行緒執行
      • 具體來說,非同步執行的執行機制如下:
        1、所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack).
        2、除主執行緒外,還有任務佇列,只要非同步任務有了執行結果,就在任務佇列中放置一個事件
        3、執行棧中所有同步任務完成時,系統就會讀取任務佇列,看看裡面有哪些事件
        這些事件又分別對應哪些非同步任務,於是該任務結束等待,進入到執行棧執行
        4、主執行緒不斷重複上面的第3步

        注:同步執行也是如此,因為它可以被視為 沒有非同步任務的非同步執行

    • 下面就是主執行緒和任務佇列的示意圖:
      在這裡插入圖片描述
      • 只要主執行緒空了,就去讀取 “任務佇列”,這就是 JS 的執行機制:一個主執行緒 + 一個任務佇列,這個過程會不斷重複。
      • 注:任務佇列裡面的事件,除了I/O裝置的事件之外,還包括 使用者產生的事件(比如滑鼠點選、頁面滾動...),只要指定過回撥函式,這些事件發生時,就會進入任務佇列,等待主執行緒讀取。
    • 任務佇列是一個先進先出的資料結構,只要“執行棧”一清空,就會讀取任務佇列上的事件,但是由於存在後文存提到的“定時器”功能,主執行緒首先要檢查一下執行時間,某些事件,只有到了規定時間,才能返回主執行緒。
  3. 回撥函式(callback):
    • 所謂的回撥函式,就是被主執行緒掛起來的程式碼
    • 前文提到:只要非同步任務有了執行結果,就在任務佇列中放置一個事件,這個事件,就是 註冊非同步任務時新增的回撥函式
    • 非同步任務必須執行回撥函式,當主執行緒執行的非同步任務,就是執行對應的回撥函式。
  4. 事件迴圈(Event Loop):
    • 實際上,主執行緒只會做兩件事情,就是從任務佇列裡面:讀取任務、執行任務,反覆如此,這種機制就叫做 事件迴圈機制,一次 讀取 + 執行 就叫一次 迴圈
    • 事件迴圈用程式碼表示大概是這樣的:
      while (true) {
          var message = queue.get();
          execute(message);
      }
    • 為了更好地理解 Event Loop ,請看下圖:
      事件迴圈
      • 上圖中,主執行緒執行的時候,產生 堆(heap)棧(stack),棧中的程式碼呼叫各種外部API,它們在任務佇列中加入各種事件(click,load,done),只要棧中的程式碼執行完畢,主執行緒就會去讀取“任務佇列”,依次執行那些事件,所對應的回撥函式。
      • 執行棧中的程式碼(同步任務),總是在讀取任務佇列(非同步任務)之前執行,如下例:
        var req = new XMLHttpRequest();
        req.open('GET', url);    
        req.onload = function (){};    
        req.onerror = function (){};    
        req.send();
      • 上面的程式碼中,req.send()方法是一個非同步任務,是用過Ajax操作向伺服器傳送資料,這意味著只有當前指令碼的所有程式碼都執行完,系統才會去讀取任務列表,所以,它等價於:
        var req = new XMLHttpRequest();
        req.open('GET', url);
        req.send();
        req.onload = function (){};    
        req.onerror = function (){}; 
      • 也就是說,指定回撥函式部分(onloadonerror),在send()方法的前面或者後面都無關緊要,因為它們就在執行棧中,系統會執行完它們之後才去讀取任務佇列

定時器

  1. 除了放置非同步任務事件,任務佇列還可以放置 定時事件,即指定某些程式碼在指定事件後執行,這就是定時器功能,即定時執行的程式碼
  2. 定時器功能主要由 setTimeout()setInterval()這兩個函式完成,其內部機制完全一樣,區別在於:前者指定的程式碼是一次性執行,後者則為反覆執行
  3. JS定時器參考博文:

相關文章