上一章我们分析了 vue 2.6.x 的异步更新策略,弄清楚了以下几个问题:
- vue 是在什么阶段进行视图更新的
- vue 是在什么位置进行视图更新的
- 实现 vue 异步更新的核心原理
而今天我们将分析 vue diff 算法,相信读完本文,将会对以下问题有初步的认知:
- vue diff 算法发生在什么位置
- vue diff 算法的核心原理
- 为什么推荐 v-for 要加上 key
那么便开始吧(๑•̀ㅂ•́)و✧!
生成虚拟 DOM
这个名词相信大家已经非常熟悉了,这里就当简单复习一下了。
首先自然是概念:
所谓虚拟 DOM,其实就是 js 的对象,主要的目的是描述真实 dom 的结构和关系。
也就是说,虚拟 DOM 就是真实 DOM 的映射。
而这也是 vue 能做到跨平台的原因之一:完成虚拟 DOM 的操作之后,将其 patch 为真实 DOM 的过程中:
- 目标是浏览器,则 patch 成浏览器结构,那么就能适应浏览器
- 目标是小程序,则 patch 成小程序的结构,那么就能适应小程序
- 目标是 app,则 patch 成 app 的结构,那么就能适应移动端
- …
那么我们来看看 vue 源码中,关于虚拟 DOM 的操作吧。
结合前面将到的,我们先定位 updateComponent
中的 _render
:
/*
* file: src/core/instance/render.js
*/
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
if (_parentVnode) {
vm.$scopedSlots = normalizeScopedSlots(_parentVnode.data.scopedSlots, vm.$slots, vm.$scopedSlots)
}
// set parent vnode. this allows render functions to have access
// to the data on the placeholder node.
vm.$vnode = _parentVnode
// render self
let vnode
try {
// There's no need to maintain a stack because all render fns are called
// separately from one another. Nested component's render fns are called
// when parent component is patched.
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`)
// return error render result,
// or previous vnode to prevent render error causing blank component
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production' && vm.$options.renderError) {
try {
vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
} catch (e) {
handleError(e, vm, `renderError`)
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
} finally {
currentRenderingInstance = null
}
// if the returned array contains only a single node, allow it
if (Array.isArray(vnode) && vnode.length === 1) {
vnode = vnode[0]
}
// return empty vnode in case the render function errored out
if (!(vnode instanceof VNode)) {
if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
warn(
'Multiple root nodes returned from render function. Render function ' + 'should return a single root node.',
vm
)
}
vnode = createEmptyVNode()
}
// set parent
vnode.parent = _parentVnode
return vnode
}
上面的核心功能在这里:
vnode = render.call(vm._renderProxy, vm.$createElement)
通过 render
生成虚拟 DOM,在最后将其返回出去。
虚拟 DOM 转真实 DOM
这里我们来到 _update
:
/*
* file: src/core/instance/lifecycle.js
*/
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
上面的核心在于:
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
从注释可以看出,当初始化的时候,是没有 prevVnode 的,所以会走 if 分支,之后会走 else 更新分支。
其核心方法都是 __patch__
:
在第一章的分析中,入口文件有一行代码:
Vue.prototype.__patch__ = inBrowser ? patch : noop
这里安装了平台特有的 patch 方法,我们可以追踪一下:
var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules })
说明 createPatchFunction 方法将返回一个 patch 方法,那么我们来到它的返回值:
/*
* file: src/core/vdom/patch.js
*/
return function patch(oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// **empty mount (likely as component), create new root element**
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
if (isRealElement) {
// mounting to a real element
// check if this is server-rendered content and if we can perform
// a successful hydration.
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
warn(
'The client-side rendered virtual DOM tree is not matching ' +
'server-rendered content. This is likely caused by incorrect ' +
'HTML markup, for example nesting block-level elements inside ' +
'<p>, or missing <tbody>. Bailing hydration and performing ' +
'full client-side render.'
)
}
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
oldVnode = emptyNodeAt(oldVnode)
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
初始化流程
对于上面的代码,我们先看看初始化的执行流程:
- 首先我们可以从注释和判断知道:通常情况下并不会走
if (isUndef(oldVnode))
这个分支,因为只有当 empty mount 的情况下,才会走这里 - 接着,初始化的时候,
oldVnode === vm.$el
,所以是真实节点,即 isRealElement 为真,那么就会走到if (isRealElement)
分支中 - 紧接着就会通过
oldVnode = emptyNodeAt(oldVnode)
将生成 oldNode 对一个的虚拟 DOM - 随后通过
createElm
创建新的 DOM 树,并且把它放在老节点的旁边 - 最后通过
removeVnodes
删除老节点
更新流程
在更新流程中,传入的 oldVnode 是 prevVnode,即这个时候它已经是一个虚拟 DOM 了,那么如果新老节点是同样类型的节点,就会走到:
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
而这就是大名鼎鼎的 diff 算法发生的地方。
在看 patchVnode
之前,我们先来看看 sameVnode
,了解一下到底怎么样才算是相同节点:
function sameVnode(a, b) {
return (
a.key === b.key &&
((a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && a.asyncFactory === b.asyncFactory && isUndef(b.asyncFactory.error)))
)
}
可以看到,首先 key 一定要相同,然后节点类型相同,如果是 input,则 type 要相同,等等。
二者都没有 key 的时候,undefined === undefined
那么我们接着来到 patchVnode
:
function patchVnode(oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
if (oldVnode === vnode) {
return
}
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}
const elm = (vnode.elm = oldVnode.elm)
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
if (
isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode)
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode)
}
}
这里总的来说,包括了三种操作:
- 节点属性更新
- 文本更新
- 子节点更新
其中本文节点和子节点是互斥的,因为一个节点一旦是文本节点,那么它必然没有子节点,反之它一定不是文本节点。
具体的更新规则如下:
-
新老节点都有子节点,则调用
updateChildren
if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) }
-
新⽼节点都没有⼦节点,进行文本替换
else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) }
-
新节点有⼦节点⽽⽼节点没有⼦节点,则先清空老节点的文本内容,然后为其新增子节点
else if (isDef(ch)) { if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch) } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) }
-
新节点没有⼦节点⽽⽼节点有⼦节点,则移除老节点的所有⼦节点
else if (isDef(oldCh)) { removeVnodes(oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '') }
那么我们来看看 updateChildren
做了什么:
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
// New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
这里的操作非常巧妙,我们一步步来看看:
-
首先创建了 4 个游标以及 4 个对应的节点,分别代表老节点和新节点的首尾
-
进行了一个循环操作,结束条件是老节点和新节点的游标重合,在循环中进行了如下操作:
-
调整节点引用,保证其有值
-
如果新老节点的首部相同,则对这两个节点进行 patch 操作,并且首部游标分别后移一位
else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] }
-
如果新老节点尾部相同,则对这两个节点进行 patch 操作,并且尾部游标分别前移一位
else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] }
-
如果老节点首部与新节点尾部相同,则对这两个节点进行 patch 操作,老节点首部游标加一,新节点尾部游标减一,并且还要做额外的移动操作,将老节点首部移动到尾部。这个操作其实并不难理解:因为满足这个条件的时候,很可能是进行了逆序的操作。
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] }
-
如果老节点尾部与新节点首部相同,则对这两个节点进行 patch 操作,老节点尾部游标减一,新节点首部游标加一,并且还要做额外的移动操作,将老节点尾部移动到首部。
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] }
-
如果上述条件都不满足,则只能进行效率较为低下的遍历操作:在老节点中找是否存在于新节点首部相同的节点,这里又存在两种情况:
- 没找到:说明这个节点是新增节点,直接创建一个新节点
- 找到了:
- 相同节点:进行 patch 操作,并且移动老节点
- 不同节点:创建新节点
-
-
当循环结束之后,如果两组节点如果数量不同,则进入下面的流程:
-
老节点先结束,说明有新增节点,进行批量创建
if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) }
-
新节点先结束,说明有节点删除,进行批量删除
else if (newStartIdx > newEndIdx) { removeVnodes(oldCh, oldStartIdx, oldEndIdx) }
-
这里的逻辑不可谓不复杂了,我们将脑图整理出来再看看:
从上面我们可以总结出一些规律:
- 节点比较一定是在同层级之间进行:同层比较
- 如果存在子节点,一定会优先获取子节点:深度优先
一些有意思的东西
关于 vue 的虚拟 DOM
这个从 patch 文件的头部我们可以看到:
Virtual DOM patching algorithm based on Snabbdom by
说明 vue 中的虚拟 DOM 是基于 Snabbdom 的。
视图更新中诡异的画面
我们将断点断在新树生成,老树删除之间,可以看到这样的画面:
vfor 中 key 的作用
要说明这个问题,我们先来看看有 key 的情况下,diff 是如何进行的:
<div id="demo">
<p v-for="item in arr" :key="item">{{a}}</p>
</div>
<script>
const app = new Vue({
el: '#demo',
data: { arr: ['A','B','C','D'] },
mounted() {
setTimeout(() => {
this.arr.splice(1, 0, 'E')
}, 1000);
}
});
</script>
首先,我们很清楚,数组最后会变成 ['A', 'E', 'B', 'C', 'D']
那么我们开始模拟 diff 流程:
- Step 1
- 老:
['A', 'B', 'C', 'D']
- 新:
['A', 'E', 'B', 'C', 'D']
- 比较二者头部,直接 patch
- 结果:
['A', 'B', 'C', 'D']
- 老:
- Step 2
- 老:
['B', 'C', 'D']
- 新:
['E', 'B', 'C', 'D']
- 比较二者头部,不同节点 → 比较二者尾部,直接 patch
- 结果:
['A', 'B', 'C', 'D']
- 老:
- Step 3
- 老:
['B', 'C']
- 新:
['E', 'B', 'C']
- 比较二者头部,不同节点 → 比较二者尾部,直接 patch
- 结果:
['A', 'B', 'C', 'D']
- 老:
- Step 4
- 老:
['B']
- 新:
['E', 'B']
- 比较二者头部,不同节点 → 比较二者尾部,直接 patch
- 结果:
['A', 'B', 'C', 'D']
- 老:
- Step 5
- 老:
[]
- 新:
['E']
- 老节点遍历结束,新节点还有剩余,新增节点,位置在 oldStartIdx 后,更新一次
- 结果:
['A', 'E', 'B', 'C', 'D']
- 老:
综上,上面的算法总共更新了一次页面。
那么,如果没有 key,将会是什么样的情况呢?
- Step 1
- 老:
['A', 'B', 'C', 'D']
- 新:
['A', 'E', 'B', 'C', 'D']
- 比较二者头部,直接 patch
- 结果:
['A', 'B', 'C', 'D']
- 老:
- Step 2
- 老:
['B', 'C', 'D']
- 新:
['E', 'B', 'C', 'D']
- 比较二者头部,直接 patch,更新一次
- 结果:
['A', 'E', 'C', 'D']
- 老:
- Step 3
- 老:
['C', 'D']
- 新:
['B', 'C', 'D']
- 比较二者头部,直接 patch,更新两次
- 结果:
['A', 'E', 'B', 'D']
- 老:
- Step 4
- 老:
['D']
- 新:
['C', 'D']
- 比较二者头部,直接 patch,更新三次
- 结果:
['A', 'E', 'B', 'C']
- 老:
- Step 5
- 老:
[]
- 新:
['D']
- 老节点遍历结束,新节点还有剩余,新增节点,位置在 oldStartIdx 后,更新四次
- 结果:
['A', 'E', 'B', 'C', 'D']
- 老:
这里仅仅是这么简单的一个数组,没有 key 的情况下,就已经如此繁琐,其效率之低下已经不言而喻。
这也说明了为什么 vue 非常推荐在使用 v-for 指令的时候为遍历的子项添加上 key,因为这样的效率远远高于没有 key 的情况。
结语
更佳阅读体验:Vue 2.6 源码分析之旅 - 四:diff 算法
那么今天的分析就到此为止啦,我们下期再见,咕咕咕~~~
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 初心Yearth 原文链接:https://juejin.im/post/6862955082237870087