🚩Vue源码——模板和数据如何渲染成最终的DOM

4,046 阅读9分钟

最近参加了很多场面试,几乎每场面试中都会问到Vue源码方面的问题。在此开一个系列的专栏,来总结一下这方面的经验,如果觉得对您有帮助的,不妨点个赞支持一下呗。

前言

面试中为什么会问Vue源码,很多人一般都这个疑问,Vue源码在平常工作中几乎很少使用到,而Vue的API在工作中经常用到。会用Vue的API不就可以满足工作岗位的需求。这岂不是面试造火箭,工作拧螺丝嘛。其实不是真正要问你Vue源码,只是借助Vue源码来考核你的JavaScript基础是否扎实,比如JS运行机制、作用域、闭包、原型链、递归、柯里化等知识的掌握程度。从而来区分面试者的能力等级。所以在高级前端工程师的面试中掌握一个框架的源码是必不可少的

一、怎么阅读源码

阅读源码是有一定技巧的,如果直接拿起源码直接看,保证你未入门就放弃。

1、抓住主线,暂时忽略支线

罗马不是一天就建成的,代码也不是一天就写好的。任何代码都是根据应用场景来开发的,随着应用场景不断添加,代码也不断地完善。例如要理解Vue中模板和数据如何渲染成最终的DOM。首先要写一个最简单demo,基于这个设定的场景来研究 。 研究过程中,跟设定的场景相关的代码就是主线,跟设定的场景无关的代码就是支线。

demo代码如下

<!DOCTYPE html>
<html>
    <head>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    </head>
    <body>
        <div id="app">
          <p>{{aa}}<span>{{bb}}</span></p>
        </div>
    </body>
    <script>
        var app = new Vue({
            el: '#app',
            data(){
            	return{
                  aa:'欢迎',
                  bb:'Vue'
                }
            }
        })
    </script>
</html>

2、利用chrome的开发者工具进行断点调试

断点调试是一个高级前端工程师必须掌握的技能。本文用断点调试来阅读Vue源码,介绍在Vue中模板和数据如何渲染成最终的DOM。

3、逻辑流程图

先上一张逻辑流程图,下面将按此图来介绍在Vue中模板和数据如何渲染成最终的DOM。

二、new Vue()

使用Vue,首先要new Vue(),那么 Vue 是个类的构造器。

function Vue(options) {
    if (!(this instanceof Vue)) {
        warn('Vue is a constructor and should be called with the `new` keyword');
    }
    this._init(options);
}

可以看到Vue只能通过new关键字调用,如果不是用new调用,就会在控制台打印出一个警告,意思为Vue是一个构造函数,应使用“new”关键字调用。

执行this._init(options),进行Vue的初始化,在这里打个断点,按F11进入this._init方法中执行

三、this._init

Vue.prototype._init = function(options) {
    var vm = this;
    if (options && options._isComponent) {
    	//...
    } else {
        vm.$options = mergeOptions(
          resolveConstructorOptions(vm.constructor),
          options || {},
          vm
        );
    }
    initProxy(vm);
    initRender(vm);
    initState(vm);
    if (vm.$options.el) {
    	vm.$mount(vm.$options.el);
    }
}

执行 mergeOptions合并 options和各种初始化后,在设定的场景中vm.$options.el的值为#app,故执行vm.$mount(vm.$options.el),调用vm.$mount方法挂载实例到 DOM 上,接下来分析 Vue 的挂载过程。

vm.$mount(vm.$options.el)此处打个断点,按F11进入vm.$mount方法中执行

1、mergeOptions

// 合并options
if (options && options._isComponent) {
} else {
    vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
    );
}

其中options._isComponenttrue是组件的意思,显然设定的场景不是组件,所以为false,走else部分代码。

先不管mergeOptions的内部逻辑,只需记住合并后可以用vm.$options访问到通过new Vue(options)传进来的参数options就行。

2、initProxy

function initProxy(vm) {
    if (hasProxy) {
        var options = vm.$options;
        var handlers = options.render && options.render._withStripped ? 
            getHandler : hasHandler;
        vm._renderProxy = new Proxy(vm, handlers);
    } else {
        vm._renderProxy = vm;
    }
}

vm用Proxy做了个代理,实现通过vm访问不存在的数据会报错提示,这里不做详细解释。

