Vue3中如何将虚拟节点渲染到网页上去(二)更新渲染流程

488 阅读19分钟

本文以vue版本为3.2.4为例

上一篇讲述了vue3中虚拟节点的初次渲染(Vue3中如何将虚拟节点渲染到网页上去(一)初次渲染 - 掘金 (juejin.cn)),然而实际上初次渲染顾名思义也就是只会执行一次,那么我们实际上遇到更多的是会进行数据的更新。那么数据更新后,vue3是怎么进行的更新渲染呢?这里便会涉及到一个即便是初次学习相关知识的也或多或少听到过的diff算法,和vue3另一块重要的模块响应式的内容了,因此响应式这次篇章不展开讲,而diff算法由于篇幅等关系,放在下一篇进行讲述。

组件的更新主要是由于数据的变化而引起的。是因为在初次渲染的过程中创建一个了副作用渲染函数setupRenderEffect,这个函数会通过响应式函数new ReativeEffect()创建一个effect函数,并通过update对其进行调用,当数据发生改变的时候,则会触发执行这个渲染函数使组件发生更新。 updatecomponent.png

副作用函数更新组件

重温一下setupRenderEffect代码内部:

//packages/runtime-core/src/renderer.ts
  const setupRenderEffect: SetupRenderEffectFn = (
     instance,//组件实例
     initialVNode,//初始化的虚拟节点
     container,//容器
     anchor,//锚点
     parentSuspense,
     isSVG,
     optimized//优化标记
  ) => {
    const componentUpdateFn = () => {
      if (!instance.isMounted) {
		//首次渲染运行流程(略)
      } else {
        // 如果不是第一次渲染,则更新组件
        // 这是由组件自身状态的改变(next: null),如果是父级调用processComponent(next: VNode)触发的。
        let { next, bu, u, parent, vnode } = instance
        //将next做缓存备份处理。
        let originNext = next    
        let vnodeHook: VNodeHook | null | undefined
        // 在生命周期前的钩子中不允许组件效果递归。
        toggleRecurse(instance, false)
        //默认情况下next是null,父组件调用processComponent触发当前调用的时候会是VNode,此时next为null
        if (next) {
          next.el = vnode.el
          updateComponentPreRender(instance, next, optimized)
        } else {
        //如果没有 next 直接指向当前 vnode
          next = vnode
        }
        // 更新前的钩子...(略)
        // 更新前节点...(略)

        //触发递归
        toggleRecurse(instance, true)
        // 渲染
        const nextTree = renderComponentRoot(instance) //创建新的节点的子树
        const prevTree = instance.subTree //当前实例的子树
        //更新子树
        instance.subTree = nextTree
        //组件更新的核心函数,根据新旧子树进行patch
        patch(
          prevTree,//n1 位置 老节点 原先的子树
          nextTree,//n2 位置 新节点 新的子树
          // 如果在传送类型中,父类可能产生改变
          hostParentNode(prevTree.el!)!,
          //如果在片段类型中,锚点会发生改变
          getNextHostNode(prevTree),
          instance,//节点实例
          parentSuspense,
          isSVG
        )
        //将更新后的DOM节点缓存
        next.el = nextTree.el
        // 自动触发更新. HOC的情况下, 更新父组件的虚拟节点的挂载元素. HOC表示为父实例的子树指向子组件的虚拟节点...(略)
        // 更新后的钩子...(略)
	    // 节点更新后...(略)
	  }
    }
       // 创建用于渲染的响应式副作用函数
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update),
      instance.scope // 在组件的效果范围内跟踪它
    ))
    //更新方法
    const update: SchedulerJob = (instance.update = () => effect.run())
    //实例的uid赋值给更新的id
    update.id = instance.uid
    // 允许递归
    // #1801, #2043 组件渲染效果应允许递归更新
    toggleRecurse(instance, true)
    update() 
  }

processComponent处理组件的渲染时,判断n1,n2新老节点异同,因为是数据的更新,所以n1和n2的数据是不同的,从而触发的是updateComponent函数并将n1,n2和优化标记传入(优化这章不讲)。处理组件的具体内容在下文中的的处理组件类型中讲解。

updateComponent2.png

