vue3组件渲染

127 阅读11分钟

一、组件渲染

vue 模板中的组件到渲染生产 DOM 需要经历

  1. 创建 vnode
  2. 渲染 vnode
  3. 生产 dom

应用程序初始化

整个组件树从根组件开始渲染

在 vue2 或者 vue3 中应用的初始化如下

// 在 Vue.js 2.x 中,初始化一个应用的方式如下
import Vue from "vue";
import App from "./App";
const app = new Vue({
  render: (h) => h(App),
});
app.$mount("#app");
// 在 Vue.js 3.0 中,初始化一个应用的方式如下

import { createApp } from "vue";
import App from "./app";
const app = createApp(App);
app.mount("#app");

createApp 是 vue3 提供的新的函数,其内部实现如下,主要做了两件事

  1. 创建 app 对象
  2. 重写 app.mount 方法
const createApp = (...args) => {
  // 创建 app 对象
  const app = ensureRenderer().createApp(...args);
  const { mount } = app;
  // 重写 mount 方法
  app.mount = (containerOrSelector) => {
    // ...
  };
  return app;
};

1. 创建 app 对象

ensureRenderer().createApp() 来创建 app 对象

首先 ensureRenderer 函数用来创建一个渲染器对象,可以简单地把渲染器理解为包含平台渲染核心逻辑的 JavaScript 对象。

ensureRenderer方法

先用 ensureRenderer() 来延时创建渲染器,这样做的好处是当用户只依赖响应式包的时候,就不会创建渲染器,因此可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码。

在整个 app 对象创建过程中,Vue.js 利用闭包和函数柯里化的技巧,很好地实现了参数保留。比如,在执行 app.mount 的时候,并不需要传入渲染器 render,这是因为在执行 createAppAPI 的时候渲染器 render 参数已经被保留下来了。

// 渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法
const rendererOptions = {
  patchProp,
  ...nodeOps
}
let renderer
// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}
function createRenderer(options) {
  return baseCreateRenderer(options)
}
function baseCreateRenderer(options) {
  function render(vnode, container) {
    // 组件渲染的核心逻辑
  }
  return {
    render,
    createApp: createAppAPI(render)
  }
}
function createAppAPI(render) {
  // createApp createApp 方法接受的两个参数:根组件的对象和 prop
  return function createApp(rootComponent, rootProps = null) {
    const app = {
      _component: rootComponent,
      _props: rootProps,
      mount(rootContainer) {
        // 创建根组件的 vnode
        const vnode = createVNode(rootComponent, rootProps)
        // 利用渲染器渲染 vnode
        render(vnode, rootContainer)
        app._container = rootContainer
        return vnode.component.proxy
      }
    }
    return app
  }
}

2. 重写app.mount 方法

createApp函数内部的app.mount方法是一个标准可跨平台的组件渲染流程,先创建vnode,再渲染vnode,。此外参数 rootContainer 也可以是不同类型的值,比如,在 Web 平台它是一个 DOM 对象,而在其他平台(比如 Weex 和小程序)中可以是其他类型的值。所以这里面的代码不应该包含任何特定平台相关的逻辑,也就是说这些代码的执行逻辑都是与平台无关的。因此我们需要在外部重写这个方法,来完善 Web 平台下的渲染逻辑。

以下是重写mount的内容

app.mount = (containerOrSelector) => {
  // 标准化容器
  const container = normalizeContainer(containerOrSelector)
  if (!container)
    return
  const component = app._component
   // 如组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
  if (!isFunction(component) && !component.render && !component.template) {
    component.template = container.innerHTML
  }
  // 挂载前清空容器内容
  container.innerHTML = ''
  // 真正的挂载
  return mount(container)
}
  1. 通过normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器)
  2. 然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
  3. 接着在挂载前清空容器内容
  4. 最终再调用 app.mount 的方法走标准的组件渲染流程

核心渲染流程:创建 vnode 和渲染 vnode

1. 创建vnode

vnode 本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。

a. 普通元素节点

<button class="btn" style="width:100px;height:50px">click me</button>

用vnode对象来表示,

  1. type 属性表示 DOM 的标签类型
  2. props 属性表示 DOM 的一些附加信息,比如 style 、class 等
  3. children 属性表示 DOM 的子节点,它也可以是一个 vnode 数组,只不过 vnode 可以用字符串表示简单的文本 。
