Vue render函數

前幾天想學學Vue中怎麼編寫可複用的組件,提到要對Vue的render函數有所瞭解。可仔細一想,對於Vue的render函數自己只是看了官方的一些介紹,並未深入一點去了解這方面的知識。爲了更好的學習後續的知識,又折回來瞭解Vue中的render函數,這一切主要都是爲了後續能更好的學習Vue的知識。

回憶Vue的一些基本概念

今天我們學習的目的是瞭解和學習Vue的render函數。如果想要更好的學習Vue的render函數相關的知識,我們有必要重溫一下Vue中的一些基本概念。那麼先上一張圖,這張圖從宏觀上展現了Vue整體流程:

image

從上圖中,不難發現一個Vue的應用程序是如何運行起來的,模板通過編譯生成AST,再由AST生成Vue的render函數(渲染函數),渲染函數結合數據生成Virtual DOM樹,Diff和Patch後生成新的UI。從這張圖中,可以接觸到Vue的一些主要概念:

  • 模板:Vue的模板基於純HTML,基於Vue的模板語法,我們可以比較方便地聲明數據和UI的關係。
  • AST:AST是Abstract Syntax Tree的簡稱,Vue使用HTML的Parser將HTML模板解析爲AST,並且對AST進行一些優化的標記處理,提取最大的靜態樹,方便Virtual DOM時直接跳過Diff。
  • 渲染函數:渲染函數是用來生成Virtual DOM的。Vue推薦使用模板來構建我們的應用界面,在底層實現中Vue會將模板編譯成渲染函數,當然我們也可以不寫模板,直接寫渲染函數,以獲得更好的控制 (這部分是我們今天主要要了解和學習的部分)。
  • Virtual DOM:虛擬DOM樹,Vue的Virtual DOM Patching算法是基於Snabbdom的實現,並在些基礎上作了很多的調整和改進。
  • Watcher:每個Vue組件都有一個對應的watcher,這個watcher將會在組件render的時候收集組件所依賴的數據,並在依賴有更新的時候,觸發組件重新渲染。你根本不需要寫shouldComponentUpdate,Vue會自動優化並更新要更新的UI。

上圖中,render函數可以作爲一道分割線,render函數的左邊可以稱之爲編譯期,將Vue的模板轉換爲渲染函數render函數的右邊是Vue的運行時,主要是基於渲染函數生成Virtual DOM樹,Diff和Patch。

渲染函數的基礎

Vue推薦在絕大多數情況下使用template來創建你的HTML。然而在一些場景中,需要使用JavaScript的編程能力和創建HTML,這就是render函數,它比template更接近編譯器。

 

<h1>
    <a name="hello-world" href="#hello-world">
        Hello world!
    </a>
</h1>

在HTML層,我們決定這樣定義組件接口:

 

<anchored-heading :level="1">Hello world!</anchored-heading>

當我們開始寫一個通過levelprop動態生成heading標籤的組件,你可能很快想到這樣實現:

 

<!-- HTML -->
<script type="text/x-template" id="anchored-heading-template">
    <h1 v-if="level === 1">
        <slot></slot>
    </h1>
    <h2 v-else-if="level === 2">
        <slot></slot>
    </h2>
    <h3 v-else-if="level === 3">
        <slot></slot>
    </h3>
    <h4 v-else-if="level === 4">
        <slot></slot>
    </h4>
    <h5 v-else-if="level === 5">
        <slot></slot>
    </h5>
    <h6 v-else-if="level === 6">
        <slot></slot>
    </h6>
</script>

<!-- Javascript -->
Vue.component('anchored-heading', {
    template: '#anchored-heading-template',
    props: {
        level: {
            type: Number,
            required: true
        }
    }
})

在這種場景中使用 template 並不是最好的選擇:首先代碼冗長,爲了在不同級別的標題中插入錨點元素,我們需要重複地使用 <slot></slot>

雖然模板在大多數組件中都非常好用,但是在這裏它就不是很簡潔的了。那麼,我們來嘗試使用 render 函數重寫上面的例子:

 

