Vue原始碼解析:Vue編譯過程的設計思路
知識要點
:
Vue的掛載過程是怎樣的?
一個編譯器由哪些部分組成?
Vue的整體編譯過程是怎樣的?
Vue編譯過程的設計思路是怎樣的?
概覽
前面的章節已經提到過,在例項化
Vue
的時候,首先會經過
選項合併環節
,將使用者傳入的選項配置與
Vue
自身的配置進行合併。其次是
資料初始化環節
,生命週期、事件和資料響應式處理都是在這個環節進行的。最後的
掛載環節($mount)
就是我們接下來重點學習的物件。
在透過原始碼來分析
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
。大致結構如下:
因此,透過
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原始碼相關的解析。