知識要點

Vue的掛載過程是怎樣的?

一個編譯器由哪些部分組成?

Vue的整體編譯過程是怎樣的?

Vue編譯過程的設計思路是怎樣的?

概覽

前面的章節已經提到過,在例項化

Vue

的時候,首先會經過

選項合併環節

,將使用者傳入的選項配置與

Vue

自身的配置進行合併。其次是

資料初始化環節

,生命週期、事件和資料響應式處理都是在這個環節進行的。最後的

掛載環節($mount)

就是我們接下來重點學習的物件。

在透過原始碼來分析

Vue

的編譯實現之前,我們先透過一張圖從全域性來看看

Vue

的編譯流程。

Vue原始碼解析:Vue編譯過程的設計思路

(圖片來自這裡)

$mount

主要分為兩個階段(這裡指的是帶模板編譯的情況):

編譯階段

更新階段

編譯階段主要任務是將template編譯成一個能生成對應Vnode(虛擬Dom)的render函式

。其核心就是圖中所示的

compile

過程。

更新階段主要任務是將render函式生成的虛擬Dom對映成真實Dom

。其核心對應的是圖中所示的

patch

過程。

接下來我們將著重學習下編譯階段,看看編譯的過程是怎樣的。

編譯原理

在瞭解

Vue

的編譯過程之前,我們先了解一下編譯原理。《編譯原理》中提到,一個編譯器的結構通常包含以下幾個部分:

詞法分析

:這個過程會將源程式的位元組流組成為有意義的

詞素

的系列。對於每個詞素,詞法分析器都會以

詞法單元(token)

的形式輸出。比如

1 + 2

這裡的

1

+

2

分別會看作一個詞法單元。

語法分析

:將詞法分析生成的詞法單元用樹形結構來表示。一個常用的表示方法是

語法樹(syntax tree)

詞義分析

:語義分析器使用語法樹和符號表中的資訊來檢查源程式是否和語言定義的語義一致。

中間程式碼生成

:在把一個源程式翻譯成目的碼的過程中,一個編譯器可能構造出一個或逗哥中間表示。語法樹是一種中間表示形式。

程式碼最佳化

:改進最佳化中間程式碼,以便生成更好的目的碼。

程式碼生成

:程式碼生成器以源程式的中間表示形式作為輸入,並把它對映到目標語言。

符號表管理

:記錄源程式中使用的變數的名字,並收集和每個名字的各種屬性有關的資訊。

這7個步驟討論的是一個編譯器的邏輯組織形式。在一個特定的實現中,多個步驟的活動可以組合成

一趟

。每趟讀入一個輸入檔案併產生一個輸出檔案。

比如,前端步驟中的詞法分析、語法分析、語義分析,以及中間程式碼的生成可以被組合在一起成為一趟。程式碼最佳化可以作為一個可選的趟。然後可以有一個為特定目標機生成程式碼的後端趟。

為什麼我們要了解這個編譯的步驟呢?因為它實在太重要了,瀏覽器頁面的渲染過程、

babel

的轉換過程、

Vue

的程式碼編譯過程等等,都離不開這幾個步驟,可見其重要性。下面我們將結合這幾個過程看看

Vue

是如何實現編譯的。

編譯過程

最能代表整個

Vue

編譯過程的就是以下幾行程式碼:

const

ast

=

parse

template

trim

(),

options

if

options

optimize

!==

false

{

optimize

ast

options

}

const

code

=

generate

ast

options

正如前面編譯原理裡面對

的解釋,這裡也正好可以分為三趟,一趟是

parse

,用於生成抽象語法樹(

ast

)。二趟是程式碼最佳化,也是可選的。三趟是

generate

,用於生成可以生成虛擬

Dom

的程式碼。下面我們用一個實際例子來說明這三趟做了什麼工作。

首先,先定義一個簡單的模板如下:

<

div

>

編譯過程

{{

isEasy

}}

<

/div>

經過

parse

函式進行解析,我們會得到一個抽象語法樹,如下:

{

“type”

1

“tag”

“div”

“attrsList”

[],

“attrsMap”

{},

“rawAttrsMap”

{},

“children”

{

“type”

2

“expression”

“\”\\n 編譯過程\“+_s(isEasy)+\”\\n\“”

“tokens”

“\n 編譯過程”

{

“@binding”

“isEasy”

},

“\n”

],

“text”

“\n 編譯過程{{ isEasy }}\n”

“static”

false

}

],

“plain”

true

“static”

false

“staticRoot”

false

}

透過語法樹的結構其實我們能夠比較清楚地看出,外層有一個

div

的標籤,它的

children

中包含一個

type

為2的節點,也就是文字節點。這個文字節點裡有幾個

token

,包含文字,動態繫結的值,和換行符。也就是說我們用一個物件結構的語法樹,可以完全表示我們寫的

template

字串,這樣就可以極大地方便我們後續的操作了。

接著是

optimize

方法,這裡沒有表示出來,它主要就是將上述的語法樹進行最佳化,也就是改變一些屬性等,從而使得生成目的碼變得更加容易。

最後則是

generate

方法,我們看看它會將語法樹處理成怎樣的:

with

this

){

return

_c

‘div’

,[

_v

“\n 編譯過程”

+

_s

isEasy

+

“\n”

)])}

這段程式碼很簡短,它的主要作用是透過處理語法樹,將標籤、變數、樣式等等屬性,然後拼接成一段能生成

Vnode

的程式碼。比如這段程式碼用

_s

方法將

isEasy

生成字串,拼接後用

_v

方法生成文字

Vnode

,透過

_c

生成最終的

Vnode

。大致結構如下:

Vue原始碼解析:Vue編譯過程的設計思路

因此,透過

parse

optimize

generate

三個階段,我們就能將任意按照

Vue

規範寫的模板編譯成對應對的

Vnode

了。後續會詳細分析下這三個階段的具體過程是怎樣的,這裡瞭解一個大概即可。

編譯入口

在整體上對編譯有了一定認識後,我們將開始進入編譯的原始碼分析環節了。編譯的最開始入口是在例項化過程中呼叫了

$mount

函式。先找到

$mount

函式的定義所在。

一個是在

platforms/web/runtime/index。js

之中:

Vue

prototype

$mount

=

function

el

?:

string

|

Element

hydrating

?:

boolean

Component

{

el

=

el

&&

inBrowser

query

el

undefined

return

mountComponent

this

el

hydrating

}

另一個則在

platforms/web/entry-runtime-with-compiler。js

中:

const

mount

=

Vue

prototype

$mount

Vue

prototype

$mount

=

function

el

?:

string

|

Element

hydrating

?:

boolean

Component

{

。。。

const

{

render

staticRenderFns

}

=

compileToFunctions

template

{

outputSourceRange

process

env

NODE_ENV

!==

‘production’

shouldDecodeNewlines

shouldDecodeNewlinesForHref

delimiters

options

delimiters

comments

options

comments

},

this

。。。

return

mount

call

this

el

hydrating

}

為什麼需要這樣做呢?因為

runtime

裡是沒有編譯環節的,所以只需要進行更新虛擬

Dom

即可。而後者是需要編譯

template

的,為了實現這一目的,需要重寫

$mount

方法:將原有

$mount

快取起來,然後在編譯模板後再呼叫原有的

$mount

方法。

$mount

方法裡主要做了幾層判斷,主要是針對

template

不同寫法而做的不同處理:

if

template

{

// * 如果是 string

if

typeof

template

===

‘string’

{

// 如 #app

if

template

charAt

0

===

‘#’

{

。。。

}

}

else

if

template

nodeType

{

// 如果 template 是實際節點

。。。

}

else

{

// 否則是無效template

if

process

env

NODE_ENV

!==

‘production’

{

warn

‘invalid template option:’

+

template

this

}

return

this

}

}

else

if

el

{

// 使用 el

template

=

getOuterHTML

el

}

做完了

template

的處理後,就要開始正式編譯了:

