第四篇:组件更新:完整的 DOM diff 流程是怎样的?(上)

news2025/2/25 8:55:02

上一节课我们梳理了组件渲染的过程,本质上就是把各种类型的 vnode 渲染成真实 DOM。我们也知道了组件是由模板、组件描述对象和数据构成的,数据的变化会影响组件的变化。组件的渲染过程中创建了一个带副作用的渲染函数,当数据变化的时候就会执行这个渲染函数来触发组件的更新。那么接下来,我们就具体分析一下组件的更新过程。

1. 副作用渲染函数更新组件的过程

我们先来回顾一下带副作用渲染函数 setupRenderEffect 的实现,但是这次我们要重点关注更新组件部分的逻辑:

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
    // 创建响应式的副作用渲染函数
    instance.update = effect(function componentEffect() {
        if (!instance.isMounted) {
            // 渲染组件
        }else {
            // 更新组件
            let { next, vnode } = instance
            // next 表示新的组件 vnode
            if (next) {
                // 更新组件 vnode 节点信息
                updateComponentPreRender(instance, next, optimized)
            }else {
                next = vnode
            }
            // 渲染新的子树 vnode
            const nextTree = renderComponentRoot(instance)
            // 缓存旧的子树 vnode
            const prevTree = instance.subTree
            // 更新子树 vnode
            instance.subTree = nextTree
            // 组件更新核心逻辑,根据新旧子树 vnode 做 patch
            patch(prevTree, nextTree,
                // 如果在 teleport 组件中父节点可能已经改变,所以容器直接找旧树 DOM 元素的父节点
                hostParentNode(prevTree.el),
                // 参考节点在 fragment 的情况可能改变,所以直接找旧树 DOM 元素的下一个节点
                getNextHostNode(prevTree),
                instance,
                parentSuspense,
                isSVG)
            // 缓存更新后的 DOM 节点
            next.el = nextTree.el
        }
    }, prodEffectOptions)
}

可以看到,更新组件主要做三件事情:更新组件 vnode 节点、渲染新的子树 vnode、根据新旧子树 vnode 执行 patch 逻辑。

首先是更新组件 vnode 节点,这里会有一个条件判断,判断组件实例中是否有新的组件 vnode(用 next 表示),有则更新组件 vnode,没有 next 指向之前的组件 vnode。为什么需要判断,这其实涉及一个组件更新策略的逻辑,我们稍后会讲。

接着是渲染新的子树 vnode,因为数据发生了变化,模板又和数据相关,所以渲染生成的子树 vnode 也会发生相应的变化。

最后就是核心的 patch 逻辑,用来找出新旧子树 vnode 的不同,并找到一种合适的方式更新 DOM,接下来我们就来分析这个过程。

2. 核心逻辑:patch 流程

我们先来看 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 保证后续都走 mount 逻辑
        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
            }
    }
}
function isSameVNodeType(n1, n2) {
    // n1 和 n2 节点的 type 和 key 都相同,才是相同节点
    return n1.type === n2.type && n1.key === n2.key
}

在这个过程中,首先判断新旧节点是否是相同的 vnode 类型,如果不同,比如一个 div 更新成一个 ul,那么最简单的操作就是删除旧的 div 节点,再去挂载新的 ul 节点。

如果是相同的 vnode 类型,就需要走 diff 更新流程了,接着会根据不同的 vnode 类型执行不同的处理逻辑,这里我们仍然只分析普通元素类型和组件类型的处理过程。

2.1. 处理组件

如何处理组件的呢?举个例子,我们在父组件 App 中里引入了 Hello 组件:

<template>
    <div class="app">
        <p>This is an app.</p>
        <hello :msg="msg"></hello>
        <button @click="toggle">Toggle msg</button>
    </div>
</template>
<script>
    export default {
        data() {
            return {
                msg: 'Vue'
            }
        },
        methods: {
            toggle() {
                this.msg = this.msg ==== 'Vue'? 'World': 'Vue'
            }
        }
  }
</script>

Hello 组件中是 <div> 包裹着一个 <p> 标签, 如下所示:

<template>
    <div class="hello">
        <p>Hello, {{ msg }}</p>
    </div>
</template>
<script>
export default {
    props: {
        msg: String,
    },
};
</script>

点击 App 组件中的按钮执行 toggle 函数,就会修改 data 中的 msg,并且会触发App 组件的重新渲染。