这里要记住,vm做完代理后赋值给vm._renderProxy,访问vm._renderProxy相当访问vm,在后续生成vnode(Virtual DOM )过程中有用到。

3、initRender

function initRender(vm) {
    vm._vnode = null;
    vm._c = function(a, b, c, d) {
        return createElement(vm, a, b, c, d, false);
    };
    vm.$createElement = function(a, b, c, d) {
        return createElement(vm, a, b, c, d, true);
    };
}

这里要记住,定义了vm._cvm.$createElement两个方法,在后续生成vnode过程中有用到

4、initState()

function initState(vm) {
    vm._watchers = [];
    var opts = vm.$options;
    if (opts.props) {
        initProps(vm, opts.props);
    }
    if (opts.methods) {
        initMethods(vm, opts.methods);
    }
    if (opts.data) {
        initData(vm);
    } else {
        observe(vm._data = {}, true /* asRootData */ );
    }
    if (opts.computed) {
        initComputed(vm, opts.computed);
    }
    if (opts.watch && opts.watch !== nativeWatch) {
        initWatch(vm, opts.watch);
    }
}

在这里初始化了propsmethodsdatacomputedwatch,在设定的场景中,只要了解初始化data的过程

设定的场景中opts.data存在,故执行initData(vm)

function initData(vm) {
    var data = vm.$options.data;
    data = vm._data = typeof data === 'function' ?
        getData(data, vm) :
        data || {};
    if (!isPlainObject(data)) {
        data = {};
        warn(
            'data functions should return an object:\n' +
            'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
            vm
        );
    }

    var keys = Object.keys(data);
    var props = vm.$options.props;
    var methods = vm.$options.methods;
    var i = keys.length;
    while (i--) {
        var key = keys[i]; {
            if (methods && hasOwn(methods, key)) {
                warn(//..);
            }
        }
        if (props && hasOwn(props, key)) {
            warn(//...);
        } else if (!isReserved(key)) {
            proxy(vm, "_data", key);
        }
    }
    observe(data, true /* asRootData */ );
}
function getData(data, vm) {
    pushTarget();
    try {
        return data.call(vm, vm)
    } catch (e) {
        handleError(e, vm, "data()");
        return {}
    } finally {
        popTarget();
    }
}
function proxy(target, sourceKey, key) {
    sharedPropertyDefinition.get = function proxyGetter() {
        return this[sourceKey][key]
    };
    sharedPropertyDefinition.set = function proxySetter(val) {
        this[sourceKey][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
}
new Vue({
    el: '#app',
    data() {
        return {
            aa: '欢迎',
            bb: 'Vue'
        }
    }
})

initData(vm)中,获取vm.$options中的 data 属性,也就是 Vue 的选项 data,如果 data 是个函数,用getData方法来获取返回的值,并赋值给vm._data,再对vm._data,做是否是对象且 key 值不能和选项props、选项methods的 key 相同的判断。

接着用isReserved方法对vm._data的数据进行过滤,不是以$_开头的,用proxy方法对其做代理,在其中使用Object.defineProperty劫持数据,例如访问vm.aa,实际是访问vm._data.aa代理完,访问vm就能访问到vm._data的值。

这就是为什么能用vm.aa访问到定义在 data 选项中的 aa,这点要记住,在生成vnode(Virtual DOM )过程中有用到

四、vm.$mount

const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function(el, hydrating) {
    el = el && query(el);
    if (el === document.body || el === document.documentElement) {
        warn("Do not mount Vue to <html> or <body> - mount to normal elements instead.");
        return this
    }
    var options = this.$options;
    if (!options.render) {
        var template = options.template;
        if (template) {
            //...
        } else if (el) {
            template = getOuterHTML(el);
        }
    }
    if (template) {
        var ref = compileToFunctions(template, {
            outputSourceRange: "development" !== 'production',
            shouldDecodeNewlines: shouldDecodeNewlines,
            shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
            delimiters: options.delimiters,
            comments: options.comments
        }, this);
        var render = ref.render;
        var staticRenderFns = ref.staticRenderFns;
        options.render = render;
        options.staticRenderFns = staticRenderFns;
    }
    return mount.call(this, el, hydrating)
}

为什么先把Vue.prototype.$mount赋值给mount,再重新定义Vue.prototype.$mount。是因为$mount方法是和平台、构建方式相关的。在不同的环境下$mount方法是不同的。所以要缓存原先原型上的$mount方法,根据场景定义不同的$mount方法,最后再调用原先原型上的$mount方法。这种技术叫做函数劫持

1、compileToFunctions

if (template) {
    var ref = compileToFunctions(template, {
        outputSourceRange: "development" !== 'production',
        shouldDecodeNewlines: shouldDecodeNewlines,
        shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
    }, this);
    var render = ref.render;
    options.render = render;
}

在设定的场景中没有定义render方法,而 Vue 中需要render方法来生成 vnode(Virtual DOM ),故要用compileToFunctions方法来生成render方法,并挂载到vm.$options

compileToFunctions方法中,要用到模板template,在设定的场景中,没有定义模板template,只定义了挂载目标el。那么要先通过el来生成template

el = el && query(el);
if (el === document.body || el === document.documentElement) {
    warn("Do not mount Vue to <html> or <body> - mount to normal elements instead.");
    return this
}
var options = this.$options;
if (!options.render) {
    var template = options.template;
    if (template) {
        //...
    } else if (el) {
        template = getOuterHTML(el);
    }
}

query方法获得el对应的DOM对象再赋值给el,对el做了判断,Vue 不能挂载在 body、html 这样的根节点上。

this.$options.template不存在,故执行template = getOuterHTML(el)template的值为el对应的HTML内容。

2、mountComponent

vm.$mount的最后执行return mount.call(this, el, hydrating),调用原先原型上的$mount方法,在此处打个断点,按F11进入原先原型上的$mount方法中

Vue.prototype.$mount = function(el,hydrating) {
    el = el && inBrowser ? query(el) : undefined;
    return mountComponent(this, el, hydrating)
};

在原先原型上的$mount方法中,最后调用mountComponent方法,参数hydrating和服务端渲染相关,在浏览器环境中为false,可忽略不看。

return mountComponent(this, el, hydrating)在此处打个断点,按F11进入mountComponent方法中

function mountComponent(vm,el,hydrating) {
    vm.$el = el;
    var updateComponent;
    updateComponent = function() {
        vm._update(vm._render(), hydrating);
    };
    new Watcher(vm, updateComponent, noop, {
        before: function before() {
            if (vm._isMounted && !vm._isDestroyed) {
                callHook(vm, 'beforeUpdate');
            }
        }
    }, true /* isRenderWatcher */ );
    hydrating = false;
    if (vm.$vnode == null) {
        vm._isMounted = true;
        callHook(vm, 'mounted');
    }
    return vm
}

此时el是挂载目标的 DOM 对象,赋值给vm.$el这里要记住vm.$el是 Vue 实例 挂载目标的 DOM 对象

实例化一个渲染Watcher,其第二参数是回调函数的意思。在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数。

回调函数是updateComponent函数,执行回调函数相当执行vm._update(vm._render(), hydrating), 其中vm._render()作为一个参数故先执行,vm._update(vm._render(), hydrating)方法处打个断点,按F11进入vm._render()方法中

五、vm._render

vm._render主要作用是生成vnode(Virtual DOM 树)并返回。

Vue.prototype._render = function(){
    var vm = this;
    var ref = vm.$options;
    var render = ref.render;
    var _parentVnode = ref._parentVnode;
    if (_parentVnode) {
        //...
    }
    vm.$vnode = _parentVnode;
    var vnode;
    try{
        vnode = render.call(vm._renderProxy, vm.$createElement);
    }catch(e){
        //...
    }
    return vnode
}

vm.$vnode表示 Vue 实例的父 Virtual DOM, 在设定的场景中,_parentVnode为undefined,故vm.$vnode为undefined。

render方法是在vm.$mount中通过compileToFunctions方法生成的,代码如下。

(function anonymous() {
    with(this) {
        return _c('div', {
            attrs: {
                "id": "app"
            }
        }, [_c('p', [_v(_s(aa)), _c('span', [_v(_s(bb))])])])
    }
})

with语句的作用是为一个或一组语句指定默认对象,例with(this){ a + b } 等同 this.a + this.b

因为render是通过call(vm._renderProxy, vm.$createElement)来调用,所以thisvm._renderProxy,在前面介绍initProxyvm._renderProxy 等同 vm, 故这里的this就是vm

那么render方法也等同如下代码

function (){
    return vm._c('div', {
            attrs: {
                "id": "app"
            }
        }, [vm._c('p', 
            [	
                vm._v(vm._s(vm.aa)), 
                vm._c('span', [vm._v(this._s(vm.bb))])
            ]
        )]
    )
}

vm._c在前面介绍initRender中定义的,同时定义的还有vm.$createElement,在设定的场景中用不到。

vm._c = function(a, b, c, d) { return createElement(vm, a, b, c, d, false) }

vm._cvm.$createElement都是内部调用createElement方法,两者只差最后一个参数是truefalse

vm._c是给模板编译成的 render 方法使用,vm.$createElement是用户手写 render 方法使用的。

const vm = new Vue({
    el:'#app',
    render: h => h(App)
})

上述代码中的h就是vm.$createElement

vnode = render.call(vm._renderProxy, vm.$createElement),在此处打个断点,按两次F11进入createElement方法

var SIMPLE_NORMALIZE = 1;
var ALWAYS_NORMALIZE = 2;
function createElement(context, tag, data, children, normalizationType, alwaysNormalize) {
    if (Array.isArray(data) || isPrimitive(data)) {
        normalizationType = children;
        children = data;
        data = undefined;
    }
    if (isTrue(alwaysNormalize)) {
        normalizationType = ALWAYS_NORMALIZE;
    }
    return _createElement(context, tag, data, children, normalizationType);
}
  • 参数context:上下文对象,即this;
  • 参数tag:一个 HTML 标签名、组件选项对象;
  • 参数data: 节点的数据对象;
  • 参数children:子级虚拟节点 (VNodes),也可以使用字符串来生成“文本虚拟节点”。
  • 参数normalizationType alwaysNormalize:控制用那种方式处理children,设定的场景用不到,忽略不看。
if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children;
    children = data;
    data = undefined;
}

处理一下参数传递兼容问题,判断第二个参数data是否为数组或者基础类型,若是则表明第二参数data应该为第三参数children,所以将children赋值给normalizationType,再将data赋值给children,再将data置为undefined

最后调用_createElement方法,在此处打个断点,按F11进入_createElement方法

1、_createElement

function _createElement(context, tag, data, children, normalizationType){
  var vnode;
  if (typeof tag === 'string') {
      if (config.isReservedTag(tag)) {
          vnode = new VNode(config.parsePlatformTagName(tag), data, 
          children, undefined, undefined, context);
      }
  }
  return vnode
}

忽略前面对参数一系列的判断逻辑,又因为vm._c方法的最后一个参数是flase,所以normalizationTypeundefined,不会去处理 children,直接来看核心逻辑。

设定的场景中,tag是字符串,经过config.isReservedTag(tag)判断tag是HTML的保留字符,执行new VNode(config.parsePlatformTagName(tag), data, children, undefined, undefined, context) 实例化 VNode 生成一个vnode

2、new VNode()

new VNode()的作用是实例化 VNode 生成一个vnode,即是 Virtual DOM。简单介绍一下Virtual DOM,浏览器中的 DOM 对象是非常庞大的,例如,控制台输入回车可以看到,DOM 对象是非常庞大的。浏览器标准把 DOM 对象设计的非常复杂。所以当我们频繁的操作 DOM 对象,会产生一定的性能问题。而Virtual DOM 用一个原生的 JS 对象去描述一个 DOM 对象,没有包含操作 DOM 的方法,所以操作它比操作 DOM 对象代价小得多。

const div = document.createElement('div');
let str= ''
for(let key in div){
    str += key + ';'
}

function VNode(tag, data, children, text, elm, context, componentOptions, asyncFactory) {
    this.tag = tag;
    this.data = data;
    this.children = children;
    this.text = text;
    this.elm = elm;
    this.context = context;
    this.key = data && data.key;
    this.parent = undefined;
}

在设定的场景中,只要关注 VNode 构造函数中这几个的属性,其他先忽略不看

  • tag:标签名
  • data:数据
  • children:子vnode
  • text:当前节点的文本
  • elm:对应的真实DOM对象
  • parent:父vnode

在设定的场景中,生成的 DOM 对象,如下图所示,是个树状结构,那么对应的 Virtual DOM 也应是个树状结构,那么跟VNode函数的第三参数children有关,children是通过_createElement函数的第四参数children传递过来,_createElement实际上在生成的render方法中调用,回到生成的render方法(对render方法做了等同处理)中。

function (){
    return vm._c('div', {
        attrs: {
                "id": "app"
            }
        }, [vm._c('p', 
            [	
                vm._v(vm._s(vm.aa)), 
                vm._c('span', [vm._v(this._s(vm.bb))])
            ]
        )]
    )
}

执行函数时,遇到参数是函数的,要先执行。

  • 执行vm._c('div'..时,遇到参数[vm._c('p', [vm._v(vm._s(vm.aa)), vm._c('span', [vm._v(vm._s(vm.bb))])])]

  • 执行vm._c('p', [vm._v(vm._s(vm.aa)), vm._c('span', [vm._v(vm._s(vm.bb))])]),遇到参数[vm._v(vm._s(vm.aa)), vm._c('span', [vm._v(vm._s(vm.bb))])]

  • 执行vm._v(vm._s(vm.aa))vm._c('span', [vm._v(vm._s(vm.bb))]),遇到参数vm._s(vm.aa)[vm._v(vm._s(vm.bb))]

  • 执行vm._s(vm.aa)vm._v(vm._s(vm.bb)),遇到参数vm._s(vm.bb)

  • 执行vm._s(vm.bb)

按上面的分析,首先要执行的是vm._s(vm.aa)vm._s(vm.bb),接着执行vm._v(vm._s(vm.bb))

installRenderHelpers函数中定义vm._vvm._s。在加载 Vue.js 时,会执行renderMixin(Vue),在其中会执行installRenderHelpers(Vue.prototype)Vue.prototype也就是vm。所以vm._v对应是createTextVNode方法,vm._s对应是toString方法。

function renderMixin (Vue) {
    installRenderHelpers(Vue.prototype)
}
function installRenderHelpers(target) {
    target._s = toString;
    target._v = createTextVNode;
}
function createTextVNode (val) {
    return new VNode(undefined, undefined, undefined, String(val))
}
function toString (val) {
  return val == null ? '' : 
	Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
	? JSON.stringify(val, null, 2) : String(val)
}
  • createTextVNode方法是用来创建一个文本节点的 Virtual DOM,注意其参数是传入new VNode的第四个参数text(当前节点的文本)。

  • toString方法用来把任意值转成字符串。

那么执行vm._v(vm._s(vm.bb))得到一个 vnode,记为 vnode1, 再执行vm._c('span', [vm._v(vm._s(vm.bb))]),相当执行vm._c('span', [vnode]),又得到一个vnode,记为 vnode2,此时vnode1 的children属性值为[vnode2]

按此类推,一层一层往上执行。当执行完vm._c('div'..就得到了一个 Virtual DOM 树,如图所示。

vm._render把模板和数据生成一个 Virtual DOM 树,然后在vm._update中实现把Virtual DOM 树渲染成 真实 DOM 树。

六、vm._update

Vue.prototype._update = function(vnode, hydrating) {
    var vm = this;
    var prevVnode = vm._vnode;
    vm._vnode = vnode;
    if (!prevVnode) {
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */ );
    } else {
        vm.$el = vm.__patch__(prevVnode, vnode);
    }
};

执行var prevVnode = vm._vnodevm._vnode是当前 Vue 实例生成的 Virtual DOM ,在设定的场景中是首次渲染,此时vm._vnode为 undefined ,故prevVnode为 undefined ,再执行vm._vnode = vnode,把当前 Vue 实例生成的 Virtual DOM 赋值给vm._vnode

因为prevVnode为 undefined ,故执行vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false),进入首次渲染DOM操作。

Vue.prototype.__patch__ = inBrowser ? patch : noop;

在浏览器环境下,Vue.prototype.__patch__ patch

1、patch

var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });

patchcreatePatchFunction生成。其参数nodeOps 是封装了一系列 DOM 操作的方法,参数modules是定义了一些模块的钩子函数,这里不详细介绍,来看一下 createPatchFunction的实现。

export function createPatchFunction(backend) {
    const {modules,nodeOps} = backend
    return function patch(oldVnode, vnode, hydrating, removeOnly) {
       //...
    }
}

在此之前,先思考一下,为什么要用createPatchFunction去生成一个patch方法。

因为patch是环境相关的,在 Web 和 Weex 环境中,都有各自的 nodeOpsmodules。但是不同环境的patch的主要逻辑是相同的,差异化部分只需要通过参数来区别,这里用了函数柯里化的技巧,通过createPatchFunction把差异化参数提前固化,不用每次调用patch时传递对应的nodeOpsmodules,这种编程技巧非常值得学习。

那么执行vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false),最终是调用patch方法,在此处打个断点,按F11进入patch方法中

