vue 快速入門 系列 —— 模板

彭加李發表於2022-01-12

其他章節請看:

vue 快速入門 系列

模板

前面提到 vue 中的虛擬 dom 主要做兩件事:

  1. 提供與真實節點對應的 vNode
  2. 新舊 vNode 對比,尋找差異,然後更新檢視

①、vNode 從何而來?

前面也說了宣告式框架只需要我們描述狀態dom 之間的對映關係。狀態檢視的轉換,框架會給我們做。

②、用什麼描述狀態與 dom 之間的對映關係?

Tip:jQuery 是命令式的框架,現代的 vue、react屬於宣告式框架。

簡介

首先公佈問題 ② 的答案:用模板描述狀態與 dom 之間的對映關係。

於是我們知道這三者之間的關係:

graph LR 狀態 --> 模板 --> dom

模板編譯器

請先看一個模板的示例:

<span>Message: {{ msg }}</span>

<h1 v-if="awesome">Vue is awesome!</h1>

<ul id="example-1">
  <li v-for="item in items" :key="item.message">
    {{ item.message }}
  </li>
</ul>

v-ifv-for{{}} 是什麼?html 中根本不存在這些東西。

我們知道 javascript 程式碼只有 javascript 引擎認識,同理,模板也只有類似模板引擎的東西認識它。

在 vue 中,類似模板引擎的叫做模板編譯器。通過模板編譯器將模板編譯成渲染函式,而執行渲染函式就會使用當前最新的狀態生成一份 vnode

graph LR 模板 -- 編譯 --> 渲染函式 -- 執行 --> vNode

至此,問題 ① 的答案顯而易見了,vNode 由渲染函式生成

模板和虛擬 dom 所處位置

我們根據上文,能輕易的知道模板所處位置:

flowchart LR 狀態 --> 模板 subgraph a[模板] 模板 -- 編譯 --> 渲染函式 -- 執行 --> vNode end vNode --> 檢視

虛擬 dom 的作用 中,我們知道虛擬 dom 所處位置:

flowchart LR 狀態 --> a subgraph a[虛擬 dom] vNode patch end a --> 檢視

最後,我們將這兩個圖合併成一個即可:

flowchart LR 狀態 --> 模板 subgraph a[模板] 模板 -- 編譯 --> 渲染函式 end 渲染函式 -- 執行 --> b subgraph b[虛擬 dom] vNode patch end b --> 檢視

Tip: 將渲染函式指向虛擬 dom,是因為 vue 官網有這麼一句話:“虛擬 DOM”是我們對由 Vue 元件樹建立起來的整個 VNode 樹的稱呼

模板是如何編譯成渲染函式,以及為什麼執行渲染函式就可以生成 vNode?請繼續看下文。

渲染函式