Vue.component('anchored-heading', {
    render: function (createElement) {
        return createElement(
            'h' + this.level,   // tag name 標籤名稱
            this.$slots.default // 子組件中的陣列
        )
    },
    props: {
        level: {
            type: Number,
            required: true
        }
    }
})

簡單清晰很多!簡單來說,這樣代碼精簡很多,但是需要非常熟悉 Vue 的實例屬性。在這個例子中,你需要知道當你不使用 slot 屬性向組件中傳遞內容時,比如 anchored-heading 中的 Hello world!,這些子元素被存儲在組件實例中的 $slots.default中。

節點、樹以及虛擬DOM

對Vue的一些概念和渲染函數的基礎有一定的瞭解之後,我們需要對一些瀏覽器的工作原理有一些瞭解,這樣對我們學習render函數是很重要的。比如下面的這段HTML代碼:

 

<div>
    <h1>My title</h1>
    Some text content
    <!-- TODO: Add tagline -->
</div>

當瀏覽器讀到這些代碼時,它會建立一個DOM節點樹來保持追蹤,如果你會畫一張家譜樹來追蹤家庭成員的發展一樣。

HTML的DOM節點樹如下圖所示:

image

每個元素都是一個節點。每片文字也是一個節點。甚至註釋也都是節點。一個節點就是頁面的一個部分。就像家譜樹一樣,每個節點都可以有孩子節點 (也就是說每個部分可以包含其它的一些部分)。

高效的更新所有這些節點會是比較困難的,不過所幸你不必再手動完成這個工作了。你只需要告訴 Vue 你希望頁面上的 HTML 是什麼,這可以是在一個模板裏:

 

<h1>{{ blogTitle }}</h1>

或者一個渲染函數裏:

 

render: function (createElement) {
    return createElement('h1', this.blogTitle)
}

在這兩種情況下,Vue 都會自動保持頁面的更新,即便 blogTitle 發生了改變。

虛擬DOM

在Vue 2.0中,渲染層的實現做了根本性改動,那就是引入了虛擬DOM。

image

Vue的編譯器在編譯模板之後,會把這些模板編譯成一個渲染函數。而函數被調用的時候就會渲染並且返回一個虛擬DOM的樹

當我們有了這個虛擬的樹之後,再交給一個Patch函數,負責把這些虛擬DOM真正施加到真實的DOM上。在這個過程中,Vue有自身的響應式系統來偵測在渲染過程中所依賴到的數據來源。在渲染過程中,偵測到數據來源之後就可以精確感知數據源的變動。到時候就可以根據需要重新進行渲染。當重新進行渲染之後,會生成一個新的樹,將新的樹與舊的樹進行對比,就可以最終得出應施加到真實DOM上的改動。最後再通過Patch函數施加改動。

簡單點講,在Vue的底層實現上,Vue將模板編譯成虛擬DOM渲染函數。結合Vue自帶的響應系統,在應該狀態改變時,Vue能夠智能地計算出重新渲染組件的最小代價並應到DOM操作上。

image

Vue支持我們通過data參數傳遞一個JavaScript對象做爲組件數據,然後Vue將遍歷此對象屬性,使用Object.defineProperty方法設置描述對象,通過存取器函數可以追蹤該屬性的變更,Vue創建了一層Watcher層,在組件渲染的過程中把屬性記錄爲依賴,之後當依賴項的setter被調用時,會通知Watcher重新計算,從而使它關聯的組件得以更新,如下圖:

image

有關於Vue的響應式相關的內容,可以閱讀下列文章:

對於Vue自帶的響應式系統,並不是咱們今天要聊的東西。我們還是回到Vue的虛擬DOM中來。對於虛擬DOM,咱們來看一個簡單的實例,就是下圖所示的這個,詳細的闡述了模板 → 渲染函數 → 虛擬DOM樹 → 真實DOM的一個過程

image

其實Vue中的虛擬DOM還是很複雜的,我也是一知半解,如果你想深入的瞭解,可以閱讀@JoeRay61的《Vue原理解析之Virtual DOM》一文。

