В эпоху динамического веб-контента появление Web 2.0 привело к появлению множества методов, направленных на то, чтобы сделать рендеринг веб-контента не только более быстрым, но и более эффективным и простым для автоматизации. Однако уровень абстракции, используемый для достижения этого, иногда может скрывать важные основы, которые делают возможными эти подвиги. Эта статья является попыткой раскрыть слои и изучить основные операции, лежащие в основе этих более сложных абстракций.

Весь код, используемый здесь, для вашего удобства находится в моем репозитории Github.

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

Это простая таблица, которая отображает только одну строку данных. Однако таблица могла бы отображать несколько строк данных, если бы было доступно больше данных. Эти данные в основном доступны из внешних источников, таких как REST API, и в различных форматах, таких как json, csv или xml. Таблица также в настоящее время отображает только четыре столбца, а имена столбцов являются статическими. Однако таблица может отображать другое количество столбцов, и столбцы могут иметь разные имена. Чтобы достичь такого уровня гибкости, статическая html-таблица определенно не уведет вас далеко.

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

В качестве отправной точки создайте файл dynamic.js в том же каталоге, что и ваш файл index.html. Извлеките все статические данные из этой таблицы и используйте переменную Javascript для их представления.

С учетом этого мы можем визуализировать разметку этой таблицы, используя строковые шаблоны Javascript. Замените содержимое тела приведенным ниже фрагментом;

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

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

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

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

  • npm init -y (инициализировать проект узла в текущей папке)
  • npx http-сервер. -p 8080 -d false (это загрузит и запустит http-сервер, не добавляя его в ваши зависимости)

В качестве альтернативы вы можете загрузить зависимость http-сервера и использовать скрипт для ее запуска.

  • npm i -D http-сервер
  • Добавьте скрипт «start»: «npx http-server. -p 8080 -d false” в ваш package.json
  • запустите npm run start, чтобы запустить сервер

Откройте браузер и перейдите по адресу http://127.0.0.1:8080.

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

Добавьте это в файл _dynamic.js_. Используя этот подход, innerHTML элемента заменяется строковым шаблоном karkup.

Достоинства

  • Очень прост в использовании

Недостатки

  • Строковая разметка не проверяется, и браузер приложит все усилия, чтобы отобразить все, что в его силах.
  • Полезно только для небольших демонстраций без обработки событий.

2. Использование document.createElement()

Замените option1 в файле _dynamic.js_, чтобы создать дочерний узел. При таком подходе метод документа createElement используется для создания новой таблицы.

В качестве альтернативы вы можете создать элементы DOM для каждого из элементов таблицы и вложить их вместе в желаемую форму. Но по мере увеличения размера разметки очень быстро становится утомительно пытаться создавать вызовы createElement вручную. Однако этот подход дает вам гораздо больше контроля над добавлением необработанной разметки в дерево документов. Использование рекурсивных вызовов для прохода по узлам дерева сделало бы этот подход в значительной степени приемлемым. Некоторые фреймворки, такие как React, используют некоторый специализированный синтаксис (JSX), чтобы абстрагироваться от низкоуровневого createElement, в то же время предоставляя вам альтернативный способ построения дерева DOM.

Достоинства

  • Разметка проверяется перед добавлением в элемент шаблона. Попробуйте написать тег body с ошибкой, например boby — вы заметите, что таблица не будет отображаться на странице.

Недостатки

  • Несмотря на простоту использования, для успешного создания нового узла таблицы необходимы дополнительные шаги.
  • Использование рекурсии для обхода дерева документов значительно повысит уровень сложности.

3. Использование DOMParser

Замените option2 в _dynamic.js_, чтобы использовать DOMParser. Интерфейс DOMParser позволяет анализировать исходный код XML или HTML из строки в документ DOM.

const doc = domparser.parseFromString (строка, mimeType)

Добавьте это в файл dynamic.js.