结合前面对渲染函数的流程分析,这里 App 组件的根节点是 div 标签,重新渲染的子树 vnode 节点是一个普通元素的 vnode,应该先走 processElement 逻辑。组件的更新最终还是要转换成内部真实 DOM 的更新,而实际上普通元素的处理流程才是真正做 DOM 的更新,由于稍后我们会详细分析普通元素的处理流程,所以我们先跳过这里,继续往下看。

和渲染过程类似,更新过程也是一个树的深度优先遍历过程,更新完当前节点后,就会遍历更新它的子节点,因此在遍历的过程中会遇到 hello 这个组件 vnode 节点,就会执行到 processComponent 处理逻辑中,我们再来看一下它的实现,我们重点关注一下组件更新的相关逻辑:

const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
    if (n1 == null) {
        // 挂载组件
    }else {
        // 更新子组件
        updateComponent(n1, n2, parentComponent, optimized)
    }
}
const updateComponent = (n1, n2, parentComponent, optimized) => {
    const instance = (n2.component = n1.component)
    // 根据新旧子组件 vnode 判断是否需要更新子组件
    if (shouldUpdateComponent(n1, n2, parentComponent, optimized)) {
        // 新的子组件 vnode 赋值给 instance.next
        instance.next = n2
        // 子组件也可能因为数据变化被添加到更新队列里了,移除它们防止对一个子组件重复更新
        invalidateJob(instance.update)
        // 执行子组件的副作用渲染函数
        instance.update()
    }else {
        // 不需要更新,只复制属性
        n2.component = n1.component
        n2.el = n1.el
    }
}

可以看到,processComponent 主要通过执行 updateComponent 函数来更新子组件,updateComponent 函数在更新子组件的时候,会先执行 shouldUpdateComponent 函数,根据新旧子组件 vnode 来判断是否需要更新子组件。这里你只需要知道,在 shouldUpdateComponent 函数的内部,主要是通过检测和对比组件 vnode 中的 props、chidren、dirs、transiton 等属性,来决定子组件是否需要更新。

这是很好理解的,因为在一个组件的子组件是否需要更新,我们主要依据子组件 vnode 是否存在一些会影响组件更新的属性变化进行判断,如果存在就会更新子组件。

虽然 Vue.js 的更新粒度是组件级别的,组件的数据变化只会影响当前组件的更新,但是在组件更新的过程中,也会对子组件做一定的检查,判断子组件是否也要更新,并通过某种机制避免子组件重复更新。

我们接着看 updateComponent 函数,如果 shouldUpdateComponent 返回 true ,那么在它的最后,先执行 invalidateJob(instance.update)避免子组件由于自身数据变化导致的重复更新,然后又执行了子组件的副作用渲染函数 instance.update 来主动触发子组件的更新。

再回到副作用渲染函数中,有了前面的讲解,我们再看组件更新的这部分代码,就能很好地理解它的逻辑了:

// 更新组件
let { next, vnode } = instance
// next 表示新的组件 vnode
if (next) {
    // 更新组件 vnode 节点信息
    updateComponentPreRender(instance, next, optimized)
}else {
    next = vnode
}
const updateComponentPreRender = (instance, nextVNode, optimized) => {
    // 新组件 vnode 的 component 属性指向组件实例
    nextVNode.component = instance
    // 旧组件 vnode 的 props 属性
    const prevProps = instance.vnode.props
    // 组件实例的 vnode 属性指向新的组件 vnode
    instance.vnode = nextVNode
    // 清空 next 属性,为了下一次重新渲染准备
    instance.next = null
    // 更新 props
    updateProps(instance, nextVNode.props, prevProps, optimized)
    // 更新 插槽
    updateSlots(instance, nextVNode.children)
}

结合上面的代码,我们在更新组件的 DOM 前,需要先更新组件 vnode 节点信息,包括更改组件实例的 vnode 指针、更新 props 和更新插槽等一系列操作,因为组件在稍后执行 renderComponentRoot 时会重新渲染新的子树 vnode ,它依赖了更新后的组件 vnode 中的 props 和 slots 等数据。

所以我们现在知道了一个组件重新渲染可能会有两种场景,一种是组件本身的数据变化,这种情况下 next 是 null;另一种是父组件在更新的过程中,遇到子组件节点,先判断子组件是否需要更新,如果需要则主动执行子组件的重新渲染方法,这种情况下 next 就是新的子组件 vnode。

