透過V8深入理解JavaScript的函數

函數

什麼是函數

在JavaScript中,函數其實也是一個對象,所以函數也可以有自己的屬性和值,它與對象不同的地方在於,它可以被執行,執行時會創建函數作用域。

V8如何去執行函數

我們知道,在執行函數的時候,我們只需要在函數名後面,加上一個括號,在括號裏傳入參數,就可以執行這個函數了,但是,V8是怎麼做到執行這個函數的,V8如何找到這個函數的相關代碼

既然函數是一個對象,那麼它就有自己的屬性,除了我們給它添加的屬性,函數本身有兩個屬性,分別是name和code。

name屬性存儲着函數的名字,而code以字符串的形式存儲函數

就像下面的代碼

function fn(){
    // ...
}

console.log(fn.name)
// fn

當我們執行代碼的時候,V8會去找到這個函數對應的code屬性,然後解釋執行code屬性的值,也就是原函數。

使用棧結構來管理函數調用

我們要知道,V8是使用棧結構來管理函數調用的,所以我們經常可以聽到函數調用棧,但爲什麼使用棧結構來管理函數調用呢?

除了棧結構本身的特點:空間連續和先進後出外,也與函數本身有關。

首先,函數是可以被調用的,我們可以在一個函數裏面去調用另外一個函數,當我們調用一個函數的時候,代碼執行的控制權將由父函數或者全局作用域轉移到子函數,而當函數調用完畢後,會返回給父函數或者全局,這與棧結構的先進後出剛好吻合。

其次,函數具有作用域機制,函數內部的變量,被我們稱爲局部變量,只要我們不暴露出去,就只能在函數內部被訪問到,即使返回,也只是返回一個值或者一個對象的地址,所以一般情況下,當我們一個函數調用完畢時,函數內部的局部變量都該被銷燬,而通過使用棧結構,當函數執行完畢出棧時,空間裏對應的變量也被清除。

函數是一等公民

什麼是一等公民,對一種語言來說,如果它的函數和它的基本類型能完成一樣的事情,比如可以賦值給變量,可以做爲函數的參數,可以做爲函數的返回值,可以做爲對象的屬性值等,那麼就說這種語言的函數是一等公民。

函數成爲一等公民,讓我們可以方便地去傳遞函數,但也正是因爲函數是一等公民,帶來了一些麻煩。當我們在函數內部去引用外部變量的時候,V8需要去判斷,這個外部變量是否真的存在,閉包就是一個典型的例子。如下面的代碼

function fn(){
    let count = 0
    function add(){
        count++
        console.log(count)
    }
    return add
}
let a = fn()
a()

這裏fn內部聲明瞭一個函數add,而這個add函數,調用了它所在的函數作用域裏的count變量,所以即使fn已經調用完了,因爲add函數內部用到了fn函數作用域裏的變量,所以V8不得不去維護這個作用域,不能去銷燬這個作用域,這就是函數做爲一等公民帶來的麻煩。

V8如何去處理閉包

首先,我們說說閉包,爲什麼JavaScript會有閉包,有以下三點

  1. JavaScript運行在函數內部定義新的函數
  2. 可以在內部函數訪問父函數中定義的變量
  3. 函數是一等公民,可以做爲函數的返回值

再有一個,我們要明白,JavaScript出於加快初次解析和減少內存浪費的目的,採用了惰性解析:當解析代碼的時候,對函數聲明只轉換成對用的對象,而不解析函數內部的內容,

我們可以看看下面這個閉包

function fn(){
    var fnNum = 1
    return function inner(){
        var innerNum = 2
        return fnNum+innerNum
    }
}
var f = fn()

在這裏,inner就是一個典型的閉包,這個閉包帶來了一定的問題。當我們調用fn函數時,它會將裏面的函數inner返回給變量f,然後當函數執行完畢的時候,fn的執行上下文會被V8銷燬,但是,我們可以看到,閉包inner調用了fn內的變量fnNum,這導致了fnNum不能被銷燬,但是這樣就帶來問題了。

我們知道,這段代碼是可以實現的,而且執行f()的時候,會返回3,那就意味着fnNum沒用被銷燬,那麼我們怎麼做到它不被銷燬,同時,上面提到了,V8採用了惰性解析,既然採用了惰性解析,那也就意味着,當我們執行fn()之前,我們是不會去解析inner函數內部的代碼的,那我們又怎麼知道,fnNum被inner函數使用了呢。

要解決這個問題,我們就需要去判斷函數內部是否會用到父函數的變量,V8引入了預解析器去執行這個任務。

引入預解析器後,V8遇到一個函數時,不會直接跳過該函數,而是會對該函數進行以此快速的預解析,預解析有兩個工作

  1. 判斷當前函數是否存在語法錯誤
    如果這個函數出現了語法錯誤,那也沒必要繼續執行下去了
  2. 檢查函數內部是否引用了外部變量,如果引用了外部變量,預解析器就會將棧中的變量複製到堆中,當下次執行到該函數的時候,直接使用堆中的引用,以此解決閉包的問題。

函數表達式

函數表達式是JavaScript中非常重要的基礎內容,我們可以使用函數表達式來執行一個函數,同時又不會把實現的邏輯代碼暴露出來。

函數表達式說簡單,看起來不難,但實際上,還是涉及到挺多東西的。它涉及到一些底層概念,什麼是語句,什麼是表達式,函數即對象的概念。我們要清楚這些概念,才能更好地去理解函數表達式。

函數聲明和函數表達式

我們知道,在JavaScript裏面,一般情況下,我們可以有兩種方式去聲明一個函數

// 函數聲明
function fn(){
    // ...
}
// 函數表達式
var fn = function(){
    // ...
}

