我們來聊聊 Vue - compile

小烜同學發表於2018-02-24

在 Vue 裡,模板編譯也是非常重要的一部分,裡面也非常複雜,這次探究不會深入探究每一個細節,而是走一個全景概要,來吧,大家和我一起去一探究竟。

初體驗

我們看了 Vue 的初始化函式就會知道,在最後一步,它進行了 vm.$mount(el) 的操作,而這個 $mount 在兩個地方定義過,分別是在 entry-runtime-with-compiler.js(簡稱:eMount)runtime/index.js(簡稱:rMount) 這兩個檔案裡,那麼這兩個有什麼區別呢?

// entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount // 這個 $mount 其實就是 rMount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  const options = this.$options
  if (!options.render) {
    ...
    if(template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
    ...
  }
  return mount.call(this, el, hydrating)
}
複製程式碼

其實 eMount 最後還是去呼叫的 rMount,只不過在 eMount 做了一定的操作,如果你提供了 render 函式,那麼它會直接去呼叫 rMount,如果沒有,它就會去找你有沒有提供 template,如果你沒有提供 template,它就會用 el 去查詢 dom 生成 template,最後通過編譯返回了一個 render 函式,再去呼叫 eMount。

從上面可以看出,最重要的一部分就是 compileToFunctions 這個函式,它最後返回了 render 函式,關於這個函式,它有點複雜,我畫了一張圖來看一看它的關係,可能會有誤差,希望大俠們可以指出。

我們來聊聊 Vue - compile

編譯三步走

看一下這個編譯的整體過程,我們其實可以發現,最核心的部分就是在這裡傳進去的 baseCompile 做的工作:

  • parse: 第一步,我們需要將 template 轉換成抽象語法樹(AST)。
  • optimizer: 第二步,我們對這個抽象語法樹進行靜態節點的標記,這樣就可以優化渲染過程。
  • generateCode: 第三步,根據 AST 生成一個 render 函式字串。

好了,我們接下來就一個一個慢慢看。

解析器

在解析器中有一個非常重要的概念 AST,大家可以去自行了解一下。

在 Vue 中,ASTNode 分幾種不同型別,關於 ASTNode 的定義在 flow/compile.js 裡面,請看下圖:

我們來聊聊 Vue - compile

我們用一個簡單的例子來說明一下:

<div id="demo">
  <h1>Latest Vue.js Commits</h1>
  <p>{{1 + 1}}</p>
</div>
複製程式碼

我們想一想這段程式碼會生成什麼樣的 AST 呢?

我們來聊聊 Vue - compile

我們這個例子最後生成的大概就是這麼一棵樹,那麼 Vue 是如何去做這樣一些解析的呢?我們繼續看。

在 parse 函式中,我們先是定義了非常多的全域性屬性以及函式,然後呼叫了 parseHTML 這麼一個函式,這也是 parse 最核心的函式,這個函式會不斷的解析模板,填充 root,最後把 root(AST) 返回回去。

parseHTML

在這個函式中,最重要的是 while 迴圈中的程式碼,而在解析過程中發揮重要作用的有這麼幾個正規表示式。

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/
複製程式碼

Vue 通過上面幾個正規表示式去匹配開始結束標籤、標籤名、屬性等等。

關於 while 的詳細註解我放在我倉庫裡了,有興趣的可以去看看。

在 while 裡,其實就是不斷的去用 html.indexOf('<') 去匹配,然後根據返回的索引的不同去做不同的解析處理:

  • __等於 0:__這就代表這是註釋、條件註釋、doctype、開始標籤、結束標籤中的某一種
  • __大於等於 0:__這就說明是文字、表示式
  • __小於 0:__表示 html 標籤解析完了,可能會剩下一些文字、表示式

parse 函式就是不斷的重複這個工作,然後將 template 轉換成 AST,在解析過程中,其實對於標籤與標籤之間的空格,Vue 也做了優化處理,有些元素之間的空格是沒用的。

compile 其實要說要說非常多的篇幅,但是這裡只能簡單的理一下思路,具體程式碼還需要各位下去深扣。

優化器

從程式碼中的註釋我們可以看出,優化器的目的就是去找出 AST 中純靜態的子樹:

  1. 把純靜態子樹提升為常量,每次重新渲染的時候就不需要建立新的節點了
  2. 在 patch 的時候就可以跳過它們

optimize 的程式碼量沒有 parse 那麼多,我們來看看:

export function optimize (root: ?ASTElement, options: CompilerOptions) {
  // 判斷 root 是否存在
  if (!root) return
  // 判斷是否是靜態的屬性
  // 'type,tag,attrsList,attrsMap,plain,parent,children,attrs'
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  // 判斷是否是平臺保留的標籤,html 或者 svg 的
  isPlatformReservedTag = options.isReservedTag || no
  // 第一遍遍歷: 給所有靜態節點打上是否是靜態節點的標記
  markStatic(root)
  // 第二遍遍歷:標記所有靜態根節點
  markStaticRoots(root, false)
}
複製程式碼

下面兩段程式碼我都剪下了一部分,因為有點多,這裡就不貼太多程式碼了,詳情請參考我的倉庫

第一遍遍歷

function markStatic (node: ASTNode) {
  node.static = isStatic(node)
  if (node.type === 1) {
    ...
  }
}
複製程式碼