Достоинства

  • Как и в случае с подходом createElement(), разметка проверяется перед добавлением в элемент шаблона. Попробуйте написать тег body с ошибкой, например boby — вы заметите, что таблица не будет отображаться на странице.

Недостатки

  • Несмотря на простоту использования, для успешного создания нового узла таблицы необходимы дополнительные шаги.

4. Использование фрагмента документа

Замените option3 в _dynamic.js_, чтобы использовать опцию Fragment. Интерфейс DocumentFragment представляет собой минимальный объект документа, не имеющий родителя. Он используется как облегченная версия Document, в которой хранится сегмент структуры документа, состоящий из узлов, как и в стандартном документе.

DocumentFragment() Создает и возвращает новый объект DocumentFragment.

Добавьте это в файл dynamic.js.

Достоинства

  • Он имеет все преимущества использования createElement или DOMParser.
  • Изменения, внесенные во фрагмент, не влияют на документ (даже при переформатировании) и не влияют на производительность при внесении изменений.

Недостатки

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

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

Теперь вернемся к таблице — рендеринг более одной строки

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

Мы могли бы создать строку в tbody вручную, но это было бы очень неэффективно. Мы будем использовать функцию карты, чтобы сделать это более логичным. Не забудьте соединить сопоставленные строки вместе, чтобы сформировать строку

Если столбцы таблицы совпадают с ключами данных в объектах строк, вы можете дополнительно реорганизовать создание элемента _thead_. Например, вы можете просто преобразовать ключи данных в имена столбцов, начав значение ключа с заглавной буквы.

Вы также можете дополнительно использовать преобразование фильтра в списке, чтобы удалить некоторые строки на основе любых критериев, которые вы придумали, прежде чем применять преобразование карты к результату. Таким образом, используя только filter и map, вы уже можете начать понимать, как можно выполнять более сложные преобразования данных перед их окончательным рендерингом в DOM.

Одно усовершенствование, которое мы можем сделать на этом этапе, — это инкапсулировать функциональность, которую мы в настоящее время плаваем в скрипте, внутри класса.

Реактивность — динамическое обновление DOM

Это совершенно другое измерение в том, что касается рендеринга в DOM, и именно здесь разные фреймворки начинают различаться — это святой Грааль, конечная цель, которая всегда немного меняется в зависимости от характера веб-приложения и цели, к достижению которых она стремится. Реактивность — это концепция динамического изменения содержимого DOM в зависимости от состояния отображаемых данных.

Реактивность с использованием прокси-сервера JavaScript

Объект Proxy позволяет вам создать прокси для другого объекта, который может перехватывать и переопределять основные операции для этого объекта.

Вы создаете прокси с двумя параметрами:

- цель: исходный объект, который вы хотите проксировать

- обработчик: объект, определяющий, какие операции будут перехватываться и как переопределять перехваченные операции.

Очень простой пример будет выглядеть так

В приведенном выше случае обработчик пуст, поэтому этот прокси ведет себя так же, как исходная цель. Однако обработчик может нацеливаться на разные методы объектов. Эти методы перехвата называются Traps.

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

Благодаря этому поведению теперь вы можете повторно отображать таблицу при преобразовании данных.

Проксирование данных таблицы

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

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

На вкладке Консоль инструментов разработчика измените возраст первого пользователя в данных таблицы.

Когда мы теперь просматриваем данные таблицы, мы видим, что значение возраста первой строки изменилось, но таблица, отображаемая в DOM, не знает об этом — она не подвергается повторному отображению.

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

Реакция на изменение данных

Давайте теперь сделаем еще один шаг и рассмотрим, как можно повторно отобразить таблицу. Это потребует небольшого рефакторинга класса UserTable.

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

2. Добавьте в класс новый метод, реагирующий на изменение данных.

В настоящее время повторный рендеринг довольно неэффективен — он заменяет весь компонент каждый раз при изменении данных. В идеале мы хотели бы уменьшить размер DOM, на который влияет повторный рендеринг. Целенаправленная стратегия повторного рендеринга была бы более оптимальной в этом отношении. Это тема, к которой мы вернемся позже, когда будем рефакторить этот код, но сейчас давайте продолжим с текущим неоптимальным рендерингом.

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