你可能还会有疑问,这个子组件对应的新的组件 vnode 是什么时候创建的呢?答案很简单,它是在父组件重新渲染的过程中,通过 renderComponentRoot 渲染子树 vnode 的时候生成,因为子树 vnode 是个树形结构,通过遍历它的子节点就可以访问到其对应的组件 vnode。再拿我们前面举的例子说,当 App 组件重新渲染的时候,在执行 renderComponentRoot 生成子树 vnode 的过程中,也生成了 hello 组件对应的新的组件 vnode。

所以 processComponent 处理组件 vnode,本质上就是去判断子组件是否需要更新,如果需要则递归执行子组件的副作用渲染函数来更新,否则仅仅更新一些 vnode 的属性,并让子组件实例保留对组件 vnode 的引用,用于子组件自身数据变化引起组件重新渲染的时候,在渲染函数内部可以拿到新的组件 vnode。

前面也说过,组件是抽象的,组件的更新最终还是会落到对普通 DOM 元素的更新。所以接下来我们详细分析一下组件更新中对普通元素的处理流程。

2.2. 处理普通元素

我们再来看如何处理普通元素,我把之前的示例稍加修改,将其中的 Hello 组件删掉,如下所示:

<template>
    <div class="app">
        <p>This is {{ msg }}.</p>
        <button @click="toggle">Toggle msg</button>
    </div>
</template>
<script>
export default {
    data() {
        return {
            msg: "Vue",
        };
    },
    methods: {
        toggle() {
            this.msg = "Vue" ? "World" : "Vue";
        },
    },
};
</script>

当我们点击 App 组件中的按钮会执行 toggle 函数,然后修改 data 中的 msg,这就触发了 App 组件的重新渲染。

App 组件的根节点是 div 标签,重新渲染的子树 vnode 节点是一个普通元素的 vnode,所以应该先走 processElement 逻辑,我们来看这个函数的实现:

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)
    }
}
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, optimized) => {
    const el = (n2.el = n1.el)
    const oldProps = (n1 && n1.props) || EMPTY_OBJ
    const newProps = n2.props || EMPTY_OBJ
    // 更新 props
    patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
    const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
    // 更新子节点
    patchChildren(n1, n2, el, null, parentComponent, parentSuspense, areChildrenSVG)
}

可以看到,更新元素的过程主要做两件事情:更新 props 和更新子节点。其实这是很好理解的,因为一个 DOM 节点元素就是由它自身的一些属性和子节点构成的。

首先是更新 props,这里的 patchProps 函数就是在更新 DOM 节点的 class、style、event 以及其它的一些 DOM 属性,这个过程我不再深入分析了,感兴趣的同学可以自己看这部分代码。

其次是更新子节点,我们来看一下这里的 patchChildren 函数的实现:

const patchChildren = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized = false) => {
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children
    const { shapeFlag } = n2
    // 子节点有 3 种可能情况:文本、数组、空
    if (shapeFlag & 8 /* TEXT_CHILDREN */) {
        if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
            // 数组 -> 文本,则删除之前的子节点
            unmountChildren(c1, parentComponent, parentSuspense)
        }

        if (c2 !== c1) {
            // 文本对比不同,则替换为新文本
            hostSetElementText(container, c2)
        }

    }else {
        if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
            // 之前的子节点是数组
            if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
                // 新的子节点仍然是数组,则做完整地 diff
                patchKeyedChildren(c1, c2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
            }else {
                // 数组 -> 空,则仅仅删除之前的子节点
                unmountChildren(c1, parentComponent, parentSuspense, true)

            }
        }else {
            // 之前的子节点是文本节点或者为空
            // 新的子节点是数组或者为空
            if (prevShapeFlag & 8 /* TEXT_CHILDREN */) {
                // 如果之前子节点是文本,则把它清空
                hostSetElementText(container, '')
            }
            if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
                // 如果新的子节点是数组,则挂载新子节点
                mountChildren(c2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
            }
        }

    }

}

对于一个元素的子节点 vnode 可能会有三种情况:纯文本、vnode 数组和空。那么根据排列组合对于新旧子节点来说就有九种情况,我们可以通过三张图来表示。

旧子节点是纯文本的情况:

1. 如果新子节点也是纯文本,那么做简单地文本替换即可;

2. 如果新子节点是空,那么删除旧子节点即可;

3. 如果新子节点是 vnode 数组,那么先把旧子节点的文本清空,再去旧子节点的父容器下添加多个新子节点;

 旧子节点是空的情况:

1. 如果新子节点是纯文本,那么在旧子节点的父容器下添加新文本节点即可;

