Vue源码阅读——从render函数到dom渲染

大部分源码解析的文章都是从项目结构开始,然后再从入口处跟着它的执行流程一步步分析,但我发现那不适合我,很容易看着看着就不知道自己在看什么,我的方法是带着问题出发,先把自己想知道的点拆解成一个个问题,由浅入深,多次有选择的去看源码,每次只关注一个问题,解决一个问题后再继续下一个,最后再回过头来总结整体

考虑下面的代码

new Vue({
  el: '#app',
  render: (h) => {
    return h(
      'div',
      {
        id: 'test',
      },
      'hello world',
    );
  },
});

上面这段 vue 代码,最终会生成下面的 dom 结构并渲染到页面上

<div id="test">hello world</div>

很显然 render 方法中传入的这个 h 参数是一个函数,这个函数可以将标签 div,属性 id,内容 hello world 转换成 dom 元素,看一下 vue 源码中的处理,注意这里简化源码但不破坏源码的结构:

vue/src/core/instance/index.js

function Vue(options) {
  this._init(options);
}
initMixin(Vue);
lifecycleMixin(Vue);
renderMixin(Vue);

new Vue()时会调用实例的_init 方法,这个方法挂载在 Vue 原型上

vue/src/core/instance/init.js

export function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this;
    vm.$options = options; //简化了源码中对用户传入options的处理

    initRender(vm); //initRender在实例上绑定render相关的代码,这里主要关注$createElement方法

    if (vm.$options.el) {
      //第一次挂载时将执行到这里,vm.$options.el即 ”el: '#app'“
      vm.$mount(vm.$options.el);
    }
  };
}

vue/src/core/instance/render.js

export function initRender(vm) {
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true);
}

vue/src/core/vdom/create-element.js

function createElement(vm, tag, data, children) {
  children = normalizeChildren(children);
  return new VNode(tag, data, children);
}

暂时只用知道,initRender在实例上绑定$createElement方法,调用vm.$createElement方法,会返回一个 VNode 实例,继续往下走,第一次渲染时,如果options中传入了 el 参数,会自动执行实例上的$mount方法,否则需要自己手动调用:

//传入el参数,自动挂载
new Vue({
  el: '#app',
  render: (h) => h('div'),
});

//手动挂载
var app = new Vue({
    render: (h) => h('div)
})
app.$mount('#app')

继续看$mount方法:

import Vue from 'core/index'; 
import { mountComponent } from 'core/instance/lifecycle';
import { query } from 'web/util/index';
Vue.prototype.$mount = function (el) {
  el = el && inBrowser ? query(el) : undefined; //query方法就是根据el选择器获取dom元素
  const vm = this;
  vm.$el = query(el)
  return mountComponent(this, el);
};
export function mountComponent(vm, el) {
  vm._update(vm._render());
}

vue/src/core/instance/render.js

export function renderMixin(Vue) {
  Vue.prototype._render = function () {
    const { render } = vm.$options; //这里解构出来的render就是new Vue时传入的render
    let vnode;
    vnode = render.call(vm._renderProxy, vm.$createElement); //vm._renderProxy可以暂看做vm本身
    return vnode;
  };
}

我们都知道,render函数的参数为h,从render.call(vm._renderProxy, vm.$createElement)看出,参数h就是vm.$createElement方法,这个方法最终返回VNode,有一个很妙的地方是h的第三个参数children, 看下面的代码:

h(
    'div', 
    {}, 
    [
        h('ul', {}, [
            h('li', {}, 1),
            h('li', {}, 2)
        ]), 
        'hello', 
        'world'
    ]
)

children的每一项都是h()的调用结果,也就是vnode对象,vue文档中说children是

{String | Array}
子级虚拟节点 (VNodes),由 createElement() 构建而成,
也可以使用字符串来生成“文本虚拟节点”。可选。

这样的写法直接构造了vnode树,是diff算法、dom挂载的基础。

继续之前的代码,_render的返回值是一个vnode,传入_update中,_update主要调用patch对比上一次渲染的vnode和本次渲染的vnode,由于是第一次渲染,不存在上一次的vnode,所以对比vm.$el和本次vnode

export function lifecycleMixin(Vue) {
  Vue.prototype._update = function (vnode) {
    const vm = this;
    vm.__patch__(vm.$el, vnode); //vm.$el是dom元素,在$mount中被赋值
  };
}
Vue.prototype.__patch__ = function (oldVnode, vnode) {
  const insertedVnodeQueue = [];
  const isRealElement = isDef(oldVnode.nodeType); //判断是否是dom元素
  if (isRealElement) {
    oldVnode = emptyNodeAt(oldVnode); //根据dom元素的tag创建VNode
  }
  const oldElm = oldVnode.elm;
  const parentElm = nodeOps.parentNode(oldElm); //获取父元素
  createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm));

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

vue/src/core/vdom/patch.js

function emptyNodeAt(elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}
function createElm(vnode, insertedVnodeQueue, parentElm, refElm) {
  if (vnode.tag) {
    vnode.elm = nodeOps.createElement(vnode.tag, vnode)
    createChildren(vnode, vnode.children, insertedVnodeQueue)
    insert(parentElm, vnode.elm, refElm)
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text);
    insert(parentElm, vnode.elm, refElm)
  }
}
function createChildren(vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; i++) {
      createElm(children[i], insertedVnodeQueue, vnode.elm)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}
function insert(parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) == parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

以上是大部分简化的代码,VNode对象的代码可以查看源码,很好理解,另外关于nodeOps的代码这里不列出来了,就是对dom元素的操作,根据名字很容易理解。

很清晰的可以看出,从createElm开始深度遍历vnode树,从上到下遍历时创建dom元素,回溯时再从下到上将子dom节点插入父节点,最终根vnode的elm属性就是完整的dom结构,最终插入$el元素的父元素上,在本例子中就是body元素,至此页面上就会渲染出dom元素了。