Метод proxify также потребует некоторой модификации для включения нового параметра — слушателя, — который будет рекурсивно передаваться в глубину, а также во вложенные объекты.

Конструктор таблицы больше не должен требовать параметр, поэтому столбец и строки по умолчанию пусты, и их изменение должно вызвать обновление таблицы.

С этими изменениями в местах, давайте теперь также изменим способ создания и первоначального рендеринга табличного компонента.

Метод таблицы onDataChanged передается функции proxify для получения уведомлений об изменении данных. Не забудьте использовать привязку — table.onDataChanged.bind(table) — при передаче этого метода, чтобы ссылка this всегда указывала на табличный компонент.

Теперь вернитесь в Инструменты разработчика, перейдите на вкладку Консоль и измените возраст данных первой строки.

И точно так же таблица повторно отображается с новыми значениями.

Но что если добавить новую строку? Вернитесь на вкладку _Console_ и добавьте новую строку.

И, как мы и ожидали, таблица перерисовывается с новой строкой.

Но что если существующая строка будет удалена? Вернитесь на вкладку Консоль и удалите вторую строку.

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

Операции splice и push перехватываются набором в proxyHandler. Чтобы использовать такую ​​операцию, как delete, необходимо установить ловушку deleteProperty в proxyHandler, а также

Целевой повторный рендеринг

Ранее я указывал, что мы вернемся к методу onDataChanged компонента UserTable, так как он слишком агрессивен в своей стратегии обновления — он заменяет весь компонент на только что отрендеренная копия. При замене существующего компонента все существующие привязки событий также теряются, и их придется выполнять заново. Понятно, что это не оптимальное решение. Можем ли мы сделать лучше?

Теневой дом

Одна из стратегий заключается в использовании теневого DOM для сравнения существующего элемента с новым перед обновлением только различных частей. Теневой DOM позволяет вам визуализировать за пределами фактического DOM, но при этом иметь точно такое же точное дерево узлов, которое было бы у фактического DOM.

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

В качестве первого шага давайте изменим метод render(), чтобы он возвращал фрагмент документа.

В качестве следующего шага давайте представим новый метод compareAndUpdate в компоненте UserTable. Этот метод будет проходить по узлам дерева и сравнивать nodeValue узлов Node.TEXT_NODE на равенство, и если они не совпадают, значение существующего узла будет обновлено до значения в компоненте, отображаемом в теньДОМ. В onDataChanged также необходимо использовать метод compareAndReplace вместо функции document.replaceChild().

И действительно, когда мы обновляем данные таблицы, обновляется только затронутый TextNode и абсолютно ничего не заменяется.

Это гораздо более эффективный подход к обновлению DOM по сравнению с последним методом, который заменял весь компонент, но он не обязательно является полным или исчерпывающим — это просто иллюстрация того, как компонент может быть прозрачно обновлен.

Визуализация нескольких вложенных компонентов

Насколько сложным будет введение, если мы добавим в смесь еще несколько компонентов? Я думаю, что первым логическим шагом должно быть использование модулей, которые помогут разделить код на модульные единицы. Во-первых, в файле index.html объявите сценарий типа module.

index.js — типичная стандартная точка входа в граф модуля, так что давайте продолжим эту традицию. Отсюда давайте посмотрим, как мы можем отобразить ту же таблицу.

Я разделил исходный контент на три файла для повторного использования. Первый — это файл data. Это абстракция хранилища данных, распространенная в таких библиотеках, как Redux.

Создайте новый файл data.js.

Второй файл — библиотека — содержит код, который можно использовать повторно и не относится к конкретному компоненту.

Создайте новый файл library.js.

Третий файл — userTable.js — представляет собой код, относящийся к конкретному компоненту и расширяющий класс библиотеки Component.

Замените файл dynamic.js в index.html новым файлом index.js.

