Я написал статью в прошлом году и написал от руки виртуальную библиотеку DOM, чтобы вы могли полностью понять алгоритм сравнения и представить виртуальный DOM
процесс patch
и процесс алгоритма.В то время использовался diff
двухсторонний алгоритм.В этом году я увидел, что был использован быстрый алгоритм ,поэтому я тоже хотел написать статью.Позвольте мне ее записать,но кто-то наверняка уже написал ее,поэтому мне было интересно,может ли она быть немного другой.Последняя статья в основном показывала каждую ситуацию и процесс Алгоритм шаг за шагом рисуя картинки, поэтому мне было интересно, можно ли его изменить в виде анимации, поэтому есть эта статья. Конечно, текущая реализация по-прежнему основана на двустороннем алгоритме, а быстрый алгоритм будет добавлен позже .diff
Vue3
diff
diff
diff
diff
Portal: анимационная демонстрация двустороннего алгоритма Diff .
Интерфейс такой.С левой стороны вы можете ввести новый и старый списки для сравнения VNode
, а затем нажать кнопку запуска, чтобы показать процесс от начала до конца в виде анимации.Справа три горизонтальных списка , представляющие старый и новый VNode
списки соответственно. Как и текущий реальный DOM
список, DOM
список изначально согласуется со старым VNode
списком и будет согласовываться с новым VNode
списком после завершения алгоритма.
Следует отметить, что эта анимация включает только diff
процесс алгоритма, а не patch
сам процесс.
Давайте сначала рассмотрим diff
функцию двустороннего алгоритма:
const diff = (el, oldChildren, newChildren) => {
// 指针
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode === null) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (oldEndVNode === null) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (newStartVNode === null) {
newStartVNode = oldChildren[++newStartIdx]
} else if (newEndVNode === null) {
newEndVNode = oldChildren[--newEndIdx]
} else if (isSameNode(oldStartVNode, newStartVNode)) {
// 头-头
patchVNode(oldStartVNode, newStartVNode)
// 更新指针
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (isSameNode(oldStartVNode, newEndVNode)) {
// 头-尾
patchVNode(oldStartVNode, newEndVNode)
// 把oldStartVNode节点移动到最后
el.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling)
// 更新指针
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (isSameNode(oldEndVNode, newStartVNode)) {
// 尾-头
patchVNode(oldEndVNode, newStartVNode)
// 把oldEndVNode节点移动到oldStartVNode前
el.insertBefore(oldEndVNode.el, oldStartVNode.el)
// 更新指针
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (isSameNode(oldEndVNode, newEndVNode)) {
// 尾-尾
patchVNode(oldEndVNode, newEndVNode)
// 更新指针
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else {
let findIndex = findSameNode(oldChildren, newStartVNode)
// newStartVNode在旧列表里不存在,那么是新节点,创建插入
if (findIndex === -1) {
el.insertBefore(createEl(newStartVNode), oldStartVNode.el)
} else {
// 在旧列表里存在,那么进行patch,并且移动到oldStartVNode前
let oldVNode = oldChildren[findIndex]
patchVNode(oldVNode, newStartVNode)
el.insertBefore(oldVNode.el, oldStartVNode.el)
// 将该VNode置为空
oldChildren[findIndex] = null
}
newStartVNode = newChildren[++newStartIdx]
}
}
// 旧列表里存在新列表里没有的节点,需要删除
if (oldStartIdx <= oldEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
removeEvent(oldChildren[i])
oldChildren[i] && el.removeChild(oldChildren[i].el)
}
} else if (newStartIdx <= newEndIdx) {
let before = newChildren[newEndIdx + 1] ? newChildren[newEndIdx + 1].el : null
for (let i = newStartIdx; i <= newEndIdx; i++) {
el.insertBefore(createEl(newChildren[i]), before)
}
}
}
Конкретные шаги реализации этой функции могут относиться к предыдущей статье, поэтому в этой статье они повторяться не будут.
Если мы хотим diff
переместить этот процесс, мы должны сначала выяснить, какие объекты анимируются.Начиная с параметров функции, сначала oldChildren
и newChildren
два VNode
списка необходимы, которые могут быть представлены двумя горизонтальными списками, а затем четырьмя указателями, что является ключ к двустороннему diff
алгоритму. Мы представляем его четырьмя стрелками, указывающими на текущий сравниваемый узел, а затем запускаем цикл. На самом деле старый и новый VNode
списки в цикле принципиально не меняются. То, чем мы фактически оперируем, это VNode
соответствующие DOM
элементы Real, включая patch
исправление, перемещение, удаление, добавление и т. д., поэтому у нас есть еще один горизонтальный список для представления текущего реального списка. В начале он должен соответствовать DOM
старому списку. Шаг за шагом через алгоритм, он станет и Новый список соответствует.VNode
diff
VNode
Давайте вернемся к функции VNode
, которая создает объект h
:
export const h = (tag, data = {
}, children) => {
let text = ''
let el
let key
// 文本节点
if (typeof children === 'string' || typeof children === 'number') {
text = children
children = undefined
} else if (!Array.isArray(children)) {
children = undefined
}
if (data && data.key) {
key = data.key
}
return {
tag, // 元素标签
children, // 子元素
text, // 文本节点的文本
el, // 真实dom
key,
data
}
}
VNode
Данные списка, которые мы вводим, будут h
созданы в VNode
объекты с помощью функций, поэтому простейшая структура, которую можно ввести, выглядит следующим образом:
[
{
tag: 'div',
children: '文本节点的内容',
data: {
key: 'a'
}
}
]
Введенные новые и старые VNode
данные списка будут сохранены в store
, который можно получить следующими способами:
// 输入的旧VNode列表
store.oldVNode
// 输入的新VNode列表
store.newVNode
Затем определите соответствующие переменные:
// 指针列表
const oldPointerList = ref([])
const newPointerList = ref([])
// 真实DOM节点列表
const actNodeList = ref([])
// 新旧节点列表
const oldVNodeList = ref([])
const newVNodeList = ref([])
// 提示信息
const info = ref('')
Анимация перемещения указателя может быть реализована с использованием свойства css
элемента transition
указателя, пока значение элемента указателя изменяется , анимация перемещения left
реального списка может быть легко реализована с помощью компонента перехода списка TransitionGroup , шаблон выглядит следующим образом:DOM
Vue
<div class="playground">
<!-- 指针 -->
<div class="pointer">
<div
class="pointerItem"
v-for="item in oldPointerList"
:key="item.name"
:style="{ left: item.value * 120 + 'px' }"
>
<div class="pointerItemName">{
{ item.name }}</div>
<div class="pointerItemValue">{
{ item.value }}</div>
<img src="../assets/箭头_向下.svg" alt="" />
</div>
</div>
<div class="nodeListBox">
<!-- 旧节点列表 -->
<div class="nodeList">
<div class="name" v-if="oldVNodeList.length > 0">旧的VNode列表</div>
<div class="nodes">
<TransitionGroup name="list">
<div
class="nodeWrap"
v-for="(item, index) in oldVNodeList"
:key="item ? item.data.key : index"
>
<div class="node">{
{ item ? item.children : '空' }}</div>
</div>
</TransitionGroup>
</div>
</div>
<!-- 新节点列表 -->
<div class="nodeList">
<div class="name" v-if="newVNodeList.length > 0">新的VNode列表</div>
<div class="nodes">
<TransitionGroup name="list">
<div
class="nodeWrap"
v-for="(item, index) in newVNodeList"
:key="item.data.key"
>
<div class="node">{
{ item.children }}</div>
</div>
</TransitionGroup>
</div>
</div>
<!-- 提示信息 -->
<div class="info">{
{ info }}</div>
</div>
<!-- 指针 -->
<div class="pointer">
<div
class="pointerItem"
v-for="item in newPointerList"
:key="item.name"
:style="{ left: item.value * 120 + 'px' }"
>
<img src="../assets/箭头_向上.svg" alt="" />
<div class="pointerItemValue">{
{ item.value }}</div>
<div class="pointerItemName">{
{ item.name }}</div>
</div>
</div>
<!-- 真实DOM列表 -->
<div class="nodeList act" v-if="actNodeList.length > 0">
<div class="name">真实DOM列表</div>
<div class="nodes">
<TransitionGroup name="list">
<div
class="nodeWrap"
v-for="item in actNodeList"
:key="item.data.key"
>
<div class="node">{
{ item.children }}</div>
</div>
</TransitionGroup>
</div>
</div>
</div>
diff
Новый список не будет изменен во время работы алгоритма double-end VNode
, но старый VNode
список может быть изменен, то есть когда сквозное сравнение не находит узел, который можно использовать повторно, но он находится VNode
при прямом поиске в старом списке, то VNode
соответствующая реальность будет перемещена DOM
, После перемещения это VNode
фактически эквивалентно обработке, но VNode
положение объекта находится в середине текущего указателя и не может быть удалено напрямую, поэтому его необходимо установите значение empty null
, чтобы вы могли видеть в шаблоне. Есть ручки для этой ситуации.
Кроме того, мы также создали info
элемент для отображения текстовой информации подсказки в виде описания анимации.
Но этого недостаточно, потому что у каждого старого VNode
есть соответствующий реальный DOM
элемент, а то, что мы вводим, — это просто обычные json
данные, поэтому шаблону также нужно добавить новый список в качестве связанного узла старого списка.Этому VNode
списку нужно только обеспечить узлы На него можно ссылаться, и он не должен быть видимым, поэтому установите display
для него значение none
:
// 根据输入的旧VNode列表创建元素
const _oldVNodeList = computed(() => {
return JSON.parse(store.oldVNode)
})
// 引用DOM元素
const oldNode = ref(null)
const oldNodeList = ref([])
<!-- 隐藏 -->
<div class="hide">
<div class="nodes" ref="oldNode">
<div
v-for="(item, index) in _oldVNodeList"
:key="index"
ref="oldNodeList"
>
{
{ item.children }}
</div>
</div>
</div>
Затем, когда мы нажимаем кнопку запуска, мы можем присвоить значения нашим трем переменным списка и использовать h
функции для создания новых и старых VNode
объектов, а затем передать их в исправленную patch
функцию, чтобы начать сравнение и обновление фактических DOM
элементов:
const start = () => {
nextTick(() => {
// 表示当前真实的DOM列表
actNodeList.value = JSON.parse(store.oldVNode)
// 表示旧的VNode列表
oldVNodeList.value = JSON.parse(store.oldVNode)
// 表示新的VNode列表
newVNodeList.value = JSON.parse(store.newVNode)
nextTick(() => {
let oldVNode = h(
'div',
{
key: 1 },
JSON.parse(store.oldVNode).map((item, index) => {
// 创建VNode对象
let vnode = h(item.tag, item.data, item.children)
// 关联真实的DOM元素
vnode.el = oldNodeList.value[index]
return vnode
})
)
// 列表的父节点也需要关联真实的DOM元素
oldVNode.el = oldNode.value
let newVNode = h(
'div',
{
key: 1 },
JSON.parse(store.newVNode).map(item => {
return h(item.tag, item.data, item.children)
})
)
// 调用patch函数进行打补丁
patch(oldVNode, newVNode)
})
})
}
Можно видеть, что новый и старый списки, которые мы вводим, VNode
используются в качестве дочерних узлов узла, Это связано с тем, что только когда два сравниваемых узла имеют дочерние узлы, которые не являются текстовыми узлами, необходимо использовать алгоритмы для diff
эффективного обновления их дочерних узлов. , Когда patch
после запуска функции вы можете открыть консоль, чтобы просмотреть скрытый список, и вы обнаружите, что он согласуется с DOM
новым списком.Тогда вы можете спросить, почему бы просто не использовать этот список как реальный список и создать дополнительный список самостоятельно в процессе работы алгоритма реальные узлы перемещаются другими методами , поэтому добавить анимацию перехода непросто, только чтобы увидеть, как узлы меняют положение мгновенно, что не удовлетворить наши потребности в анимации.VNode
DOM
actNodeList
diff
insertBefore
DOM
Эффект здесь следующий:
Далее сначала вытащим указатель, создадим объект обрабатывающей функции, который будет монтировать некоторые методы для вызова во время работы алгоритма diff
и обновления соответствующих переменных в функции.
const handles = {
// 更新指针
updatePointers(oldStartIdx, oldEndIdx, newStartIdx, newEndIdx) {
oldPointerList.value = [
{
name: 'oldStartIdx',
value: oldStartIdx
},
{
name: 'oldEndIdx',
value: oldEndIdx
}
]
newPointerList.value = [
{
name: 'newStartIdx',
value: newStartIdx
},
{
name: 'newEndIdx',
value: newEndIdx
}
]
},
}
Затем мы можем обновить указатель в diff
функции handles.updatePointers()
:
const diff = (el, oldChildren, newChildren) => {
// 指针
// ...
handles.updatePointers(oldStartIdx, oldEndIdx, newStartIdx, newEndIdx)
// ...
}
Итак, указатель выходит:
Тогда while
эти четыре указателя будут постоянно изменяться в цикле, поэтому их тоже нужно обновлять в цикле:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
handles.updatePointers(oldStartIdx, oldEndIdx, newStartIdx, newEndIdx)
}
Но это явно не возможно, почему, ведь цикл заканчивается в одно мгновение, и мы каждый раз надеемся задержаться на какое-то время, это очень просто, пишем функцию ожидания:
const wait = t => {
return new Promise(resolve => {
setTimeout(
() => {
resolve()
},
t || 3000
)
})
}
Затем мы async/await
можем легко реализовать ожидание в циклах, используя синтаксис:
const diff = async (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
handles.updatePointers(oldStartIdx, oldEndIdx, newStartIdx, newEndIdx)
await wait()
}
}
Затем мы добавляем две переменные, чтобы выделить две, которые мы сейчас сравниваем VNode
:
// 当前比较中的节点索引
const currentCompareOldNodeIndex = ref(-1)
const currentCompareNewNodeIndex = ref(-1)
const handles = {
// 更新当前比较节点
updateCompareNodes(a, b) {
currentCompareOldNodeIndex.value = a
currentCompareNewNodeIndex.value = b
}
}
<div
class="nodeWrap"
v-for="(item, index) in oldVNodeList"
:key="item ? item.data.key : index"
:class="{
current: currentCompareOldNodeIndex === index,
}"
>
<div class="node">{
{ item ? item.children : '空' }}</div>
</div>
<div
class="nodeWrap"
v-for="(item, index) in newVNodeList"
:key="item.data.key"
:class="{
current: currentCompareNewNodeIndex === index,
}"
>
<div class="node">{
{ item.children }}</div>
</div>
Добавляем имя класса в ноду в текущем сравнении для подсветки, следующий шаг все тот же, нужно вызывать diff
функцию в функции, но как ее добавить:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if // ...
} else if (isSameNode(oldStartVNode, newStartVNode)) {
// ...
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (isSameNode(oldStartVNode, newEndVNode)) {
// ...
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (isSameNode(oldEndVNode, newStartVNode)) {
// ...
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (isSameNode(oldEndVNode, newEndVNode)) {
// ...
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else {
// ...
newStartVNode = newChildren[++newStartIdx]
}
Мы хотим показать процесс прямого сравнения, фактически, в этих if
условиях, то есть if
оставаться в каждом состоянии в течение определенного периода времени, поэтому мы можем сделать это напрямую:
const isSameNode = async () => {
// ...
handles.updateCompareNodes()
await wait()
}
if (await isSameNode(oldStartVNode, newStartVNode))
К сожалению, мне не удалось попробовать, поэтому я могу только переписать его в других формах:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
let stop = false
let _isSameNode = false
if (oldStartVNode === null) {
callbacks.updateInfo('')
oldStartVNode = oldChildren[++oldStartIdx]
stop = true
}
// ...
if (!stop) {
callbacks.updateInfo('头-头比较')
callbacks.updateCompareNodes(oldStartIdx, newStartIdx)
_isSameNode = isSameNode(oldStartVNode, newStartVNode)
if (_isSameNode) {
callbacks.updateInfo(
'key值相同,可以复用,进行patch打补丁操作。新旧节点位置相同,不需要移动对应的真实DOM节点'
)
}
await wait()
}
if (!stop && _isSameNode) {
// ...
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
stop = true
}
// ...
}
Мы используем переменную, чтобы указать, вошли ли мы в определенную ветку, а затем сохраняем результат проверки возможности повторного использования узла в переменной, чтобы мы могли постоянно проверять значения этих двух переменных, чтобы определить, следует ли входить В последующей ветке сравнения логики сравнения нет в if
условии, и ее можно использовать await
При этом также используем updateInfo
добавленную подсказку:
const handles = {
// 更新提示信息
updateInfo(tip) {
info.value = tip
}
}
Далее, давайте посмотрим на операцию перемещения узла.Когда голова ( oldStartIdx
соответствующий oldStartVNode
узел) и хвост ( newEndIdx
соответствующий newEndVNode
узел) сравниваются и обнаруживаются, что их можно использовать повторно, после завершения исправления соответствующий oldStartVNode
реальный DOM
элемент необходимо переместить в позиция oldEndVNode
соответствующего реального DOM
элемента, то есть Вставить перед oldEndVNode
соответствующим реальным элементом DOM
назад на один узел:
if (!stop && _isSameNode) {
// 头-尾
patchVNode(oldStartVNode, newEndVNode)
// 把oldStartVNode节点移动到最后
el.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling)
// 更新指针
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
stop = true
}
Тогда мы можем вызвать метод перемещения узлов нашего смоделированного списка сразу после того, как метод insertBefore
переместит реальные элементы:DOM
if (!stop && _isSameNode) {
// ...
el.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling)
callbacks.moveNode(oldStartIdx, oldEndIdx + 1)
// ...
}
То, чем мы хотим оперировать, на самом деле представляет собой список, представляющий реальные DOM
узлы actNodeList
, поэтому ключ в том, чтобы выяснить, какой из них, в первую очередь, четыре указателя узла в начале и в конце указывают позицию в старом и новом списках VNode
, поэтому мы можем получить oldStartIdx
соответствующее Position , а затем найти соответствующий узел в списке через значение и выполнить такие операции, как перемещение, удаление и вставка:oldEndIdx
oldVNodeList
VNode
key
actNodeList
const handles = {
// 移动节点
moveNode(oldIndex, newIndex) {
let oldVNode = oldVNodeList.value[oldIndex]
let newVNode = oldVNodeList.value[newIndex]
let fromIndex = findIndex(oldVNode)
let toIndex = findIndex(newVNode)
actNodeList.value[fromIndex] = '#'
actNodeList.value.splice(toIndex, 0, oldVNode)
actNodeList.value = actNodeList.value.filter(item => {
return item !== '#'
})
}
}
const findIndex = (vnode) => {
return !vnode
? -1
: actNodeList.value.findIndex(item => {
return item && item.data.key === vnode.data.key
})
}
Другие узлы вставки и удаления аналогичны:
Вставить узел:
const handles = {
// 插入节点
insertNode(newVNode, index, inNewVNode) {
let node = {
data: newVNode.data,
children: newVNode.text
}
let targetIndex = 0
if (index === -1) {
actNodeList.value.push(node)
} else {
if (inNewVNode) {
let vNode = newVNodeList.value[index]
targetIndex = findIndex(vNode)
} else {
let vNode = oldVNodeList.value[index]
targetIndex = findIndex(vNode)
}
actNodeList.value.splice(targetIndex, 0, node)
}
}
}
удалить узел:
const handles = {
// 删除节点
removeChild(index) {
let vNode = oldVNodeList.value[index]
let targetIndex = findIndex(vNode)
actNodeList.value.splice(targetIndex, 1)
}
}
Позиция выполнения этих методов в diff
функции на самом деле является местом, где выполняется метод insertBefore
Вы removeChild
можете обратиться к исходному коду этой статьи для получения подробной информации, поэтому я не буду его здесь представлять.
Кроме того, вы также можете выделить элементы, которые были сравнены, элементы, которые будут добавлены, элементы, которые будут удалены и т. д., окончательный эффект:
По причинам времени в настоящее время реализован только эффект двухстороннего алгоритма diff
, а анимация процесса быстрого алгоритма будет добавлена в будущем.Если diff
вам интересно, вы можете обратить внимание~