const

{

render

staticRenderFns

}

=

compileToFunctions

template

{

outputSourceRange

process

env

NODE_ENV

!==

‘production’

shouldDecodeNewlines

shouldDecodeNewlinesForHref

delimiters

options

delimiters

comments

options

comments

},

this

可以看出,將

template

方法編譯成

render

函式的核心方法是

compileToFunctions

方法,找到該方法,在

platforms/web/compiler/index。js

中:

const

{

compile

compileToFunctions

}

=

createCompiler

baseOptions

發現

compileToFunctions

方法又是

createCompiler

方法建立的,找到

src/compiler/index。js

檔案:

export

const

createCompiler

=

createCompilerCreator

function

baseCompile

template

string

options

CompilerOptions

CompiledResult

{

。。。

})

createCompiler

方法又是

createCompilerCreator

方法建立的,找到

src/compiler/create-compiler。js

檔案:

export

function

createCompilerCreator

baseCompile

Function

Function

{

return

function

createCompiler

baseOptions

CompilerOptions

{

function

compile

template

string

options

?:

CompilerOptions

CompiledResult

{

。。。

return

{

compile

compileToFunctions

createCompileToFunctionFn

compile

}

}

}

這裡非常繞,因為分了好幾個模組,且回調了很多次,非常難以理解。在講解這個之前,我們先簡單瞭解一下函式科裡化。比如現在有一個

sum

函式用於加法運算,程式碼如下:

const

sum

=

a

b

=>

{

return

a

+

b

}

sum

1

2

// 3

現在我們想將其轉換成

sum(1)(2)

這種形式執行,那麼該如何定義

sum

函式呢?定義如下:

const

sum

=

a

=>

{

return

b

=>

{

return

a

+

b

}

}

函式科裡化就是將接收多個引數的函式變為接收一個單一引數的函式

。由於篇幅有限,這裡介紹得比較簡單,有興趣的話可以單獨查詢相關文章進行學習。

但是為什麼要這麼做呢?其實這樣做的最大好處就是程式碼複用。回到

Vue

的編譯過程,我們可以將程式碼簡化成如下程式碼:

function

createCompilerCreator

baseCompile

{

return

function

createCompiler

baseOptions

{

function

compile

template

optioins

{

。。。

const

compiled

=

baseCompile

template

trim

(),

finalOptions

。。。

}

return

{

compile

compileToFunctions

createCompileToFunctionFn

compile

}

}

}

有沒有發現和

sum

函式很像?這裡同樣也是把

baseCompile

baseOptions

兩個函式分開來傳。下面我們來看一下為什麼要這麼做?

先看

createCompiler

,這裡我們傳入了

baseCompile

函式,那麼他就會按照

baseCompile

來編譯。

export

const

createCompiler

=

createCompilerCreator

function

baseCompile

template

string

options

CompilerOptions

CompiledResult

{

。。。

})

// src/compiler/create-compiler。js

const

compiled

=

baseCompile

template

trim

(),

finalOptions

但是如果我們想以不同的方式來編譯模板該怎麼辦呢?比如服務端渲染的模板編譯過程和瀏覽器編譯的過程是不一樣的。這裡就可以重寫一個服務端編譯過程,如叫做

serverCompile

,然後呼叫

createCompilerCreator(serverCompile)

就能實現一個基於服務端的編譯流程了。而且不需要改變

createCompilerCreator

內部的內容,也不需要把

createCompilerCreator

內部的內容複製過來重寫一遍。

同樣的道理,看下

compileToFunctions

方法:

const

{

compile

compileToFunctions

}

=

createCompiler

baseOptions

不同的平臺它們的節點處理方式,樣式定義等等都是不一樣的,因此需要根據不同的平臺生成不同的編譯函式。所有這裡將這些不同點抽離出來了作為一個

baseOptions

引數,以後想要在不同平臺下生成編譯函式就只需要傳入不同的

options

就行了。這也就是為什麼這段程式碼是在

platforms

檔案下的原因。

透過科裡化的處理,