2. 如果新子节点也是空,那么什么都不需要做;

3. 如果新子节点是 vnode 数组,那么直接去旧子节点的父容器下添加多个新子节点即可;

旧子节点是 vnode 数组的情况:

1. 如果新子节点是纯文本,那么先删除旧子节点,再去旧子节点的父容器下添加新文本节点;

2. 如果新子节点是空,那么删除旧子节点即可;

3. 如果新子节点也是 vnode 数组,那么就需要做完整的 diff 新旧子节点了,这是最复杂的情况,内部运用了核心 diff 算法;

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/863140.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

判断时间段是否重叠

1、逻辑公式 时间段1&#xff1a;start1&#xff08;开始时间&#xff09;&#xff0c;end1&#xff08;结束时间&#xff09; 时间段2&#xff1a;start2&#xff08;开始时间&#xff09;&#xff0c;end2&#xff08;结束时间&#xff09; 重叠条件为&#xff1a;start1 <…

ad+硬件每日学习十个知识点(30)23.8.10 (SDIO端口扩展器TXS02612RTWR,模数转换器ADC121C027)

文章目录 1.cpu->SDIO端口扩展器->SD卡槽->SD卡(当然也可以反向读取)2.SDIO端口扩展器介绍3.SDIO端口扩展器TXS02612RTWR4.SD卡槽5.什么是模数转换器&#xff1f;6.I2C模数转换器ADC121C0277.模数转换方案 1.cpu->SDIO端口扩展器->SD卡槽->SD卡(当然也可以反…

win11(不符合要求)安装方法

win11&#xff08;不符合要求&#xff09;安装方法&#xff1a; 注意&#xff01;这样安装win11可能会导致文件丢失&#xff0c;请备份重要文件&#xff08;会删除c盘很多文件&#xff0c;可以在c盘Windows.old文件夹恢复。 第一步、下载win11ISO镜像&#xff1a;恰到好处系统…

【C++】开源:spdlog跨平台日志库配置使用

&#x1f60f;★,:.☆(&#xffe3;▽&#xffe3;)/$:.★ &#x1f60f; 这篇文章主要介绍spdlog日志库配置使用。 无专精则不能成&#xff0c;无涉猎则不能通。——梁启超 欢迎来到我的博客&#xff0c;一起学习&#xff0c;共同进步。 喜欢的朋友可以关注一下&#xff0c;下…

虚拟机安装国产操作系统的方法

1.这里以银河麒麟为例&#xff0c;其他以liunx为基础的国产操作系统都是一样的方法。 2.下载操作系统如下&#xff08;选第一个就行&#xff09;&#xff1a; 任选其一下载&#xff1a; 3.安装虚拟机软件(这里以virtualbox为例&#xff0c;vmare也是一样都可以) 4.打开虚拟机…

Django配置(部署环境较乱,暂时启用)

django配置 web服务器中部署项目及WSGI简介 web服务器 WSGI 在IIS中部署django项目 安装 wfastcgi &#xff1a;pip install wfastcgi安装IIS&#xff1a; 以上选择项勾选后确定 将CGI文件复制到项目中&#xff0c; 将项目复制到IIS默认目录中 部署IIS 添加变量信息如下…

Apache Paimon 流式数据湖 V 0.4 与后续展望

摘要&#xff1a;本文整理自阿里云开源大数据表存储团队负责人、阿里巴巴高级技术专家&#xff0c;Apache Flink PMC&#xff0c;Paimon PPMC 李劲松&#xff08;之信&#xff09;在 Apache Paimon Meetup 的分享。本篇内容主要分为四个部分&#xff1a; 1. 湖存储上的难点 2. …

“海纳“二维码生成器(绿色版本,离线无需安装)

介绍一款所见即所得的二维码生成器&#xff1a;"海纳"二维码生成器&#xff0c;免费、离线&#xff0c;简单、快捷。 主要功能&#xff1a; 图形界面&#xff0c;所见即所得&#xff1b;支持数字、字符、汉字等生成二维码&#xff1b;支持网址、邮件地址&#xff1…

Nodejs+vue+elementui汽车租赁管理系统_1ma2x

语言 node.js 框架&#xff1a;Express 前端:Vue.js 数据库&#xff1a;mysql 数据库工具&#xff1a;Navicat 开发软件&#xff1a;VScode 前端nodejsvueelementui, 课题主要分为三大模块&#xff1a;即管理员模块、用户模块和普通管理员模块&#xff0c;主要功能包括&#…