function patch(oldVnode, vnode, hydrating, removeOnly) {
    const insertedVnodeQueue = []
    if (isUndef(oldVnode)) {} else {
        const isRealElement = isDef(oldVnode.nodeType)
        if (!isRealElement && sameVnode(oldVnode, vnode)) {} else {
            if (isRealElement) {
                oldVnode = emptyNodeAt(oldVnode)
            }
            const oldElm = oldVnode.elm
            const parentElm = nodeOps.parentNode(oldElm)
            createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))
            if (isDef(parentElm)) {
                removeVnodes(parentElm, [oldVnode], 0, 0)
            } else if (isDef(oldVnode.tag)) {}
        }
    }
    return vnode.elm
}
  • 参数oldVnode:上一次的 Virtual DOM ,设定的场景中的值为vm.$el是个 DOM 对象;
  • 参数vnode:这一次的 Virtual DOM ;
  • 参数hydrating:在非服务端渲染情况下为 false,可以忽略;
  • 参数removeOnly: 是在transition-group场景下用,设定场景中没有,为false,可以忽略。

如果oldVnode不是 Virtual DOM 而是 DOM 对象,要把oldVnodeemptyNodeAt转成一个 Virtual DOM,并在其属性elm赋值上被转换的 DOM 对象,所以oldElm等同vm.$el,在用nodeOps.parentNode(oldElm)获取oldElm的父级 DOM 节点,此时为 body。