这个流程图,很直观的就表现出了无论是初次渲染还是后续的更新,都会涉及到patch方法和其中的核心的响应式函数,而响应式函数的优点就是每次数据的变动都会触发重新渲染,然而每次渲染都会不停的遍历所有节点,当节点数量够多时就会影响到渲染性能,那么如何优化和减少不必要的遍历则是patch方法所做的。

可见更新组件中主要做的是,获取更新组件实例,获取新节点的子树vnode,然后根据新旧子树调用patch方法。

什么是patch?

在说是什么之前,先说一下为什么?实际上的前端开发过程中,所创建的VNode中,不可能只有简单的几个,也不可能仅仅只有一层父元素一层子元素,会有几百,上千甚至更多的虚拟节点,而且会层层嵌套,特别是类似<table>标签,会有大量的数据渲染。简单了解过render遍会知道,对渲染节点的渲染有大量的遍历操作,这会影响到运行性能,那么优化流程,减少不必要的操作便是patch所要做的。 Vue在使用VNode虚拟节点渲染真实DOM的过程中,并不会直接将当前的VNode更新DOM节点,而是通过将新旧的2个VNode节点通过patch进行对比比较,然后通过对比出的不同,找出差异的属性或者节点进行所需求的更新变更,从而达到提高性能的效果。

完整的patch的流程

//packages/runtime-core/src/renderer.ts
// 注意:此闭包中的函数应使用 'const xxx = () => {}'样式,以防止被小写器内联。
const patch: PatchFn = (
    n1,//老节点
    n2,//新节点
    container,//宿主元素 container
    anchor = null,//锚点,用来标识当我们对新旧节点做增删或移动等操作时,以哪个节点为参照物
    parentComponent = null,//父组件
    parentSuspense = null,//父悬念
    isSVG = false,
    slotScopeIds = null,//插槽
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
    if (n1 === n2) {// 如果新老节点相同则停止
      return
    }
    // 打补丁且不是相同类型,则卸载旧节点,锚点后移
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null //n1复位,保证后续的逻辑通畅
    }
	//是否动态节点优化
    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }
	//结构n2新节点,获取新节点的类型
    const { type, ref, shapeFlag } = n2
    switch (type) {
      case Text: //文本类
	    processText(n1, n2, container, anchor)//文本节点处理
        break
      case Comment://注释类
        processCommentNode(n1, n2, container, anchor)//处理注释节点
        break
      case Static://静态类
        if (n1 == null) {//如果老节点不存在
          mountStaticNode(n2, container, anchor, isSVG)//挂载静态节点
        }
        break
      case Fragment://片段类
        processFragment(
         //进行片段处理
        )
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {//如果类型编码是元素
          processElement(
	       n1,
           n2,
           container,
           anchor,
           parentComponent,
           parentSuspense,
           isSVG,
           slotScopeIds,
           optimized
          )
        } else if (shapeFlag & ShapeFlags.COMPONENT) {//如果类型编码是组件
          processComponent(
           n1,
           n2,
           container,
           anchor,
           parentComponent,
           parentSuspense,
           isSVG,
           slotScopeIds,
           optimized
          )
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          ;(type as typeof TeleportImpl).process(
          // 如果类型是传送,进行处理
          )
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          ;(type as typeof SuspenseImpl).process(
          //悬念处理
          )
        } 
    }
  
    // 设置 参考 ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
    }
  }

判断新旧节点是否是同个类型的方法:

//packages/runtime-core/src/vnode.ts
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
	//不光是新旧节点的类型需要相同,连key也需要相同才能算是相同类型的节点
  return n1.type === n2.type && n1.key === n2.key
}

在这个逻辑中,首先对n1是否与n2 相同进行判断,如果相同直接停止并返回。然后再判断n1是否存在,且与n2的节点类型是否相同,如果从节点上看就是不同的,例如<p>更新成了<div>,那么直接卸载<p>节点,然后挂载新的<div>节点。 而当节点的类型相同的时候,则需要更加细分的判断,会根据节点类型的不同而执行不同的处理逻辑。

patch类型分类.png

从中可以看到分类了5个情况,而且每个分类都会有对应的处理方法。在编译的过程中,vue会将模板语法编译成为渲染函数,会把第一个参数节点类型type赋值,从而在patch的过程中,进入逻辑判断,并且执行相应的process方法处理。

