Привет.


У многих frontend-разработчиков бытует мнение, что технология VDOM, которая, в частности, используется в React.js, работает как черный ящик. Так же на просторах npm есть куча библиотек, реализующих эту технологию, однако вот как по мне — так в них черт ногу сломит. Сама тема VDOM-а меня заинтересовала некоторое время назад и я начал экспериментировать наощупь, сам. В конечном итоге все кончилось тем, что я сделал свою реализацию VDOM-а и встроил в свой фрейморк для датагридов (о нём я как-нибудь напишу). Это оказалось не просто, а очень просто и в этой статье я детально объясню что к чему, зачем и почему. И как оно работает тоже расскажу. Ныряйте под кат и мы предадимся интересному опыту.


Disclamer


Я не фронтенд-разработчик. Не бейте меня тапками.
Так же, не стоит писать "возьми готовый фреймворк, дубина". Я знаю о том, что есть готовые фреймворки, но мне нужна собственная реализация по ряду сугубо технических причин.


Зачем оно надо


TL;DR: Пример из этой статьи на jsfiddle, исходники


На хабре где-то уже была статья про VDOM, но объяснение сути дела в ней мне не нравится. Поэтому давайте я на пальцах раскидаю. Итак, положим у вас есть веб-страничка, ага? В ней отрисован некий HTML-элемент (вероятно, довольно большой). Чтобы немного добавить контекста — скажу что этот элемент отрисован посредством натягивания шаблона на вью-модель. Ну все пользовались фреймворками, которые делают подобное, ведь так? Handlebars там, lodash-шаблоны, AngularJS в какой-то мере… И вот короче представьте что модель, на которую натягивается шаблон — поменялась. И вам надо отрисовать эти изменения внутри HTML-элемента. Возникает вопрос — а как это сделать и при этом не попасть впросак? Тут, как водится, есть несколько вариантов.


innerHTML


Можно прогнать модель данных через шаблонизатор, получить HTML и просто сделать element.innerHTML = ourHtmlString;. Дешево и сердито, но для некоторых задач вполне прокатывает. Что же здесь ужасного, спросите вы?


А я вам отвечу: этот вариант плох прежде всего потому, что все дочерние элементы, расположенные внутри element будут беспощадно убиты браузером и заменены новыми. Следовательно? Да, следовательно вы потеряете все подписки на события (в рамках вашего элемента, конечно же), которые у вас были. Вам придется переподписывать вновь созданные элементы заново. Тут я, честно признаюсь, не в курсе как работает сборка мусора в разных js-движках, но допускаю что если ранее в своем коде вы сохранили какие-то из дочерних элементов, например, в переменные, то эти элементы будут убраны из дерева, но не уничтожены. Привет, утечка памяти, мы по тебе скучали. Ну это если предположить что браузер использует для DOM-элементов reference counting, например. Кто знает подробности — отпишите в комментариях пожалуйста.


Окромя этого — сей вариант дюже медленный, особливо когда дочерних элементов много. То есть браузер честно сотрет все, что было отрисовано внутри элемента, пересчитает и перерисует заново. Вот представьте — нарисована у вас карточка пользователя Гриши, в которой 100 разных полей. А у вас в данных изменилась только дата рождения. И что теперь — "Все фигня, Гриша, давай по-новой" ради одного несчастного куска текста? Убьем Гришу целиком, посчитаем и нарисуем заново? А вы знаете, что перерисовка интерфейса в браузере — это вообще-то долго? И если Гриша состоит из 150 элементов — то еще куда ни шло. А вот если из тысячи (вскрытие показывает у Гриши обилие мелких деталей) — то на отдельных машинах перерисовка может достигать нескольких секунд, что дюже снижает отзывчивость интерфейса.


Ну и самое печальное: через innerHTML отрисовываются не все элементы. Как пример — в IE8 невозможно установить innerHTML у элемента tbody. В Google Chrome через innerHTML плохо рендерятся заголовки таблиц (th). Все, что вы туда вставите через innerHTML — будет урезано до обычного текста по неведомой мне причине. Пруфлинка не будет — информация из собственного опыта. Вероятно сейчас это уже починили, но осадочек остался.


Таким образом innerHTML — как быстрый и грязный хак. Ненадежен в общем случае, имеет кучу сайд-эффектов, да и вообще — мы тут не в 9 классе школы чтобы так писать. Из плюсов — реализуется на коленке.


Парсер HTML


Откровенно говоря, этот вариант решает только последнюю и самую печальную проблему innerHTML — крайнюю привередливость в создаваемых элементах. Но упомянуть его определенно стоит, во многом потому что он нам понадобится для реализации VDOM.


Как оно работает? Да просто работает. Парсит HTML и вызывает document.createElement для каждого узла с последующей установкой атрибутов. Потом делаете element.innerHTML = '' и проходитесь по полученным элементам, вызывая appendChild/insertBefore.


Ну… Во-первых, мы не решили проблему с убиением элементов. Во-вторых, с парсерингом HTML всё не так гладко в этом мире.


Парсить HTML — это примерно как парсить XML-документ и в парсерах для клиентской стороны чаще всего употребляется в пищу поточный парсер. Дада, это примерно как StAX в Java. Доказано, что употребление поточного парсера XML полезно для здоровья. Почему? Да потому что работает быстро и пишется легко. Не стоит использовать для этой задачи классический древесный парсер как для XML. Оно тут просто не нужно.