function emptyNodeAt (elm) {
    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}
//nodeOps.parentNode
function parentNode (node) {
    return node.parentNode
}

执行createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))生成真实 DOM ,在此处打个断点,按F11进入createElm方法中nodeOps.nextSibling(oldElm)oldElm的下一个兄弟节点。

2、createElm

function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) {
    vnode.isRootInsert = !nested;
    var data = vnode.data;
    var children = vnode.children;
    var tag = vnode.tag;
    if (isDef(tag)) {
        vnode.elm = nodeOps.createElement(tag, vnode);
        createChildren(vnode, children, insertedVnodeQueue);
        if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue);
        }
        insert(parentElm, vnode.elm, refElm);
    } else if (isTrue(vnode.isComment)) {
    } else {
        vnode.elm = nodeOps.createTextNode(vnode.text);
        insert(parentElm, vnode.elm, refElm);
    }
}
  • 参数vnode: Virtual DOM;
  • 参数insertedVnodeQueue:钩子函数队列;
  • 参数parentElm: 参数vnode对应真实 DOM 对象的父节点 DOM 对象;
  • 参数refElm: 占位节点对象,例如,参数vnode对应 DOM 对象的下个兄弟节点;
  • 参数nested:判断vnode是否是根实例的 Virtual DOM;
  • 参数ownerArray:自身的子节点数组
  • 参数index: 子节点数组下标