前置须知

以下两点涉及到编译优化的方式,使之在更新阶段基于这些标记集合,对节点进行按需更新。

1.PatchFlags

patchFlag是在编译template模板的时候,给vnode添加的一个标识信息,代表了vnode哪些部位绑定了动态值。这样可以通过这个标识判断哪些需要更新。

以下便是所有类型的枚举:

// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
  //表示具有动态的textContent属性
  TEXT = 1,
  //表示具有动态的class类
  CLASS = 1 << 1,
  //表示具有动态的style样式
  STYLE = 1 << 2,
  //表示具有动态的非class和style的props
  PROPS = 1 << 3,
  //表示props具有动态的key,与CLASS、STYLE、PROPS冲突
  FULL_PROPS = 1 << 4,
  //表示有监听事件(在同构期间需要添加)
  HYDRATE_EVENTS = 1 << 5,
  //表示一个children顺序不会变化的fragment。
  STABLE_FRAGMENT = 1 << 6,
  // 表示children带有key的fragment
  KEYED_FRAGMENT = 1 << 7,
  //表明children没有key的的fragment
  UNKEYED_FRAGMENT = 1 << 8,
  //表示只需要非props的patch。例如标签里只有ref或者指令(onVondexxx钩子)
  NEED_PATCH = 1 << 9,
  //表示具有动态的插槽
  DYNAMIC_SLOTS = 1 << 10,
  //表示用户在模板的根层存在的注释而创建的fragment,这是一个仅仅用于开发中的标记,因为在生产的环境中,注释是会被清除的。
  DEV_ROOT_FRAGMENT = 1 << 11,
  
  //以下是特殊的标记
  //表示经过静态提升
  HOISTED = -1,
  //表示diff算法应该退出优化模式
  BAIL = -2
}

patchFlag 使用二进制进行存储,每一位存储一个信息。如果 PatchFlag 第一位为 1,就说明 Text 是动态的,如果第二位为 1,就说明 Class 是动态的。

PatchFlags是由编译器生成的优化提示。当在diff过程中遇到一个带有dynamicChildren的块时,该算法进入 "优化模式"。在这种模式下,我们知道这个vdom是由一个由编译器生成的渲染函数,所以算法只需要处理由这些补丁标志明确标记的更新。补丁标志可以使用|位操作符进行组合,并且可以检查使用&运算符,例如

const flag = TEXT | CLASS 
if (flag & TEXT) { ... }

位于'.../.../runtime-core/src/renderer.ts'中的patchElement函数,看看如何在diff过程中处理标志。标记在diff过程中被处理。

判断的时候,

2.dynamicChildren

dynamicChildren的值对应一个数组,其中包含了patchFlag属性的虚拟节点,存放了所有的动态节点。传统的diff算法会进行全量的比较,而那些静态的节点是不会发生改变的,那么只将动态的节点收集起来,只对比这部分的节点就可以极大的提高效率。

处理元素类型

示例代码:

<template>
	<div class="app">
		<div>{{ count }}</div>
		<button @click='add'>点我</button>
	</div>
	</template>
<script>
import {ref} from 'vue'
export default {
	setup(){
		const count = ref(0)
		function add(){
			count.value ++
		}
		return{
			count,
			add
		}
	}
}
</script>

代码功能:点击点我按钮后,<div>标签里的count,页面上显示的0 ,会随着每次点击点我按钮后,数字加1。

在代码的运行中改模板中的<div>因为是一个普通的DOM元素节点,因此当这个节点运行到patch的时候,会根据判断节点的类型进行对应的处理。所以应该走的是processElement方法。

