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 挂载组件,$mount 实际调用的是 mountComponent

// src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  callHook(vm, 'beforeMount')

  let updateComponent
  // 核心渲染方法
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  
  // 创建渲染 Watcher,数据变化时重新渲染
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  
  callHook(vm, 'mounted')
  return vm
}

updateComponent 是渲染视图的核心方法,包含两个关键步骤:

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

构建 VNode(_render)

_render 方法用于构建组件的 VNode:

// src/core/instance/render.js
Vue.prototype._render = function () {
  const vm = this
  const { render, _parentVnode } = vm.$options
  
  // 执行 render 函数生成 VNode
  vnode = render.call(vm._renderProxy, vm.$createElement)
  
  return vnode
}

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

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

createElement

createElement 是创建 VNode 的核心方法:

// 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 是实际创建 VNode 的方法:

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 规范化 children 为 VNode 数组
  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 = (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 是组件类型,直接创建组件 VNode
    vnode = createComponent(tag, data, context, children)
  }
  
  return vnode
}

children 规范化有两种方式:

// 编译生成的 render 函数使用,只需简单扁平化
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

// 用户手写 render 函数使用,需要完整规范化
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

生成真实 DOM(_update)

_update 方法将 VNode 转换为真实 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)
  }
  
  // 更新引用
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
}

__patch__ 方法在 web 平台的定义:

// src/platforms/web/runtime/index.js
import { patch } from './patch'
Vue.prototype.__patch__ = inBrowser ? patch : noop

patchcreatePatchFunction 创建:

// 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]])
      }
    }
  }
  
  // 返回 patch 函数
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

这里有两个重要对象:

  • nodeOps:封装的原生 DOM 操作方法
  • modules:各模块的钩子函数(指令、ref 等)

patch 过程

初始渲染时,oldVnode 是根节点的真实 DOM:

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)
      }
      
      // 获取父节点
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)
      
      // 创建新节点
      createElm(
        vnode,
        insertedVnodeQueue,
        parentElm,
        nodeOps.nextSibling(oldElm)
      )
      
      // 移除旧节点
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      }
    }
  }
  
  // 触发 insert 钩子
  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)
    }
  }
}

Vue 通过递归调用 createElm 创建节点树,最深的子节点会先调用 insert 插入,所以整个节点树的插入顺序是”先子后父”。

最后触发 insert 钩子:

function invokeInsertHook (vnode, queue, initial) {
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

关键点

  • 渲染流程分为三个阶段:挂载组件($mount)、构建 VNode(_render)、生成真实 DOM(_update
  • createElement 根据 tag 类型创建不同的 VNode,包括普通元素、组件、未知标签三种
  • children 规范化有两种方式:编译生成的 render 使用 simpleNormalizeChildren,手写 render 使用 normalizeChildren
  • createElm 通过递归创建节点树,插入顺序是”先子后父”,最深的子节点最先插入
  • 初始渲染时,Vue 不是替换根节点,而是在其后插入新节点,再移除旧节点