通過前面的學習,我們初步瞭解到Vue通過建立一個虛擬DOM對真實DOM發生的變化保持追蹤。比如下面這行代碼:

 

return createElement('h1', this.blogTitle)

createElement 到底會返回什麼呢?其實不是一個實際的 DOM 元素。它更準確的名字可能是 createNodeDescription,因爲它所包含的信息會告訴 Vue 頁面上需要渲染什麼樣的節點,及其子節點。我們把這樣的節點描述爲“虛擬節點 (Virtual Node)”,也常簡寫它爲“VNode”。“虛擬 DOM”是我們對由 Vue 組件樹建立起來的整個 VNode 樹的稱呼。

Vue組件樹建立起來的整個VNode樹是唯一的。這意味着,下面的render函數是無效的:

 

render: function (createElement) {
    var myParagraphVNode = createElement('p', 'hi')
    return createElement('div', [
        // 錯誤-重複的 VNodes
        myParagraphVNode, myParagraphVNode
    ])
}

如果你真的需要重複很多次的元素/組件,你可以使用工廠函數來實現。例如,下面這個例子 render 函數完美有效地渲染了 20 個重複的段落:

 

render: function (createElement) {
    return createElement('div',
        Array.apply(null, { length: 20 }).map(function () {
            return createElement('p', 'hi')
        })
    )
}

Vue的渲染機制

image

上圖展示的是獨立構建時的一個渲染流程圖。

繼續使用上面用到的模板到真實DOM過程的一個圖:

image

這裏會涉及到Vue的另外兩個概念:

  • 獨立構建:包含模板編譯器,渲染過程HTML字符串 → render函數 → VNode → 真實DOM節點
  • 運行時構建:不包含模板編譯器,渲染過程render函數 → VNode → 真實DOM節點

運行時構建的包,會比獨立構建少一個模板編譯器。在$mount函數上也不同。而$mount方法又是整個渲染過程的起始點。用一張流程圖來說明:

image

由此圖可以看到,在渲染過程中,提供了三種渲染模式,自定義render函數、templateel均可以渲染頁面,也就是對應我們使用Vue時,三種寫法:

自定義render函數

 

Vue.component('anchored-heading', {
    render: function (createElement) {
        return createElement (
            'h' + this.level,   // tag name標籤名稱
            this.$slots.default // 子組件中的陣列
        )
    },
    props: {
        level: {
            type: Number,
            required: true
        }
    }
})

template寫法

 

let app = new Vue({
    template: `<div>{{ msg }}</div>`,
    data () {
        return {
            msg: ''
        }
    }
})

el寫法

 

let app = new Vue({
    el: '#app',
    data () {
        return {
            msg: 'Hello Vue!'
        }
    }
})

這三種渲染模式最終都是要得到render函數。只不過用戶自定義的render函數省去了程序分析的過程,等同於處理過的render函數,而普通的template或者el只是字符串,需要解析成AST,再將AST轉化爲render函數。

記住一點,無論哪種方法,都要得到render函數。

我們在使用過程中具體要使用哪種調用方式,要根據具體的需求來。

如果是比較簡單的邏輯,使用templateel比較好,因爲這兩種都屬於聲明式渲染,對用戶理解比較容易,但靈活性比較差,因爲最終生成的render函數是由程序通過AST解析優化得到的;而使用自定義render函數相當於人已經將邏輯翻譯給程序,能夠勝任複雜的邏輯,靈活性高,但對於用戶的理解相對差點。

理解createElement

在使用render函數,其中還有另一個需要掌握的部分,那就是createElement。接下來我們需要熟悉的是如何在createElement函數中生成模板。那麼我們分兩個部分來對createElement進行理解。

createElement參數

createElement可以是接受多個參數:

第一個參數:{String | Object | Function}

第一個參數對於createElement而言是一個必須的參數,這個參數可以是字符串string、是一個對象object,也可以是一個函數function

 

<div id="app">
    <custom-element></custom-element>
</div>

Vue.component('custom-element', {
    render: function (createElement) {
        return createElement('div')
    }
})

