【Vue3.0】- 如何渲染组件

组件

  • 组件是一个抽象的概念,它是对一棵 DOM 树的抽象
  • 可以描述组件信息的JavaScript对象
  • 从表现上来看
    • 组件的模板决定了组件生成的DOM标签
    • Vue.js内部,一个组件想要真正的渲染生成DOM
      image.png

应用程序初始化

  • 整个组件树是由根组件开始渲染的
  • 为了找到根组件的渲染入口,从应用程序的初始化过程开始分析
  • 对比vue2.0vue3.0入口
// 在 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')
  • Vue.js 3.0中导入了一个createApp,这是个入口函数,它是 Vue.js对外暴露的一个函数

createApp内部实现

const createApp = ((...args) => {
  // 创建 app 对象
  const app = ensureRenderer().createApp(...args)
  const { mount } = app
  // 重写 mount 方法
  app.mount = (containerOrSelector) => {
    // ...
  }
  return app
})
  • createApp主要做了两件事情
    • 1)创建app对象
    • 2)重写app.mount方法

创建app对象

  • ensureRenderer().createApp() 来创建 app 对象
  • 实现了跨平台渲染
const app = ensureRenderer().createApp(...args)
  • ensureRenderer()用来创建一个渲染器对象
ensureRenderer 内部实现
// 渲染相关的一些配置,比如更新属性的方法,操作 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
  }
}
  • 首先用ensureRenderer()来延时创建渲染器
    • 好处是当用户只依赖响应式包的时候,就不会创建渲染器
    • 可以通过tree-shaking的方式移除核心渲染逻辑相关的代码
  • 通过createRenderer创建一个渲染器
    • 这个渲染器内部会有一个createApp方法
      • 它是执行createAppAPI方法返回的函数
      • 接受了rootComponentrootProps两个参数
    • 我们在应用层面执行createApp(App)方法时:
      • 会把App组件对象作为根组件传递给rootComponent
      • 这样,createApp内部就创建了一个app对象
      • 它会提供mount方法,这个方法是用来挂载组件的。

值得注意的是

  • app对象创建过程中,Vue.js利用闭包和函数柯里化的技巧,很好地实现了参数保留

重写app.mount方法

为什么重写?
  • createApp返回的app对象已经拥有了mount方法了,为什么还有在入口重写?
    • 为了支持跨平台渲染
    • createApp函数内部的app.mount方法是一个标准的可跨平台的组件渲染流程:
mount(rootContainer) {
  // 创建根组件的 vnode
  const vnode = createVNode(rootComponent, rootProps)
  // 利用渲染器渲染 vnode
  render(vnode, rootContainer)
  app._container = rootContainer
  return vnode.component.proxy
}
  • 主要流程是,先创建vnode,再渲染 vnode
  • 参数rootContainer根据平台不同而不同,
  • 这里面的代码不应该包含任何特定平台相关的逻辑,因此我们需要在外部重写这个方法
app.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)
}
  • 首先是通过normalizeContainer标准化容器(这里可以传字符串选择器或者DOM对象,但如果是字符串选择器,就需要把它转成 DOM对象,作为最终挂载的容器)
  • 然后做一个if判断,如果组件对象没有定义render函数和 template模板,则取容器的innerHTML作为组件模板内容
  • 接着在挂载前清空容器内容,最终再调用app.mount的方法走标准的组件渲染流程

优势

  • 跨平台实现
  • 兼容vue2.0写法
  • app.mount既可以传dom,又可以传字符串选择器

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

创建 vnode

  • 1、 vnode本质上是用来描述DOMJavaScript对象

它在Vue.js中可以描述不同类型的节点,比如普通元素节点、组件节点等

vnode如何描述
// vnode 这样表示<button>标签
const vnode = {
  type: 'button',
  props: { 
    'class': 'btn',
    style: {
      width: '100px',
      height: '50px'
    }
  },
  children: 'click me'
}
  • type属性表示DOM的标签类型

  • props属性表示DOM的一些附加信息,比如styleclass

  • children属性表示DOM的子节点,它也可以是一个vnode数组,只不过vnode可以用字符串表示简单的文本

  • 2、 vnode除了用于描述一个真实的DOM,也可以用来描述组件

vnode其实是对抽象事物的描述

// vnode 这样表示 <custom-component>
const CustomComponent = {
  // 在这里定义组件对象
}
const vnode = {
  type: CustomComponent,
  props: { 
    msg: 'test'
  }
}
  • 3、其他的,还有纯文本vnode,注释vnode
  • 4、Vue.js 3.0中,vnodetype,做了更详尽的分类,包括 SuspenseTeleport等,且把vnode的类型信息做了编码,以便在后面的patch阶段,可以根据不同的类型执行相应的处理逻辑