EMC传导干扰滤波电路设计

1.EMC概念 2.EMC 传导干扰详解 EMC传导滤波电路的设计--传导干扰详解 3.EMC 传导干扰的测量方法 4.EMC 滤波电路设计 5.浪涌抑制电路设计 6.开关电源的安全要求 7.当前开关电源灯的应用

恒盛策略:怎样看k线图实图详解如何看懂k线图?

K线图是股票剖析中常用的一种图表&#xff0c;它能够反映一段时间内股票价格的变化状况&#xff0c;对于股票投资者来说非常重要。但是&#xff0c;由于k线图并不是很好理解&#xff0c;很多投资者并不知道怎样看懂它。那么&#xff0c;咱们就从多个视点来看看怎样看k线图实图&…

echarts barEchart柱状图边框、透明、文字靠左展示

效果一 先上效果图 说明&#xff1a; 给柱状图加上 边框&#xff0c;改变柱状图颜色并透明&#xff0c;改变 X,Y轴字体颜色&#xff0c;改变 轴线颜色&#xff0c;将所有的轴线全都改成一致效果 echarts 的代码 javascript const renderBarEcharts (ele: HTMLDivElement) >…

CM11 链表分割 题解

题目描述&#xff1a; 链表分割_牛客题霸_牛客网 (nowcoder.com) 现有一链表的头指针 ListNode* pHead&#xff0c;给一定值x&#xff0c;编写一段代码将所有小于x的结点排在其余结点之前&#xff0c;且不能改变原来的数据顺序&#xff0c;返回重新排列后的链表的头指针。 题解…

交换机的堆叠技术

目录 一、堆叠的优势 1、提高可靠性 2、简化组网 3、简化管理 4、强大的网络拓展 二、堆叠的方式 1、堆叠卡堆叠 2、业务口堆叠 3、堆叠卡和业务卡堆叠的优缺点 三、堆叠的原理 1、角色 2、单机堆叠 3、堆叠ID 4、堆叠的优先级 5、堆叠的建立过程 1&#xff09…

13.1.2 对CIFAR-10数据集使用图像增广来训练ResNet

%matplotlib inline import torch import torchvision from torch import nn from d2l import torch as d2l import time使用CIFAR-10数据集,而不是我们之前使用的Fashion-MNIST数据集。这是因为Fashion-MNIST数据集中对象的位置和大小已被规范化,而CIFAR-10数据集中对象的颜色…

探索未知,即刻搭建AI原生应用!WAVE SUMMIT Workshop等你来参加

你是否希望掌握大模型开发的秘诀&#xff1f;你是否渴望得到实践操作的机会&#xff1f;如果你的心中充满热情和期待&#xff0c;那么&#xff0c;WAVE SUMMIT 2023特别设置的Workshop将会是你的知识启航站&#xff01; 本次Workshop专注于AI开发与大模型应用&#xff0c;邀请一…

Python调用阿里云短信接口

注&#xff1a; 要使用Python调用阿里云的短信服务&#xff0c;你可以使用阿里云官方提供的SDK&#xff0c;即阿里云SDK for Python。首先&#xff0c;确保你已经在阿里云上创建了短信服务并获取了相应的Access Key ID和Access Key Secret。 1、准备工作 阿里云短信网址 1、…

使用C语言实现UDP消息接收

目录 简介:步骤:步骤 1: 创建套接字步骤 2: 接收消息步骤 3: 完成 函数及变量解释总结: 简介: 在网络通信中&#xff0c;UDP&#xff08;User Datagram Protocol&#xff09;是一种无连接协议&#xff0c;它提供了一种快速、高效的数据传输方法。本文将向您展示如何使用C语言编…

Effective Java笔记(33)优先考虑类型安全的异构容器

泛型最常用于集合&#xff0c;如 Set<E &#xff1e;和 Map<K ,V&#xff1e;&#xff0c;以及单个元素的容器 &#xff0c;如 ThreadLocal<T>和 AtomicReference<T&#xff1e; 。 在所有这些用法中&#xff0c;它都充当被参数化了的容器 。 这样就限制每个容器…

基于C函数封装的C++读写锁(附源码)

C++常用功能源码系列 文章目录 C++常用功能源码系列前言一、读写锁是什么二、读写锁封装源码三、读写锁注意点总结前言 本文是C/C++常用功能代码封装专栏的导航贴。部分来源于实战项目中的部分功能提炼,希望能够达到你在自己的项目中拿来就用的效果,这样更好的服务于工作实践…