// packages/runtime-core/src/renderer.ts
const processElement= (n1, n2, container, ...) => {
  // 无旧节点,首次渲染逻辑
  if (n1 === null) {
    // 挂载元素方法(略)
  } else {
     patchElement(
        n1,
        n2,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
  }
}

而现在讲的是在更新的流程中,所以接下来是patchElement函数

//packages/runtime-core/src/renderer.ts
  const patchElement = (
    n1: VNode,
    n2: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
  //对ts不熟悉的可能对赋值内容后的'!'有疑惑,这个使null和underfined类型可以赋值给其他类型并通过编译,表示该变量可空
  //这里因为是更新步骤所以n1和el是必然存在的,所以将n1的el赋值给n2,并且解构赋值给el
    const el = (n2.el = n1.el!)
    //取出新节点中的patchFlag和dynamicChildren用作后面的判断
    let { patchFlag, dynamicChildren, dirs } = n2
    // #1426 考虑到旧的vnode的补丁标志,因为用户可能会克隆一个编译器生成的vnode,这就去掉了FULL_PROPS。
    patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
    //将旧节点的props存入oldProps
    const oldProps = n1.props || EMPTY_OBJ
    //将新节点的props存入newProps
    const newProps = n2.props || EMPTY_OBJ
    let vnodeHook: VNodeHook | undefined | null

    // 禁用beforeUpdate钩子中的递归功能(略)

    //判断是否有动态子节点收集字段
    if (dynamicChildren) {
      patchBlockChildren(
        n1.dynamicChildren!,
        dynamicChildren,
        el,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds
      )
    } else if (!optimized) {
      // 全量对比
      patchChildren(
        n1,
        n2,
        el,
        null,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds,
        false
      )
    }

    if (patchFlag > 0) {
	//patchFlag值大于0,意味着该元素的渲染代码是由编译器生成的,可以才用快速路径
	//在该路径中,保证旧节点和新节点具有相同的shape(即在源模板的完全相同的位置)
      if (patchFlag & PatchFlags.FULL_PROPS) {
        //元素包含了动态key,需要进行完全差异化运算
        patchProps(
          el,
          n2,
          oldProps,
          newProps,
          parentComponent,
          parentSuspense,
          isSVG
        )
      } else {
        // class类
        // 当元素具有绑定动态class类,匹配这个flag
        if (patchFlag & PatchFlags.CLASS) {
          if (oldProps.class !== newProps.class) {
            hostPatchProp(el, 'class', null, newProps.class, isSVG)
          }
        }

        // style
        // 当元素具有绑定动态style样式,匹配这个flag
        if (patchFlag & PatchFlags.STYLE) {
          hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
        }

        // props
        // 当元素具有除类和样式之外的动态prop/attr绑定时,匹配此flag。保存动态属性/属性的key以加快迭代。
        // 请注意,像[foo]=“bar”这样的动态key将导致此优化退出并进行完全差异运算,因为我们需要取消设置旧key。
        if (patchFlag & PatchFlags.PROPS) {
          // 如果该flag存在,则dynamicProps必须为非空
          const propsToUpdate = n2.dynamicProps!
          for (let i = 0; i < propsToUpdate.length; i++) {
            const key = propsToUpdate[i]
            const prev = oldProps[key]
            const next = newProps[key]
            if (next !== prev || key === 'value') {
              hostPatchProp(
                el,
                key,
                prev,
                next,
                isSVG,
                n1.children as VNode[],
                parentComponent,
                parentSuspense,
                unmountChildren
              )
            }
          }
        }
      }

      // text
      // 当元素只有动态文本子级时,匹配此flag。
      if (patchFlag & PatchFlags.TEXT) {
        if (n1.children !== n2.children) {
          hostSetElementText(el, n2.children as string)
        }
      }
    } else if  (!optimized && dynamicChildren == null) {
      // 未优化,完全差异运算
      patchProps(
        el,
        n2,
        oldProps,
        newProps,
        parentComponent,
        parentSuspense,
        isSVG
      )
    }
  }

这里能看到对于DOM节点的更新是跟props和子节点的更新,使用节点中的patchFlag和dynamicChildren的字段进行优化,从而避免了每次全量的对比更新。

patchElement.png

从图可以看到代码的判断过程,当拥有动态的子节点的时候,会进行patchBlockChildren方法处理。而非动态则会进入patchChildren。 当patchFlag>0的时候,会根据flag的情况进行全量或者局部的按需更新, 比如class改变,会调用hostPatchProp传入对应的参数class相关做特定的更新。

接下来看看图上几个函数的具体作用:

patchBlockChildren

DOM中嵌套的情况可谓司空见惯,因而DOM会以tree来形容。面对树结构的情况,需要拿到其中的数据,首先的便是遍历,因此对子节点更新也是一个深度优先遍历的过程,即是更新完当前节点后,回去更新当前节点的子节点,因为dynamicChildren已经收集了所有的动态子节点,所以只要直接遍历对比就可以了,vue3在这其中新增了一个optimized参数来标记以来阻止不必要的渲染。

//packages/runtime-core/src/renderer.ts
  // blocks的快速路径.
  const patchBlockChildren: PatchBlockChildrenFn = (
    oldChildren,
    newChildren,
    fallbackContainer,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds
  ) => {
    for (let i = 0; i < newChildren.length; i++) {
      const oldVNode = oldChildren[i]
      const newVNode = newChildren[i]
      // 确定patch的容器(父元素)
      const container =
        // oldVNode可能是Suspense中的一个错误的async setup()组件,它不会有一个已安装的元素。
        oldVNode.el &&
        // - 在片段的情况下,我们需要提供片段本身的实际父级 以便它能移动它的子类。
        (oldVNode.type === Fragment ||
          // - 在不同节点的情况下,会有一个替换,这也需要正确的父容器
          !isSameVNodeType(oldVNode, newVNode) ||
          // - 对于一个组件来说,它可以包含任何东西。
          oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
          ? hostParentNode(oldVNode.el)!
          : // 在其他情况下,父容器实际上没有被使用,所以我们只是在这里传递块元素以避免DOM parentNode的调用。
            fallbackContainer
      patch(
        oldVNode,
        newVNode,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        true
      )
    }
  }

这里其实就是直接遍历所有动态节点,对父级的容器进行一定的判断和处理后进行patch

patchChildren

当节点中不包含动态子节点,且没有优化标记的情况下,会对节点执行patchChildren函数进行逻辑处理。

//packages/runtime-core/src/renderer.ts
  const patchChildren: PatchChildrenFn = (
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized = false
  ) => {
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // 快速通道 patchFlag出现代表了子节点一定是数组。
    //当patchFlag中包含Fragment的含key和不含key的情况下的处理方法(略)
	//因为这里讲的而是当传入的是元素类型时,不会出现fragment,故而省略。(略)
	//...(略)

    // 子级有三种可能:文本、数组或无子级。
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 文本子级快速路径
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
	    //旧子节点是数组,新子节点是文本, 则卸载旧子节点
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
      }
      if (c2 !== c1) {
	    //旧子节点是文本或者空,新子节点是文本,则设置新文本
        hostSetElementText(container, c2 as string)
      }
    } else {
	    //新子节点是数组或者空
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        //老子节点是数组,新子节点也是数组
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 两个数组,不能假设任何情况,执行full diff
          patchKeyedChildren(
            c1 as VNode[],
            c2 as VNodeArrayChildren,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else {
          // 新节点是空,所以卸载旧子节点
          unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
        }
      } else {
        // 旧子节点是文本或者空
         // 新子节点是数组或者空
        if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        //旧子节点是文本,则清空文本内容
          hostSetElementText(container, '')
        }
        // 挂载新的如果是新的数组(因为不管旧节点是空还是文本,对于新节点是数组还是空来说,都是要清空的,所以这里只要旧节点是文本就清空。而新子节点为数组的时候只要挂载上去就行了。)
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          mountChildren(
            c2 as VNodeArrayChildren,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        }
      }
    }
  }

