Виртуальный DOM и алгоритм сравнения
предисловие
虚拟DOM
иdiff
Алгоритмы, вы часто их иногда слышите, так что же они реализованы?Это то, что Xiaolang я изучаю虚拟DOM
иdiff
Во время резюме, здесь, чтобы дать вам более глубокое пониманиеvirtual DOM
иdiff
алгоритм, изsnabbdomОсновное использование , чтобы реализовать упрощенную версию самостоятельноsnabbdom, реализуйте самиh функция(создать виртуальный DOM)patchфункция (обновление представления путем сравнения старого и нового виртуального DOM), здесь я также рисую несколько анимаций, чтобы помочь вам понятьdiffЧетыре стратегии оптимизации, статья немного длинная, надеюсь, вы терпеливо ее читаете, и, наконец, все коды будут опубликованы, вы можете попробовать.
Наконец, я надеюсь, что каждый сможет дать Сяолануотличный
Прошлые основные моменты:
Написание простого ответа vue поможет вам понять принцип ответа.
От использования до самостоятельной реализации простого Vue Router — просто взгляните на это
Необходимые базовые знания о фронтенд-интервью, хотя и немногочисленные, но вы не можете не знать
1. Введение
Virtual DOMкраткое введение
даJavaScript
в соответствии сDOM
Структура для создания объекта виртуальной древовидной структуры состоит в следующем:DOM
абстракция, чемDOM
более легкий
зачем использоватьVirtual DOM
- Конечно, это фронтенд-оптимизация, избегание частых операций.
DOM
, частая работаDOM
Это может привести к переформатированию и перерисовке браузера, производительность будет очень низкой, и есть ручное управлениеDOM
Это все еще довольно проблематично, и следует учитывать проблемы совместимости браузеров.jQuery
и т.д. библиотека упрощаетDOM
Операция, но проект сложный,DOM
Операции по-прежнему будут сложными, и операции с данными также станут сложными. - Не во всех случаях используются виртуальные
DOM
Оба улучшают производительность и нацелены на использование в сложных проектах. Если простая операция, используйте виртуальныйDOM
, чтобы создать виртуальныйDOM
Серия операций, таких как объекты, не так хороша, как обычныеDOM
действовать - виртуальный
DOM
Может быть достигнут кросс-платформенный рендеринг, рендеринг сервера, апплеты и собственные приложения используют виртуальныеDOM
- использовать виртуальный
DOM
Изменения текущего состояния не требуют немедленного обновленияDOM
И обновленный контент обновляется, и для неизмененного контента не выполняется никаких операций, и сравнивается разница между двумя до и после. - Виртуальный DOM может поддерживать состояние программы и отслеживать последнее состояние.
2. Введение в снаббдом
Сначала давайте представим snabbdom
Если мы хотим понять виртуальный DOM, мы должны сначала понять его предка, то естьsnabbdom
snabbdomэто проект с открытым исходным кодом,Vueвнутри виртуальногоDOMОн был заимствован изsnabbdom, мы можем понять поsnabbdomвиртуальныйDOMпонятьVueвиртуальныйDOM,Vueслишком много исходного кода,snabbdomОн более лаконичен, поэтому используйте его для расширения виртуальногоDOMИсследовательская работа
Установить через нпм
npm install snabbdom
1. Snabbdom прост в использовании
Давайте напишем простой пример, используя snabdom
<body>
<div id="app"></div>
<script src="./js/test.js"></script>
</body>
Напишите test.js для использования
/* test.js */
// 导入 snabbdom
import { h, init, thunk } from 'snabbdom'
// init() 方法返回一个 patch 函数 用来比较两个虚拟DOM 的差异 然后更新到真实的DOM里
// 这里暂时传入一个空数组 []
let patch = init([])
// h 方法是用来创建 Virtual DOM
// 第一个参数是 虚拟DOM 标签
// 第二个参数是 虚拟DOM 的数据
// 第三个参数是 虚拟DOM 的子虚拟DOM
// 它有好几种传参方式 h函数做了重载 这里就 用上面的传参
// 而且可以进行嵌套使用
let vnode = h('div#box', '测试', [
h('ul.list', [
h('li', '我是一个li'),
h('li', '我是一个li'),
h('li', '我是一个li'),
]),
])
// 获取到 html 的 div#app
let app = document.querySelector('#app')
// 用来比较两个虚拟DOM 的差异 然后更新到真实的DOM里
let oldNode = patch(app, vnode)
// 再来模拟一个异步请求
setTimeout(() => {
let vNode = h('div#box', '重新获取了数据', [
h('ul.list', [
h('li', '我是一个li'),
h('li', '通过path判断了差异性'),
h('li', '更新了数据'),
]),
])
// 再来进行比较差异判断是否更新
patch(oldNode, vNode)
}, 3000)
видно поставить виртуалкуDOMобновлено до реальногоDOM, прямо поставить предыдущуюdiv#appобновлено, чтобы заменить
Через 3 секунды сравнить виртуальныйDOMразницу добавить к реальнойDOM, тут поменяли второе и третьеliВизуализировать как виртуальный с помощью функции hDOMиoldNodeНе то же самое, поэтому было сделано обновление сравнения
2. Внедрить модули в снаббдом
Несколько модулей кратко здесь
имя модуля | Введение |
---|---|
attributes | Пользовательские свойства DOM, включая два логических значенияchecked selected ,пройти черезsetAttribute() настраивать |
props | является атрибутом свойства DOM, черезelement[attr] = value настраивать |
dataset | даdata- Атрибуты, начинающиеся с data-src... |
style | Встроенные стили |
eventListeners | Используется для регистрации и удаления событий |
С приведенным выше введением давайте использовать его просто
/* module_test.js */
// 第一步当然是先导入 snabbdom 的 init() h()
import { h, init } from 'snabbdom'
// 导入模块
import attr from 'snabbdom/modules/attributes'
import style from 'snabbdom/modules/style'
import eventListeners from 'snabbdom/modules/eventlisteners'
// init()注册模块 返回值是 patch 函数用来比较 两个虚拟DOM 差异 然后添加到 真实DOM
let patch = init([attr, style, eventListeners])
// 使用 h() 渲染一个虚拟DOM
let vnode = h(
'div#app',
{
// 自定义属性
attrs: {
myattr: '我是自定义属性',
},
// 行内样式
style: {
fontSize: '29px',
color: 'skyblue',
},
// 事件绑定
on: {
click: clickHandler,
},
},
'我是内容'
)
// 点击处理方法
function clickHandler() {
// 拿到当前 DOM
let elm = this.elm
elm.style.color = 'red'
elm.textContent = '我被点击了'
}
// 获取到 div#app
let app = document.querySelector('#app')
// patch 比较差异 ,然后添加到真实DOM 中
patch(app, vnode)
после этогоhtml
введен в
<body>
<div id="app"></div>
<script src="./js/module_test.js"></script>
<script></script>
</body>
увидеть эффект
Вы можете видеть, что настраиваемые атрибуты, встроенные стили и события кликов — все этоh()оказанный
Вышеупомянутое использование было кратко передано, поэтому давайте посмотримsnabbdomисходный код в
3. Пример виртуального DOM
сказал так долгоh()функции и виртуальныеDOMЗатем визуализированный виртуальныйDOMна что это похоже
реальная структура DOM
<div class="container">
<p>哈哈</p>
<ul class="list">
<li>1</li>
<li>2</li>
</ul>
</div>
Структура после преобразования в виртуальный DOM
{
// 选择器
"sel": "div",
// 数据
"data": {
"class": { "container": true }
},
// DOM
"elm": undefined,
// 和 Vue :key 一样是一种优化
"key": undefined,
// 子节点
"children": [
{
"elm": undefined,
"key": undefined,
"sel": "p",
"data": { "text": "哈哈" }
},
{
"elm": undefined,
"key": undefined,
"sel": "ul",
"data": {
"class": { "list": true }
},
"children": [
{
"elm": undefined,
"key": undefined,
"sel": "li",
"data": {
"text": "1"
},
"children": undefined
},
{
"elm": undefined,
"key": undefined,
"sel": "li",
"data": {
"text": "1"
},
"children": undefined
}
]
}
]
}
упоминалось ранееsnabbdom
серединаpatch
метод
верноновый виртуальный DOMистарый виртуальный DOMпровестиdiff(детальное сравнение), выясняем, что наименьшее количество обновлений приходится на виртуальныйDOMсравнивать
невозможно поставить всеDOMУдалите их все и перерисуйте их все
4.h Функция
Выше мы испыталивиртуальный DOMuse , то теперь мы реализуем упрощенную версиюsnabbdom
Вводится функция h
существуетsnabbdomМы также использовали несколькоhФункция, основная функция заключается в создании виртуальных узлов
snabbdomиспользоватьTSнапиши, такhсделано в функцииперегрузка методаГибкость в использовании
Нижеsnabbdomсерединаhфункции, видно, что есть несколько способов параметров
export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
Реализовать функцию vnode
письмоhРеализовать перед функциейvnodeфункция,vnodeфункция быть вhиспользуется, на самом деле этоvnodeФункция реализации функции очень проста вTSВ нем указано много типов, но я использую его здесь и далее.JSнаписать
/* vnode.js */
/**
* 把传入的 参数 作为 对象返回
* @param {string} sel 选择器
* @param {object} data 数据
* @param {array} children 子节点
* @param {string} text 文本
* @param {dom} elm DOM
* @returns object
*/
export default function (sel, data, children, text, elm) {
return { sel, data, children, text, elm }
}
Реализовать простую h-функцию
Написанная здесь функция h реализует только основную функцию, не реализует перегрузку и напрямую реализует функцию h с 3 параметрами.
/* h.js */
// 导入 vnode
import vnode from './vnode'
// 导出 h 方法
// 这里就实现简单3个参数 参数写死
/**
*
* @param {string} a sel
* @param {object} b data
* @param {any} c 是子节点 可以是文本,数组
*/
export default function h(a, b, c) {
// 先判断是否有三个参数
if (arguments.length < 3) throw new Error('请检查参数个数')
// 第三个参数有不确定性 进行判断
// 1.第三个参数是文本节点
if (typeof c === 'string' || typeof c === 'number') {
// 调用 vnode 这直接传 text 进去
// 返回值 {sel,data,children,text,elm} 再返回出去
return vnode(a, b, undefined, c, undefined)
} // 2.第三个参数是数组 [h(),h()] [h(),text] 这些情况
else if (Array.isArray(c)) {
// 然而 数组里必须是 h() 函数
// children 用收集返回结果
let children = []
// 先判断里面是否全是 h()执行完的返回结果 是的话添加到 chilren 里
for (let i = 0; i < c.length; i++) {
// h() 的返回结果 是{} 而且 包含 sel
if (!(typeof c[i] === 'object' && c[i].sel))
throw new Error('第三个参数为数组时只能传递 h() 函数')
// 满足条件进行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
children.push(c[i])
}
// 调用 vnode 返回 {sel,data,children,text,elm} 再返回
return vnode(a, b, children, undefined, undefined)
} // 3.第三个参数直接就是函数 返回的是 {sel,data,children,text,elm}
else if (typeof c === 'object' && c.sel) {
// 这个时候在 使用h()的时候 c = {sel,data,children,text,elm} 直接放入children
let children = [c]
// 调用 vnode 返回 {sel,data,children,text,elm} 再返回
return vnode(a, b, children, undefined, undefined)
}
}
Разве это не очень просто, он сказал, что это не рекурсивно, как своего рода вложение, постоянно собирая{sel,data,children,text,elm}
chirldrenПоложите это внутрь{sel,data,children,text,elm}
Например
/* index.js */
import h from './my-snabbdom/h'
let vnode = h('div', {},
h('ul', {}, [
h('li', {}, '我是一个li'),
h('li', {}, '我是一个li'),
h('li', {}, '我是一个li'),
),
])
console.log(vnode)
<body>
<div id="container"></div>
<script src="/virtualdir/bundle.js"></script>
</body>
OK, написаноhС функцией проблем нет, дамми генерируетсяDOMдерево, которое генерирует виртуальный DOM, который мы будем использовать позже
Кратко опишите процесс
все знаютjs
Выполнение функции, конечно, сначала выполнить самую внутреннюю функцию
-
1.
h('li', {}, '我是一个li')
Первое выполнение возвращает{sel,data,children,text,elm}
Три ли подряд это -
2. Тогда есть
h('ul', {}, [])
Введите второе суждение, является ли это массивом, а затем оцените, является ли каждый элемент объектом и имеет ли онselсвойства, затем добавьте вchildrenВнутри и обратно{sel,data,children,text,elm}
-
3. Третье — реализация
h('div', {},h())
, третий параметр напрямуюh()
функция ={sel,data,children,text,elm}
,егоchildrenиспользовать его[ ]заворачиватьвернуться кvnode
5.функция патча
Введение
существуетsnabbdomсреди нас черезinit()вернулсяpatchфункцию, черезpatchИдите вперед и сравните два виртуальных DOM и добавьте реальный.DOMНа дереве среднее сравнение — это то, о чем мы поговорим позже.diff
Давайте сначала разберемсяpatchчто внутри
В соответствии с описанным выше процессом, давайте напишем простойpatch
1.patch
Сначала напишите тот же Vnode
используется для сравнения двух виртуальныхDOMизkeyиsel
/* sameVnode.js */
/**
* 判断两个虚拟节点是否是同一节点
* @param {vnode} vnode1 虚拟节点1
* @param {vnode} vnode2 虚拟节点2
* @returns boolean
*/
export default function sameVnode(vnode1, vnode2) {
return (
(vnode1.data ? vnode1.data.key : undefined) ===
(vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
)
}
Написать базовый патч
/* patch.js */
// 导入 vnode
import vnode from './vnode'
// 导出 patch
/**
*
* @param {vnode/DOM} oldVnode
* @param {vnode} newVnode
*/
export default function patch(oldVnode, newVnode) {
// 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
if (!oldVnode.sel) {
// 转为虚拟DOM
oldVnode = emptyNodeAt(oldVnode)
}
// 判断 oldVnode 和 newVnode 是否为同一虚拟节点
// 通过 key 和 sel 进行判断
if (sameVnode(oldVnode, newVnode)) {
// 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
...
} else {
// 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
...
}
newVnode.elm = oldVnode.elm
// 返回newVnode作为 旧的虚拟节点
return newVnode
}
/**
* 转为 虚拟 DOM
* @param {DOM} elm DOM节点
* @returns {object}
*/
function emptyNodeAt(elm) {
// 把 sel 和 elm 传入 vnode 并返回
// 这里主要选择器给转小写返回vnode
// 这里功能做的简陋,没有去解析 # .
// data 也可以传 ID 和 class
return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}
Теперь нам предстоит разобраться с вопросом, является ли это одним и тем же виртуальным узлом
2.createElm
Сначала разберитесь с другим виртуальным узлом
Чтобы справиться с этим, мы должны написать метод для создания узла здесьcreateElm.jsзавершено в
/* createElm.js */
/**
* 创建元素
* @param {vnode} vnode 要创建的节点
*/
export default function createElm(vnode) {
// 拿出 新创建的 vnode 中的 sel
let node = document.createElement(vnode.sel)
// 存在子节点
// 子节点是文本
if (
vnode.text !== '' &&
(vnode.children === undefined || vnode.children.length === 0)
) {
// 直接添加文字到 node 中
node.textContent = vnode.text
// 子节点是数组
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
let children = vnode.children
// 遍历数组
for (let i = 0; i < children.length; i++) {
// 获取到每一个数组中的 子节点
let ch = children[i]
// 递归的方式 创建节点
let chDom = createElm(ch)
// 把子节点添加到 自己身上
node.appendChild(chDom)
}
}
// 更新vnode 中的 elm
vnode.elm = node
// 返回 DOM
return node
}
вышеcreateElmЭто использовать рекурсивный способ создания дочерних узлов, а затем мы переходим к патчу, чтобы вызвать метод создания узлов конкретно
/* patch.js */
// 导入 vnode createELm
import vnode from './vnode'
import createElm from './createElm'
// 导出 patch
/**
*
* @param {vnode/DOM} oldVnode
* @param {vnode} newVnode
*/
export default function patch(oldVnode, newVnode) {
// 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
if (!oldVnode.sel) {
// 转为虚拟DOM
oldVnode = emptyNodeAt(oldVnode)
}
// 判断 oldVnode 和 newVnode 是否为同一虚拟节点
// 通过 key 和 sel 进行判断
if (sameVnode(oldVnode, newVnode)) {
// 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
...
} else {
// 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
// 这里通过 createElm 递归 转为 真实的 DOM 节点
let newNode = createElm(newVnode)
// 旧节点的父节点
if (oldVnode.elm.parentNode) {
let parentNode = oldVnode.elm.parentNode
// 添加节点到真实的DOM 上
parentNode.insertBefore(newNode, oldVnode.elm)
// 删除旧节点
parentNode.removeChild(oldVnode.elm)
}
}
newVnode.elm = oldVnode.elm
return newVnode
}
...
}
Рекурсивное добавление дочерних узлов в конце, в котором мы находимся.patchдобавить к реальномуDOM, удалите старый узел перед
Я написал это здесь, чтобы попробовать, действительно ли добавлены разные узлы
/* index.js */
import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'
let app = document.querySelector('#app')
let vnode = h('ul', {}, [
h('li', {}, '我是一个li'),
h('li', {}, [
h('p', {}, '我是一个p'),
h('p', {}, '我是一个p'),
h('p', {}, '我是一个p'),
]),
h('li', {}, '我是一个li'),
])
let oldVnode = patch(app, vnode)
<body>
<div id="app">hellow</div>
<script src="/virtualdir/bundle.js"></script>
</body>
Пучокdiv#appЗаменено и успешно заменено
3.patchVnode
Давайте теперь реализуем ту же виртуальную обработку DOM
в patchVnode
Все шаги написаны в соответствии с предыдущей блок-схемой, мы сравниваем дватакой жеКод виртуального DOM написан наpatchVnode.jsсередина
Есть несколько случаев при сравнении двух идентичных веток виртуальных узлов
/* patchVnode.js */
// 导入 vnode createELm
import createElm from './createElm'
/**
*
* @param {vnode} oldVnode 老的虚拟节点
* @param {vnode} newVnode 新的虚拟节点
* @returns
*/
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
// 1.判断是否相同对象
console.log('同一个虚拟节点')
if (oldVnode === newVnode) return
// 2.判断newVnode上有没有text
// 这里为啥不考虑 oldVnode呢,因为 newVnode有text说明就没children
if (newVnode.text && !newVnode.children) {
// 判断是text否相同
if (oldVnode.text !== newVnode.text) {
console.log('文字不相同')
// 不相同就直接把 newVnode中text 给 elm.textContent
oldVnode.elm.textContent = newVnode.text
}
} else {
// 3.判断oldVnode有children, 这个时候newVnode 没有text但是有 children
if (oldVnode.children) {
...这里新旧节点都存在children 这里要使用 updateChildren 下面进行实现
} else {
console.log('old没有children,new有children')
// oldVnode没有 children ,newVnode 有children
// 这个时候oldVnode 只有text 我们把 newVnode 的children拿过来
// 先清空 oldVnode 中text
oldVnode.elm.innerHTML = ''
// 遍历 newVnode 中的 children
let newChildren = newVnode.children
for (let i = 0; i < newChildren.length; i++) {
// 通过递归拿到了 newVnode 子节点
let node = createElm(newChildren[i])
// 添加到 oldVnode.elm 中
oldVnode.elm.appendChild(node)
}
}
}
}
Следуйте блок-схеме для кодирования, теперь для обработкиnewVnodeиoldVnodeоба существуютchildrenситуация
Здесь мы собираемся провести утонченное сравнение, о чем мы часто говоримdiff
4.diff
часто слышалdiff (отличное сравнение), тогда давайте сначала разберемся
Четыре стратегии оптимизации для diff
Здесь используются 4 указателя, начиная с порядка 1-4, чтобы попасть в стратегию оптимизации, нажать один и переместить указатель.(新前和旧前向下移动,新后和旧后向上移动)
, никаких хитов, просто используйтеследующая стратегия, если ни одна из четырех стратегий не сработала, вы можете найти ее, только зациклив
попадание: два узлаselиkeyТакой же
- новый и старый
- новый и старый
- новый и старый
- до и после
Поговорим о новом
Четыре стратегии выполняются в цикле
while(旧前<=旧后&&新前<=新后){
...
}
Как можно видетьстарый дочерний узелПосле того, как цикл завершится первым, это означает, что новый дочерний узел должен добавить новый дочерний узел.
новый фронтиновый постУзел — это байт, который нужно добавить
Удалено дело 1
Здесь новый дочерний узел завершает цикл первым, указывая на то, что в старом дочернем узле есть узлы, которые необходимо удалить.
Удалено дело 2
Когда мы удаляем несколько, и ни одна из 4 стратегий не удовлетворяется, мы должны пройтиwhileцикл старый дочерний узел найти новый дочерний узел нужно найти узел и пометить какundefined
виртуальный узелundefinedна самом деле вDOMпереместил его,старый фронтиСтарыйУзел между - это узел, который необходимо удалить.
Усложнение 1
Когда срабатывает четвертая стратегия, узел нужно переместить сюда, а узел, на который указывает после старого (отмеченный как виртуальный узел какundefined), Настоящийновый фронтУзел указывает наDOMв движении кперед старым
Усложнение 2
Когда срабатывает третья стратегия, узел также необходимо переместить сюда, а узел, на который указывает старый прежний (помеченный как виртуальный узелundefined), Настоящийновый постУзел указывает наDOMв движении кпосле старого
Обратите внимание на несколько моментов:
-
h('li',{key:'A'} : "A"})
Например, ключ в этом — уникальный идентификатор этого узла. - его существование говоритdiff, они одинаковы до и после измененияDOMузел.
- Толькотот же виртуальный узел,Только сделайте уточненное сравнение, иначе этогрубая сила удалить старую, вставить новый
- Один и тот же виртуальный узел должен иметь не только один и тот же ключ, но и один и тот же селектор, т.
h()
В объекте виртуального узла, созданном функциейsel
- Выполняются сравнения только одного уровня, межуровневые сравнения не выполняются.
5.updateChildren
См. выше дляdiff, я не знаю, четкая ли картинка, которую я нарисовал, и тогда мы продолжаем доделыватьpatchVnode
мы должны написатьupdateChildrenдля подробного сравнения
Этот файлdiffСердце алгоритма, который мы используем для сравненияoldVnodeиnewVnodeоба существуютchildrenСлучай
Здесь немного запутанно, комментарии все написаны, пожалуйста, смотрите с терпением, процесс написания в соответствии с четырьмя стратегиями diff, а также имеет дело с отсутствием совпадений.
/* updateChilren.js */
// 导入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'
// 导出 updateChildren
/**
*
* @param {dom} parentElm 父节点
* @param {array} oldCh 旧子节点
* @param {array} newCh 新子节点
*/
export default function updateChildren(parentElm, oldCh, newCh) {
// 下面先来定义一下之前讲过的 diff 的几个指针 和 指针指向的 节点
// 旧前 和 新前
let oldStartIdx = 0,
newStartIdx = 0
let oldEndIdx = oldCh.length - 1 //旧后
let newEndIdx = newCh.length - 1 //新后
let oldStartVnode = oldCh[0] //旧前 节点
let oldEndVnode = oldCh[oldEndIdx] //旧后节点
let newStartVnode = newCh[0] //新前节点
let newEndVnode = newCh[newEndIdx] //新后节点
let keyMap = null //用来做缓存
// 写循环条件
while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
console.log('---进入diff---')
// 下面按照 diff 的4种策略来写 这里面还得调用 pathVnode
// patchVnode 和 updateChildren 是互相调用的关系,不过这可不是死循环
// 指针走完后就不调用了
// 这一段都是为了忽视我们加过 undefined 节点,这些节点实际上已经移动了
if (oldCh[oldStartIdx] == undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldCh[oldEndIdx] == undefined) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newCh[newStartIdx] == undefined) {
newStartVnode = newCh[++newStartIdx]
} else if (newCh[newEndIdx] == undefined) {
newEndVnode = newCh[--newEndIdx]
}
// 忽视了所有的 undefined 我们这里来 判断四种diff优化策略
// 1.新前 和 旧前
else if (sameVnode(oldStartVnode, newStartVnode)) {
console.log('1命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldStartVnode, newStartVnode)
// 指针移动
newStartVnode = newCh[++newStartIdx]
oldStartVnode = oldCh[++oldStartIdx]
} // 2.新后 和 旧后
else if (sameVnode(oldEndVnode, newEndVnode)) {
console.log('2命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldEndVnode, newEndVnode)
// 指针移动
newEndVnode = newCh[--newEndIdx]
oldEndVnode = oldCh[--oldEndIdx]
} // 3.新后 和 旧前
else if (sameVnode(oldStartVnode, newEndVnode)) {
console.log('3命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldStartVnode, newEndVnode)
// 策略3是需要移动节点的 把旧前节点 移动到 旧后 之后
// insertBefore 如果参照节点为空,就插入到最后 和 appendChild一样
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
// 指针移动
newEndVnode = newCh[--newEndIdx]
oldStartVnode = oldCh[++oldStartIdx]
}
// 4.新前 和 旧后
else if (sameVnode(oldEndVnode, newStartVnode)) {
console.log('4命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldEndVnode, newStartVnode)
// 策略4是也需要移动节点的 把旧后节点 移动到 旧前 之前
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
// 指针移动
newStartVnode = newCh[++newStartIdx]
oldEndVnode = oldCh[--oldEndIdx]
} else {
console.log('diff四种优化策略都没命中')
// 当四种策略都没有命中
// keyMap 为缓存,这样就不用每次都遍历老对象
if (!keyMap) {
// 初始化 keyMap
keyMap = {}
// 从oldStartIdx到oldEndIdx进行遍历
for (let i = oldStartIdx; i < oldEndIdx; i++) {
// 拿个每个子对象 的 key
const key = oldCh[i].data.key
// 如果 key 不为 undefined 添加到缓存中
if (!key) keyMap[key] = i
}
}
// 判断当前项是否存在 keyMap 中 ,当前项时 新前(newStartVnode)
let idInOld = keyMap[newStartIdx.data]
? keyMap[newStartIdx.data.key]
: undefined
// 存在的话就是移动操作
if (idInOld) {
console.log('移动节点')
// 从 老子节点 取出要移动的项
let moveElm = oldCh[idInOld]
// 调用 patchVnode 进行对比 修改
patchVnode(moveElm, newStartVnode)
// 将这一项设置为 undefined
oldCh[idInOld] = undefined
// 移动 节点 ,对于存在的节点使用 insertBefore移动
// 移动的 旧前 之前 ,因为 旧前 与 旧后 之间的要被删除
parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
} else {
console.log('添加新节点')
// 不存在就是要新增的项
// 添加的节点还是虚拟节点要通过 createElm 进行创建 DOM
// 同样添加到 旧前 之前
parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
}
// 处理完上面的添加和移动 我们要 新前 指针继续向下走
newStartVnode = newCh[++newStartIdx]
}
}
// 我们添加和删除操作还没做呢
// 首先来完成添加操作 新前 和 新后 中间是否还存在节点
if (newStartIdx <= newEndIdx) {
console.log('进入添加剩余节点')
// 这是一个标识
// let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
// new 里面还有剩余节点 遍历添加
for (let i = newStartIdx; i <= newEndIdx; i++) {
// newCh里面的子节点还需要 从虚拟DOM 转为 DOM
parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('进入删除多余节点')
// old 里面还有剩余 节点 ,旧前 和 旧后 之间的节点需要删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
// 删除 剩余节点之前 先判断下是否存在
if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
}
}
}
На данный момент мы в основном завершили написание,hфункция создания виртуальныхDOM , patchСравнить виртуальныеDOMсделать вид обновления
6. Давайте проверим, что мы написали
На самом деле при написании кода происходит его постоянная отладка. . . Протестируйте несколько прямо сейчас
1. Код
html
<body>
<button class="btn">策略3</button>
<button class="btn">复杂</button>
<button class="btn">删除</button>
<button class="btn">复杂</button>
<button class="btn">复杂</button>
<ul id="app">
hellow
</ul>
<script src="/virtualdir/bundle.js"></script>
</body>
index.js
/* index.js */
import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'
let app = document.querySelector('#app')
let vnode = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'E' }, 'E'),
])
let oldVnode = patch(app, vnode)
let vnode2 = h('ul', {}, [
h('li', { key: 'E' }, 'E'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'A' }, 'A'),
])
let vnode3 = h('ul', {}, [
h('li', { key: 'E' }, 'E'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'K' }, 'K'),
])
let vnode4 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
])
let vnode5 = h('ul', {}, [
h('li', { key: 'E' }, 'E'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'V' }, 'V'),
])
let vnode6 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
h(
'li',
{ key: 'E' },
h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'E' }, h('div', { key: 'R' }, 'R')),
])
),
])
let vnodeList = [vnode2, vnode3, vnode4, vnode5, vnode6]
let btn = document.querySelectorAll('.btn')
for (let i = 0; i < btn.length; i++) {
btn[i].onclick = () => {
patch(vnode, vnodeList[i])
}
}
2. Демонстрация
Стратегия 3
сложный
Удалить
сложный
Комплекс (здесь просто..)
7. Заключение
Я написал все комментарии.Вы можете сравнить картинки, которые я нарисовал выше.Вы можете прочитать их терпеливо и неоднократно.
Если посмотреть, то не почувствуешь, можешь написать сам, весь код выложу ниже
Код также размещен вgithub
Полный код:
h.js
/* h.js */
// 导入 vnode
import vnode from './vnode'
// 导出 h 方法
// 这里就实现简单3个参数 参数写死
/**
*
* @param {string} a sel
* @param {object} b data
* @param {any} c 是子节点 可以是文本,数组
*/
export default function h(a, b, c) {
// 先判断是否有三个参数
if (arguments.length < 3) throw new Error('请检查参数个数')
// 第三个参数有不确定性 进行判断
// 1.第三个参数是文本节点
if (typeof c === 'string' || typeof c === 'number') {
// 调用 vnode 这直接传 text 进去
// 返回值 {sel,data,children,text,elm} 再返回出去
return vnode(a, b, undefined, c, undefined)
} // 2.第三个参数是数组 [h(),h()] [h(),text] 这些情况
else if (Array.isArray(c)) {
// 然而 数组里必须是 h() 函数
// children 用收集返回结果
let children = []
// 先判断里面是否全是 h()执行完的返回结果 是的话添加到 chilren 里
for (let i = 0; i < c.length; i++) {
// h() 的返回结果 是{} 而且 包含 sel
if (!(typeof c[i] === 'object' && c[i].sel))
throw new Error('第三个参数为数组时只能传递 h() 函数')
// 满足条件进行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
children.push(c[i])
}
// 调用 vnode 返回 {sel,data,children,text,elm} 再返回
return vnode(a, b, children, undefined, undefined)
} // 3.第三个参数直接就是函数 返回的是 {sel,data,children,text,elm}
else if (typeof c === 'object' && c.sel) {
// 这个时候在 使用h()的时候 c = {sel,data,children,text,elm} 直接放入children
let children = [c]
// 调用 vnode 返回 {sel,data,children,text,elm} 再返回
return vnode(a, b, children, undefined, undefined)
}
}
patch.js
/* patch.js */
// 导入 vnode createELm patchVnode sameVnode.js
import vnode from './vnode'
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'
// 导出 patch
/**
*
* @param {vnode/DOM} oldVnode
* @param {vnode} newVnode
*/
export default function patch(oldVnode, newVnode) {
// 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
if (!oldVnode.sel) {
// 转为虚拟DOM
oldVnode = emptyNodeAt(oldVnode)
}
// 判断 oldVnode 和 newVnode 是否为同一虚拟节点
// 通过 key 和 sel 进行判断
if (sameVnode(oldVnode, newVnode)) {
// 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
patchVnode(oldVnode, newVnode)
} else {
// 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
// 这里通过 createElm 递归 转为 真实的 DOM 节点
let newNode = createElm(newVnode)
// 旧节点的父节点
if (oldVnode.elm.parentNode) {
let parentNode = oldVnode.elm.parentNode
// 添加节点到真实的DOM 上
parentNode.insertBefore(newNode, oldVnode.elm)
// 删除旧节点
parentNode.removeChild(oldVnode.elm)
}
}
newVnode.elm = oldVnode.elm
// console.log(newVnode.elm)
// 返回newVnode作为 旧的虚拟节点
return newVnode
}
/**
* 转为 虚拟 DOM
* @param {DOM} elm DOM节点
* @returns {object}
*/
function emptyNodeAt(elm) {
// 把 sel 和 elm 传入 vnode 并返回
// 这里主要选择器给转小写返回vnode
// 这里功能做的简陋,没有去解析 # .
// data 也可以传 ID 和 class
return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}
createElm.js
/* createElm.js */
/**
* 创建元素
* @param {vnode} vnode 要创建的节点
*/
export default function createElm(vnode) {
// 拿出 新创建的 vnode 中的 sel
let node = document.createElement(vnode.sel)
// 存在子节点
// 子节点是文本
if (
vnode.text !== '' &&
(vnode.children === undefined || vnode.children.length === 0)
) {
// 直接添加文字到 node 中
node.textContent = vnode.text
// 子节点是数组
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
let children = vnode.children
// 遍历数组
for (let i = 0; i < children.length; i++) {
// 获取到每一个数组中的 子节点
let ch = children[i]
// 递归的方式 创建节点
let chDom = createElm(ch)
// 把子节点添加到 自己身上
node.appendChild(chDom)
}
}
// 更新vnode 中的 elm
vnode.elm = node
// 返回 DOM
return node
}
vnode.js
/* vnode.js */
/**
* 把传入的 参数 作为 对象返回
* @param {string} sel 选择器
* @param {object} data 数据
* @param {array} children 子节点
* @param {string} text 文本
* @param {dom} elm DOM
* @returns
*/
export default function (sel, data, children, text, elm) {
return { sel, data, children, text, elm }
}
patchVnode.js
/* patchVnode.js */
// 导入 vnode createELm patchVnode updateChildren
import createElm from './createElm'
import updateChildren from './updateChildren'
/**
*
* @param {vnode} oldVnode 老的虚拟节点
* @param {vnode} newVnode 新的虚拟节点
* @returns
*/
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
// 1.判断是否相同对象
// console.log('同一个虚拟节点')
if (oldVnode === newVnode) return
// 2.判断newVnode上有没有text
// 这里为啥不考虑 oldVnode呢,因为 newVnode有text说明就没children
if (newVnode.text && !newVnode.children) {
// 判断是text否相同
if (oldVnode.text !== newVnode.text) {
console.log('文字不相同')
// 不相同就直接把 newVnode中text 给 elm.textContent
oldVnode.elm.textContent = newVnode.text
}
} else {
// 3.判断oldVnode有children, 这个时候newVnode 没有text但是有 children
if (oldVnode.children) {
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else {
console.log('old没有children,new有children')
// oldVnode没有 children ,newVnode 有children
// 这个时候oldVnode 只有text 我们把 newVnode 的children拿过来
// 先清空 oldVnode 中text
oldVnode.elm.innerHTML = ''
// 遍历 newVnode 中的 children
let newChildren = newVnode.children
for (let i = 0; i < newChildren.length; i++) {
// 通过递归拿到了 newVnode 子节点
let node = createElm(newChildren[i])
// 添加到 oldVnode.elm 中
oldVnode.elm.appendChild(node)
}
}
}
}
sameVnode.js
/* sameVnode.js */
/**
* 判断两个虚拟节点是否是同一节点
* @param {vnode} vnode1 虚拟节点1
* @param {vnode} vnode2 虚拟节点2
* @returns boolean
*/
export default function sameVnode(vnode1, vnode2) {
return (
(vnode1.data ? vnode1.data.key : undefined) ===
(vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
)
}
updateChildren.js
/* updateChilren.js */
// 导入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'
// 导出 updateChildren
/**
*
* @param {dom} parentElm 父节点
* @param {array} oldCh 旧子节点
* @param {array} newCh 新子节点
*/
export default function updateChildren(parentElm, oldCh, newCh) {
// 下面先来定义一下之前讲过的 diff 的几个指针 和 指针指向的 节点
// 旧前 和 新前
let oldStartIdx = 0,
newStartIdx = 0
let oldEndIdx = oldCh.length - 1 //旧后
let newEndIdx = newCh.length - 1 //新后
let oldStartVnode = oldCh[0] //旧前 节点
let oldEndVnode = oldCh[oldEndIdx] //旧后节点
let newStartVnode = newCh[0] //新前节点
let newEndVnode = newCh[newEndIdx] //新后节点
let keyMap = null //用来做缓存
// 写循环条件
while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
console.log('---进入diff---')
// 下面按照 diff 的4种策略来写 这里面还得调用 pathVnode
// patchVnode 和 updateChildren 是互相调用的关系,不过这可不是死循环
// 指针走完后就不调用了
// 这一段都是为了忽视我们加过 undefined 节点,这些节点实际上已经移动了
if (oldCh[oldStartIdx] == undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldCh[oldEndIdx] == undefined) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newCh[newStartIdx] == undefined) {
newStartVnode = newCh[++newStartIdx]
} else if (newCh[newEndIdx] == undefined) {
newEndVnode = newCh[--newEndIdx]
}
// 忽视了所有的 undefined 我们这里来 判断四种diff优化策略
// 1.新前 和 旧前
else if (sameVnode(oldStartVnode, newStartVnode)) {
console.log('1命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldStartVnode, newStartVnode)
// 指针移动
newStartVnode = newCh[++newStartIdx]
oldStartVnode = oldCh[++oldStartIdx]
} // 2.新后 和 旧后
else if (sameVnode(oldEndVnode, newEndVnode)) {
console.log('2命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldEndVnode, newEndVnode)
// 指针移动
newEndVnode = newCh[--newEndIdx]
oldEndVnode = oldCh[--oldEndIdx]
} // 3.新后 和 旧前
else if (sameVnode(oldStartVnode, newEndVnode)) {
console.log('3命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldStartVnode, newEndVnode)
// 策略3是需要移动节点的 把旧前节点 移动到 旧后 之后
// insertBefore 如果参照节点为空,就插入到最后 和 appendChild一样
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
// 指针移动
newEndVnode = newCh[--newEndIdx]
oldStartVnode = oldCh[++oldStartIdx]
}
// 4.新前 和 旧后
else if (sameVnode(oldEndVnode, newStartVnode)) {
console.log('4命中')
// 调用 patchVnode 对比两个节点的 对象 文本 children
patchVnode(oldEndVnode, newStartVnode)
// 策略4是也需要移动节点的 把旧后节点 移动到 旧前 之前
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
// 指针移动
newStartVnode = newCh[++newStartIdx]
oldEndVnode = oldCh[--oldEndIdx]
} else {
console.log('diff四种优化策略都没命中')
// 当四种策略都没有命中
// keyMap 为缓存,这样就不用每次都遍历老对象
if (!keyMap) {
// 初始化 keyMap
keyMap = {}
// 从oldStartIdx到oldEndIdx进行遍历
for (let i = oldStartIdx; i < oldEndIdx; i++) {
// 拿个每个子对象 的 key
const key = oldCh[i].data.key
// 如果 key 不为 undefined 添加到缓存中
if (!key) keyMap[key] = i
}
}
// 判断当前项是否存在 keyMap 中 ,当前项时 新前(newStartVnode)
let idInOld = keyMap[newStartIdx.data]
? keyMap[newStartIdx.data.key]
: undefined
// 存在的话就是移动操作
if (idInOld) {
console.log('移动节点')
// 从 老子节点 取出要移动的项
let moveElm = oldCh[idInOld]
// 调用 patchVnode 进行对比 修改
patchVnode(moveElm, newStartVnode)
// 将这一项设置为 undefined
oldCh[idInOld] = undefined
// 移动 节点 ,对于存在的节点使用 insertBefore移动
// 移动的 旧前 之前 ,因为 旧前 与 旧后 之间的要被删除
parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
} else {
console.log('添加新节点')
// 不存在就是要新增的项
// 添加的节点还是虚拟节点要通过 createElm 进行创建 DOM
// 同样添加到 旧前 之前
parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
}
// 处理完上面的添加和移动 我们要 新前 指针继续向下走
newStartVnode = newCh[++newStartIdx]
}
}
// 我们添加和删除操作还没做呢
// 首先来完成添加操作 新前 和 新后 中间是否还存在节点
if (newStartIdx <= newEndIdx) {
console.log('进入添加剩余节点')
// 这是一个标识
// let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
// new 里面还有剩余节点 遍历添加
for (let i = newStartIdx; i <= newEndIdx; i++) {
// newCh里面的子节点还需要 从虚拟DOM 转为 DOM
parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('进入删除多余节点')
// old 里面还有剩余 节点 ,旧前 和 旧后 之间的节点需要删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
// 删除 剩余节点之前 先判断下是否存在
if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
}
}
}