let app = new Vue({
    el: '#app'
})

上面的示例,給createElement傳了一個String參數'div',即傳了一個HTML標籤字符。最後會有一個div元素渲染出來:

image

接着把上例中的String換成一個Object,比如:

 

Vue.component('custom-element', {
    render: function (createElement) {
        return createElement({
            template: `<div>Hello Vue!</div>`
        })
    }
})

上例傳了一個{template: '<div>Hello Vue!</div>'}對象。此時custom-element組件渲染出來的結果如下:

image

除此之外,還可以傳一個Function,比如:

 

Vue.component('custom-element', {
    render: function (createElement) {
        var eleFun = function () {
            return {
                template: `<div>Hello Vue!</div>`
            }
        }
        return createElement(eleFun())
    }
})

最終得到的結果和上圖是一樣的。這裏傳了一個eleFun()函數給createElement,而這個函數返回的是一個對象。

第二個參數:{Object}

createElement是一個可選參數,這個參數是一個Object。來看一個小示例:

 

<div id="app">
    <custom-element></custom-element>
</div>

Vue.component('custom-element', {
    render: function (createElement) {
        var self = this

        // 第一個參數是一個簡單的HTML標籤字符 “必選”
        // 第二個參數是一個包含模板相關屬性的數據對象 “可選”
        return createElement('div', {
            'class': {
                foo: true,
                bar: false
            },
            style: {
                color: 'red',
                fontSize: '14px'
            },
            attrs: {
                id: 'boo'
            },
            domProps: {
                innerHTML: 'Hello Vue!'
            }
        })
    }
})

let app = new Vue({
    el: '#app'
})

最終生成的DOM,將會帶一些屬性和內容的div元素,如下圖所示:

image

第三個參數:{String | Array}

createElement還有第三個參數,這個參數是可選的,可以給其傳一個StringArray。比如下面這個小示例:

 

<div id="app">
    <custom-element></custom-element>
</div>

Vue.component('custom-element', {
    render: function (createElement) {
        var self = this

        return createElement(
            'div', // 第一個參數是一個簡單的HTML標籤字符 “必選”
            {
                class: {
                    title: true
                },
                style: {
                    border: '1px solid',
                    padding: '10px'
                }
            }, // 第二個參數是一個包含模板相關屬性的數據對象 “可選”
            [
                createElement('h1', 'Hello Vue!'),
                createElement('p', '開始學習Vue!')
            ] // 第三個參數是傳了多個子元素的一個數組 “可選”
        )
    }
})

let app = new Vue({
    el: '#app'
})

最終的效果如下:

image

其實從上面這幾個小例來看,不難發現,以往我們使用Vue.component()創建組件的方式,都可以用render函數配合createElement來完成。你也會發現,使用Vue.component()render各有所長,正如文章開頭的一個示例代碼,就不適合Vue.component()template,而使用render更方便。

接下來看一個小示例,看看templaterender方式怎麼創建相同效果的一個組件:

 

<div id="app">
    <custom-element></custom-element>
</div>

Vue.component('custom-element', {
    template: `<div id="box" :class="{show: show}" @click="handleClick">Hello Vue!</div>`,
    data () {
        return {
            show: true
        }
    },
    methods: {
        handleClick: function () {
            console.log('Clicked!')
        }
    }
})

上面Vue.component()中的代碼換成render函數之後,可以這樣寫:

 

Vue.component('custom-element', {
    render: function (createElement) {
        return createElement('div', {
            class: {
                show: this.show
            },
            attrs: {
                id: 'box'
            },
            on: {
                click: this.handleClick
            }
        }, 'Hello Vue!')
    },
    data () {
        return {
            show: true
        }
    },
    methods: {
        handleClick: function () {
            console.log('Clicked!')
        }
    }
})

最後聲明一個Vue實例,並掛載到id#app的一個元素上:

 

let app = new Vue({
    el: '#app'
})

createElement解析過程