重温patchFlagshapeFlag:
patchChildren的过程中,利用到了patchFlagshapeFlag两个标记。 patchFlag是在编译template模板的时候,给vnode添加的一个标识信息,代表了vnode哪些部位绑定了动态值。这样可以通过这个标识判断哪些需要更新。 shapeFlag是用于判断vnode的类型,如,vnode.shapeFlag & ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN ,便指的是这个节点类型是元素,且他的子节点是数组。

patchChildren-shapeFlag.png

hostPatchProp

//packages/runtime-dom/src/patchProp.ts
export const patchProp: DOMRendererOptions['patchProp'] = (
  el,
  key,
  prevValue,
  nextValue,
  isSVG = false,
  prevChildren,
  parentComponent,
  parentSuspense,
  unmountChildren
) => {
//当key值为class时,更新class
  if (key === 'class') {
    patchClass(el, nextValue, isSVG)
    //当key值是style时,更新style
  } else if (key === 'style') {
    patchStyle(el, prevValue, nextValue)
  } else if (isOn(key)) {
    // 忽视 v-model 监听器
    if (!isModelListener(key)) {
      patchEvent(el, key, prevValue, nextValue, parentComponent)
    }
  } else if (
    key[0] === '.'
      ? ((key = key.slice(1)), true)
      : key[0] === '^'
      ? ((key = key.slice(1)), false)
      : shouldSetAsProp(el, key, nextValue, isSVG)
  ) {
    patchDOMProp(
      el,
      key,
      nextValue,
      prevChildren,
      parentComponent,
      parentSuspense,
      unmountChildren
    )
  } else {
   //...特殊情况处理
    patchAttr(el, key, nextValue, isSVG, parentComponent)
  }
}

