vuejs 设计与实现 - 双端diff算法
我们介绍了简单 Diff 算法的实现原理。简单 Diff 算法利用虚拟节点的 key 属性,尽可能地复用 DOM元素,并通过移动 DOM的方式来完成更新,从而减少不断地创建和销毁 DOM 元素带来的性能开销。但是,简单 Diff 算法仍然存在很多缺陷,这些缺陷可以通过本章将要介绍的双端 Diff 算法解决。
1.双端比较的原理
双端 Diff 算法是一种同时对新旧两组子节点的两个端点进行比较的算法。因此,我们需要四个索引值,分别指向新旧两组子节点的端点
.如下图:
双端比较的方式:
在双端比较中,每一轮比较都分为四个步骤,如图 10-5 中的连线所示。
比较的过程如下描述:
第一步
: 比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4,看看它们是否相同。由于两者的key 值不同,因此不相同,不可复用,于是什么都不做。
第二步
:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
第三步
:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
第四步
:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子节点 p-4。由于它们的 key 值相同,因此可以进行 DOM 复用。
function patchChildren(n1, n2, container) {patchKeyedChildren(n1, n2, container)
}function patchKeyedChildren(n1, n2, container){const oldChildren = n1.children const newChildren = n2.children// 四个索引值let oldStartIdx = 0let oldEndIdx = oldChildren.length - 1let newStartIdx = 0let newEndIdx = newChildren.length - 1// 四个索引指向的 vnode 节点let oldStartVNode = oldChildren[oldStartIdx]let oldEndVNode = oldChildren[oldEndIdx]let newStartVNode = newChildren[newStartIdx]let newEndVNode = newChildren[newEndIdx]if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]}}
第一轮 DOM 移动操作完成 态状的点节后,新旧两组子节点以及真实 DOM 节点的状态如下:
此时,真实 DOM 节点顺序为 p-4、p-1、p-2、p-3,这与新的 一组子节点顺序不一致。这是因为diff算法还没结束,还需要进行下一轮更新。因此,我们需要将更新逻辑封装到一个 while 循环中,
function patchChildren(n1, n2, container) {patchKeyedChildren(n1, n2, container)}function patchKeyedChildren(n1, n2, container){const oldChildren = n1.children const newChildren = n2.children// 四个索引值let oldStartIdx = 0let oldEndIdx = oldChildren.length - 1let newStartIdx = 0let newEndIdx = newChildren.length - 1// 四个索引指向的 vnode 节点let oldStartVNode = oldChildren[oldStartIdx]let oldEndVNode = oldChildren[oldEndIdx]let newStartVNode = newChildren[newStartIdx]let newEndVNode = newChildren[newEndIdx]+ while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]}+ }}
由于在每一轮更新完成之后,紧接着都会更新四个索引中与当前更新轮次相关联的索引,所以整个 while 循环执行的条件是:头部索引值要小于等于尾部索引值。
在第一轮更新结束后循环条件仍然成立,因此需要进行下一轮的比较:
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-2,看看它们是否相同。由于两者的 key 值不
同,不可复用,所以什么都不做。
这里,我们使用了新的名词: 。它指的是头部索引oldStartIdx 和 newStartIdx 所指向的节点。
第二步:比较旧的一组子节点中的尾部节点 p-3 与新的一组子节点中的尾部节点 p-3,两者的 key 值相同,可以复用。另外,由于两者都处于尾部,因此不需要对真实 DOM 进行移动操作,只需要打补丁即可:
function patchChildren(n1, n2, container) {patchKeyedChildren(n1, n2, container)}function patchKeyedChildren(n1, n2, container){const oldChildren = n1.children const newChildren = n2.children// 四个索引值let oldStartIdx = 0let oldEndIdx = oldChildren.length - 1let newStartIdx = 0let newEndIdx = newChildren.length - 1// 四个索引指向的 vnode 节点let oldStartVNode = oldChildren[oldStartIdx]let oldEndVNode = oldChildren[oldEndIdx]let newStartVNode = newChildren[newStartIdx]let newEndVNode = newChildren[newEndIdx]while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁+ patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量+ oldEndVNode = oldChildren[--oldEndIdx]+ newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]}}}
真实 DOM 的顺序相比上一轮没有变化,因为在这一轮的比较中没有对 DOM 节点进行移动,只是对 p-3 节点打补丁。接下来,我们再根据图 上图所示的状态执行下一轮
的比较:
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-2,看看它们是否相同。由于两者的 key 值不
同,不可复用,因此什么都不做。
第二步:比较旧的一组子节点中的尾部节点 p-2 与新的一组子节点中的尾部节点 p-1,看看它们是否相同,由于两者的 key 值不
同,不可复用,因此什么都不做。
第三步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的尾部节点 p-1。两者的 key 值相同,可以复用。
在第三步的比较中,我们找到了相同的节点,这说明: p-1原本是头部节点,但是在新的顺序中,它变成了尾部节点。因此,我们需要将节点p-1对应的真实 DOM 移动到旧的一组子节点的尾部节点 p-2 所对应的真实 DOM 后面,同时还需要更新相应的索引到下一个位置,如图 下图所示:
function patchChildren(n1, n2, container) {patchKeyedChildren(n1, n2, container)}function patchKeyedChildren(n1, n2, container){const oldChildren = n1.children const newChildren = n2.children// 四个索引值let oldStartIdx = 0let oldEndIdx = oldChildren.length - 1let newStartIdx = 0let newEndIdx = newChildren.length - 1// 四个索引指向的 vnode 节点let oldStartVNode = oldChildren[oldStartIdx]let oldEndVNode = oldChildren[oldEndIdx]let newStartVNode = newChildren[newStartIdx]let newEndVNode = newChildren[newEndIdx]while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量oldEndVNode = oldChildren[--oldEndIdx]newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较+ patch(oldStartVNode, newEndVNode, container)+ insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)+ oldStartVNode = oldChildren[++oldStartIdx]+ newEndVNode = newChildren[--newEndIdx]} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]}}}
下一轮循环:
第一步:比较旧的一组子节点中的头部节点 p-2 与新的一组 子节点中的头部节点 p-2。发现两者 key 值相同,可以复用。但 两者在新旧两组子节点中都是头部节点,因此不需要移动,只需 要调用 patch 函数进行打补丁即可。
function patchChildren(n1, n2, container) {patchKeyedChildren(n1, n2, container)}function patchKeyedChildren(n1, n2, container){const oldChildren = n1.children const newChildren = n2.children// 四个索引值let oldStartIdx = 0let oldEndIdx = oldChildren.length - 1let newStartIdx = 0let newEndIdx = newChildren.length - 1// 四个索引指向的 vnode 节点let oldStartVNode = oldChildren[oldStartIdx]let oldEndVNode = oldChildren[oldEndIdx]let newStartVNode = newChildren[newStartIdx]let newEndVNode = newChildren[newEndIdx]while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁+ patch(oldStartVNode, newStartVNode, container)// 更新相关索引,指向下一个位置+ oldStartVNode = oldChildren[++oldStartIdx]+ newStartVNode = newChildren[++newStartIdx]} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量oldEndVNode = oldChildren[--oldEndIdx]newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较patch(oldStartVNode, newEndVNode, container)insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)oldStartVNode = oldChildren[++oldStartIdx]newEndVNode = newChildren[--newEndIdx]} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]}}}
在这一轮更新之后,新旧两组子节点与真实 DOM 节点的状态如图下图 10-10 所示。
双端比较的优势
优势:减少移动操作。
案例分析:如下图的新旧两组子节点:
简单diff:移动两次
双端diff:移动一次
非理想状态的处理方式
第一轮都无法命中
:
- 旧的一组子节点:p-1、p-2、p-3、p-4。
- 新的一组子节点:p-2、p-4、p-1、p-3。
当我们尝试按照双端 Diff 算法的思路进行第一轮比较时,会发现无法命中四个步骤中的任何一步
。这个时候怎么办呢?这时,我们只能通过增加额外的处理步骤来处理这种非理想情况。既然两个头部和两个尾部的四个节点中都没有可复用的节点,那么我们就尝试看看非头部、非尾部的节点能否复用。具体做法是,拿新的一组子节点中的头部节点去旧的一组子节点中寻找
:如下面的代码:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁patch(oldStartVNode, newStartVNode, container)// 更新相关索引,指向下一个位置oldStartVNode = oldChildren[++oldStartIdx]newStartVNode = newChildren[++newStartIdx]} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量oldEndVNode = oldChildren[--oldEndIdx]newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较patch(oldStartVNode, newEndVNode, container)insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)oldStartVNode = oldChildren[++oldStartIdx]newEndVNode = newChildren[--newEndIdx]} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]} else {
+ // 处理非理想情况// 在旧的一 组子节点中,找到与新的一组子节点的头部节点具有相同 key 值的节点// 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点// idxInOld 就是新的一组子节点的头部节点在旧的一组子节点中的索引+ const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)}}
如下图在旧子节点中寻找可复用节点:
function patchChildren(n1, n2, container) {patchKeyedChildren(n1, n2, container)}function patchKeyedChildren(n1, n2, container){const oldChildren = n1.children const newChildren = n2.children// 四个索引值let oldStartIdx = 0let oldEndIdx = oldChildren.length - 1let newStartIdx = 0let newEndIdx = newChildren.length - 1// 四个索引指向的 vnode 节点let oldStartVNode = oldChildren[oldStartIdx]let oldEndVNode = oldChildren[oldEndIdx]let newStartVNode = newChildren[newStartIdx]let newEndVNode = newChildren[newEndIdx]while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁patch(oldStartVNode, newStartVNode, container)// 更新相关索引,指向下一个位置oldStartVNode = oldChildren[++oldStartIdx]newStartVNode = newChildren[++newStartIdx]} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量oldEndVNode = oldChildren[--oldEndIdx]newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较patch(oldStartVNode, newEndVNode, container)insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)oldStartVNode = oldChildren[++oldStartIdx]newEndVNode = newChildren[--newEndIdx]} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]} else {// 处理非理想情况// 在旧的一 组子节点中,找到与新的一组子节点的头部节点具有相同 key 值的节点// 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点// idxInOld 就是新的一组子节点的头部节点在旧的一组子节点中的索引const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)// idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实DOM 移动到头部+ if(idxInOld > 0) {+ // idxInOld 位置对应的 vnode 就是需要移动的节点const vnodeToMove = oldChildren[idxInOld]// 不要忘记除移动操作外还应该打补丁+ patch(vnodeToMove, newStartVNode, container)// 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点
+ insert(vnodeToMove.el, container, oldStartVNode.el)// 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefined+ oldChildren[idxInOld] = undefined// 最后更新 newStartIdx 到下一个位置+ newStartVNode = newChildren[++newStartIdx]}}}}
在上面这段代码中,首先判断 idxInOld 是否大于 0。如果条件 成立,则说明找到了可复用的节点,然后将该节点对应的真实 DOM 移 动到头部。为此,我们先要获取需要移动的节点,这里的 oldChildren[idxInOld] 所指向的节点就是需要移动的节点。在移 动节点之前,不要忘记调用 patch 函数进行打补丁。接着,调用 insert 函数,并以现在的头部节点对应的真实 DOM 节点 oldStartVNode.el 作为锚点参数来完成节点的移动操作。当节点移 动完成后,还有两步工作需要做:
-
- 由于处于 idxInOld 处的节点已经处理过了(对应的真实 DOM 移到了别处),因此我们应该将 oldChildren[idxInOld] 设 置为undefined。
-
- 新的一组子节点中的头部节点已经处理完毕,因此将 newStartIdx 前进到下一个位置。
经过上述两个步骤的操作后,新旧两组子节点以及真实 DOM 节点 的状态如图 下图所示:
此时,真实 DOM 的顺序为:p-2、p-1、p-3、p-4。接着,双端 Diff 算法会继续进行。如下图所示:
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-4,两者 key 值不同,不可复用。
第二步:比较旧的一组子节点中的尾部节点 p-4 与新的一组子节点中的尾部节点 p-3,两者 key 值不同,不可复用。
第三步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的尾部节点 p-3,两者 key 值不同,不可复用。
第四步:比较旧的一组子节点中的尾部节点 p-4 与新的一组子节点中的头部节点 p-4,两者的 key 值相同,可以复用。
在这一轮比较的第四步中,我们找到了可复用的节点。因此,按照双端 Diff 算法的逻辑移动真实 DOM,即把节点 p-4 对应的真实DOM 移动到旧的一组子节点中头部节点 p-1 所对应的真实 DOM 前面,如图 下图 所示:
此时,真实 DOM 节点的顺序是:p-2、p-4、p-1、p-3。接着,开始下一轮的比较:
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-1,两者的 key 值相同,可以复用。
在这一轮比较中,第一步就找到了可复用的节点。由于两者都处于头部,所以不需要对真实 DOM 进行移动,只需要打补丁即可。在这一步操作过后,新旧两组子节点与真实 DOM 节点的状态如图 下图 所示:
此时,真实 DOM 节点的顺序是:p-2、p-4、p-1、p-3。接着,进行下一轮的比较。需要注意的一点是,此时旧的一组子节点的
头部节点是 undefined。这说明该节点已经被处理过了,因此不需要再处理它了,直接跳过即可。为此,我们需要补充这部分逻辑的代码,具体实现如下:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {// 增加两个判断分支,如果头尾部节点为 undefined,则说明该节点已经被处理过了,直接跳到下一个位置+ if (!oldStartVNode) {+ oldStartVNode = oldChildren[++oldStartIdx]+ } else if (!oldEndVNode) {+ oldEndVNode = oldChildren[--oldEndIdx]+ }else if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁patch(oldStartVNode, newStartVNode, container)// 更新相关索引,指向下一个位置oldStartVNode = oldChildren[++oldStartIdx]newStartVNode = newChildren[++newStartIdx]} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量oldEndVNode = oldChildren[--oldEndIdx]newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较patch(oldStartVNode, newEndVNode, container)insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)oldStartVNode = oldChildren[++oldStartIdx]newEndVNode = newChildren[--newEndIdx]} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]} else {// 处理非理想情况// 在旧的一 组子节点中,找到与新的一组子节点的头部节点具有相同 key 值的节点// 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点// idxInOld 就是新的一组子节点的头部节点在旧的一组子节点中的索引const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)// idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实DOM 移动到头部if(idxInOld > 0) {// idxInOld 位置对应的 vnode 就是需要移动的节点const vnodeToMove = oldChildren[idxInOld]// 不要忘记除移动操作外还应该打补丁patch(vnodeToMove, newStartVNode, container)// 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点insert(vnodeToMove.el, container, oldStartVNode.el)// 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefinedoldChildren[idxInOld] = undefined// 最后更新 newStartIdx 到下一个位置newStartVNode = newChildren[++newStartIdx]}}
}
观察上面的代码,在循环开始时,我们优先判断头部节点和尾部节点是否存在。如果不存在,则说明它们已经被处理过了,直接跳到下一个位置即可。在这一轮比较过后,新旧两组子节点与真实 DOM 节点的状态如图 下图 所示:
现在,四个步骤又重合了,接着进行最后一轮的比较:
第一步:比较旧的一组子节点中的头部节点 p-3 与新的一组子节点中的头部节点 p-3,两者的 key 值相同,可以复用。在第一步中找到了可复用的节点。由于两者都是头部节点,因此不需要进行 DOM 移动操作,直接打补丁即可。在这一轮比较过后,最终状态如图 下图 所示:
这时,满足循环停止的条件,于是更新完成。最终,真实 DOM 节点的顺序与新的一组子节点的顺序一致,都是:p-2、p-4、p-1、p-3。
添加新元素
添加新元素的时机:1.四个步骤的比较中都找不到可复用的节点 。 2.尝试拿新的一组子节点中的头部节点 p-4 去旧的一组子节点中寻找具有相同 key 值的节点,但在旧的一组子节点中根本就没有 p-4 节点。这说明节点 p-4 是一个新增节点。
案例1如下:
- 旧的一组子节点:p-1、p-2、p-3。
- 新的一组子节点:p-4、p-1、p-3、p-2。
代码如下:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (!oldStartVNode) {oldStartVNode = oldChildren[++oldStartIdx]} else if (oldStartVNode.key === newStartVNode.key) {// 步骤一:oldStartVNode 和 newStartVNode 比较// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁patch(oldStartVNode, newStartVNode, container)// 更新相关索引,指向下一个位置oldStartVNode = oldChildren[++oldStartIdx]newStartVNode = newChildren[++newStartIdx]} else if (oldEndVNode.key === newEndVNode.key) {// 步骤二:oldEndVNode 和 newEndVNode 比较// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁patch(oldEndVNode, newEndVNode, container)// 更新索引和头尾部节点变量oldEndVNode = oldChildren[--oldEndIdx]newEndVNode = newChildren[--newEndIdx]} else if(oldStartVNode.key === newEndVNode.key) {// 步骤三:oldStartVNode 和 newEndVNode 比较patch(oldStartVNode, newEndVNode, container)insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)oldStartVNode = oldChildren[++oldStartIdx]newEndVNode = newChildren[--newEndIdx]} else if (oldEndVNode.key === newStartVNode.key) {// 我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。// 于是,我们只需要以头部元素oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。// 但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。// 第四步:oldEndVNode 和 newStartVNode 比较// 仍然需要调用 patch 函数进行打补丁patch(oldEndVNode, newStartVNode, container)// 移动dom操作 oldEndVNode.el 移动到 oldStartVNode.el 前面insert(oldEndVNode.el, container, oldStartVNode.el)// 移动 DOM 完成后,更新索引值,指向下一个位置oldEndVNode = oldChildren[--oldEndIdx]newStartVNode = newChildren[++newStartIdx]} else {// 处理非理想情况// 在旧的一 组子节点中,找到与新的一组子节点的头部节点具有相同 key 值的节点// 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点// idxInOld 就是新的一组子节点的头部节点在旧的一组子节点中的索引const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)// idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实DOM 移动到头部if(idxInOld > 0) {// idxInOld 位置对应的 vnode 就是需要移动的节点const vnodeToMove = oldChildren[idxInOld]// 不要忘记除移动操作外还应该打补丁patch(vnodeToMove, newStartVNode, container)// 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点insert(vnodeToMove.el, container, oldStartVNode.el)// 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefinedoldChildren[idxInOld] = undefined// 最后更新 newStartIdx 到下一个位置newStartVNode = newChildren[++newStartIdx]} else {+ // 新增节点
+ // 将 newStartVNode 作为新节点挂载到头部,使用当前头部节点oldStartVNode.el 作为锚点
+ patch(null, newStartVNode, container, oldStartVNode.el)}}}
当条件idxInOld > 0不成立时,说明 newStartVNode 节点是全新的节点。又由于 newStartVNode 节点 是头部节点,因此我们应该将其作为新的头部节点进行挂载。所以, 在调用 patch 函数挂载节点时,我们使用 oldStartVNode.el 作为 锚点。在这一步操作完成之后,新旧两组子节点以及真实 DOM 节点的 状态如下图所示:
案例2
- 旧的一组子节点:p-1、p-2、p-3。
- 新的一组子节点:p-4、p-1、p-2、p-3。
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-4,两者的 key 值不同,不可以复用。
第二步:比较旧的一组子节点中的尾部节点 p-3 与新的一组子节点中的尾部节点 p-3,两者的 key 值相同,可以复用。
在第二步中找到了可复用的节点,因此进行更新。更新后的新旧两组子节点以及真实 DOM 节点的状态如图下图 所示:
接着进行下一轮的比较:
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-4,两者的 key 值不同,不可以复用。
第二步:比较旧的一组子节点中的尾部节点 p-2 与新的一组子节点中的尾部节点 p-2,两者的 key 值相同,可以复用。
我们又在第二步找到了可复用的节点,于是再次进行更新。更新后的新旧两组子节点以及真实 DOM 节点的状态如图 下图 所示:
接着,进行下一轮的更新:
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-4,两者的 key 值不同,不可以复用。
第二步:比较旧的一组子节点中的尾部节点 p-1 与新的一组子节点中的尾部节点 p-1,两者的 key 值相同,可以复用。
还是在第二步找到了可复用的节点,再次进行更新。更新后的新旧两组子节点以及真实 DOM 节点的状态如图 下图 所示:
当这一轮更新完毕后,由于变量 oldStartIdx 的值大于oldEndIdx 的值,满足更新停止的条件,因此更新停止。但通过观察可知,节点 p-4 在整个更新过程中被遗漏了,没有得到任何处理,这说明我们的算法是有缺陷的。为了弥补这个缺陷,我们需要添加额外的处理代码:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 省略部分代码}// 循环结束后检查索引值的情况,
+ if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
+ // 如果满足条件,则说明有新的节点遗留,需要挂载它们
+ for (let i = newStartIdx; i <= newEndIdx; i++) {
+ patch(null, newChildren[i], container, oldStartVNode.el)+ }}
我们在 while 循环结束后增加了一个 if 条件语句,检查四个索引值的情况。根据图上图可知,如果条件oldEndIdx <oldStartIdx && newStartIdx <= newEndIdx成立,说明新的一组子节点中有遗留的节点需要作为新节点挂载。哪些节点是新节点呢?索引值位于 newStartIdx 和 newEndIdx 这个区间内的节点都是新节点。``于是我们开启一个 for 循环来遍历这个区间内的节点并逐一挂载。挂载时的锚点仍然使用当前的头部节点oldStartVNode.el,这样就完成了对新增元素的处理。
移除不存在的元素
案例如下:
- 旧的一组子节点:p-1、p-2、p-3。
- 新的一组子节点:p-1、p-3。
可以看到,在新的一组子节点中 p-2 节点已经不存在了。为了搞清楚应该如何处理节点被移除的情况,我们还是按照双端 Diff 算法的思路执行更新。
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-1,两者的 key 值相同,可以复用。
在第一步的比较中找到了可复用的节点,于是执行更新。在这一轮比较过后,新旧两组子节点以及真实 DOM 节点的状态如图下图所示:
接着,执行下一轮更新:
第一步:比较旧的一组子节点中的头部节点 p-2 与新的一组子节点中的头部节点 p-3,两者的 key 值不同,不可以复用。
第二步:比较旧的一组子节点中的尾部节点 p-3 与新的一组子节点中的尾部节点 p-3,两者的 key 值相同,可以复用。
在第二步中找到了可复用的节点,于是进行更新。更新后的新旧两组子节点以及真实 DOM 节点的状态如图 下图所示:
此时变量 newStartIdx 的值大于变量 newEndIdx 的值,满足更新停止的条件,于是更新结束。但观察图 10-34 可知,旧的一组子节点中存在未被处理的节点,应该将其移除。因此,我们需要增加额外的代码来处理它,如下所示:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 省略部分代码}// 循环结束后检查索引值的情况,
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {// 如果满足条件,则说明有新的节点遗留,需要挂载它们for (let i = newStartIdx; i <= newEndIdx; i++) {patch(null, newChildren[i], container, oldStartVNode.el)}
+ } else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
+ for (let i = oldStartIdx; i <= oldEndIdx; i++) {
+ unmount(oldChildren[i])
+ }}
与处理新增节点类似,我们在 while 循环结束后又增加了一个else…if 分支,用于卸载已经不存在的节点。由图 上图 可知,索引值位于 oldStartIdx 和 oldEndIdx 这个区间内的节点都应该被卸载,于是我们开启一个 for 循环将它们逐一卸载。
相关文章:

vuejs 设计与实现 - 双端diff算法
我们介绍了简单 Diff 算法的实现原理。简单 Diff 算法利用虚拟节点的 key 属性,尽可能地复用 DOM元素,并通过移动 DOM的方式来完成更新,从而减少不断地创建和销毁 DOM 元素带来的性能开销。但是,简单 Diff 算法仍然存在很多缺陷&a…...

RISC-V在快速发展的处理器生态系统中找到立足点
原文:RISC-V Finds Its Foothold in a Rapidly Evolving Processor Ecosystem 作者:Agam Shah 转载自:https://thenewstack.io/risc-v-finds-its-foothold-in-a-rapidly-evolving-processor-ecosystem/ 以下是正文 But the open source pr…...
面试题02
这里写目录标题 主存储器和CPU之间增加Cache的目的是?判断一个char变量c1是否为小写字母循环链表顺序存储的线性表,访问结点和增加删除结点的时间复杂度请列举你所知道的c/c++ 语言中引入性能开销或阻碍编译优化的语言特性,并尝试说明对应的解决办法请列举CPU cache对编程开…...

第六章 SpringBoot注解 @ConditionalOnBean
满足条件的则进行组件的注入 Configuration(proxyBeanMethods true) //告诉SpringBoot这是一个配置类 配置文件 ConditionalOnBean(name "tom") public class MyConfig {Bean("tom")public Pet tom(){return new Pet("tomPet");}/*** 外部无论…...

MySQL8的下载与安装-MySQL8知识详解
本文的内容是mysql8的下载与安装。主要讲的是两点:从官方网站下载MySQL8安装和从集成环境安装MySQL8。 一、从官方网站下载MySQL8.0安装 MySQL8.0官方下载地址是:(见图) 官方正式版的最新版本是8.0.34,也推出了创新版…...

ATF(TF-A)安全通告 TFV-9 (CVE-2022-23960)
ATF(TF-A)安全通告汇总 目录 一、ATF(TF-A)安全通告 TFV-9 (CVE-2022-23960) 二、CVE-2022-23960 一、ATF(TF-A)安全通告 TFV-9 (CVE-2022-23960) Title TF-A披露通过分支预测目标重用(branch prediction target reuse)引发的前瞻执行处理器漏洞 CV…...

docker实现Nginx
文章目录 1.docker 安装2.docker环境实现Nginx 1.docker 安装 1.使用环境为红帽8.1,添加源 yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo2.安装 yum install docker-ce docker-ce-cli containerd.io显示出错 Docker C…...

【Java 回忆录】Java全栈开发笔记文档
这里能学到什么? 实战代码文档一比一记录实战问题和解决方案涉及前端、后端、服务器、运维、测试各方面通过各方面的文档与代码,封装一套低代码开发平台直接开腾讯会议,实实在线一起分享技术问题核心以 Spring Boot 作为基础框架进行整合后期…...

数据结构:力扣刷题
题一:旋转数组 给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。 思路一: 创建reverse()函数传入三个值分别为数组地址,从第几个数组元素开始,结束元素位置; 在r…...
【Java】常用设计模式的理解
设计模式 前言 有一些重要的设计原则在开篇和大家分享下,这些原则将贯通全文: 面向接口编程,而不是面向实现。这个很重要,也是优雅的、可扩展的代码的第一步,这就不需要多说了吧。 职责单一原则。每个类都应该只有一…...
python - 爬虫简介
什么是爬虫? 模拟浏览器对网站服务器发送请求解析服务器返回的响应数据,并保存数据 爬虫能获取哪些数据? 原则上所有可以通过浏览器获取的数据都可以爬取爬虫也只能获取爬取浏览器可以正常获取的数据 爬虫的应用场景? 数据分…...
【结构型设计模式】C#设计模式之外观模式
题目描述: 假设你正在开发一个音乐播放器应用程序,该应用程序需要与多个子系统进行交互,包括音频解码、音量控制和播放控制等。请使用外观模式设计一个音乐播放器的外观类,并实现相应的子系统类。 要求: 创建一个外观…...

Linux网络编程 socket编程篇(一) socket编程基础
目录 一、预备知识 1.IP地址 2.端口号 3.网络通信 4.TCP协议简介 5.UDP协议简介 6.网络字节序 二、socket 1.什么是socket(套接字)? 2.为什么要有套接字? 3.套接字的主要类型 拓】网络套接字 三、socket API 1.socket API是什么? 2.为什么…...

【二】SPI IP核的使用
【一】SPI IP核使用:传送门 基于qsys通过spi外部总线协议对sd卡进行读写操作 一、实验平台与实验的目的: 正点原子开拓者、芯片型号:EP4CE10F17C8;还需要一张sd卡。 该实验主要是利用SPI IP核驱动SD卡来实现读写实验&am…...

面试热题(二叉树的锯齿形层次遍历)
给你二叉树的根节点 root ,返回其节点值的 锯齿形层序遍历 。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行) 输入:root [3,9,20,null,null,15,7] 输出:[[3…...

JVM—内存管理(运行时数据区)、垃圾回收
背景介绍 当JVM类加载器加载完字节码文件之后,会交给执行引擎执行,在执行的过程中会有一块JVM内存区域来存放程序运行过程中的数据,也就是我们图中放的运行时数据区,那这一块运行时数据区究竟帮我们做了哪些工作?我们…...

一百五十一、Kettle——Linux上安装的kettle8.2开启carte服务
一、目的 kettle8.2在Linux上安装好可以启动界面、并且可以连接MySQL、Hive、ClickHouse等数据库后,准备在Linux上启动kettle的carte服务 二、实施步骤 (一)carte服务文件路径 kettle的Linux运行的carte服务文件是carte.sh (二…...

19. python从入门到精通——Web编程
HTTP协议 HTTP协议的常用方法 方法 描述 GET 请求指定的页面信息,并返回实体主体。 POST 向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。 …...
PostMan 教程
安装https://www.cnblogs.com/mafly/p/postman.html Postman 使用方法详解https://blog.csdn.net/fxbin123/article/details/80428216 postman进行http接口测试https://blog.csdn.net/five3/article/details/53021084 postman的使用方法详解!最全面的教程https:/…...
Http常见状态码
一、状态码大类 状态码分类说明1xx响应中——临时状态码,表示请求已经接受,告诉客户端应该继续请求或者如果它已经完成则忽略它2xx成功——表示请求已经被成功接收,处理已完成3xx重定向——重定向到其它地方:它让客户端再发起一个…...
谷歌浏览器插件
项目中有时候会用到插件 sync-cookie-extension1.0.0:开发环境同步测试 cookie 至 localhost,便于本地请求服务携带 cookie 参考地址:https://juejin.cn/post/7139354571712757767 里面有源码下载下来,加在到扩展即可使用FeHelp…...

XCTF-web-easyupload
试了试php,php7,pht,phtml等,都没有用 尝试.user.ini 抓包修改将.user.ini修改为jpg图片 在上传一个123.jpg 用蚁剑连接,得到flag...
Auto-Coder使用GPT-4o完成:在用TabPFN这个模型构建一个预测未来3天涨跌的分类任务
通过akshare库,获取股票数据,并生成TabPFN这个模型 可以识别、处理的格式,写一个完整的预处理示例,并构建一个预测未来 3 天股价涨跌的分类任务 用TabPFN这个模型构建一个预测未来 3 天股价涨跌的分类任务,进行预测并输…...
postgresql|数据库|只读用户的创建和删除(备忘)
CREATE USER read_only WITH PASSWORD 密码 -- 连接到xxx数据库 \c xxx -- 授予对xxx数据库的只读权限 GRANT CONNECT ON DATABASE xxx TO read_only; GRANT USAGE ON SCHEMA public TO read_only; GRANT SELECT ON ALL TABLES IN SCHEMA public TO read_only; GRANT EXECUTE O…...

Python爬虫(一):爬虫伪装
一、网站防爬机制概述 在当今互联网环境中,具有一定规模或盈利性质的网站几乎都实施了各种防爬措施。这些措施主要分为两大类: 身份验证机制:直接将未经授权的爬虫阻挡在外反爬技术体系:通过各种技术手段增加爬虫获取数据的难度…...

USB Over IP专用硬件的5个特点
USB over IP技术通过将USB协议数据封装在标准TCP/IP网络数据包中,从根本上改变了USB连接。这允许客户端通过局域网或广域网远程访问和控制物理连接到服务器的USB设备(如专用硬件设备),从而消除了直接物理连接的需要。USB over IP的…...
重启Eureka集群中的节点,对已经注册的服务有什么影响
先看答案,如果正确地操作,重启Eureka集群中的节点,对已经注册的服务影响非常小,甚至可以做到无感知。 但如果操作不当,可能会引发短暂的服务发现问题。 下面我们从Eureka的核心工作原理来详细分析这个问题。 Eureka的…...

2025季度云服务器排行榜
在全球云服务器市场,各厂商的排名和地位并非一成不变,而是由其独特的优势、战略布局和市场适应性共同决定的。以下是根据2025年市场趋势,对主要云服务器厂商在排行榜中占据重要位置的原因和优势进行深度分析: 一、全球“三巨头”…...

Selenium常用函数介绍
目录 一,元素定位 1.1 cssSeector 1.2 xpath 二,操作测试对象 三,窗口 3.1 案例 3.2 窗口切换 3.3 窗口大小 3.4 屏幕截图 3.5 关闭窗口 四,弹窗 五,等待 六,导航 七,文件上传 …...

Windows安装Miniconda
一、下载 https://www.anaconda.com/download/success 二、安装 三、配置镜像源 Anaconda/Miniconda pip 配置清华镜像源_anaconda配置清华源-CSDN博客 四、常用操作命令 Anaconda/Miniconda 基本操作命令_miniconda创建环境命令-CSDN博客...