渲染器:组件是如何完成更新的?
了解了数据访问代理的过程以及组件实例初始化的过程,接下来,我们将介绍组件的更新逻辑,这部分逻辑主要包含在 [setupRenderEffect]{.mark} 这个函数中。
前言
了解了数据访问代理的过程以及组件实例初始化的过程,接下来,我们将介绍组件的更新逻辑,这部分逻辑主要包含在 [setupRenderEffect]{.mark} 这个函数中。
组件更新
const setupRenderEffect = (instance, initialVNode, container,
anchor, parentSuspense, isSVG, optimized) => {
function componentUpdateFn() {
if (!instance.isMounted) {
// 初始化组件
}
else {
// 更新组件
}
}
// 创建响应式的副作用渲染函数
instance.update = effect(componentUpdateFn, prodEffectOptions)
}
在前面的小节中,我们说完了关于 [mounted]{.mark} 的流程。接下来我们将着重来看一下组件更新的逻辑:
const setupRenderEffect = (instance, initialVNode, container,
anchor, parentSuspense, isSVG, optimized) => {
function componentUpdateFn() {
if (!instance.isMounted) {
// 初始化组件
}
else {
// 更新组件
let { next, vnode } = instance
// 如果有 next 的话说明需要更新组件的数组(props, slot 等)
if (next) {
next.el = vnode.el
// 更新组件实例信息
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 获取新的子树 vnode
const nextTree = renderComponentRoot(instance)
// 获取旧的子树 vnode
const prevTree = instance.subTree
// 更新子树 vnode
instance.subTree = nextTree
// patch 新老子树的 vnode
patch(
prevTree,
nextTree,
// 处理 teleport 相关
hostParentNode(prevTree.el),
// 处理 fragment 相关
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG)
// 缓存更新后的 DOM 节点
next.el = nextTree.el
}
}
// 创建响应式的副作用渲染函数
instance.update = effect(componentUpdateFn, prodEffectOptions)
}
这里的核心流程是通过 [next]{.mark} 来判断当前是否需要更新 [vnode]{.mark} 的节点信息,然后渲染出新的子树 [nextTree]{.mark},再进行比对新旧子树并找出需要更新的点,进行 [DOM]{.mark} 更新。我们先来看一下 [patch]{.mark} 的更新流程:
function patch(n1, n2, container = null, anchor = null,
parentComponent = null) {
// 对于类型不同的新老节点,直接进行卸载
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// 基于 n2 的类型来判断
// 因为 n2 是新的 vnode
const { type, shapeFlag } = n2;
switch (type) {
case Text:
processText(n1, n2, container);
break;
// 其中还有几个类型比如: static fragment comment
case Fragment:
processFragment(n1, n2, container);
break;
default:
// 这里就基于 shapeFlag 来处理
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor, parentComponent);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container, parentComponent);
}
}
}
首先判断当 [n1]{.mark} 存在,即存在老节点,但新节点和老节点不是同类型的节点情况,那么执行销毁老节点,新增新节点。那么 [Vue]{.mark} 如何判断是否是不同类型的节点呢?答案就在 [isSameVNodeType]{.mark} 函数中:
export function isSameVNodeType(n1, n2) {
// 新老节点的 type 和 key 都相同
return n1.type === n2.type && n1.key === n2.key
}
这里比如从 [div]{.mark} 变成了 [p]{.mark} 标签,那么 [isSameVNodeType]{.mark} 就会是个 [false]{.mark}。
如果当新老节点是同类型的节点,则会根据 [shapeFlag]{.mark}不同走到不同的逻辑,如果是普通元素更新,那么就会走到 [processElement]{.mark} 的逻辑中;如果是组件更新,则会走到 [processComponent]{.mark} 中。
接下来分别看看这两种更新机制有什么不同。
processElement
这里我们也着重看一下 [processElement]{.mark} 的更新流程:
const processElement = (n1, n2, container, anchor,
parentComponent, parentSuspense, isSVG, optimized) => {
isSVG = isSVG || n2.type === 'svg'
if (n1 == null) {
// 初始化的过程
}
else {
// 更新的过程
patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
}
}
[processElement]{.mark} 更新逻辑调用 [patchElement]{.mark} 函数:
const patchElement = (n1, n2, parentComponent, parentSuspense,
isSVG, optimized) => {
const el = (n2.el = n1.el)
let { patchFlag, dynamicChildren, dirs } = n2
// ...
// 旧节点的 props
const oldProps = (n1 && n1.props) || EMPTY_OBJ
// 新节点的 props
const newProps = n2.props || EMPTY_OBJ
// 对比 props 并更新
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense,
isSVG)
// 先省略 dynamicChildren 的逻辑,后续介绍...
// 全量比对子节点更新
patchChildren(n1, n2, el, null, parentComponent, parentSuspense,
areChildrenSVG)
}
可以看到普通元素的更新主要做的就是先更新 [props]{.mark} ,当 [props]{.mark} 更新完成后,然后再统一更新子节点。关于如何进行 [patchProps]{.mark} 做节点的属性更新不是本小节的重点,这里先跳过。
这里省略了对 [dynamicChildren]{.mark} 存在时,执行 [patchBlockChildren]{.mark} 的优化 [diff]{.mark} 过程,我们直接先看全量 [diff]{.mark} 也就是 [patchChildren]{.mark} 函数。关于 [patchBlockChildren]{.mark} 我们将在编译过程中的优化小节中进行详细介绍
接着来看 [patchChildren]{.mark} 更新子节点的函数:
const patchChildren = (n1, n2, container, anchor,
parentComponent, parentSuspense, isSVG, optimized = false) => {
// c1 代表旧节点的子节点元素
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
// c2 代表新节点的子节点元素
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
// 新节点是文本
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 旧节点是数组
if (prevShapeFlag & ARRAY_CHILDREN) {
// 卸载旧节点
unmountChildren(c1, parentComponent, parentSuspense)
}
if (c2 !== c1) {
// 新旧节点都是文本,但内容不一样,则替换
hostSetElementText(container, c2)
}
} else {
// 新节点不为文本
// 旧节点是数组
if (prevShapeFlag & ARRAY_CHILDREN) {
// 新节点也是数组
if (shapeFlag & ARRAY_CHILDREN) {
// 进行新旧节点的 diff
patchKeyedChildren(c1, c2, container, anchor, parentComponent,
parentSuspense, isSVG, optimized)
} else {
// 卸载旧节点
unmountChildren(c1, parentComponent, parentSuspense, true)
}
} else {
// 新节点不为文本
// 旧节点不是数组
// 旧节点是文本
if (prevShapeFlag & TEXT_CHILDREN) {
// 则把它清空
hostSetElementText(container, '')
}
// 新节点是数组
if (shapeFlag & ARRAY_CHILDREN) {
// 挂载新节点
mountChildren(c2, container, anchor, parentComponent, parentSuspense,
isSVG, optimized)
}
}
}
}
对于子节点来说,节点类型只会有三种可能,分别是:文本节点、数组节点、空节点。所以这个方法里所有的 [if
else]{.mark} 分支就是在考虑新旧节点可能的全部情况,并进行相应的处理。这里流程分支有点多,画个图大家就明白在做啥了:
其中新旧节点都是数组的情况涉及到我们平常所说的 [diff]{.mark} 算法,会放到后面专门去解析。
看完处理[DOM]{.mark}元素的情况,接下来看处理[vue]{.mark}组件。
processComponent
const processComponent = (n1, n2, container, anchor,
parentComponent, parentSuspense, isSVG, optimized) => {
if (n1 == null) {
// 初始化的过程
}
else {
// 更新的过程
updateComponent(n1, n2, parentComponent, optimized)
}
}
[processComponent]{.mark} 更新逻辑调用 [updateComponent]{.mark} 函数:
const updateComponent = (n1, n2, optimized) => {
const instance = (n2.component = n1.component)!
// 根据新老节点判断是否需要更新子组件
if (shouldUpdateComponent(n1, n2, optimized)) {
//...
// 如果需要更新,则将新节点 vnode 赋值给 next
instance.next = n2
// 执行前面定义在 instance 上的 update 函数。
instance.update()
} else {
// 如果不需要更新,则将就节点的内容更新到新节点上即可
n2.el = n1.el
instance.vnode = n2
}
}
[updateComponent]{.mark} 函数首先通过 [shouldUpdateComponent]{.mark} 函数来判断当前是否需要更新。
因为有些 [VNode]{.mark} 值的变化并不需要立即显示更新子组件,举个例子:
<template>
<div></div>
<Child />
</template>
<script setup>
import { ref } from 'vue'
const msg = ref('hello')
<script>
因为子组件不依赖父组件的状态数据,所以子组件是不需要更新的。这也从侧面反映出 [Vue]{.mark} 的更新不仅是组件层面的细粒度更新,更在源码层面帮我们处理了一些不必要的子节点更新!
最后执行的 [instance.update]{.mark},这个函数其实就是在 [setupRenderEffect]{.mark} 内创建的。最终子组件的更新还会走一遍自己副作用函数的渲染,然后 [patch]{.mark} 子组件的子模板 [DOM]{.mark},接上上面的流程。
回过头来再看这里我们多次出现了 [next]{.mark} 变量。为了更好地理解整体的流程,我们再来看一个 [demo]{.mark}:
<template>
<div>
hello world
<hello :msg="msg" />
<button @click="changeMsg">修改 msg</button>
</div>
</template>
<script>
import { ref } from 'vue'
export default {
setup () {
const msg = ref('你好')
function changeMsg() {
msg.value = '你好啊,我变了'
}
return {
msg,
changeMsg
}
}
}
</script>
// hello.vue
<template>
<div>
</div>
</template>
<script>
export default {
props: {
msg: String
}
}
</script>
这里有个 [App.vue]{.mark} 组件,内部有一个 [hello]{.mark} 组件,我们来从头再捋一下整体的流程,就清楚了 [next]{.mark} 的作用。
当点击
修改 msg 后,
App 组件自身的数据变化,导致
App 组件进入
update 逻辑,此时是没有
next 的,接下来渲染新的子组件
vnode,得到真实的模板
vnode nextTree,用新旧
subTree进行
patch。
此时
patch的元素类型是
div,进入更新普通元素的流程,先更新
props,再更新子节点,当前
div下的子节点有
Hello组件时,进入组件的的更新流程。
在更新
Hello 组件时,根据
updateComponent 函数执行的逻辑,会先将
Hello组件
instance.next 赋值为最新的子组件
vnode,之后再主动调用
instance.update 进入上面的副作用渲染函数,这次的实例是
Hello 组件自身的渲染,且
next 存在值。
当 [next]{.mark} 存在时,会执行 [updateComponentPreRender]{.mark} 函数:
const updateComponentPreRender = (instance, nextVNode,
optimized) => {
// 新节点 vnode.component 赋值为 instance
nextVNode.component = instance
// 获取老节点的 props
const prevProps = instance.vnode.props
// 为 instance.vnode 赋值为新的组件 vnode
instance.vnode = nextVNode
instance.next = null
// 更新 props
updateProps(instance, nextVNode.props, prevProps, optimized)
// 更新 slots
updateSlots(instance, nextVNode.children)
}
[updateComponentPreRender]{.mark} 函数核心功能就是完成了对实例上的属性、[vnode]{.mark} 信息、[slots]{.mark} 进行更新,当后续组件渲染的时候,得到的就是最新的值。
总而言之,[next]{.mark} 就是用来标记接下来需要渲染的子组件,如果 [next]{.mark} 存在,则会进行子组件实例相关内容属性的更新操作,再进行子组件的更新流程。
总结
本节着重介绍了组件的更新逻辑,我们再补齐一下第二节中的流程图:
本文介绍了关于普通元素的简单更新过程,那关于复杂的更新过程的逻辑,也就是新老子节点都是数组的普通元素,应该如何进行更新呢?这就涉及到了 [diff]{.mark} 算法