簡單的來看一下createElement解析的過程,這部分需要對JS有一些功底。不然看起來有點蛋疼:

 

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {

    // 兼容不傳data的情況
    if (Array.isArray(data) || isPrimitive(data)) {
        normalizationType = children
        children = data
        data = undefined
    }

    // 如果alwaysNormalize是true
    // 那麼normalizationType應該設置爲常量ALWAYS_NORMALIZE的值
    if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
        // 調用_createElement創建虛擬節點
        return _createElement(context, tag, data, children, normalizationType)
    }

    function _createElement (context, tag, data, children, normalizationType) {
        /**
        * 如果存在data.__ob__,說明data是被Observer觀察的數據
        * 不能用作虛擬節點的data
        * 需要拋出警告,並返回一個空節點
        * 
        * 被監控的data不能被用作vnode渲染的數據的原因是:
        * data在vnode渲染過程中可能會被改變,這樣會觸發監控,導致不符合預期的操作
        */
        if (data && data.__ob__) {
            process.env.NODE_ENV !== 'production' && warn(
            `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
            'Always create fresh vnode data objects in each render!',
            context
            )
            return createEmptyVNode()
        }

        // 當組件的is屬性被設置爲一個falsy的值
        // Vue將不會知道要把這個組件渲染成什麼
        // 所以渲染一個空節點
        if (!tag) {
            return createEmptyVNode()
        }

        // 作用域插槽
        if (Array.isArray(children) && typeof children[0] === 'function') {
            data = data || {}
            data.scopedSlots = { default: children[0] }
            children.length = 0
        }

        // 根據normalizationType的值,選擇不同的處理方法
        if (normalizationType === ALWAYS_NORMALIZE) {
            children = normalizeChildren(children)
        } else if (normalizationType === SIMPLE_NORMALIZE) {
            children = simpleNormalizeChildren(children)
        }
        let vnode, ns

        // 如果標籤名是字符串類型
        if (typeof tag === 'string') {
            let Ctor
            // 獲取標籤名的命名空間
            ns = config.getTagNamespace(tag)

            // 判斷是否爲保留標籤
            if (config.isReservedTag(tag)) {
                // 如果是保留標籤,就創建一個這樣的vnode
                vnode = new VNode(
                    config.parsePlatformTagName(tag), data, children,
                    undefined, undefined, context
                )

                // 如果不是保留標籤,那麼我們將嘗試從vm的components上查找是否有這個標籤的定義
            } else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
                // 如果找到了這個標籤的定義,就以此創建虛擬組件節點
                vnode = createComponent(Ctor, data, context, children, tag)
            } else {
                // 兜底方案,正常創建一個vnode
                vnode = new VNode(
                    tag, data, children,
                    undefined, undefined, context
                )
            }

        // 當tag不是字符串的時候,我們認爲tag是組件的構造類
        // 所以直接創建
        } else {
            vnode = createComponent(tag, data, context, children)
        }

        // 如果有vnode
        if (vnode) {
            // 如果有namespace,就應用下namespace,然後返回vnode
            if (ns) applyNS(vnode, ns)
            return vnode
        // 否則,返回一個空節點
        } else {
            return createEmptyVNode()
        }
    }
}

簡單的梳理了一個流程圖,可以參考下

image

這部分代碼和流程圖來自於@JoeRay61的《Vue原理解析之Virtual DOM》一文。

使用JavaScript代替模板功能

在使用Vue模板的時候,我們可以在模板中靈活的使用v-ifv-forv-model<slot>之類的。但在render函數中是沒有提供專用的API。如果在render使用這些,需要使用原生的JavaScript來實現。

v-ifv-for

render函數中可以使用if/elsemap來實現template中的v-ifv-for

 

<ul v-if="items.length">
    <li v-for="item in items">{{ item }}</li>
</ul>
<p v-else>No items found.</p>

換成render函數,可以這樣寫:

 

Vue.component('item-list',{
    props: ['items'],
    render: function (createElement) {
        if (this.items.length) {
            return createElement('ul', this.items.map((item) => {
                return createElement('item')
            }))
        } else {
            return createElement('p', 'No items found.')
        }
    }
})

<div id="app">
    <item-list :items="items"></item-list>
</div>

let app = new Vue({
    el: '#app',
    data () {
        return {
            items: ['大漠', 'W3cplus', 'blog']
        }
    }
})

得到的效果如下:

image

v-model

render函數中也沒有與v-model相應的API,如果要實現v-model類似的功能,同樣需要使用原生JavaScript來實現。

 

<div id="app">
    <el-input :name="name" @input="val => name = val"></el-input>
</div>

Vue.component('el-input', {
    render: function (createElement) {
        var self = this
        return createElement('input', {
            domProps: {
                value: self.name
            },
            on: {
                input: function (event) {
                    self.$emit('input', event.target.value)
                }
            }
        })
    },
    props: {
        name: String
    }
})

let app = new Vue({
    el: '#app',
    data () {
        return {
            name: '大漠'
        }
    }
})

刷新你的瀏覽器,可以看到效果如下:

image

這就是深入底層要付出的,儘管麻煩了一些,但相對於 v-model 來說,你可以更靈活地控制。

插槽

你可以從this.$slots獲取VNodes列表中的靜態內容:

 

render: function (createElement) {
    // 相當於 `<div><slot></slot></div>`
    return createElement('div', this.$slots.default)
}

還可以從this.$scopedSlots中獲得能用作函數的作用域插槽,這個函數返回VNodes:

 

props: ['message'],
render: function (createElement) {
    // `<div><slot :text="message"></slot></div>`
    return createElement('div', [
        this.$scopedSlots.default({
            text: this.message
        })
    ])
}

如果要用渲染函數向子組件中傳遞作用域插槽,可以利用VNode數據中的scopedSlots域:

 

<div id="app">
    <custom-ele></custom-ele>
</div>

Vue.component('custom-ele', {
    render: function (createElement) {
        return createElement('div', [
            createElement('child', {
                scopedSlots: {
                    default: function (props) {
                        return [
                            createElement('span', 'From Parent Component'),
                            createElement('span', props.text)
                        ]
                    }
                }
            })
        ])
    }
})

Vue.component('child', {
    render: function (createElement) {
        return createElement('strong', this.$scopedSlots.default({
            text: 'This is Child Component'
        }))
    }
})

let app = new Vue({
    el: '#app'
})

JSX

如果寫習慣了template,然後要用render函數來寫,一定會感覺好痛苦,特別是面對複雜的組件的時候。不過我們在Vue中使用JSX可以讓我們回到更接近於模板的語法上。

 

import AnchoredHeading from './AnchoredHeading.vue'

new Vue({
    el: '#demo',
    render: function (h) {
        return (
            <AnchoredHeading level={1}>
                <span>Hello</span> world!
            </AnchoredHeading>
        )
    }
})

h 作爲 createElement 的別名是 Vue 生態系統中的一個通用慣例,實際上也是 JSX 所要求的,如果在作用域中 h 失去作用,在應用中會觸發報錯。

總結

回過頭來看,Vue中的渲染核心關鍵的幾步流程還是非常清晰的:

  • new Vue,執行初始化
  • 掛載$mount方法,通過自定義render方法、templateel等生成render函數
  • 通過Watcher監聽數據的變化
  • 當數據發生變化時,render函數執行生成VNode對象
  • 通過patch方法,對比新舊VNode對象,通過DOM Diff算法,添加、修改、刪除真正的DOM元素

至此,整個new Vue的渲染過程完畢。

而這篇文章,主要把精力集中在render函數這一部分。學習了怎麼用render函數來創建組件,以及瞭解了其中createElement

最後要說的是,上文雖然以學習render函數,但文中涉及了Vue不少的知識點,也有點零亂。初學者自己根據自己獲取所要的知識點。由於本人也是初涉Vue相關的知識點,如果文章中有不對之處,煩請路過的大神拍正。

此文轉載於:[https://www.w3cplus.com/vue/vue-render-function.html]



作者:kangaroo_v
鏈接:https://www.jianshu.com/p/7508d2a114d3
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章