看一看其中的class的更新方法 patchClass方法

//packages/runtime-dom/src/modules/class.ts
import { ElementWithTransition } from '../components/Transition'
// 编译器应将同一元素上的class+:class绑定规范化为单个绑定['staticClass',dynamic]
export function patchClass(el: Element, value: string | null, isSVG: boolean) {
  // 理论上,直接设置className应该比setAttribute更快。如果这是转换期间的元素,请考虑临时转换类。
  const transitionClasses = (el as ElementWithTransition)._vtc
  if (transitionClasses) {
    value = (
      value ? [value, ...transitionClasses] : [...transitionClasses]
    ).join(' ')
  }
  if (value == null) {
    el.removeAttribute('class')
  } else if (isSVG) {
    el.setAttribute('class', value)
  } else {
    el.className = value
  }
}

落实到最后便是调用原生DOM API来进行更新。

patchProps

源码里官方很直接的指出了,这部分是全量更新的函数。

//packages/runtime-core/src/renderer.ts
  const patchProps = (
    el: RendererElement,
    vnode: VNode,
    oldProps: Data,
    newProps: Data,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean
  ) => {
  //如果旧props不等于新props
    if (oldProps !== newProps) {
    //旧节点的props 不等于空集合
      if (oldProps !== EMPTY_OBJ) {
      //遍历旧props的所有的内容
        for (const key in oldProps) {
        //如果存在 newProps里不存在的属性则调用以下方法移除该属性
          if (!isReservedProp(key) && !(key in newProps)) {
            hostPatchProp(
              el,
              key,
              oldProps[key],
              null,//赋值为null,即为移除该属性
              isSVG,
              vnode.children as VNode[],
              parentComponent,
              parentSuspense,
              unmountChildren
            )
          }
        }
      }
      //遍历新Props所有内容
      for (const key in newProps) {
        // 空字符串不是有效的属性
        if (isReservedProp(key)) continue
        const next = newProps[key]
        const prev = oldProps[key]
        // 延迟修补值
        if (next !== prev && key !== 'value') {
          hostPatchProp(
            el,
            key,
            prev,
            next,
            isSVG,
            vnode.children as VNode[],
            parentComponent,
            parentSuspense,
            unmountChildren
          )
        }
      }
      //如果属性中有字符串value
      if ('value' in newProps) {
        hostPatchProp(el, 'value', oldProps.value, newProps.value)
      }
    }
  }

全量更新,就是直接的遍历oldProps和newProps,进行全部更新。

处理组件类型

示例代码:

<template>
	<div class="app">
		<hello :count="count" />
		<button @click='add'>点我</button>
	</div>
</template>
<script>
import {ref} from 'vue'
export default {
	setup(){
		const count = ref(0)
		function add(){
			count.value ++
		}
		return{
			count,
			add
		}
	}
}
</script>

//hello 组件
<template>
	<div>
		<p>hello. I'm have {{count}}</p>
	</div>
</template>
<script>
	export default{
		props:{
			count:Number
		}
	}
</script>

这个代码相对于之前的普通的元素标签相比,便是替换成了hello组件,hello组件接收count props,点击点我后就能让count的值加一。 这里的组件的根节点其实是<div>重新渲染的时候其实建立的子树节点是一个普通元素的节点,先会走processElement的逻辑,然后会落实到操作真实DOM的更新。然后继续进行遍历更新,便会更新到hello这个组件,从而到执行processComponent函数逻辑中。