其實 markStatic 就是一個遞迴的過程,不斷地去檢查 AST 上的節點,然後打上標記。

剛剛我們說過,AST 節點分三種,在 isStatic 這個函式中我們對不同型別的節點做了判斷:

function isStatic (node: ASTNode): boolean {
  if (node.type === 2) { // expression
    return false
  }
  if (node.type === 3) { // text
    return true
  }
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) &&
    Object.keys(node).every(isStaticKey)
  ))
}
複製程式碼

可以看到 Vue 對下面幾種情況做了處理:

  1. 當這個節點的 type 為 2,也就是表示式節點的時候,很明顯它不是一個靜態節點,所以返回 false
  2. 當 type 為 3 的時候,也就是文字節點,那它就是一個靜態節點,返回 true
  3. 如果你在元素節點中使用了 v-pre 或者使用了 <pre> 標籤,就會在這個節點上加上 pre 為 true,那麼這就是個靜態節點
  4. 如果它是靜態節點,那麼需要它不能有動態的繫結、不能有 v-if、v-for、v-else 這些指令,不能是 slot 或者 component 標籤、不是我們自定義的標籤、沒有父節點或者元素的父節點不能是帶 v-for 的 template、 這個節點的屬性都在 type,tag,attrsList,attrsMap,plain,parent,children,attrs 裡面,滿足這些條件,就認為它是靜態的節點。

接下來,就開始對 AST 進行遞迴操作,標記靜態的節點,至於裡面做了哪些操作,可以到上面那個倉庫裡去看,這裡就不展開了。

第二遍遍歷

第二遍遍歷的過程是標記靜態根節點,那麼我們對靜態根節點的定義是什麼,首先根節點的意思就是他不能是葉子節點,起碼要有子節點,並且它是靜態的。在這裡 Vue 做了一個說明,如果一個靜態節點它只擁有一個子節點並且這個子節點是文字節點,那麼就不做靜態處理,它的成本大於收益,不如直接渲染。

同樣的,我們在函式中不斷的遞迴進行標記,最後在所有靜態根節點上加上 staticRoot 的標記,關於這段程式碼也可以去上面的倉庫看一看。

程式碼生成器

在這個函式中,我們將 AST 轉換成為 render 函式字串,程式碼量還是挺多的,我們可以來看一看。

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  // 這就是編譯的一些引數
  const state = new CodegenState(options)
  // 生成 render 字串
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}
複製程式碼

可以看到在最後程式碼生成階段,最重要的函式就是 genElement 這個函式,針對不同的指令、屬性,我們會選擇不同的程式碼生成函式。最後我們按照 AST 生成拼接成一個字串,如下所示:

with(this){return _c('div',{attrs:{"id":"demo"}},[(1>0)?_c('h1',[_v("Latest Vue.js Commits")]):_e(),...}
複製程式碼

在 render 這個函式字串中,我們會看到一些函式,那麼這些函式是在什麼地方定義的呢?我們可以在 core/instance/index.js 這個檔案中找到這些函式:

// v-once
target._o = markOnce
// 轉換
target._n = toNumber
target._s = toString
// v-for
target._l = renderList
// slot
target._t = renderSlot
// 是否相等
target._q = looseEqual
// 檢測陣列裡是否有相等的值
target._i = looseIndexOf
// 渲染靜態樹
target._m = renderStatic
// 過濾器處理
target._f = resolveFilter
// 檢查關鍵字
target._k = checkKeyCodes
// v-bind
target._b = bindObjectProps
// 建立文字節點
target._v = createTextVNode
// 建立空節點
target._e = createEmptyVNode
// 處理 scopeslot
target._u = resolveScopedSlots
// 處理事件繫結
target._g = bindObjectListeners
// 建立 VNode 節點
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
複製程式碼

在編譯結束後,我們根據不同的指令、屬性等等去選擇需要呼叫哪一個處理函式,最後拼接成一個函式字串。

我們可以很清楚的看到,最後生成了一個 render 渲染字串,那麼我們要如何去使用它呢?其實在後面進行渲染的時候,我們進行了 new Function(render) 的操作,然後我們就能夠正常的使用 render 函式了。

總結

大流程走完之後,我相信大家會對編譯過程有一個比較清晰的認識,然後再去挖細節相信也會容易的多了,讀原始碼,其實並不是一個為了讀而讀的過程,我們可以在原始碼中學到很多我們可能在日常開發中沒有了解到的知識。

至於最後程式碼生成器中的那一大段程式碼,我還沒有把它註釋好,後面應該會將原始碼註釋放到倉庫裡,不過我也相信大家也能夠順利的去讀懂原始碼。

還有一點要提的是在 render 函式中,Vue 使用了 with 函式,我們平時肯定沒見過,因為官方不推薦我們去使用 with,我抱著這樣的想法去找了找原因,最後我在知乎上找到了尤大大的回答,這是連結,大家可以去了解下。

在結束之際,給大家拜個晚年,祝各位同行者在新的一年成為自己心中的自己。

To Be yourself!

最後,借組長的話:願平靜、快樂與你同在!

PS: 如果各位大俠看的可還行,歡迎到我的倉庫給個 star 支援一波,筆芯。

相關文章