Как понять и освоить суть виртуального 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, чтобы рассмотреть его поближе. идеи стоят изучения.