vode优势
  • 抽象
  • 跨平台
  • 但是,和手动修改dom对比,并不一定有优势

如何创建vnode

  • app.mount函数的实现,内部是通过createVNode函数创建了根组件的vnode
const vnode = createVNode(rootComponent, rootProps)
createVNode 函数的大致实现
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
}
  • props做标准化处理
  • vnode的类型信息编码
  • 创建vnode对象
  • 标准化子节点children

渲染 vnode

render(vnode, rootContainer)
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
}
  • 如果它的第一个参数vnode为空,则执行销毁组件的逻辑
  • 否则执行创建或者更新组件的逻辑
patch函数
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
      }
  }
}
  • 这个函数有两个功能:
    • 一个是根据vnode挂载DOM
    • 一个是根据新旧vnode更新DOM。对于初次渲染
  • patch函数入参
    • 第一个参数 n1 表示vnode,当 n1null 的时候,表示是一次挂载的过程;
    • 第二个参数 n2 表示vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;
    • 第三个参数container表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。

渲染节点

  • 对组件的处理
  • 对普通DOM元素的处理
对组件的处理
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)
  }
}
  • 如果n1null,则执行挂载组件的逻辑
  • 否则执行更新组件的逻辑
mountComponent挂载组件的实现
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)
}

主要做三件事情

  • 1、 创建组件实例
    • Vue.js 3.0虽然不像Vue.js 2.x那样通过类的方式去实例化组件,但内部也通过对象的方式去创建了当前渲染的组件实例
  • 2、 设置组件实例
    • instance保留了很多组件相关的数据,维护了组件的上下文,包括对props、插槽,以及其他实例的属性的初始化处理
  • 3、 设置并运行带副作用的渲染函数(setupRenderEffect)
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)
}
  • 该函数利用响应式库的effect函数创建了一个副作用渲染函数 componentEffect

副作用
当组件的数据发生变化时,effect函数包裹的内部渲染函数 componentEffect会重新执行一遍,从而达到重新渲染组件的目的

  • 渲染函数内部也会判断这是一次初始渲染还是组件更新,在初始渲染流程中

初始渲染主要做两件事情

  • 1、 渲染组件生成subTree
    注意,不要弄混subTree(执行renderComponentRoot生成的子树vnode)和initialVNode(组件 vnode
    • 每个组件都有render函数,template也会编译成render函数
    • renderComponentRoot函数就是去执行 render 函数创建整个组件树内部的 vnode
    • 把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:subTree(子树vnode
  • 2、把subTree挂载到container
    • 继续调用 patch 函数把子树 vnode 挂载到 container
    • 继续对这个子树vnode类型进行判断,此时子树vnode为普通元素vnode
对普通 DOM 元素的处理
processElement函数
  • 用来处理普通DOM 元素
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)
  }
}
  • 如果n1null,走挂载元素节点的逻辑
  • 否则走更新元素节点逻辑
mountElement 函数
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)
}
  • 主要做四件事
  • 1、 创建DOM元素节点
    通过hostCreateElement方法创建,这是一个平台相关的方法,在web端实现:
// 调用了底层的 DOM API document.createElement 创建元素
function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}
  • 2、 处理props
    给这个DOM节点添加相关的 classstyleevent 等属性,并做相关的处理
  • 3、 处理children
    • 子节点是纯文本,则执行hostSetElementText方法,它在 Web环境下通过设置DOM元素的textContent属性设置文本:
function setElementText(el, text) {
  el.textContent = text
}
* 如果子节点是数组,则执行`mountChildren`方法
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)
  }
}
  • 遍历children获取到每一个child vnode

  • mountChildren函数的第二个参数是container,传入的是mountElement时创建的DOM节点,很好的建立了父子关系

  • 通过递归patch这种深度优先遍历树的方式,我们就可以构造完整的DOM树,完成组件的渲染。

  • 4、 挂载DOM元素到container
    调用hostInsert方法

function insert(child, parent, anchor) {
  if (anchor) {
    parent.insertBefore(child, anchor)
  }
  else {
    parent.appendChild(child)
  }
}
嵌套组件的处理
  • mountChildren的时候递归执行的是 patch 函数,而不是 mountElement函数,这是因为子节点可能有其他类型的vnode,比如组件vnode

组件渲染流程图

image.png

推荐阅读更多精彩内容