
Как понять и освоить суть виртуального DOM? Рекомендую всем изучить проект Snabbdom.
Snabbdom — это библиотека реализации виртуального DOM. Причины рекомендации: во-первых, код относительно небольшой, а основной код составляет всего несколько сотен строк; во-вторых, Vue использует идеи этого проекта для реализации виртуального DOM; идеи дизайна/реализации и расширения этого проекта заслуживают внимания.
snabb /snab/ по-шведски означает «быстрый».
Настройте удобную позу для сидения и взбодритесь. Давайте начнем. Чтобы изучить виртуальный DOM, мы должны сначала знать базовые знания DOM и болевые точки непосредственного использования DOM с JS.
(объектная модель документа) — это объектная модель документа, которая использует древовидную структуру объектов для представления документа HTML/XML. Конец каждой ветви дерева — это узел, содержащий объекты. Методы DOM API позволяют вам манипулировать этим деревом определенными способами. С помощью этих методов вы можете изменить структуру, стиль или содержимое документа.
Все узлы в дереве DOM — это сначала Node , Node — базовый класс. Element , Text и Comment наследуются от него.
Другими словами, Element , Text и Comment — это три специальных Node , которые называются ELEMENT_NODE соответственно.
TEXT_NODE и COMMENT_NODE представляют узлы элементов (теги HTML), текстовые узлы и узлы комментариев. У Element также есть подкласс HTMLElement . В чем разница между HTMLElement и Element ? HTMLElement представляет элементы HTML, такие как: <span> , <img> и т. д., а некоторые элементы не являются стандартными HTML, например <svg> . Чтобы определить, является ли этот элемент элементом HTMLElement можно использовать следующий метод:
document.getElementById('myIMG') instanceof HTMLElement Создание DOM для браузера обходится дорого. Давайте возьмем классический пример. Мы можем создать простой элемент p с помощью document.createElement('p') и распечатать все атрибуты:

Вы можете видеть, что печатается много атрибутов. При частом обновлении сложных деревьев DOM возникают проблемы с производительностью. Виртуальный DOM использует собственный объект JS для описания узла DOM, поэтому создание объекта JS обходится гораздо дешевле, чем создание объекта DOM.
VNode — это объектная структура, описывающая виртуальный DOM в Snabbdom. Содержимое следующее:
тип Key = строка число |
интерфейс VNode {
// CSS-селектор, например: 'p#container'.
выбор: строка не определена;
// Управляйте классами CSS, атрибутами и т. д. с помощью модулей.
данные: VNodeData | не определено;
// Массив виртуальных дочерних узлов, элементы массива также могут быть строками.
дети: Массив<VNode | строка> | не определено;
// Укажите на реальный созданный объект DOM.
вяз: Узел не определен;
/**
* Для текстового атрибута возможны две ситуации:
* 1. Селектор sel не установлен, что указывает на то, что узел сам по себе является текстовым узлом.
* 2. Установлен sel, указывающий, что содержимое этого узла является текстовым узлом.
*/
текст: строка не определена;
// Используется для предоставления идентификатора существующего DOM, который должен быть уникальным среди одноуровневых элементов, чтобы эффективно избежать ненужных операций реконструкции.
ключ: Ключ не определен;
}
// Некоторые настройки vnode.data, перехватчиков функций класса или жизненного цикла и т. д.
интерфейс VNodeData {
реквизит?: Реквизит;
атрибуты?: Attrs;
класс?: Классы;
стиль?: VNodeStyle;
набор данных?: Набор данных;
вкл?: Вкл;
AttachData?: AttachData;
крючок?: Крючки;
ключ?: Ключ;
ns?: строка // для SVG
fn?: () => VNode // для переходников;
args?: Any[]; // для переходников
is?: string; // для пользовательских элементов v1
[ключ: строка]: любой // для любого другого стороннего модуля
} Например, определите объект vnode следующим образом:
const vnode = h(
'п#контейнер',
{ класс: {активный: true } },
[
h('span', { style: { FontWeight: 'bold' } }, 'Это жирный шрифт'),
'и это обычный текст'
]); Мы создаем объекты vnode с помощью функции h(sel, b, c) . Реализация кода h() в основном определяет, существуют ли параметры b и c, и обрабатывает их в данные, а дочерние элементы в конечном итоге будут иметь форму массива. Наконец, формат типа VNode , определенный выше, возвращается через функцию vnode() .
Сначала давайте возьмем простую диаграмму рабочего процесса и сначала разберем общую концепцию процесса:

Обработка различий — это процесс, используемый для вычисления разницы между новыми и старыми узлами.
Давайте посмотрим на пример кода, выполняемого Snabbdom:
import {
инициализация,
классМодуль,
реквизитМодуль,
стильМодуль,
eventListenersModule,
час,
} из «неприхотливости»;
const patch = init([
// Инициализируем функцию исправления classModule, передав модуль, // Включаем функцию классов propsModule, // Поддержка передачи реквизитов
styleModule, // Поддерживает встроенные стили и анимацию eventListenersModule, // Добавляет прослушивание событий]);
// <p id="контейнер"></p>
const контейнер = document.getElementById('контейнер');
const vnode = h(
'p#container.two.classes',
{ on: { click: someFn } },
[
h('span', { style: { FontWeight: 'bold' } }, 'Это жирный шрифт'),
'и это обычный текст',
h('a', { props: { href: '/foo' } }, "Я разнесу тебя по местам!"),
]
);
// Передаем пустой узел элемента.
патч (контейнер, vnode);
const newVnode = h(
'p#container.two.classes',
{ on: { click:otherEventHandler } },
[
час(
'охватывать',
{стиль: {fontWeight: 'нормальный', FontStyle: 'курсив' } },
«Теперь это курсив»
),
'и это все еще обычный текст',
h('a', { props: { href: ''/bar' } }, "Я разнесу вас по местам!"),
]
);
// Вызов patch() еще раз, чтобы обновить старый узел на новый.
patch(vnode, newVnode); Как видно из диаграммы процесса и примера кода, работающий процесс Snabbdom описывается следующим образом:
сначала вызывается init() для инициализации, а используемые модули необходимо настроить во время инициализации. Например, модуль classModule используется для настройки атрибута class элементов в виде объектов; модуль eventListenersModule используется для настройки прослушивателей событий и т. д. Функция patch() будет возвращена после вызова init() .
Создайте инициализированный объект vnode с помощью функции h() , вызовите функцию patch() для его обновления и, наконец, создайте настоящий объект DOM с помощью createElm() .
Когда требуется обновление, создайте новый объект vnode, вызовите функцию patch() для обновления и завершите дифференциальное обновление этого узла и дочерних узлов с помощью patchVnode() и updateChildren() .
Snabbdom использует дизайн модулей для расширения обновления связанных свойств вместо того, чтобы записывать все это в основной код. Так как же это спроектировано и реализовано? Далее, давайте сначала перейдем к основному содержанию конструкции Канкана — хукам — функциям жизненного цикла.
Snabbdom предоставляет ряд богатых функций жизненного цикла, также известных как функции-хуки. Эти функции жизненного цикла применимы в модулях или могут быть определены непосредственно на vnode. Например, мы можем определить выполнение перехватчика на vnode следующим образом:
h('p.row', {
ключ: 'myRow',
крюк: {
вставить: (vnode) => {
console.log(vnode.elm.offsetHeight);
},
},
}); Все функции жизненного цикла объявляются следующим образом:
| имя | триггерного узла | параметры обратного вызова |
|---|---|---|
pre | началом выполнения исправления | none |
init | добавляется vnode | vnode |
create | элемент DOM на основе vnode создается | emptyVnode, vnode |
insert | vnode вставляется в | vnode |
prepatch | vnode is собираюсь исправить | oldVnode, vnode |
update | vnode был обновлен | oldVnode, vnode |
postpatch | vnode был исправлен | oldVnode, vnode |
destroy | vnode был удален прямо или | vnode |
remove | vnode удалил vnode из DOM | vnode, removeCallback |
post | завершило процесс исправления | ничего |
применимо в модуль: pre , create , update , destroy , remove , post . К объявлениям vnode применимы: init , create , insert , prepatch , update , postpatch , destroy , remove .
Давайте посмотрим, как реализован Kangkang. В качестве примера возьмем модуль classModule :
import { VNode, VNodeData } from "../vnode";
импортировать {Модуль} из "./module";
Тип экспорта Классы = Record<string, boolean>;
функция updateClass (oldVnode: VNode, vnode: VNode): void {
// Вот подробности обновления атрибута класса, пока игнорируйте их.
// ...
}
Export const classModule: Module = { create: updateClass, update: updateClass }; Вы можете видеть, что последнее экспортированное определение модуля является объектом. Ключом объекта является имя функции-подключателя. Module . следующим образом:
импортировать {
ПреХук,
СоздатьКрюк,
ОбновлениеHook,
УничтожитьКрюк,
Удалитькрючок,
ПостХук,
} из "../крючков";
тип экспорта Модуль = Частичный<{
предварительно: PreHook;
создать: CreateHook;
обновление: UpdateHook;
уничтожить: DestroyHook;
удалить: RemoveHook;
сообщение: PostHook;
}>; Partial в TS означает, что атрибуты каждого ключа в объекте могут быть пустыми. То есть просто определите, какой крючок вам нужен, в определении модуля. Теперь, когда хук определен, как он выполняется в процессе? Далее давайте посмотрим на функцию init() :
// Какие перехватчики могут быть определены в модуле.
константные перехватчики: Array<keyof Module> = [
"создавать",
"обновлять",
"удалять",
"разрушать",
"предварительно",
"почта",
];
функция экспорта init(
модули: Массив<Частичный<Модуль>>,
domApi?: ДОМАПИ,
варианты?: Варианты
) {
//Функция-перехватчик, определенная в модуле, наконец, будет сохранена здесь.
const cbs: ModuleHooks = {
создавать: [],
обновлять: [],
удалять: [],
разрушать: [],
до: [],
почта: [],
};
// ...
// Обходим хуки, определенные в модуле, и сохраняем их вместе.
for (const крючок крючков) {
for (константный модуль модулей) {
const currentHook = модуль [крючок];
if (currentHook !== не определено) {
(cbs[hook] as Any[]).push(currentHook);
}
}
}
// ...
} Вы можете видеть, что init() сначала обходит каждый модуль во время выполнения, а затем сохраняет функцию перехвата в объекте cbs . При выполнении вы можете использовать функцию patch() :
функция экспорта init(
модули: Массив<Частичный<Модуль>>,
domApi?: ДОМАПИ,
варианты?: Варианты
) {
// ...
патч функции возврата(
oldVnode: Элемент VNode |
vnode: VNode
): VNode {
// ...
// патч запускается, выполняем pre-hook.
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// ...
}
} Здесь мы возьмем pre перехватчик в качестве примера. Время выполнения pre перехватчика — это момент начала выполнения патча. Вы можете видеть, что функция patch() циклически вызывает pre связанные перехватчики, хранящиеся в cbs , в начале выполнения. Вызовы других функций жизненного цикла аналогичны этим. Вы можете увидеть соответствующие вызовы функций жизненного цикла в другом месте исходного кода.
Идея дизайна здесь — шаблон наблюдателя . Snabbdom реализует неосновные функции, распределяя их по модулям. В сочетании с определением жизненного цикла модуль может определять интересующие его хуки. Затем, когда выполняется init() , он обрабатывается в объектах cbs для регистрации этих хуков; когда придет время выполнения, вызовите эти перехватчики, чтобы уведомить об обработке модуля. Это разделяет основной код и код модуля. Отсюда мы видим, что шаблон наблюдателя является распространенным шаблоном для разделения кода.
Далее мы переходим к базовой функции Kangkang patch() . Эта функция возвращается после вызова init() . Ее функция — монтировать и обновлять VNode. Сигнатура следующая:
function patch(oldVnode: VNode | Element. DocumentFragment , vnode: VNode): VNode {
// Для простоты не обращайте внимания на DocumentFragment.
// ...
} Параметр oldVnode — это старый элемент VNode или DOM или фрагмент документа, а параметр vnode — обновленный объект. Здесь я сразу публикую описание процесса:
вызов pre хука, зарегистрированного на модуле.
Если oldVnode — это Element , он преобразуется в пустой объект vnode , а в атрибут записывается elm .
Здесь решается, является ли это Element (oldVnode as any).nodeType === 1 завершено. nodeType === 1 указывает, что это ELEMENT_NODE, который определен здесь.
Затем определите, совпадают ли oldVnode и vnode . Здесь будет вызвана sameVnode() , чтобы определить:
function SameVnode(vnode1: VNode, vnode2: VNode): boolean {
//Тот же ключ.
const isSameKey = vnode1.key === vnode2.key;
// Веб-компонент, имя тега пользовательского элемента, см. здесь:
// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement
const isSameIs = vnode1.data?.is === vnode2.data?.is;
//Тот же селектор.
const isSameSel = vnode1.sel === vnode2.sel;
// Все три одинаковы.
return isSameSel && isSameKey && isSameIs;
} patchVnode() для обновления различий.createElm() чтобы создать новый узел DOM после создания, вставьте узел DOM и удалите старый узел DOM;Новые узлы можно вставить, вызвав очередь insert , зарегистрированную в объекте vnode, участвующем в вышеуказанной операции, patchVnode() createElm() . Что касается того, почему это делается, об этом будет упомянуто в createElm() .
Наконец, вызывается перехватчик post , зарегистрированный в модуле.
По сути, процесс заключается в том, чтобы выполнить различие, если vnodes одинаковые, а если они разные, создать новые и удалить старые. Далее давайте посмотрим, как createElm() создает узлы DOM.
createElm() создает узел DOM на основе конфигурации vnode. Процесс выглядит следующим образом:
вызовите перехватчик init , который может существовать в объекте vnode.
Затем мы рассмотрим несколько ситуаций:
если vnode.sel === '!' , это метод, используемый Snabbdom для удаления исходного узла, чтобы был вставлен новый узел комментария. Поскольку старые узлы будут удалены после createElm() , этот параметр может достичь цели удаления.
Если определение селектора vnode.sel существует:
проанализируйте селектор и получите id , tag и class .
Вызовите document.createElement() или document.createElementNS , чтобы создать узел DOM, записать его в vnode.elm и установить id , tag и class на основе результатов предыдущего шага.
Вызовите ловушку create на модуле.
Обработка children массива:
если children — это массив, рекурсивно вызовите createElm() , чтобы создать дочерний узел, а затем вызовите appendChild , чтобы смонтировать его в vnode.elm .
Если children не является массивом, но существует vnode.text , это означает, что содержимое этого элемента является текстом. В этот момент вызывается createTextNode для создания текстового узла и монтируется в vnode.elm .
Вызовите ловушку create на vnode. И добавьте перехватчик insert на vnode в очередь перехватчиков insert .
Оставшаяся ситуация такова, что vnode.sel не существует, что указывает на то, что сам узел является текстовым, затем вызовите createTextNode , чтобы создать текстовый узел и записать его в vnode.elm .
Наконец, верните vnode.elm .
Из всего процесса видно, что createElm() выбирает способ создания узлов DOM на основе различных настроек селектора sel . Здесь нужно добавить деталь: очередь insert упомянутая в patch() . Причина, по которой необходима эта очередь insert , заключается в том, что ей необходимо дождаться фактической вставки DOM перед ее выполнением, а также необходимо дождаться, пока не будут вставлены все узлы-потомки, чтобы мы могли вычислить информацию о размере и положении элемент во insert чтобы быть точным. В сочетании с описанным выше процессом создания дочерних узлов createElm() представляет собой рекурсивный вызов для создания дочерних узлов, поэтому очередь сначала записывает дочерние узлы, а затем себя. Таким образом, порядок может быть гарантирован при выполнении очереди в конце patch() .
Далее давайте посмотрим, как Snabbdom использует patchVnode() для выполнения различий, что является ядром виртуального DOM. Поток обработки patchVnode() следующий:
сначала выполните ловушку prepatch на vnode.
Если oldVnode и vnode являются одной и той же ссылкой на объект, они будут возвращены напрямую без обработки.
Вызов обработчиков update на модулях и виртуальных узлах.
Если vnode.text не определен, обрабатываются несколько случаев children :
если oldVnode.children и vnode.children существуют и не совпадают. Затем вызовите updateChildren для обновления.
vnode.children существует, но oldVnode.children не существует. Если oldVnode.text существует, сначала очистите его, а затем вызовите addVnodes , чтобы добавить новый vnode.children .
vnode.children не существует, но oldVnode.children существует. Вызовите removeVnodes , чтобы удалить oldVnode.children .
Если ни oldVnode.children , ни vnode.children не существуют. Очистите файл oldVnode.text если он существует.
Если vnode.text определен и отличается от oldVnode.text . Если oldVnode.children существует, вызовите removeVnodes чтобы очистить его. Затем установите текстовое содержимое через textContent .
Наконец, выполните хук postpatch на vnode.
Из процесса видно, что изменения связанных атрибутов собственных узлов в diff, таких как class , style и т. д., обновляются модулем. Однако при необходимости мы не будем здесь слишком углубляться. можете взглянуть на код, связанный с модулем. Основная основная обработка diff сосредоточена на children . Далее Kangkang diff обрабатывает несколько связанных children функций.
очень проста: сначала вызовите createElm() , чтобы создать его, а затем вставьте в соответствующий родительский объект.
destory удалении remove
destory , этот хук вызывается первым. Логика заключается в том, чтобы сначала вызвать перехватчик на объекте vnode, а затем вызвать перехватчик на модуле. Затем этот хук вызывается рекурсивно на vnode.children в этом порядке.remove , этот хук будет срабатывать только тогда, когда текущий элемент будет удален из его родителя. Дочерние элементы в удаленном элементе не будут срабатывать, и этот хук будет вызываться как для модуля, так и для объекта vnode. сначала включите модуль, а затем вызовите vnode. Более особенным является то, что элемент не будет фактически удален до тех пор, пока не будут вызваны все remove . Это может обеспечить некоторые требования к отложенному удалению.Из вышесказанного видно, что логика вызова этих двух хуков различна. В частности, remove будет вызываться только для элементов, которые напрямую отделены от родителя.
updateChildren() используется для обработки различий дочерних узлов, и это также относительно сложная функция в Snabbdom. Общая идея состоит в том, чтобы установить в общей сложности четыре указателя начала и конца для oldCh и newCh . Эти четыре указателя — oldStartIdx , oldEndIdx , newStartIdx и newEndIdx соответственно. Затем сравните два массива в while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) чтобы найти одинаковые части для повторного использования и обновления, и переместите до одной пары указателей для каждого сравнения. Подробный процесс обхода выполняется в следующем порядке:
если какой-либо из четырех указателей указывает на vnode == null, то указатель перемещается в середину, например: start++ или end--, возникновение значения null будет объяснено позже.
Если старый и новый начальные узлы одинаковы, то есть sameVnode(oldStartVnode, newStartVnode) возвращает true, используйте patchVnode() для выполнения сравнения, и оба начальных узла сдвинутся на один шаг к середине.
Если старый и новый конечные узлы одинаковы, также используется patchVnode() , и два конечных узла перемещаются на один шаг назад к середине.
Если старый начальный узел совпадает с новым конечным узлом, используйте patchVnode() для первой обработки обновления. Затем необходимо переместить узел DOM, соответствующий oldStart. Стратегия перемещения заключается в перемещении до следующего узла-близнеца узла DOM, соответствующего oldEndVnode . Почему он так движется? Прежде всего, oldStart аналогичен newEnd, что означает, что в текущем цикле обработки начальный узел старого массива перемещается вправо, поскольку каждая обработка перемещает указатели головы и хвоста в середину, мы обновляем; старый массив в новый. В это время oldEnd, возможно, еще не был обработан, но в это время было определено, что oldStart является последним в текущей обработке нового массива, поэтому разумно перейти к следующему одноуровневому массиву. узел oldEnd. После завершения перемещения oldStart++ и newEnd перемещаются на один шаг в середину своих соответствующих массивов.
Если старый конечный узел совпадает с новым начальным узлом, сначала используется patchVnode() для обработки обновления, а затем узел DOM, соответствующий oldEnd, перемещается в узел DOM, соответствующий oldStartVnode . Причиной перемещения является то же, что и предыдущий шаг. После завершения перемещения oldEnd--, newStart++.
Если ничего из вышеперечисленного не имеет место, используйте ключ newStartVnode, чтобы найти идентификатор индекса в oldChildren . В зависимости от того, существует ли индекс, существует две разные логики обработки:
Если индекс не существует, это означает, что newStartVnode создан заново. Создайте новый DOM с помощью createElm() и вставьте его перед DOM, соответствующим oldStartVnode .
Если индекс существует, он будет обработан в двух случаях:
если sel двух vnode различен, он все равно будет считаться вновь созданным, создать новый DOM с помощью createElm() и вставить его перед DOM, соответствующим oldStartVnode .
Если sel тот же, обновление обрабатывается через patchVnode() , а vnode, соответствующий нижнему индексу oldChildren устанавливается в неопределенное значение. Вот почему == null появляется в предыдущем обходе двойного указателя. Затем вставьте обновленный узел в DOM, соответствующий oldStartVnode .
После завершения вышеуказанных операций newStart++.
После завершения обхода остаются еще две ситуации, с которыми приходится иметь дело. Во-первых, oldCh полностью обработан, но в newCh все еще есть новые узлы, и для каждого оставшегося newCh необходимо создать новый DOM, во-вторых, newCh полностью обработан, а в oldCh все еще есть старые узлы. Лишние узлы необходимо удалить. Эти две ситуации обрабатываются следующим образом:
функция updateChildren(
родительЭлм: Узел,
oldCh: VNode[],
новыйCh: VNode[],
вставленныйVnodeQueue: VNodeQueue
) {
// Процесс обхода двойного указателя.
// ...
// В newCh есть новые узлы, которые необходимо создать.
если (newStartIdx <= newEndIdx) {
//Необходимо вставить перед последним обработанным newEndIdx.
до = newCh[newEndIdx + 1] == null? null: newCh[newEndIdx + 1].elm;
addVnodes(
родительВяз,
до,
новыйЧ,
новыйStartIdx,
новыйEndx,
вставленныйVnodeQueue
);
}
// В oldCh все еще есть старые узлы, которые необходимо удалить.
если (oldStartIdx <= oldEndIdx) {
RemoveVnodes (parentElm, oldCh, oldStartIdx, oldEndIdx);
}
} Давайте на практическом примере рассмотрим процесс обработки updateChildren() :
начальное состояние следующее: старый массив дочерних узлов — [A, B, C], а новый массив узлов — [B, A, C] , Д]:

В первом раунде сравнения начальный и конечный узлы различны, поэтому мы проверяем, существует ли newStartVnode в старом узле, и находим позицию oldCh[1]. Затем сначала выполняем patchVnode() для обновления, а затем устанавливаем oldCh[1]. ] = undefined и вставьте DOM перед oldStartVnode , newStartIdx переместится на один шаг назад, и статус после обработки будет следующим:

Во втором раунде сравнения oldStartVnode и newStartVnode совпадают. Когда для обновления выполняется patchVnode() , oldStartIdx и newStartIdx перемещаются в середину. После обработки статус следующий:

В третьем раунде сравнения oldStartVnode == null , oldStartIdx перемещается в середину, а статус обновляется следующим образом:

В четвертом раунде сравнения oldStartVnode и newStartVnode совпадают. Когда для обновления выполняется patchVnode() , oldStartIdx и newStartIdx перемещаются в середину. После обработки статус следующий.

В этот момент значение oldStartIdx больше, чем oldEndIdx , и цикл завершается. На данный момент все еще есть новые узлы, которые не были обработаны в newCh , и вам нужно вызвать addVnodes() чтобы вставить их. Окончательный статус следующий:

, здесь мы разобрали основное содержание виртуального DOM. Я считаю, что принципы проектирования и реализации Snabbdom очень хороши. Если у вас есть время, вы можете перейти к деталям исходного кода Kangkang, чтобы рассмотреть его поближе. идеи стоят изучения.