Vue

將不同平臺不同端的程式碼就抽離封裝起來了,剛開始可能覺得很難理解,但是一旦理解了之後,會發現是一個十分巧妙的過程,非常值得我們學習。

一些細節

接下來我們再看看這個過程中的一些其他的細節。

首先是

baseOptions

,它是在

platforms/web/compiler/options。js

檔案裡定義。

const

baseOptions

CompilerOptions

=

{

expectHTML

true

// 平臺相關的節點處理

modules

// 平臺相關的指令

directives

//* tag 是否是pre

isPreTag

//* 是否是單標籤

isUnaryTag

//* 必須使用屬性,比如 option 需要 selected

mustUseProp

//* 自閉和標籤

canBeLeftOpenTag

//* html / svg 標籤

isReservedTag

//* svg / math

getTagNamespace

//* 獲取 staticKeys => ‘staticClass,staticStyle’

staticKeys

genStaticKeys

modules

}

這裡都是平臺相關的一些方法和屬性,大部分已經有相關注釋,這裡就不再重複。需要注意的是

directives

v-model

v-text

v-html

三個指令就是在這裡定義的。

然後就是

baseCompile

,這個函式就是我們前面提到的編譯流程核心實現的地方,後面章節會進行詳細講解。

const

ast

=

parse

template

trim

(),

options

if

options

optimize

!==

false

{

optimize

ast

options

}

const

code

=

generate

ast

options

再接著就是

src/complier/create-compiler。js

最內層的

compile

函式。

const

finalOptions

=

Object

create

baseOptions

。。。

finalOptions

warn

=

warn

const

compiled

=

baseCompile

template

trim

(),

finalOptions

compiled

errors

=

errors

compiled

tips

=

tips

其實它就是在編譯流程前後做了一些

options

處理和新增訊息提示函式的工作,最後返回了編譯後的程式碼:

return

{

compile

compileToFunctions

createCompileToFunctionFn

compile

}

這裡需要看一下

createCompileToFunctionFn

函式。我們前面提到了編譯後會生成一段

render

程式碼:

with

this

){

return

_c

‘div’

,[

_v

“\n 編譯過程”

+

_s

isEasy

+

“\n”

)])}

這段程式碼實際上生成的時候是字串,那麼我們怎麼才能執行這段的程式碼呢?答案就是

new Function(code)

這裡

createCompileToFunctionFn

主要做了兩項工作:

第一項就是將編譯好的程式碼快取起來,模板作為

key

,這樣下次就不用再次編譯了:

const

key

=

options

delimiters

String

options

delimiters

+

template

template

if

cache

key

])

{

return

cache

key

}

第二項工作就是將編譯好的程式碼字串透過

new Function

轉換成函式形式:

function

createFunction

code

errors

{

。。。

return

new

Function

code

}

res

render

=

createFunction

compiled

render

fnGenErrors

res

staticRenderFns

=

compiled

staticRenderFns

map

code

=>

{

return

createFunction

code

fnGenErrors

})

這裡的

render

函式容易理解,就是用於生成

Vnode

的函式。

那麼staticRenderFns是做什麼用的呢

?其實

staticRenderFns

也是編譯裡的一些最佳化,對於那麼靜態的節點,由於不會因為資料的變化而導致節點發生變化,那麼每次編譯後的

Vnode

其實是一樣的。那麼,我們可以直接將這些

Vnode

快取下來,下次編譯的時候就不用再次編譯了,

staticRenderFns

就是儲存這些

Vnode

的地方。

總結

這一章我們從整體上了解

Vue

的掛載過程,同時也學習了編譯的基本原理,瞭解了

Vue

整體的編譯過程。隨後透過原始碼的形式,解釋了為什麼

Vue

會以多次回撥的形式來處理編譯函式。下一章我們將會結合原始碼來學習

Vue

內部實際的編譯過程,從而瞭解

template

是如何轉換成能生成

Vnode

render

函式的。

最後,點選這裡可以更方便地檢視Vue原始碼相關的解析。