//packages/runtime-core/src/renderer.ts
  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    n2.slotScopeIds = slotScopeIds
    if (n1 == null) {
	//...这是当旧节点不存在的时候的触发的首次渲染过程(略)
    } else {
    //更新组件
      updateComponent(n1, n2, optimized)
    }
  }

毕竟这篇讲的是更新部分的内容,便只关注updateComponent

//packages/runtime-core/src/renderer.ts
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
    const instance = (n2.component = n1.component)!
    //判断是否需要对组件进行更新。
    if (shouldUpdateComponent(n1, n2, optimized)) {
      //...异步且依旧等待,只是更新props和插槽,因为组件的反应式效果还没建立起来。
        // 普通更新
	    //将虚拟节点n2赋值给instance组件实例化的next属性中
        instance.next = n2
        //如果子组件也在队列中,请删除它以避免在同一刷新中重复更新同一子组件。
        invalidateJob(instance.update)
        // instance.update是反应性的效果。该方法便是在初次渲染setupRenderEffect时,所创建的响应式的effect函数。
        instance.update()
      }
    } else {
      // 无需更新。只需复制属性。
      n2.el = n1.el
      instance.vnode = n2
    }
  }

updateComponent的内部逻辑看,先将原先的component内容进行缓存,然后才会进入下一步的对组件进行判断需不需要进行更新的判断shouldUpdateComponent。如果进行判断之后需要继续更新的,则将新节点赋值给组件实例的next值中。而instance.updata是一个反应式的函数,在组件渲染过程中,副作用将会使用到instance.next中的值。

vue3的更新粒度是组件级别的,组件的更新是影响自身的数据更新的,不过依旧会对子组件进行检测,从而判断子组件是否也需要更新,并通过下面的机制防止子组件重复更新。

invalidateJob(instance.update)在源码官方备注中,直接说明如果子组件也在队列中,请删除它以避免在同一刷新中重复更新同一子组件。讲的更详细一点便是,因为instance.update是一个相应式的函数,而在组件的渲染过程中,父组件和其子组件是要经过不断遍历的,从而导致多次触发这个函数,所以在此过程中会进行检查,如果已经存在了,则删除,然后再触发。这样可以防止重复渲染。

然后便是触发instance.update,是由第一次渲染的时候在setupRenderEffect中创建的函数,使子组件重新触发一遍自己的副作用渲染函数,然后继续调用patch子组件的模板,继续上面的流程。

下面是shouldUpdateComponent的详细代码;

//packages/runtime-core/src/componentRenderUtils.ts
export function shouldUpdateComponent(
  prevVNode: VNode,
  nextVNode: VNode,
  optimized?: boolean
): boolean {
//取出之前节点和新节点的props和children的值,和旧节点的component和新节点的patchFlag
  const { props: prevProps, children: prevChildren, component } = prevVNode
  const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
  const emits = component!.emitsOptions
	//对比
  if (nextVNode.dirs || nextVNode.transition) {
    return true
  }
  if (optimized && patchFlag >= 0) {
    if (patchFlag & PatchFlags.DYNAMIC_SLOTS) {
      //引用可能已更改的值的槽内容,
      // e.g. in a v-for
      return true
    }
    if (patchFlag & PatchFlags.FULL_PROPS) {
      if (!prevProps) {
        return !!nextProps
      }
      // 此标志的存在表明props始终为非空
      return hasPropsChanged(prevProps, nextProps!, emits)
      //判断新节点的patchFlag是否有动态props
    } else if (patchFlag & PatchFlags.PROPS) {
      const dynamicProps = nextVNode.dynamicProps!
      for (let i = 0; i < dynamicProps.length; i++) {
        const key = dynamicProps[i]
        if (
          nextProps![key] !== prevProps![key] &&
          !isEmitListener(emits, key)
        ) {
          return true
        }
      }
    }
  } else {
    // 此路径仅由手动编写的渲染函数使用,因此任何子级的存在都会导致强制更新
    if (prevChildren || nextChildren) {
      if (!nextChildren || !(nextChildren as any).$stable) {
        return true
      }
    }
    //旧的props和新的props
    if (prevProps === nextProps) {
      return false
    }
    //不存在旧的props
    if (!prevProps) {
      return !!nextProps
    }
    //不存在新的props
    if (!nextProps) {
      return true
    }
    return hasPropsChanged(prevProps, nextProps, emits)
  }
  return false
}