参数ownerArrayindex是为了解决vnode在之前被渲染过,现在作为一个新 Virtual DOM,覆盖它的elm 导致的错误,设定的场景是首次渲染,可忽略不看。

  • vnode.tag如果存在,执行nodeOps.createElement(tag, vnode)生成一个真实 DOM 节点,并赋值给vnode.elmnodeOps.createElement对应的是createElement方法。
function createElement (tagName, vnode) {
    var elm = document.createElement(tagName);
    if (tagName !== 'select') {
      return elm
    }
    if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
      	elm.setAttribute('multiple', 'multiple');
    }
    return elm
}
  • vnode.tag如果不存在,则它有可能是一个注释或者纯文本节点,执行nodeOps.createTextNode(vnode.text)生成一个注释或者纯文本节点,并赋值给vnode.elmnodeOps.createTextNode对应的是createTextNode方法。
function createTextNode (text) {
    return document.createTextNode(text)
}

createChildren(vnode, children, insertedVnodeQueue)是用来处理vnode的子 Virtual DOM, 在此处打个断点,按F11进入createChildren方法

3、createChildren

function createChildren(vnode, children, insertedVnodeQueue) {
    if (Array.isArray(children)) {
        checkDuplicateKeys(children);
        for (var i = 0; i < children.length; ++i) {
            createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i);
        }
    } else if (isPrimitive(vnode.text)) {
        nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)));
    }
}

