Vue原理之虚拟DOM和render函数

相关vue原理之虚拟dom参考资料:javascript

1.如何实现一个 Virtual DOM 算法
html

2.《Vue原理解析之Virtual Dom》vue

3. Vue官方《渲染函数文档》java

4. Vue渲染函数源码解析node

最近学习Vue过程当中对虚拟Dom以及Render函数原理不是很理解,虽然官方有介绍,但仍是没能深刻理解,因此找了点资料来补补,这一切都是为了深刻掌握Vue知识。react

-----------分割线--------------
git

回忆Vue的一些基本概念

今天咱们学习的目的是了解和学习Vue的render函数。若是想要更好的学习Vue的render函数相关的知识,咱们有必要重温一下Vue中的一些基本概念。那么先上一张图,这张图从宏观上展示了Vue总体流程:github

从上图中,不难发现一个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节点树以下图所示:

每一个元素都是一个节点。每片文字也是一个节点。甚至注释也都是节点。一个节点就是页面的一个部分。就像家谱树同样,每一个节点均可以有孩子节点 (也就是说每一个部分能够包含其它的一些部分)。

高效的更新全部这些节点会是比较困难的,不过所幸你没必要再手动完成这个工做了。你只须要告诉 Vue 你但愿页面上的 HTML 是什么,这能够是在一个模板里:

<h1>{{ blogTitle }}</h1>
复制代码

或者一个渲染函数里:

render: function (createElement) {
    return createElement('h1', this.blogTitle)
}
复制代码

在这两种状况下,Vue 都会自动保持页面的更新,即使 blogTitle 发生了改变。

虚拟DOM

在Vue 2.0中,渲染层的实现作了根本性改动,那就是引入了虚拟DOM。

Vue的编译器在编译模板以后,会把这些模板编译成一个渲染函数。而函数被调用的时候就会渲染而且返回一个虚拟DOM的树

当咱们有了这个虚拟的树以后,再交给一个Patch函数,负责把这些虚拟DOM真正施加到真实的DOM上。在这个过程当中,Vue有自身的响应式系统来侦测在渲染过程当中所依赖到的数据来源。在渲染过程当中,侦测到数据来源以后就能够精确感知数据源的变更。到时候就能够根据须要从新进行渲染。当从新进行渲染以后,会生成一个新的树,将新的树与旧的树进行对比,就能够最终得出应施加到真实DOM上的改动。最后再经过Patch函数施加改动。

简单点讲,在Vue的底层实现上,Vue将模板编译成虚拟DOM渲染函数。结合Vue自带的响应系统,在应该状态改变时,Vue可以智能地计算出从新渲染组件的最小代价并应到DOM操做上。

Vue支持咱们经过data参数传递一个JavaScript对象作为组件数据,而后Vue将遍历此对象属性,使用Object.defineProperty方法设置描述对象,经过存取器函数能够追踪该属性的变动,Vue建立了一层Watcher层,在组件渲染的过程当中把属性记录为依赖,以后当依赖项的setter被调用时,会通知Watcher从新计算,从而使它关联的组件得以更新,以下图:

有关于Vue的响应式相关的内容,能够阅读下列文章:

对于Vue自带的响应式系统,并非我们今天要聊的东西。咱们仍是回到Vue的虚拟DOM中来。对于虚拟DOM,我们来看一个简单的实例,就是下图所示的这个,详细的阐述了模板 → 渲染函数 → 虚拟DOM树 → 真实DOM的一个过程

其实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的渲染机制

上图展现的是独立构建时的一个渲染流程图。

继续使用上面用到的模板到真实DOM过程的一个图:

这里会涉及到Vue的另外两个概念:

  • 独立构建:包含模板编译器,渲染过程HTML字符串 → render函数 → VNode → 真实DOM节点
  • 运行时构建:不包含模板编译器,渲染过程render函数 → VNode → 真实DOM节点

运行时构建的包,会比独立构建少一个模板编译器。在$mount函数上也不一样。而$mount方法又是整个渲染过程的起始点。用一张流程图来讲明:

由此图能够看到,在渲染过程当中,提供了三种渲染模式,自定义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元素渲染出来:

接着把上例中的String换成一个Object,好比:

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

上例传了一个{template: '<div>Hello Vue!</div>'}对象。此时custom-element组件渲染出来的结果以下:

除此以外,还能够传一个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元素,以下图所示:

第三个参数:{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'
})
复制代码

最终的效果以下:

其实从上面这几个小例来看,不难发现,以往咱们使用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()
        }
    }
}
复制代码

简单的梳理了一个流程图,能够参考下

这部分代码和流程图来自于@JoeRay61的《Vue原理解析之Virtual DOM》一文。

使用JavaScript代替模板功能

在使用Vue模板的时候,咱们能够在模板中灵活的使用v-ifv-forv-model<slot>之类的。但在render函数中是没有提供专用的API。若是在render使用这些,须要使用原生的JavaScript来实现。

v-if和v-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']
        }
    }
})
复制代码

获得的效果以下:

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: '大漠'
        }
    }
})
复制代码

刷新你的浏览器,能够看到效果以下:

这就是深刻底层要付出的,尽管麻烦了一些,但相对于 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相关的知识点,若是文章中有不对之处,烦请路过的大神拍正。

原文转自: 大漠