上面的兩行代碼,雖然最終都會得到一個函數fn,但是還是存在不同的。

哪裏不同,其實從名字就能看出來了,函數聲明和函數表達式,既然有聲明,那就可能會涉及到JavaScript非人的設計,變量提升。V8在執行JavaScript代碼的時候,會先創建全局作用域,然後對代碼進行編譯,而在編譯期間,會把聲明內容放到全局作用域中,像下面的這段代碼

fn()
function fn(){
    console.log('fn')
}

V8處理時會變爲

// 編譯階段
function fn(){
    console.log('fn')
}

// 執行階段
fn()

而對於函數表達式,如下代碼

fn()
var fn = function(){
    console.log('fn')
}

V8處理時會是

// 編譯階段
var fn = undefined

// 執行階段
fn()
fn = function(){
    console.log('fn')
}

所以這段代碼會報錯,因爲在fn執行的時候,fn還不是方法。

實際上,這就涉及到了表達式和語句的區別了,表達式在編譯階段是不會執行的。

我們可以簡單地區分,表達式,是JavaScript中的一個短語,JavaScript編譯執行它會返回一個結果,而語句,是一個完整的句子,它可以單獨執行。就像下面的函數表達式

(function(){
    //...
})()

我們如果不加括號,那麼function(){}是會報錯的,因爲編譯器看到開頭是function,將其看做一個函數聲明語句,而函數聲明語句function後面要加函數名,但是,放到括號裏後,它就被看成一個表達式了,這個括號內通過JavaScript編譯器“計算”得到一個函數,然後使用()調用。

我們能看到,函數表達式和函數聲明的在創建一個函數時的區別,函數聲明可以讓聲明的函數在編譯階段就放到作用域中,也即是說可以在聲明前就調用這個方法了,而函數表達式,可以做爲一個匿名函數使用,可以不污染環境。

像上面舉例用的的函數表達式

(function(){
    //...
})()

這是一個立即執行的函數表達式(IIFE),使用這樣的函數調用可以讓我們不用去污染環境變量。

V8實現回調函數

回調函數,說到底只是一個函數而已,我們可以在很多個場景見到回調函數,一個簡單的定時器setTimeout或者setInterval,一個ajax異步回調,又或者一個forEach方法的回調函數參數,甚至於回調函數的濫用,讓我們聽到了回調地獄。

具體來說,就我們上面舉到的例子其實可以看到了,回調函數分爲同步回調和異步回調,同步回調是直接在執行函數內部執行的,而異步回調函數,往往會在另一個任務裏面執行,像定時器,或者是ajax異步回調,它們的回調函數和本身所在的函數執行,是在兩個宏任務的

對於同步回調函數,我們很容易理解,它在函數內部執行完後就執行,但是異步回調可能就不是很好理解了,這涉及到V8的事件循環機制和消息隊列等。而這些又與V8的線程模型相關,所以我們要先分析一下V8的線程模型

線程架構

首先我們要明白的是,JavaScript在瀏覽器中,是在UI線程裏面執行的,這與早期瀏覽器只有UI線程有關。

所謂UI線程,是指運行窗口的線程,當你運行一個窗口時,無論該頁面是Windows上的窗口系統,還是Android或者iOS上的窗口系統,它們都需要處理各種事件,諸如有觸發繪製頁面的事件,有鼠標點擊、拖拽、放大縮小的事件,有資源下載、文件讀寫的事件,等等。

我們知道,上面說到的事件,往往會在同一段時間內發生,可能上一個事件沒有處理完,下一個事件就來了,我們要如何控制這些事件的執行,才能保證CPU正常運行和事件有序執行。

針對這種情況,瀏覽器實現了一個消息隊列,每當我們產生一個事件的時候,瀏覽器就將這個事件推入到隊列中,然後依次把事件從隊列中推出,因爲隊列的特點是先進先出,所以事件的執行,是按照事件觸發的順序,我們把UI線程每次從消息隊列中取出事件,執行事件的過程稱爲一個任務。

異步回調函數的調用時機

對於定時器來說,會有另一個隊列來存放定時器裏面的回調函數,當我們執行一個定時器時,會將這個回調函數推入這個隊列,然後又一個任務調度器來控制,當時間到了的時候,任務調度器就會將這個回調函數從隊列中取出,壓入到上面說的消息隊列裏面。

而對於像XHR這種和網絡相關的異步回調,又存在了不同的處理。因爲我們的XHR請求會去請求服務端的資源,而這個資源請求花費的時間,相對於我們一個普通的事件來說是很長的,如果只是獲取一些數據還好,如果是下載一整個文件,那時間就不是以ms爲單位了,甚至會以h爲單位了。

而就像上面說的,JavaScript運行在UI線程上,如果我們在這裏還是由UI線程來處理這個請求事件的話,那無疑會對頁面造成嚴重的阻塞,爲了解決這個問題,瀏覽器藉助了網絡線程的力量,由網絡線程來處理數據的獲取

當我們在UI線程,也就是JavaScript發起一個XHR任務時

  • V8會分析出這是一個下載任務,主線程會將該任務交給網絡線程去執行
  • 網絡線程接到任務後,會和服務端建立連接,並不斷接受服務端發來的數據
  • 網絡線程在接受數據的過程中,每接收一次數據,都會將此次接收數據的大小,字節,存放在內存中的位置,封裝在一個事件裏壓入消息隊列中
  • UI線程不間斷地執行消息隊列裏的任務,當發現下載事件的任務狀態變爲完成時,就將回調函數壓入消息隊列中
  • 回調函數前面的任務都被執行完,回調函數出隊列,被執行,UI線程回調顯示數據獲取完成
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章