const vnode = {
  type: 'button',
  props: { 
    class: 'btn',
    style: {
      width: '100px',
      height: '50px'
    }
  },
  children: 'click me'
}
b. 组件节点

vnode对象除了描述真实的dom,也可以用来描述组件

如,在模板中定义一个组件

<custom-component msg="test"></custom-component>

用vnode对象来表示,这里的vnode只是对组件对象的抽象描述,我们不会在页面渲染这个组件标签,而是渲染组件内部定义的html标签

const CustomComponent = {
  // 在这里定义组件对象
}

const vnode = {
  type: CustomComponent,
  props: { 
    msg: 'test'
  }
}
c. 其他节点

还有比如纯文本vnode,注释vnode等等,且vue3内部还对vnode的type做了更详细的分类,如Suspense,Teleport等

d. createVNode函数创建vnode

主要做了以下几点

  1. 对 props 做标准化处理
  2. 对 vnode 的类型信息编码
  3. 创建 vnode 对象
  4. 标准化子节点 children
 const vnode = createVNode(rootComponent, rootProps)
function createVNode(type, props = null ,children = null) {
  if (props) {
    // 处理 props 相关逻辑,标准化 class 和 style
  }

  // 对 vnode 类型信息编码
  const shapeFlag = isString(type)
    ? 1 /* ELEMENT */
    : isSuspense(type)
      ? 128 /* SUSPENSE */
      : isTeleport(type)
        ? 64 /* TELEPORT */
        : isObject(type)
          ? 4 /* STATEFUL_COMPONENT */
          : isFunction(type)
            ? 2 /* FUNCTIONAL_COMPONENT */
            : 0
  const vnode = {
    type,
    props,
    shapeFlag,
    // 一些其他属性
  }

  // 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
  normalizeChildren(vnode, children)
  return vnode
}

2. 渲染vnode

render(vnode, rootContainer)

render函数的vnode如果为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑

const render = (vnode, container) => {
  if (vnode == null) {
    // 销毁组件
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    // 创建或者更新组件
    patch(container._vnode || null, vnode, container)
  }
  // 缓存 vnode 节点,表示已经渲染
  container._vnode = vnode
}

a. patch函数

patch函数的实现,patch 本意是打补丁的意思,这个函数有两个功能,一个是根据 vnode 挂载 DOM,一个是根据新旧 vnode 更新 DOM。对于初次渲染,我们这里只分析创建过程

  1. 第一个参数n1表示旧的vnode节点,当n1为null的时候,表示是一次挂载的过程
  2. 第二个参数n2表示新的vnode节点,后续会根据这个 vnode 类型执行不同的处理逻辑
  3. 第三个参数container表示dom容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。
  4. 这里我们只关注对组件的处理和对普通dom元素的处理
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
  // 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
  const { type, shapeFlag } = n2
  switch (type) {
    case Text:
      // 处理文本节点

      break
    case Comment:
      // 处理注释节点

      break
    case Static:

      // 处理静态节点
      break
    case Fragment:

      // 处理 Fragment 元素
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      } else if (shapeFlag & 6 /* COMPONENT */) {
        // 处理组件
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      } else if (shapeFlag & 64 /* TELEPORT */) {
        // 处理 TELEPORT

      } else if (shapeFlag & 128 /* SUSPENSE */) {
        // 处理 SUSPENSE

      }
  }
}

b. processComponent函数,对组件的处理

processComponent函数

const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  if (n1 == null) {
   // 挂载组件
   mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  } else {
    // 更新组件
    updateComponent(n1, n2, parentComponent, optimized)
  }
}

mountComponent函数,主要做了三件事

  1. 创建组件实例,vue3不像vue2用class的方式实例化组件,而是对象的方式创建组件实例
  2. 设置组件实例,实例上保留了很多组件相关的数据,维护了组件的上下文,包括对 props、插槽,以及其他实例的属性的初始化处理
  3. 设置并运行带副作用的渲染函数,重点看该渲染函数
const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  // 创建组件实例
  const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
  // 设置组件实例
  setupComponent(instance)
  // 设置并运行带副作用的渲染函数
  setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}