Все в мире HTML-парсеров хорошо, кроме одного: нету их, бедняжек, встроенных в браузеры. В MDN упоминается DOMParser, однако он все еще экспериментален и, видимо, отдаст вам на выходе дерево. Ну вот только обходить деревья и вызывать document.createElement нам на стороне клиента-то и не хватало, ага. Словом, странный он. Давайте не будем его трогать. Есть htmlparser2 — вон лежит в npm. Событийный поточный парсер, но я сам его не пользовал, ибо как идеология моего фреймворка не позволяет мне использовать сторонние зависимости. И вот есть статья и пример кода Джона Резига (пожалуйста, воспринимайте его в отрыве от jQuery), где он объяснет всё на пальцах и дает код простенького парсера. Мне он не нравится тем, что работает на регулярных выражениях. Вижу сие зело трудозатраным. Однако, вполне работоспособным. Долгое время у меня (каюсь) использовался как раз код из этой статьи, переработанный под TypeScript. Однако потом я заменил его на поточный парсер на простенькой state-машине (коим я с вами сегодня и поделюсь), дабы минимизировать сравнения строк и сделать поддержку создания виртуальных элементов (что в пример Джона запиливать было несколько неудобно).


VDOM собственной персоной


И вот мы наконец подошли к самому хитрому подходу, который решает проблемы как с производительностью, так и с привередливостью innerHTML к элементам. А именно — используя парсер HTML мы можем, внезапно, распарсить наш HTML, но вызывать для каждого элемента не document.createElement, а просто создание какого-либо объекта, который будет хранить имя тега и атрибуты. Этот самый объект и будет называться виртуальным DOM-узлом, а их совокупность — виртуальным DOM-деревом, или VDOM. Здесь стоит отметить, что создать тысячу объектов в JS через var a = {}; — это быстро. Очень быстро. А вот создать тысячу реальных DOM-узлов — это медленно. Из этого обстоятельства, собсно, и проистекает прирост производительности.


Окей, создали. И что же мы будем с этим добром делать? Снимать штаны и бегать Штаны можно не снимать, а вот бегать придется: мы будем сравнивать структуру нашего VDOM-дерева с тем, что уже отрисовано, составлять из этого некое подобие diff-патча (что надо доабвить, что убрать, куда докинуть атрибутов, где поменять контент) и накатывать его на существующее DOM-дерево. Вот прям так же, как это происходит в вашем любимом git во время мерджей. Благо, задача построения diff-а в программировании довольно известная — гуглится, внезапно, по словам нахождение наибольшей общей подпоследовательности. Решается она динамическим программированием. Алгоритм имеет квадратичную сложность. В хороших ВУЗах решение этой задачи изучают на первых курсах, но здесь я его тезисно опишу и подскажу как адаптировать его под деревья.


UPD: в комментариях меня уже закидали помидорами на предмет того, что LCS для данной задачи неоптимален — необходимо использовать LIS, но я не могу переписать статью и код так быстро. Так что просто держите в голове, что ту часть, которая занимается высчитыванием diff-а можно оптимизировать гораздо "плотнее".


В итоге, VDOM-подход не убивает те элементы дерева, которые не изменились и не создаёт лишних элементов, что знатно экономит память и сохраняет подписки на события (если элементы не уничтожаются), одновременно заставляя ваш CPU больше заниматься сравнениями, нежели созданием HTML-элементов. А это даёт знатный прирост производительности, когда из 1000 элементов на экране изменился только один.


Приступим


Мы напишем небольшое приложение, которое будет состоять из textarea слева, в которой вы будете вбивать и/или изменять свой HTML, а в окошке справа — видеть результат работы. Никаких дополнительных библиотек — только TypeScript и браузер. Будем, что называется, творить магию из воздуха.


Вы знаете, сначала я хотел добавить в эту статью много кода с построчным разбором оного, но памятуя свои предыдущие статьи, не решился это делать. Непосредственно код снижает читаемость и нагоняет тоску. Так что, с вашего позволения, я просто буду давать ссылки на Github и объясню как и что работает. Наш VDOM будет состоять из трёх главных компонент — парсера HTML, непосредственно компаратора и калькулятора батчей, а так же app.ts, который соберёт это всё добро вместе и заставит работать.


Парсер


Тут надо сделать оговорочку. Как я понимаю, React.js работает без парсера HTML в виду того, что его шаблоны (jsx/tsx) уже собираются в соответствующие вызовы создания нод. Это хороший ход, улучшающий быстродействие, но вы знаете… создавать свой язык шаблонизации и писать для него компилятор не входило в мои планы, когда я писал эту статью. Так что мы будем парсить голый HTML руками. Такая реализация гарантирует нам возможность встраивать нашу поделку куда угодно, ну и позволит избежать чисто педагогических курьёзов, если вы понимаете о чём я :) Итак, поехали.


Стек


JavaScript не содержит эффективных инструментов для работы со стеками, а таковой нам понадобится. Поэтому делаем простенький стек. Без комментариев.


Конструктор нод