createChildren的逻辑很简单,实际上是遍历vnode子 Virtual DOM,递归调用createElm,这是一种常用的深度优先的遍历算法,在遍历过程中会把vnode.elm作为 的 children[i](Virtual DOM)对应真实 DOM 的父节点传入。

children不是数组时。判断vnode.text是否是基础类型,若是调用nodeOps.createTextNode生成一个纯文本节点,再调用nodeOps.appendChild插入到vnode.elm中。

4、insert

执行insert(parentElm, vnode.elm, refElm)把生成的 DOM (vnode.elm) 插入到对应父节点(parentElm)中,因为是递归调用,子 Virtual DOM 会优先调用insert,所以整个 Virtual DOM 树生成真实 DOM 后的插入顺序是先子后父。

insert(parentElm, vnode.elm, refElm)处打个断点,按F11进入insert方法

function insert(parent, elm, ref$$1) {
    if (isDef(parent)) {
        if (isDef(ref$$1)) {
            if (nodeOps.parentNode(ref$$1) === parent) {
                nodeOps.insertBefore(parent, elm, ref$$1);
            }
        } else {
            nodeOps.appendChild(parent, elm);
        }
    }
}
  • 参数parent:要插入节点的父节点
  • 参数elm: 要插入节点
  • 参数ref$$1:参考节点,会在参考节点前插入