setupRenderEffect函数

  1. 利用响应式库的effect函数,传入了一个副作用渲染函数componentEffect,每当组件的数据发生改变,副作用渲染函数componentEffect会重新执行一遍,从而重新渲染组件
  2. 副作用渲染函数componentEffect
    1. 生产组件vnode的子树vnode,subtree
    2. 把subTree挂在到container中
    3. 这里initialVNode和subTree的区别是initialVNode对应组件的vnode,subTree对应组件内部整个dom节点对应的vnode
  3. 每个组件都有render函数或者template(也会被编译成render函数),而renderComponentRoot就是去执行render函数创建整个组件树内部的vnode,把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:子树 vnode。
  4. 渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了
  5. patch函数中继续对vnode的类型判断,如果是普通元素vnode,则进入普通元素的处理流程
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
  // 创建响应式的副作用渲染函数
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      // 渲染组件生成子树 vnode
      const subTree = (instance.subTree = renderComponentRoot(instance))
      // 把子树 vnode 挂载到 container 中
      patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
      // 保留渲染生成的子树根 DOM 节点
      initialVNode.el = subTree.el
      instance.isMounted = true
    } else {
      // 更新组件
    }
  }, prodEffectOptions)
}
c. processElement函数,对普通元素的处理
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  isSVG = isSVG || n2.type === 'svg'
  if (n1 == null) {
    //挂载元素节点
    mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  } else {
    //更新元素节点
    patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
  }
}

mountElement函数,主要做了四件事

  1. 创建 DOM 元素节点
  2. 处理 props
  3. 处理 children
  4. 挂载 DOM 元素到 container 上
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let el
  const { type, props, shapeFlag } = vnode
  // 创建 DOM 元素节点
  el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
  if (props) {
    // 处理 props,比如 class、style、event 等属性
    for (const key in props) {
      if (!isReservedProp(key)) {
        hostPatchProp(el, key, null, props[key], isSVG)
      }
    }
  }
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    // 处理子节点是纯文本的情况
    hostSetElementText(el, vnode.children)
  } else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
    // 处理子节点是数组的情况
    mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
  }
  // 把创建的 DOM 元素节点挂载到 container 上
  hostInsert(el, container, anchor)
}

hostCreateElement函数创建dom元素节点

  1. 底层还是用dom api 的createElement创建元素,并没有什么神奇的地方
  2. 如果是其他平台,比如weex,hostCreateElement就不再是操作dom,而是平台相关的api了,这些平台相关的方法是在创建渲染器阶段作为参数传入的。
function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}

hostPatchProp函数处理props,给这个 DOM 节点添加相关的 class、style、event 等属性,并做相关的处理

mountChildren函数处理子节点,vnode和dom都是一个树,并且一一映射

  1. 遍历子节点children,得到每个child,并做预处理
  2. 递归执行patch挂载每个child,这里执行patch的原因是child有可能有其他类型的vnode
  3. 通过这种深度优先遍历树的方式,我们构造完整的dom树,完成组件的渲染
const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
  for (let i = start; i < children.length; i++) {
    // 预处理 child
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i])
      : normalizeVNode(children[i]))
    // 递归 patch 挂载 child
    patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
}

hostInsert函数,把创建的dom元素节点挂载到 container 上, 在web下

  1. 因为 insert 的执行是在处理子节点后,所以挂载的顺序是先子节点,后父节点,最终挂载到最外层的容器上。
function insert(child, parent, anchor) {
  if (anchor) {
    parent.insertBefore(child, anchor)
  }
  else {
    parent.appendChild(child)
  }
}

3. vnode的意义

a. 抽象化

引入 vnode,可以把组件和渲染过程抽象化

b. 跨平台

因为 patch vnode 的过程不同平台可以有自己的实现,基于 vnode 再做服务端渲染、Weex 平台、小程序平台的渲染都变得容易了很多。

在web平台,需要实现将vnode转换为真实dom,所以和手动操作dom的原理是一样的,这种基于 vnode 实现的 MVVM 框架,在每次 render to vnode 的过程中,渲染组件会有一定的 JavaScript 耗时,特别是大组件,比如一个 1000 * 10 的 Table 组件,render to vnode 的过程会遍历 1000 * 10 次去创建内部 cell vnode,整个耗时就会变得比较长,加上 patch vnode 的过程也会有一定的耗时,当我们去更新组件的时候,用户会感觉到明显的卡顿。虽然 diff 算法在减少 DOM 操作方面足够优秀,但最终还是免不了操作 DOM,所以说性能并不是 vnode 的优势。