Как вы знаете, с точки зрения JavaScript, HTML-документ состоит из нод. Некоторые из которых вполне себе являются HTML-элементами, такими как HTMLInputElement (тег input), HTMLDivElement (тег div). А некоторые — нет (текст, комментарий). Все доступные типы нод перечислены вот здесь, в MDN. Мы же начнем с простого — объявим интерфейс т.н. "конструктора нод". Чтобы не вхардкоживать в наш парсер HTML-я вызовы document.createElement и использовать один и тот же парсер что для DOM, что для VDOM. В моей реализации он выглядит вот так. Как видим, мы ограничиваемся тремя типами нод — HTML-элемент, текст (контент) и комментарий. Интерфейс крайне прост и предусматривает возможность создания всех трех типов нод, а для HTML-элемента ещё и установку аттрибутов. Чтобы далеко не отходить от кассы, тут же реализуем его для реальных HTML-нод. Это очень просто. Справится даже ребёнок.


Тут же мы продумаем как мы будем хранить VDOM-ноды. Что нам важно знать о ноде, помимо её типа? В случае с HTML-элементом — тег да атрибуты. В случае с комментарием и текстом — контент. Плюс ко всему прочему нам так же важен список дочерних нод. Отлично. Описываем интерфейс и enum для типов, после чего реализуем сам конструктор: вот так.


Парсер HTML


Парсер — это у нас будет такая штука, у которой есть ограниченной число состояний. Она неспеша идёт по переданному ей HTML, символ за символом и в зависимости от текущего символа меняет своё состояние. При переходе из состояния в состояние парсер будет дёргать методы конструктора нод, выполняя соответствующие действия.


Например: читает парсер символы, никого не трогает. Оп — встретил <, запоминает где его увидел, меняет состояние на "слушаем внимательно, сейчас будет HTML-тег". Читает дальше. Оп — встречает пробельный символ, и такой: ага! вот оно и имя тега. Вспоминает где встретил <, выгрызает текст оттуда до текущей позиции — вот вам и имя тега. Стало быть, надо дернуть нод-конструктор и вызвать у него create, передав имя тега. Дальше парсер пропускает все пробельные символы и если видит >, то переходит в изначальное состояние. А если видит букву алфавита — так же ловит имя атрибута, =, значение атрибута в кавычках, дергает нод-конструктор… Ну и так далее, пока не дойдет до конца.


Созданные теги мы будем хранить в стеке. Каждый раз, когда мы доходим до открывающего тега — кладём его в стек. При создании элемента через конструктор, указываем текущую макушку стека как родительский элемент. Тривиально. Уверен, все хоть раз, да делали нечто подобное, когда писали калькулятор на первых курсах университета.


Все состояния, а так же действия при переходах мы сложим в state-машину, которая технически представляет из себя огромный Dictionary, чёртов C# в голове hash-объект вида "состояние" -> "описание действий". Описание действий в нашем случае состоит из трёх частей:


  • функция, которая выполнится на входе в состояние;
  • функция, которая выполнится на выходе из состояния;
  • функция, которая будет вызываться для каждого следующего считанного символа;

Я объединил эти три функции в штуку, которую назвал IStateInfo. Там же я описал все возможные состояния парсера. Сделал сам парсер, снабдив его несколькими полезными тестер-функциями (это когда мы смотрим какой у нас символ текущий, что было n символов назад, складываются ли следующие за текущим m символов в такое-то слово, ну и иже с ними). Особливо выделяется функция fix(): когда мы меняем состояние парсера — она запоминает позицию текущего символа с той мыслью, чтобы сдвинувшись на несколько символов далее, мы могли выгрызть кусок текста между запомненной позицией и текущим положением. Выгрызание куска текста от запомненной позиции до текущей производится функцией cut(). Обычно, после вызова cut() — state-машина подает парсеру сигнал — вроде "о, смотри, открывающий тег поймали, вот его имя". Почему так сложно? Ну… я — человек экономный. Сделал парсер, который не создает лишних строк без необходимости.


Ну и, собственно, дальше я просто перечислил все возможные состояния парсера и все возможные действия — что называется, запрограммировал state-машину. Там есть много нюансов, связанных с кавычками вокруг атрибутов, самозакрывающимися тегами, символами - и : в именах атрибутов. Разрешите мне опустить здесь детальное разжевывание каждого из этих кейсов — всё есть в коде, при желании вы сами это увидите.


Ещё одна особенность: теги style и script парсер вырезает и складывает отдельно, дабы пользователь парсера сам решил казнить их или помиловать что с ними дальше делать. Можно, например, сделать eval у скриптов. А вот что делать со стилями — вопрос неясный даже для меня. Единственное, что не учтено в моей реализации — это хитрая возможная структура этих тегов. Скажем, если в script-е у вас встречается текст в духе return '</script>';, то парсер по ошибке распознает оный как закрытие тега script, что неприятно, однако легко чинится , просто мне лень.


Высчитываем разницу


Итак, на текущем этапе мы имеем годный HTML-парсер, которому можно передать кусок HTML-я и распарсить его в реальные DOM-элементы, или же в VDOM-элементы. Это просто прекрасно.
Теперь представьте, что у нас уже отрисован на экране какой-то HTML, мы хотим его незначительно поменять. Берём его, вносим коррективы, скармливаем парсеру с VDOM-конструктором. Получаем список VDOM-нод. Теперь нам надобно высчитать разницу между HTML-ем, который отрисован на экране и тем, что нам напарсил наш парсер. По сути дела надо взять parent-нод, внутри которого отрисован наш HTML, взять массив всех его детей и сравнить его с аналогичным массивом виртуальных детей, который нам создал HTML-парсер. И когда я говорю "сравнить" — я имею в виду не просто ответить на вопрос "совпадают или нет", но и сгенерировать т.н. "update batch" — список какие элементы куда надо добавить, а какие откуда удалить чтобы из старого куска дерева получить новое. После чего, полученная пачка изменений просто накатывается на уже отрисованный HTML, не уничтожая уже отрисованное и неизменённое, но умно меняя только затронутые ноды. Называется эта операция VDOM update. Вот как-то так. Но, обо всём по порядку.