將模板編譯成渲染函式,只需要 3 步:

  1. 解析器:將HTML字串轉換為 AST
    • AST 就是一個普通的 javascript 物件,描述了該節點的資訊以及子節點的資訊,類似 vNode
  2. 優化器:遍歷 AST,標記靜態節點,用於提高效能
    • <p>hello</p> 是靜態節點,渲染之後不會再改變
    • <p>{{hello}}</p> 不是靜態節點,因為狀態會影響它
  3. 生成器:使用 AST 生成渲染函式
    • 執行渲染函式就會根據現在的狀態生成一份虛擬 dom(vNode

為什麼是這 3 步?不重要,這只是一種演算法而已。

Tip:倘若我們能理解這 3 步確實能將模板編譯成渲染函式,而渲染函式執行後能生成 vNode。那麼 vue 中模板這一部分,也算是入門了。

分析

我們採用最直接的方法,即執行一段程式碼,看看 AST 是什麼?優化器做了什麼?渲染函式是什麼?渲染函式又是如何生成 vNode 的?

程式碼很簡單,一個 html 頁面,裡面引入 vue.js,然後在 vue.js 中打上一個斷點(輸入 debugger),最後執行 test.html

// test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src='vue.js'></script>
</head>
<body>
<!-- 模板 -->
<div id='app'>
    <p title='testTitle' @click='say'>number: {{num}}</p>
</div>
<!-- /模板 -->

<script>
const app = new Vue({
    el: '#app',
    data: {
        num: 0
    },
    methods: {
      say(){
        this.num += 1;
      }
    }
})
</script>
</body>
</html>
// vue.js
// 打上斷點(行{1})

  var createCompiler = createCompilerCreator(function baseCompile (
    template,
    options
  ) {
    debugger // {1}
    // 解析器
    var ast = parse(template.trim(), options);
    if (options.optimize !== false) {
      // 優化器
      optimize(ast, options);
    }
    // 生成器
    var code = generate(ast, options);
    return {
      ast: ast,
      render: code.render,
      staticRenderFns: code.staticRenderFns
    }
  });

AST

執行完 var ast = parse(template.trim(), options);ast 為:

// ast:
{
    "type":1,
    "tag":"div",
    "attrsList":[
        {
            "name":"id",
            "value":"app"
        }
    ],
    "attrsMap":{
        "id":"app"
    },
    "children":[
        {
            "type":1,
            "tag":"p",
            "attrsList":[
                {
                    "name":"title",
                    "value":"testTitle"
                },
                {
                    "name":"@click",
                    "value":"say"
                }
            ],
            "attrsMap":{
                "title":"testTitle",
                "@click":"say"
            },
            "children":[
                {
                    "type":2,
                    "expression":"'number: '+_s(num)",
                    "tokens":[
                        "number: ",
                        {
                            "@binding":"num"
                        }
                    ],
                    "text":"number: {{num}}"
                }
            ],
            "plain":false,
            "attrs":[
                {
                    "name":"title",
                    "value":"testTitle"
                }
            ],
            "hasBindings":true,
            "events":{
                "click":{
                    "value":"say"
                }
            }
        }
    ],
    "plain":false,
    "attrs":[
        {
            "name":"id",
            "value":"app"
        }
    ]
}

於是我們知道,AST 就是一個普通的 javascript 物件,類似虛擬節點或 dom Node,裡面有節點的型別、屬性、子節點等等。

優化器的作用

將 ast 交給優化器處理後(optimize(ast, options);),ast 為:

// 優化器:(在上一步的基礎上增加 static 和 staticRoot 兩個屬性)
{
    "type":1,
    "tag":"div",
    "attrsList":[
        {
            "name":"id",
            "value":"app"
        }
    ],
    "attrsMap":{
        "id":"app"
    },
    "children":[
        {
            "type":1,
            "tag":"p",
            "attrsList":[
                {
                    "name":"title",
                    "value":"testTitle"
                },
                {
                    "name":"@click",
                    "value":"say"
                }
            ],
            "attrsMap":{
                "title":"testTitle",
                "@click":"say"
            },
            "children":[
                {
                    "type":2,
                    "expression":"'number: '+_s(num)",
                    "tokens":[
                        "number: ",
                        {
                            "@binding":"num"
                        }
                    ],
                    "text":"number: {{num}}",
                    "static":false
                }
            ],
            "plain":false,
            "attrs":[
                {
                    "name":"title",
                    "value":"testTitle"
                }
            ],
            "hasBindings":true,
            "events":{
                "click":{
                    "value":"say"
                }
            },
            "static":false,
            "staticRoot":false
        }
    ],
    "plain":false,
    "attrs":[
        {
            "name":"id",
            "value":"app"
        }
    ],
    "static":false,
    "staticRoot":false
}

優化器給 ast 增加 staticstaticRoot 兩個屬性,用於標記靜態節點。

生成器

接著將 ast 交給生成器處理(var code = generate(ast, options);),code 為:

// code
{"render":"with(this){return _c('div',{attrs:{\"id\":\"app\"}},[_c('p',{attrs:{\"title\":\"testTitle\"},on:{\"click\":say}},[_v(\"number: \"+_s(num))])])}","staticRenderFns":[]}

code.render 字串格式化:

// code.render
with(this) {
    return _c(
        'div', 
        {
            attrs: {
                "id": "app"
            }
        },
        [
            _c(
                'p', 
                {
                    attrs: {
                        "title": "testTitle"
                    },
                    on: {
                        "click": say
                    }
                },
                [
                    _v("number: " + _s(num))
                ]
            )
        ]
    )
}

code.render 這個字串匯出到外界,會放到一個函式中,這個函式就是渲染函式

不理解?沒關係,我們先看另一個示例:

new Function ([arg1[, arg2[, ...argN]],] functionBody)

const obj = {name: 'ph'}
const code = `with(this){console.log('hello: ' + name)}`
const renderFunction = new Function(code)
renderFunction.call(obj)

// 等同於

const obj = {name: 'ph'}
function renderFunction(){
  with(this){console.log('hello: ' + name)}
}
renderFunction.call(obj) // hello: ph

這下理解了吧。我們將 code.render 指向的字串匯出到外界,外界利用 new Function() 建立渲染函式。

前面提到執行渲染函式會生成 vNode。看看 code.render 就能知曉,裡面出現的 _v_c,分別用於生成元素型別的 vNode 和文字型別的 vNode。請看相關原始碼:

// 建立文字型別的 vNode
target._v = createTextVNode;
function createTextVNode (val) {
    return new VNode(undefined, undefined, undefined, String(val))
}

// 建立元素型別的 vNode
vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
function createElement (
    context,
    tag,
    data,
    children,
    normalizationType,
    alwaysNormalize
  ) {
    ...
    return _createElement(context, tag, data, children, normalizationType)
  }

Tip: 關於 vue 中解析器、優化器和生成器裡面具體是如何實現的,本系列就不展開了。

其他章節請看:

vue 快速入門 系列

相關文章