nodeOps.insertBefore对应insertBefore方法,nodeOps.appendChild对应appendChild方法,

function insertBefore (parentNode, newNode, referenceNode) {
     parentNode.insertBefore(newNode, referenceNode);
}
function appendChild (node, child) {
     node.appendChild(child);
}

insertBefore方法和appendChild方法其实就是调用原生 DOM 的 API 进行 DOM 操作。

5、回到createElm

当调用createChildren,把vnode子 Virtual DOM 都遍历结束后,回到最初调用的createElm方法中,接着执行insert(parentElm, vnode.elm, refElm),其参数parentElm的值是在patch方法执行

const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

获取的,在设定的场景中 oldElm<div id="app"><p>{{aa}}<span>{{bb}}</span></p></div>这个DOM 对象,故parentElm为 body 对象。vnode.elm为 Virtual DOM 树生成对应真实的 DOM。

实际上整个过程就是递归调用createElm创建了一颗真实的 DOM 树插入到 body 上。

看到这里,或许你会恍然大悟,原来 Vue 是这样动态创建的 DOM。

另外在invokeCreateHooks方法中会执行一系列钩子函数,比如将dataattrs: {id: "app"}属性怎么添加到 div 标签上,这里不做介绍。

if (isDef(parentElm)) {
    removeVnodes([oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
}

因为parentElm存在,在patch方法中最后调用removeVnodes把原来模板上的<div id="app"><p>{{aa}}<span>{{bb}}</span></p></div>从它的父节点中删除。这也印证了为啥要对Vue实例的挂载目标作限制,不能挂载到body或html对象上。

回到vm._updata方法中,因为patch方法会返回新的当前Vue实例挂载目标的DOM对象,所以要更新一下vm.$el

vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);

6、回到mountComponent

if (vm.$vnode == null) {
    vm._isMounted = true;
    callHook(vm, 'mounted');
}
return vm

vm.$vnode 表示 Vue 实例的父 Virtual DOM ,它为 null 则表示当前是根 Vue 的实例,设置 vm._isMounted 为 true,表示这个实例已经挂载了,同时执行mounted钩子函数。

七、总结

模板和数据渲染成最终的 DOM 的关键过程可以总结为一句话:

vm._render中从子到父生成 Virtual DOM 树,在vm._update中从父到子生成真实 DOM ,再从子到父把生成真实 DOM 插入对应的父节点中,生成了一棵真实的 DOM 树,最后插入 body 中