Pull to refresh

Comments 37

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

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

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

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

Ещё мне в принципе не нравятся шаблоны на клиенте. Понимаю на сервере шаблоны для страницы целиком. Но для клиента поддерживать кучу мелких HTML-фрагментов не очень.
> Таким образом 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` операций.
Совершенно ненужная вещь

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


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

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


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

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


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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

mithril.js.org/hyperscript.html

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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


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


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

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

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

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

UFO just landed and posted this here
В Реакте для этого есть костыль с указанием 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 приходит все, что юзер пропустил будучи оффлайн)

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Sign up to leave a comment.

Articles