И, конечно же, когда мы обновим окно, все должно отображаться так же, как и раньше. Одно тонкое отличие состоит в том, что глобальные переменные, которыми мы ранее манипулировали на вкладке Консоль в Инструментах разработчика, больше не доступны.

Итак, с этим первоначальным рефакторингом давайте попробуем обернуть компонент userTable c внутри родительского компонента App в index.js.

Если вы сейчас попытаетесь обновить страницу, все развалится. Что визуализируется:

[object DocumentFragment]

Это связано с тем, что this.userTable().render() создает фрагмент, который преобразуется в строковый литерал в методе template(). Это явно не желаемый результат. Мы должны ввести способ отличить компоненты от разметки.

Вероятным способом достижения этой цели было бы введение в класс Component другого метода для захвата дочернего компонента при его добавлении и вместо этого возврата заполнителя, который позже будет заменен фактическим дочерним узлом.

Класс Component должен поддерживать список своих дочерних элементов. Добавьте конструктор и инициализируйте новую переменную экземпляра в library.js

Обновите метод render() в классе Component, заменив заполнители фактическим дочерним узлом.

И теперь все должно снова отображаться как раньше.

Давайте добавим еще несколько компонентов таблицы, используя другие данные. Начните с добавления другого набора данных в data.js.

Теперь обновите компонент App в index.js.

Таким образом, с этими небольшими добавлениями три компонента таблицы отображаются так, как вы и ожидали.

Добавление обработчиков событий

До сих пор мы имели дело только с элементами DOM, которые не считаются событиями DOM. Как их следует лечить? Чтобы попробовать это, давайте добавим новый файл userForm.js и создадим компонент формы для обновления данных таблицы.

Давайте представим это как дочерний элемент компонента App в index.js.

Когда страница отображается, это совершенно не то, что мы ожидали увидеть. Обработчики событий обрабатываются как строковые литералы в методе template.

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

Вероятным решением может быть создание базовых компонентов для элементов управления формы, как в этом случае, TextInput и ButtonInput.

Создайте новый файл textInput.js.

Создайте новый файл buttonInput.js.

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

Добавьте поле formData в файл data.js

Обновите компонент userForm в userForm.js, чтобы использовать прокси, а также новые элементы управления, которые мы только что создали.

Обновите App.js, чтобы учесть изменения, внесенные в компонент userForm.

Теперь форма отображается без проблем

Однако мы до сих пор не определили, как прикрепить обработчики событий к соответствующим элементам управления. Что ж, это можно сделать в функции render(), которая проверит, содержит ли Компонент функции-обработчики.

Добавьте новый член — this.element — в конструктор класса Component.

Обновите функцию render() и при этом назначьте новому свойству t нового элемента отображаемое значение компонента.

Обновите функцию onDataChanged, чтобы использовать новый элемент

В этот момент форма отображается на странице, и события запускаются, как и ожидалось.

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

Обновление данных таблицы с помощью формы

Теперь пришло время добавить связь компонента таблицы с компонентом формы и посмотреть, как они могут взаимодействовать. Но сначала проведите рефакторинг как userTable.js, так и userForm.js, чтобы экспортировать только класс. Мы подключим компоненты к нужным им данным в компоненте App.

Обновленный index.js для отражения изменений, внесенных в компоненты.

Страница должна продолжать отображаться и вести себя точно так же, как до этапа рефакторинга.

Я думаю, что это хорошее место, чтобы нажать на паузу. Мы могли бы продолжать углубляться и исследовать различные возможности, но я думаю, что основная мысль здесь уже сделана. Это было довольно сложное путешествие по изучению различных способов использования ванильного JavaScript для реализации реактивности. В другой статье я раскрою то, что мы рассмотрели здесь, и применю это в контексте веб-компонентов.

Есть много отличных и проверенных в бою фреймворков, которые уже делают это и имеют гораздо больше возможностей (как количественно, так и качественно). Я надеюсь, что это вдохновляет вас всегда быть любопытными.