从新旧节点通过解构赋值获取,props,children,和旧节点的component和新节点的patchFlag。对比各自的dirs,transition,props,children等属性来判断是否需要进行更新。这样做可以避免进行不必要的渲染。

总结

按照举例组件的运行逻辑,组件的根组件是app,在根组件app的模板<template>中的根元素是<div> ,而<div>中还有一个<hello>组件和<button>元素。app<hello>中会通过props进行count数据通信,点击<button>count会加1。

当点击点击按钮后,count数据的更新,便是触发了重新渲染更新,其表现出的响应式的特点,便是在初次渲染的中,由setupRenderEffect函数,在组件实例instance中添加的的响应式update方法,使之能在数据改变的时候自动调用其副作用函数从而改变数据重新渲染。

 const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    const componentUpdateFn = () => {
      if (!instance.isMounted) {
      //...首次渲染(略)
      } else {
        let { next, vnode } = instance
        //next代表着新的组件vnode
        if (next) {
          next.el = vnode.el
          //更新组件vnode节点信息
          updateComponentPreRender(instance, next, optimized)
        } else {
          next = vnode
        }
        const nextTree = renderComponentRoot(instance)
        const prevTree = instance.subTree
        instance.subTree = nextTree
        patch(
          prevTree,
          nextTree,
          hostParentNode(prevTree.el!)!,
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          isSVG
        )
        next.el = nextTree.el
      }
    }
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update),
      instance.scope
    ))
    const update: SchedulerJob = (instance.update = () => effect.run())
    update.id = instance.uid
    update()
  }

继续刚才举例app组件的逻辑下去,在开始是属于app组件自身的变化,所以这个时候是不存在next的值的,所以接下来是渲染新的子组件vnode,然后得到真实模板的vnode nextTree,根据新旧 subTree进行patch

在app组件中的模板内,首先是根元素div的普通元素类型,便会进入更新普通元素类型的逻辑,先更新props,然后更新子节点,当前div的子节点便是组件类型的hello和普通元素类型的button先更新hello组件在更新button元素。

更新hello组件的时候,会将组件instance.next赋值为hello子组件的vnode,然后在主动调用update触发上面的副作用渲染函数.因为父组件的add操作更新影响了子组件的数据,所以这次实例是hello组件next存在值。触发下面对组件渲染前的对组件props和插槽等的更新。之后便是上面同样的逻辑,进入patch

从渲染副作用函数中的update中,这部分代码中

const updateComponentPreRender = (
    instance: ComponentInternalInstance,
    nextVNode: VNode,
    optimized: boolean
  ) => {
	//新组件vnode的component属性指向了组件实例
    nextVNode.component = instance
    //组件实例的props属性
    const prevProps = instance.vnode.props
    //组件实例指向新节点
    instance.vnode = nextVNode
    //清空next属性,以便下一次重新渲染
    instance.next = null
    //更新props
    updateProps(instance, nextVNode.props, prevProps, optimized)
    //更新插槽
    updateSlots(instance, nextVNode.children, optimized)

    pauseTracking()
	//props更新可能触发了预刷新观察者。
	//在渲染更新之前刷新它们。
    flushPreFlushCbs()
    resetTracking()
  }

根据以上的代码,在更新组件的DOM之前,会进行一个updateComponentPreRender的处理,将更改实例中的vnode指向,更新props和更新插槽等逻辑。在后面的renderComponentRoot会重新渲染新的子树vnode。他包含了更新后的组件中的相关数据。

组件更新有2种情况。一种是组件自身数据引起的变化,这种情况下next值为null。另一种是父组件的更新过程中,自然对子组件进行判断是否需要更新,如果需要子组件执行重新渲染的方法,那么next就是新子组件的vnode,也就是父组件更新子组件的时候引发的update。从而完成组件的更新渲染。

下图是对组件渲染的一个更新渲染流程:

update.png

之前的文章:

后续相关: