Vue 页面渲染流程

Vue 从挂载组件到生成真实 DOM 的完整渲染过程

问题

Vue 是如何将模板渲染成页面的?从 new Vue() 开始,到页面显示内容,中间经历了哪些步骤?

解答

挂载组件($mount)

Vue 通过 new 关键字实例化时,会调用 _init 方法进行初始化:

// src/core/instance/index.js
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

_init 内部会调用 $mount 挂载组件,最终执行 mountComponent

// src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
  const vm: Component = this
  // ...
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

mountComponent 的核心是 updateComponent 方法:

vm._update(vm._render(), hydrating)

这行代码包含两个关键步骤:

  • vm._render() 创建并返回 VNode
  • vm._update() 将 VNode 转为真实 DOM

updateComponent 会被传入渲染 Watcher,数据变化时触发重新渲染。初始化时会执行一次完成首次渲染。

构建 VNode(_render)

_render 方法负责构建组件的 VNode:

// src/core/instance/render.js
Vue.prototype._render = function () {
  const { render, _parentVnode } = vm.$options
  vnode = render.call(vm._renderProxy, vm.$createElement)
  return vnode
}

在初始化时,Vue 会为实例绑定两个创建 VNode 的方法:

// src/core/instance/render.js
export function initRender (vm) {
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}
  • vm._c:模板编译生成的 render 函数使用
  • vm.$createElement:用户手写 render 函数使用

createElement

createElement 是创建 VNode 的核心方法,它会对参数进行处理后调用 _createElement

// src/core/vdom/create-elemenet.js
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  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)
}

_createElement 首先会规范化 children,将其转为标准的 VNode 数组:

if (normalizationType === ALWAYS_NORMALIZE) {
  children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
  children = simpleNormalizeChildren(children)
}
  • simpleNormalizeChildren:编译生成的 render 函数使用,只需简单扁平化
  • normalizeChildren:用户手写的 render 函数使用,需要完整规范化

然后根据 tag 类型创建不同的 VNode:

let vnode, ns
if (typeof tag === 'string') {
  let Ctor
  ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
  if (config.isReservedTag(tag)) {
    // 内置标签,创建普通 VNode
    vnode = new VNode(
      config.parsePlatformTagName(tag), data, children,
      undefined, undefined, context
    )
  } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
    // 已注册组件,创建组件类型 VNode
    vnode = createComponent(Ctor, data, context, children, tag)
  } else {
    // 未知标签
    vnode = new VNode(tag, data, children, undefined, undefined, context)
  }
} else {
  // tag 是 Component 类型,直接创建组件 VNode
  vnode = createComponent(tag, data, context, children)
}

生成真实 DOM(_update)

_update 方法接收 VNode,通过 __patch__ 方法生成真实 DOM:

// src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  vm._vnode = vnode
  
  if (!prevVnode) {
    // 初始渲染
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)
  } else {
    // 更新
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}

__patch__ 方法由 createPatchFunction 创建:

// src/core/vdom/patch.js
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

export function createPatchFunction (backend) {
  let i, j
  const cbs = {}
  const { modules, nodeOps } = backend

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

这里有两个重要对象:

  • nodeOps:封装的原生 DOM 操作方法
  • modules:不同阶段的钩子函数(指令、ref 等)

patch

首次渲染时,oldVnode 是根节点的真实 DOM(如 id="app" 的 div):

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      if (isRealElement) {
        // 将真实 DOM 转为 VNode
        oldVnode = emptyNodeAt(oldVnode)
      }
      // 创建真实节点
      createElm(vnode, insertedVnodeQueue, oldVnode.elm, ...)
      // 移除旧节点
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      }
    }
  }
  
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

createElm

createElm 是将 VNode 转为真实 DOM 的核心方法:

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // 尝试创建组件节点
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  
  if (isDef(tag)) {
    // 创建元素节点
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    
    // 递归创建子节点
    createChildren(vnode, children, insertedVnodeQueue)
    
    // 执行 create 钩子
    if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    
    // 插入父节点
    insert(parentElm, vnode.elm, refElm)
  } else if (isTrue(vnode.isComment)) {
    // 注释节点
    vnode.elm = nodeOps.createComment(vnode.text)
    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, null, true, children, i)
    }
  } 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)
    }
  }
}

整个过程是递归的,最深的子节点会先插入,所以插入顺序是”先子后父”。

最后移除旧节点,触发 insert 钩子函数,完成渲染。

关键点

  • 渲染流程分为三步:$mount 挂载组件 → _render 构建 VNode → _update 生成真实 DOM
  • createElement 根据 tag 类型创建不同的 VNode,遇到组件会递归执行 $mount
  • children 需要规范化为 VNode 数组,编译生成和手写 render 函数的处理方式不同
  • createElm 通过递归创建节点树,插入顺序是”先子后父”
  • Vue 初始渲染时不是替换根节点,而是在其后插入新节点再移除旧节点