Кэш сравнений


Для начала надо научиться сравнивать HTML-ноды и VDOM-ноды. Делается это относительно просто:


  1. Сравниваем типы. Если не совпадает — ноды разные
  2. Если обе ноды текстовые или комментарии — сравниваем контент. Совпадает? Одинаковые. Не совпадает? Разные.
  3. Сравниваем количество атрибутов. Не совпадает? Разные.
  4. Содержательно сравниваем наличие атрибутов и их значения. Что-то не совпало? Ну вы поняли.
  5. Проделываем шаги 1-4 для каждой ноды-потомка.
  6. Если вы это читаете, значит ноды всё-таки совпадают.

Как можно заметить, каждый раз с нуля сравнивать ноды — это трудозатратно. Во многом из-за рекурсии на 5 шаге. Поэтому я сделал кэш-компаратор, который живет в рамках одного update-а и хранит результаты сравнений нод друг с другом. Таким образом, если тот факт, что два нода — разные уже когда-то был установлен, то он не устанавливается заново, а берется из кэша. Много однотипного кода.


LCS


Это сокращение от "Longest Common Subsequence". Этот класс по сути представляет собой матрицу для решения LCS-задачи методом динамического программирования. Мы ему даём на вход два массива — один из реальных нод, другой — из виртуальных, так же скармливаем кэш сравнений, про который я рассказал выше. Дальше, вызываем produceModifyBatch и получаем массив batch entries (описаны в самом низу), который по сути говорит что надо сделать с такой-то по счету нодой — обновить, удалить, или вставить другую (и какую именно) ноду ПЕРЕД ней.


Первым делом после вызова produceModifyBatch, LCS урезает одинаковые элементы с начала и с конца массивов (функция doSkips). Далее на оставшихся данных стоится матрица динамического программирования — точно так, как объясняется вот в этом видео. Затем оную обходят, определяя какие ноды надо удалить, а какие — добавить (тоже описано в видео). Обратите внимание, что на этом этапе мы не получаем список нод, которые надо обновить. Результат содержит только удаления-добавления, но! Но удаление-добавление ноды в одно и то же место (или добавление-удаление) — это и есть обновление. Операция normalizeBatch как раз и делает, что схлопывает рядом стоящее добавление-удаление в одну операцию — обновление, переформатируя первичный массив batch entry-ев. Полученный результат, наконец, можно вернуть счастливому пользователю.


LCS — и есть самое сложное в VDOM-е, из-за чего многим он кажется страшной магией. Как видите, сам алгоритм, что называется, tricky, но вполне себе понимаемый. Сложность его, кстати, квадратичная, но бояться нечего. Если изменений относительно мало и они где-то в середине пачки элементов — большинство отсекается на стадии doSkips и в результате матрица динамического программирования нечасто превышает по размерам 3x3. Конечно же, если ваши пользователи не перерисовывают 10 тысяч кнопочек, стоящих друг под другом. На практике такие случаи происходят редко. Так что имеет смысл не строить LCS-матрицу, если у вас 2 или 1 элемент, а обработать результат "руками". Гораздо чаще в реальной жизни встречается большая вложенность элементов, если на то пошло. Но, однако, недостаточная чтобы сорвать стек вызовов в JS (у нас дальше будет рекурсивная обработка). Вот так и живём. Не знаю, возможно тот же React.js пошел ещё дальше в оптимизации этого процесса — я его исходники не читал. Кто знает — отпишитесь. Все мои задачи же вписываются в означенные ограничения с огромным запасом.


Материализатор и DOM Differ


Наконец, пишем метод update, который заменяет потомков выбранного нами parent-элемента, через LCS-сравнение, на полученный от HTML-парсера массив виртуальных нод. Тут нам, очевидно, понадобится материализатор нод — штукенция, которая превращает виртуальную ноду в реальную. Без комментариев.


И создать кэш сравнений заранее! И животноводство!


Сам update: берем потомков parent-а, берем массив VDOM-нод. Создаем LCS, получаем update batch. Меланхолично проходим по нему, собираем всех, кого надо добавить в один массив, всех, кого надо удалить — в другой. Всех, кого надо обновить — сначала обновляем список аттрибутов несложной функцией updateAttributes, а потом рекурсивно применяем тот же самый метод update, который мы в настоящий момент и описываем (предварительно, достав массив потомков из VDOM-ноды-донора). В итоге: всех, кого надо добавить — прогоняем через материализатор, делаем parent.insertBefore. Всех, кого надо удалить — делаем parent.removeChild. Упражнение закончено. Код вот тут.


Для дебага я ещё добавил функцию diff, которая выводит в консоль содержимое update batch-а. Однако, на практике её вывод несколько сложен для понимания.


Все вместе


Создаем простенький HTML-документ с бутстраповскими стилями, создаем app.ts, из которого и задействуем нашу прекрасную наработку для демонстрации.


Итоги


Вы прочли статью о том, как устроен VDOM. Надеюсь, после такого экскурса, у вас появится больше понимания зачем оно надо и как оно устроено, исчезнет страх перед неизвестным и вы будете немного больше ориентироваться в происходящем внутри модных JS-фреймворков. Кому-то, возможно, понравится компактный HTML-парсер — забирайте, я не возражаю.


Всем спасибо за прочтение, комментарии приветствуются.

Комментарии (37)


  1. Ares_ekb
    06.02.2018 05:24

    Интересная статья, но на мой взгляд подход d3js на много проще. Там не нужно парсить HTML. Описываешь отображение JavaScript-объектов в HTML, SVG или что угодно. При изменении исходных данных d3js обновляет DOM, при этом обработчики событий и т.п. сохраняются. Слияние происходит на уровне данных, а не HTML.


    1. pnovikov Автор
      06.02.2018 07:41

      Видите ли в чем дело… Как бы объяснить. Тут, конечно, на любителя, но я привык что разметка — она все-таки делается в HTML. Используя подходы в духе того, что предлагает d3js, вы как бы меняете свой язык разметки с привычного глазу HTML на DSL, в виде системы функций d3js. То есть по сути вы, как человек и как пароход, выполняете работу HTML-парсера. Если конвертация HTML в набор вызовов не происходит автоматически, аки в tsx — то как по мне поддерживать подобный код — весьма больно (а в ситуации с моим фреймворком такой подход и вовсе не применим — я пытался). Но, конечно, дело вкуса.


      1. Ares_ekb
        06.02.2018 10:13

        Согласен. Зато в d3js проще описывать переходы для добавленных, удаленных, измененных данных. Можно сделать, чтобы они не просто добавлялись/удалялись, а чтобы это происходило с анимацией. Или если нужно обновить только определенные свойства определенных узлов (например, только расположение), то пишешь под это отдельную функцию и не сравниваешь/обновляешь всё.

        Наверное зависит от проекта. Если пользователь активно взаимодействует с содержимым страницы — перетаскивает какие-то узлы и т.п., то перегенерировать целиком и обновлять каждый раз HTML очень накладно.

        Ещё мне в принципе не нравятся шаблоны на клиенте. Понимаю на сервере шаблоны для страницы целиком. Но для клиента поддерживать кучу мелких HTML-фрагментов не очень.


  1. nuit
    06.02.2018 08:23
    +1

    > Таким образом innerHTML — как быстрый и грязный хак. Ненадежен в общем случае, имеет кучу сайд-эффектов, да и вообще — мы тут не в 9 классе школы чтобы так писать.

    Самое ужасное последствие от использования innerHTML — это то что внутреннее состояние DOM элементов будет потеряно (цсс анимации, видео, аудио, позиция курсора в полях ввода, позиции скролла и много других состояний).

    > Парсер HTML… Но упомянуть его определенно стоит, во многом потому что он нам понадобится для реализации VDOM.

    Совершенно ненужная вещь, одна из основных фич vdom'а — это то что все узлы виртуального дома являются обычными жаваскрипт значениями и с ними можно работать так как душе угодно, никак не ограничивая себя примитивным шаблонизатором.

    > Как я понимаю, React.js работает без парсера HTML в виду того, что его шаблоны (jsx/tsx) уже собираются в соответствующие вызовы создания нод.

    Это не шаблоны, это обычный жаваскрипт и синтаксический сахар вокруг вызова `createElement`.

    > По сути дела надо взять parent-нод, внутри которого отрисован наш HTML, взять массив всех его детей и сравнить его с аналогичным массивом виртуальных детей, который нам создал HTML-парсер.

    Так работают медленные vdom реализации, все быстрые реализации производят все сравнения на vdom структурах.

    > LCS — и есть самое сложное в VDOM-е, из-за чего многим он кажется страшной магией.

    Единственная известная мне реализация vdom'а которая использует LCS на сегодняшний день — это github.com/yelouafi/petit-dom. Я перестал использовать LCS для решения этой проблемы ещё 4 года назад, github.com/ivijs/ivi/blob/2c81ead934b9128e092cc2a5ef2d3cabc73cb5dd/packages/ivi/src/vdom/implementation.ts#L1366-L1593 здесь можете ознакомиться с моим решением этой проблемы.

    И подавляющее большинство vdom библиотек не замарачиваются и используют примитивный линейный алгоритм, из-за чего в том же реакте простая перестановка элемента может привести к N-1 `insertBefore` операций.


    1. pnovikov Автор
      06.02.2018 08:29

      Совершенно ненужная вещь

      Зависит от того, что у вас на входе. Если больше чем сырой HTML вы получить не можете (мой случай) — придется парсить. Ну и да — пущай HTML-парсер все же будет в этой статье. Некоторым таковой нужен для своих тёмных делишек — так пусть же лучше от меня узнают, чем от какого-нибудь… соавтора jQuery :)


      Это не шаблоны, это обычный жаваскрипт и синтаксический сахар вокруг вызова createElement.

      Я не спорю, но выглядит скорее как шаблоны.


      все быстрые реализации производят все сравнения на vdom структурах

      Если вы имеете в виду immutable-структуры, то согласен. В противном случае не очень понимаю о чем речь.


      здесь можете ознакомиться с моим решением этой проблемы

      Я погляжу, большое спасибо.


      не замарачиваются и используют примитивный линейный алгоритм

      Очень странно. LCS — первое, что пришло лично мне в голову.


      1. nuit
        06.02.2018 08:48

        > Зависит от того, что у вас на входе. Если больше чем сырой HTML вы получить не можете (мой случай) — придется парсить.

        Для vdom'а на входе примитивная деревяшка жаваскрипт объектов, которая не требует каких-то особых шаблонизаторов или парсеров.

        > Если вы имеете в виду immutable-структуры, то согласен. В противном случае не очень понимаю о чем речь.

        Имею в виду что DOM объекты трогают лишь в случае если нужно производить какие-то изменения, для сравнения используют только виртуальный дом.

        > Очень странно. LCS — первое, что пришло лично мне в голову.

        Да, мне тоже когда-то давно это первое что пришло в голову, и моя первая реализация использовала LCS, но потом началась жёсткая борьба в попытках получить лучшие цифры в бэнчмарках и пришлось искать как можно ускорить всё это дело под большинство реальных кейсов.


        1. pnovikov Автор
          06.02.2018 08:52

          примитивная деревяшка жаваскрипт объектов, которая не требует каких-то особых шаблонизаторов или парсеров

          В вашем случае — да. В моей задаче (фреймворк для датагридов) — увы, нет. :(


          DOM объекты трогают лишь в случае если нужно производить какие-то изменения

          Тоже хорошее допущение, однако лично мне недоступное. В рамках статейного примера, конечно, можно было бы, но в нутрях фреймворка я, к сожалению, не могу сохранить VDOM-дерево после предыдущей перерисовки для последующего сравнения :(


          1. nuit
            06.02.2018 09:05

            > В вашем случае — да. В моей задаче (фреймворк для датагридов) — увы, нет. :(

            Так вы же пытаетесь описать vdom в этой статье и парсер HTML ну совсем не имеет никакого отношения к vdom и лишь наоборот только вводит в заблуждение тех кто ещё не до конца понимает в чём заключается одна из его ключевых особенностей.

            Когда термин «виртуальный дом» стал массово использоваться, были даже дискуссии среди вдом авторов на тему того что сам по себе термин совершенно не в тему, и предлагались идеи вроде «Value DOM».


            1. pnovikov Автор
              06.02.2018 09:14

              Я хочу не "описать vdom" — его из без мой помощи уже порядочно… описали :) Я хочу показать пример, как можно не имея ничего сделать конкретное приложение, использующее VDOM и потрогать его руками. Для этого, увы, сферического в вакууме VDOM-а недостаточно. Городить сотню функций для каждого тега вижу неиллюстративным, подобие tsx — дюже трудозатратным. Остаётся HTML-парсер.
              Надо где-то написать крупными буквами, что он отношения к VDOM самому по себе не имеет.


              1. nuit
                06.02.2018 09:19

                > Городить сотню функций для каждого тега вижу неиллюстративным, подобие tsx — дюже трудозатратным

                hyperscript можно реализовать в десяток строк, используется в куче vdom либ для тех кто не любит jsx.

                mithril.js.org/hyperscript.html


                1. pnovikov Автор
                  06.02.2018 09:23

                  Да ну… не иллюстративно, как мне кажется. Придут люди, далекие от JS и скажут "что вы тут мне какие-то функции подсовываете вместо привычного HTML?".


      1. justboris
        06.02.2018 23:30

        Если больше чем сырой HTML вы получить не можете

        Как минимум, у вас на входе есть уже подготовленный браузером DOM. Сериализовать его путем вызова element.innerHTML, чтобы затем начать его героически парсить — это как-то странно


        1. pnovikov Автор
          07.02.2018 01:15

          Простите, но…
          image


          Я половину статьи расписывал почему нельзя использовать innerHTML, и тут такое.


    1. pnovikov Автор
      06.02.2018 08:40

      Самое ужасное последствие от использования innerHTML

      Для меня самым ужасным и несовместимым с жизнью было то, что теряются данные в пользовательских input-ах.


    1. mayorovp
      06.02.2018 10:04

      Это не шаблоны, это обычный жаваскрипт и синтаксический сахар вокруг вызова createElement.

      Вы так пишите как будто шаблон не может быть обычным жаваскриптом или синтаксическим сахаром...


      1. nuit
        06.02.2018 10:20

        В дискуссиях на тему vdom vs шаблонизаторы, под шаблонизаторами обычно подразумевают язык с набором ограничений, созданный специально для задачи описания пользовательских интерфейсов. youtu.be/mVVNJKv9esE?t=1217


        1. mayorovp
          06.02.2018 10:26

          А где я сказал «шаблонизатор»?


          1. nuit
            06.02.2018 10:30

            Это было в контексте «создавать свой язык шаблонизации и писать для него компилятор не входило в мои планы, когда я писал эту статью. Так что мы будем парсить голый HTML руками.»


  1. vintage
    06.02.2018 11:42

    При поверхностном взгляде кажется, что нужно просто сравнить два дерева и произвести минимальные изменения для трансформации одного в другое. И это действительно так, когда мы полностью контролируем состояние. К сожалению, как выше отметили, далеко не все состояния элементов нам подконтрольно. Поэтому мы не можем рендерить одни и те же данные то в одни узлы, то в другие. То есть узлы должны однозначно соответствовать исходным данным. В Реакте для этого есть костыль с указанием key для элементов полученным из массивов. Но эта проблема куда фундаментальней. key не поможет при переносе элемента из одного массива в другой, из одного контейнера в другой и тд. И самое печальное, что идентифицировать элементы невозможно автоматически — только программисту известно где перенос, а где удаление и добавление чего-то похожего. Поэтому в моих реализациях программист всегда явно задаёт уникальное имя каждому элементу исходя их данных, что он рендерит. Например: https://github.com/eigenmethod/mol/tree/master/dom/jsx


    1. nuit
      06.02.2018 11:53

      > В Реакте для этого есть костыль с указанием key для элементов полученным из массивов

      Все остальные костыли хуже.

      > key не поможет при переносе элемента из одного массива в другой, из одного контейнера в другой и тд

      При переносе дом узлов всё равно потеряется внутреннее состояние, которое невозможно никак иначе контроллировать. А там где с этим нет проблем flutter.io, там можно задавать глобальные ключи для переноса между контейнерами.


      1. vintage
        07.02.2018 00:41

        Можно и без костылей.


        Какое-то потеряется, какое-то нет. Но тут есть более стрёмный момент — при рендеринге других данных в тот же узел его внутреннее состояние останется от старых. Например, удалилась строка где-то выше и фокус сместился на другие данные. А у глобальных идентификаторов куча плюсов и помимо возможности переносить узлы куда угодно:


        1. понятна его семантика
        2. понятно кто его владелец
        3. быстрый доступ из консоли
        4. не ломающиеся при каждом чихе тесты
        5. ну и никакие дифы считать не надо, элементы располагаются за линейное время

        mayorovp это совсем не трудоёмкая операция, если используются правильные инструменты.


    1. mayorovp
      06.02.2018 12:06

      Поэтому в моих реализациях программист всегда явно задаёт уникальное имя каждому элементу исходя их данных, что он рендерит.

      Замечательно, самую трудоемкую операцию взяли и объявили ручной.


    1. babylon
      06.02.2018 20:11

      То есть узлы должны однозначно соответствовать исходным данным.

      Я по другому формулирую — исходные данные должны знать контекст владельца. Контекст может быть разным. Но именно контекст однозначно определяет принадлежность тому или иному узлу-владельцу. Т.е. данные (контент, имущество и т.д. ) должны быть с контекстом владельца. Контекст появляется у данных на этапе проектирования, а используется или изменяется в процессе работы с данными. Идеально будет если создадут редактор, который устанавливает такие связи на основе визуального представления.


    1. nsinreal
      07.02.2018 01:21

      В Реакте для этого есть костыль с указанием key для элементов полученным из массивов.

      Это не костыль. Это единственно верное решение, которое не приводит к нестабильному и потенциально некорректному поведению.

      1. За элементом VDOM может стоять компонент с каким-то состоянием.
      2. Элемент VDOM привязан к DOM-узлу с определенным unmanaged состоянием.
      3. Если у вас есть несколько похожих по типу элементов VDOM подряд, то это с большой вероятностью значит, что там цикл, а где цикл — там список объектов. Таким образом VDOM элемент с большой вероятностью привязан к объекту.

      Исходя из этих пунктов вытекает следующее: некорректно проставленный key может испортить текущий стейт неопределенным образом. Это было бы не критично, если бы у вас весь стейт был бы глобальным деревом, которым управляете вы. Но в том же DOM есть очень много внутреннего состояния, которое мы не можем программным образом контролировать.

      Поясню более детально. Кейс:
      1. В массиве есть один объект.
      2. Объект меняется. А может даже вместо него создается копия с модифицированными полями — т.е. на ссылки полагаться нельзя.
      3. Добавляется еще один объект.

      Нельзя просто так взять и идентифицировать объект — нужен айдишник. А айдишник может отсутствовать. Или может быть записан в id, Id, key, Key, isoCode или еще какое-то упоротое поле. А значит нельзя понять соответствие внутреннего состояния между старыми компонентами (или DOM-узлами) и новыми компонентами. Терять это внутреннее состояние мы не можем: пользователь или может этого не хотеть, или может не мочь восстановить это состояние сам.

      Этот кейс вылазит в следующих случаях:
      1. У вас данные из одного объекта имеют сложную связь с его местоположением в массиве. Например, первый объект не может иметь каких-то свойств. Это например, цепочка ответственности отображенная на UI. Кейс редкий, да :-)
      2. У вас перерисовка происходит батчами и стейт успевает поменяться два раза (например с сервера по Websockets приходит все, что юзер пропустил будучи оффлайн)


      1. vintage
        07.02.2018 09:03

        Спасибо, конечно, что развернули мою мысль, но эта проблема есть не только в массивах, в чём собственно и заключается мой тезис о костыльности локальных ключей лишь для элементов массива, вместо глобальных вообще для всего.


  1. bgnx
    06.02.2018 14:17

    Благо, задача построения diff-а в программировании довольно известная — гуглится, внезапно, по словам нахождение наибольшей общей подпоследовательности. Решается она динамическим программированием. Алгоритм имеет квадратичную сложность.

    Вы не до конца разобрались в этой теме «диффинга». LCS который имеет O(n^2) сводится (для случая когда элементы не повторяются) к LIS (longest increasing subsequence) которая имеет сложность n*log(n) и реализуется как часть «терпеливой» сортировки. Алгоритм примерно такой — создаем временный массив и проходимся по элементам нового массива — вытаскиваем элемент по ключу «key» и смотрим какой был индекс в предыдущем массиве. Если индекс больше индекса элемента который мы обработали до него то добавляем его в конец этого временного массива и в отдельном свойстве «prev» записываем элемент который стоит перед ним в временном массиве а если индекс меньше — то ищем бинарным поиском элемент с наименьшим индексом который больше нашего. По достижению конца списка смотрим на последний элемент во временном массиве и проходимся связанному списку «prev» — это и будет наибольшая возрастающая последовательность. Кстати в отличии от inferno, ivi и прочих этот алгоритм можно еще сильнее оптимизировать если не создавать новые временные массивы а переиспользовать один глобальный каждый раз при diff-е и уменьшить количество проходов — на первом проходе формируем тот временный массив а на обратном сразу можно предпринять перестановку элементов — не дожидаясь формирования конечного lis — то есть считываем свойство «prev» — если элемент равен текущему элементу в новом массиве то его не трогаем. Если не равен то нужный элемент вставляем в текущее место через insertBefore.


    1. bgnx
      06.02.2018 14:38

      Но надо добавить что этот LIS-алгоритм нужен если вам захотелось оптимизаций. Тот же реакт, несмотря на то что многие думают что у него есть какой-то там diff-алгоритм, ничего такого не имеет — там простое сравнение по ключу и перемещение несовпавшего элемента на текущее место. Из-за чего вместо одной операции получаем n-1 операций insertBefore если переместить последний элемент наверх (или первый в самый низ, точно не помню). В принципе если перемещение одного элемента еще можно оптимизировать добавив while-цикл со c пропуском одинаковых элементов на концах то при перемещениях двух и больше элементов только lis-алгоритм будет гарантировать минимальное количество insertBefore операций


    1. nuit
      06.02.2018 14:44

      > Кстати в отличии от inferno, ivi и прочих этот алгоритм можно еще сильнее оптимизировать если не создавать новые временные массивы а переиспользовать один глобальный каждый раз при diff-е и уменьшить количество проходов — на первом проходе формируем тот временный массив а на обратном сразу можно предпринять перестановку элементов — не дожидаясь формирования конечного lis — то есть считываем свойство «prev» — если элемент равен текущему элементу в новом массиве то его не трогаем.

      В 99% случаев в реальном приложении при апдэйте списков не придётся никого переставлять, поэтому усложнение предпроходов может сказаться на производительности более частых случаев. Переиспользование массива точно не даст никакого прироста.


      1. bgnx
        06.02.2018 14:57

        Если в 99% случаях при обновлениях массив не меняется, то тогда зачем вообще нам этот алгоритм lis? Я имел ввиду что если интегрировать алгоритм lis в процесс diff-а виртуальных нод можно избавиться от лишнего прохода. При этом не пострадают другие случаи потому что этот процесс нужен уже когда дошли до самого lis-алгоритма. То есть не передавать функции lis массив индексов чтобы она вернула массив других индексов а интегрировать прямо в функцию перестановки — нам ведь не нужно знать этот самый массив lis, нам достаточно только за один проход заполнить массив и получить последний элемент lis-последовательности и (вместо отдельного прохода для получения всей lis-последовательности) мы тут же можем начать выполнять перестановку дом-элементов сравнивая текущий элемент массива с текущем элементом в lis-последовательности (элемент которой будет доступен через связанный список по полю «prev» на виртуальной ноде).

        Переиспользование массива точно не даст никакого прироста.
        Меньшее создание объектов будет меньше требовать cpu-времени на создание а потом на вызов сборщика мусора


        1. nuit
          06.02.2018 15:16

          А, понял.

          Возможно ускорит устранение отдельного lis прохода, но так же возможно что и наоборот, нужно как минимум тестировать на синтетических бэнчмарках. Не пробовал.


    1. pnovikov Автор
      06.02.2018 15:37

      Мне уже nuit написал об этом. Я обязательно ознакомлюсь с его реализацией и вашими пояснениями, но пожалуй только он может дискутировать с вами состоятельно на этот момент.


  1. justboris
    06.02.2018 23:34

    Слишком много всего написано, и ни одного примера кода.
    А еще не помешало бы демо, которое можно открыть в браузере и поиграться. Ссылка на github напрямую не заработает, потому что исходники в typescript.


    1. pnovikov Автор
      07.02.2018 01:13

      А то есть вы ссылку на jsfiddle вначале статьи не увидели и клонировать репозиторий на github не можете?
      Беда.


      1. justboris
        07.02.2018 01:44

        Теперь вижу ссылку, перешел, покликал. Вроде работает, неплохо.


        клонировать репозиторий на github не можете?

        клонировать могу, но там в readme никакого описания, как его собрать. А банальное открывание index.html файла ничего не дает.


        1. pnovikov Автор
          07.02.2018 01:49

          Эам… там VS-солюшен. Запускаете и нажимаете F5.


          1. justboris
            07.02.2018 11:36

            Не все в этом мире пользуются Visual Studio, тем более для фронтенда.


            1. pnovikov Автор
              07.02.2018 11:59

              Если вы не пользуетесь студией, то стянуть проект и вызвать в консоли tsc для вас так же не должно составлять труда.