В последнее время видны масштабные изменения в способах разработки веб-приложений. Если раньше интерфейс создавался на сервере, а на стороне клиента выполнялись лишь незначительные сценарии, то в наши дни стандартом является использование какой-либо из различных библиотек реактивного рендеринга для создания сложных стейтфул клиентских приложений. Хотя многие разработчики успешно применяют такие библиотеки как React или Vue, понимание их точной внутренней работы не слишком широко изучено. В этой статье я расскажу о создании собственной библиотеки реактивного рендеринга, и разъясню, что происходит под капотом. Но прежде чем мы начнем, небольшое предупреждение. Хотя наша библиотека вполне рабочая (см. примеры приложений в конце), это, прежде всего, демонстрационный инструмент, а не легковесная альтернатива React. Весь проект можно найти на GitHub. Главное в любой библиотеке реактивного рендеринга - это компоненты. Работа компонента заключается в управлении так называемым "реактивным пространством". Компонент содержит переменную ("state") и отвечает за ее рендеринг в DOM. Здесь уже встречаются некоторые понятия, знакомые вам по написанию react-компонентов : this.state, this.setState и render. Архитектура базового реактивного компонента. Каждая интеракция создает новое state (состояние), формирующее новую VDOM, обновляющую HTML приложения Критическим моментом в реактивном мышлении является то, что мы не определяем модификации DOM для каждой вещи, которая может произойти с состоянием нашего приложения (как это можно увидеть в кодовой базе jQuery). Вместо этого есть одна функция рендеринга, и она используется каждый раз, когда происходит какое-то изменение состояния. Хотя идея "всего одной функции рендеринга" великолепна (меньше кода, отсутствие состояния в DOM и т.д.), возникает другая проблема: если мы будем заменять всю структуру DOM при каждом обновлении, это вызовет множество неудобств. Помимо потери производительности, это приведет к утрате фокусировки пользователем, странным скачкам при прокрутке и т.д. Чтобы предотвратить эти проблемы, библиотеки реактивного рендеринга используют виртуальную DOM с алгоритмом сравнения (diffing-алгоритм) для генерации стратегии обновления, которая затем может быть применена к реальной DOM. Внутри этой виртуальной DOM мы можем ререндерить все, что захотим, это просто объект JavaScript с причудливым названием, а не реальная DOM. И с помощью умного diffing-алгоритма можно убедиться, что в реальную DOM внесено минимальное количество изменений, что делает такую фактическую модификацию DOM даже более эффективной, чем любое применение функции обновления DOM, написанной вручную. Начнем с имплементации такой виртуальной DOM. Она будет намного проще той, что вы найдете в исходном коде React, но будет делать то же самое достаточно хорошо и, что важно, продемонстрирует внутреннюю работу виртуальной DOM. После этого мы рассмотрим создание стейтфул-компонентов с состоянием и пропсами, и то, как концепция таких компонентов обыгрывается в виртуальной DOM. В заключение я покажу вам библиотеку в действии с помощью реальных примеров приложений. По своей сути виртуальная DOM состоит из двух структур данных: дерева синтаксиса HTML и diff-дерева. Дерево HTML представляет состояние HTML в JavaScript-объекте . Это возвращаемый тип метода рендеринга компонентов и то, с чем будет взаимодействовать разработчик, использующий библиотеку. Diff-дерево описывает, какие шаги необходимо предпринять для обновления элемента HTML из состояния одного синтаксического дерева в другое. Эта структура данных будет хранить состояние нашей виртуальной DOM. Пока мы будем поддерживать только обычные элементы и текстовые узлы. Позже эта структура расширится, для того чтобы использовать стейтфул-компоненты внутри дерева. Определение типа приведено ниже. Свойство key будет использоваться при сравнении дочерних элементов, те, кто знаком с React, уже поняли это. Определение типа VDOM. Наша библиотека не будет поддерживать JSX, и поэтому нам нужны некоторые функции-хелперы для создания элементов VDOM удобным способом. Они будут использоваться в методах рендеринга наших компонентов и являются аналогом вызовов React.createElement, с которыми вы, возможно, сталкивались в своих пакетах JavaScript. Функции для создания элементов VDOM. Начнем с механизма сравнения (diffing), который работает с одним элементом, а затем расширим его за счет поддержки дочерних. Сначала создадим тип со всеми операциями. Операция - это мутация DOM, которая будет произведена с одним элементом HTML. Основными операциями являются update и replace. Также есть skip, указывающая на отказ от изменений. Типы для операций, применяемых к одному элементу. Операция update фактически представляет собой diff-дерево и состоит из небольших шагов, которые необходимо предпринять для обработки результата большинства вызовов setState. Также здесь содержится ссылка на обновления для дочерних элементов. Операции для дочерних элементов немного отличаются, потому что они будут применяться к коллекции элементов. Помимо тех операций, которые мы уже определили, они также включают insert и remove: Типы для операций, которые могут быть применены к дочерним элементам. Теперь нам удалось построить виртуальную DOM и diff, далее создадим то, что делает VDOM стоящим усилий: diffing-алгоритм . Это позволит нашим компонентам эффективно переходить из одного состояния в другое, это важно, когда речь о плавной работе реактивного приложения. Медленный или даже прерывистый diffing-алгоритм полностью уничтожает пользовательский опыт любого приложения, независимо от того, насколько тщательно проработаны его компоненты. Мы разложим данный фрагмент на две части: сначала diffing отдельного элемента, потом diffing коллекции (дочерних) элементов, так же, как это делалось для определений типов. При diffing'е VDOM мы имеем дело с двумя различными вещами, которые могут обновляться: текстовые узлы и HTML-элементы. Начнем с текстовых узлов, т.к. они довольно просты. Если оба элемента являются текстовыми узлами и их значение не изменилось, мы возвращаем операцию skip. В любом другом случае когда есть текстовый узел, нам придется заменить старый элемент новым: Diffing текстовых узлов. После выполнения проверок мы убедились, что оба узла являются обычными элементами. Но остался один случай, где необходимо полностью заменить узел: когда tagname старого и нового элемента отличаются. Это следующее,что мы добавим в нашу функцию: Обновленное tagname означает, что весь компонент должен быть смонтирован заново. Два имеющихся у нас элемента имеют одинаковый тип и могут быть обновлены из одного состояния в другое без создания нового элемента. Для этого создадим: атрибуты, которые были удалены; атрибуты, которые должны быть установлены; и обновления для дочерних элементов: Генерация всех данных для обновления. Мы завершили логику diffing'а для отдельных элементов. Как и в случае с деревом синтаксиса HTML, позже добавим поддержку diffing'а и монтирования компонентов. Переходим к логике diffing'а для дочерних элементов. Последней частью diffing-алгоритма является diffing для дочерних элементов. Это, самая сложная часть VDOM, потому что существует множество вариантов того, что может произойти с дочерними элементами. Можно вставить новый элемент в начало, в середину или в конец; удалить, обновить или пересортировать существующие элементы. Здесь в игру вступает key элемента: предполжим, что если ключи совпадают, то и элементы одинаковы (но могут быть изменены). С помощью ключей определим, что первый элемент в текущем дереве соответствует третьему в новом дереве, потому что добавилось два элемента впереди. Без ключей это практически невозможно узнать. Сделаем немного упрощенную имплементацию, не поддерживающую переупорядочивание ключей (в случае переупорядочивания она будет выполнять remove и insert дочерних элементов, которые находятся не по порядку). Это экономит много кода. Начнем функцию с создания двух стеков из оставшихся необработанными дочерних элементов для старого и нового дерева: Базовая структура для функции childsDiff. В строке 6 добавим логику, которая обрабатывает все дочерние элементы и заполняет operations массив операциями для преобразования старого дерева в новое. Этот алгоритм будет сосредоточен на поиске элементов (= ключей), которые присутствуют как в старой, так и в новой VDOM. Для них будем генерировать обновления, а все остальные элементы будут удалены или вставлены. Ниже показана эта часть логики. Пока в стеках остаются обновленные дочерние элементы, мы продолжаем генерировать для них update операции. При diffing'е дочерних элементов мы заботимся о генерации операций обновления для новых компонентов, чтобы предотвратить перемонтирование существующих.  В этой функции важно отметить, что nextUpdatedKey не обязательно должен быть первым из элементов как в remainingOldChilds, так и в remainingNewChilds. Перед ними в стеках могут быть элементы, которые были удалены или вставлены и потому не содержатся одновременно в обеих коллекциях. Сначала надо удалить все старые элементы и вставить новые, прежде чем мы сможем создать обновление. Старые элементы надо удалить, а новые - вставить, прежде чем добавлять операцию обновления к результату. После обработки всех обновленных узлов все еще могут оставаться элементы. Это происходит, когда в нижней части дерева добавляются или удаляются дочерние элементы. Смотрите ниже: Обработаем оставшиеся операции remove и insert после завершения всех обновлений. Теперь есть полноценный diffing-алгоритм, способный генерировать ключевые diffs сложных деревьев и позволяющий получить приемлемый эффективный diff. Возможны оптимизации, каждая пара remove и insert выполняется с помощью одной replace. Можно поддерживать сортировку элементов по ключам, что важно для некоторых приложений. Теперь есть готовая виртуальная DOM, пора соединить ее с реальной. Для этого понадобятся две отдельные части: функция, которая берет VDOM и рендерит ее в DOM, и функция, которая принимает diff и применяет его к элементу в DOM. Чтобы начать использовать новый виртуальный DOM, - надо осуществить его рендеринг в реальный. Это будет рендеринг первой версии компонента. После этого функция applyDiff станет отвечать за соединение виртуальной и реальной DOM друг с другом. По сути, она сводится к вызову document.createElement для каждого элемента в дереве: Преобразование VDOM в HTML-элемент. Мы присваиваем каждое свойство непосредственно элементу. Конечно, в React эта логика реализована не так. Правильной будет установка атрибутов с помощью setAttribute и использование синтетической системы для связи событий внутри DOM и VDOM. Здесь я сократил часть статьи, чтобы не увеличивать ее в размерах... Другая половина рендеринга - это применение diff. Для этого функция берет HTMLElement и diff и применяет его к элементу. Здесь вы видите те же операции, которые мы определили ранее. Для краткости я опустил все валидации, которые позволяет TypeScript, единственное, что мы проверяем, это то, что мы присваиваем атрибуты действительному элементу, а не текстовому узлу. Применение diff между двумя элементами VDOM к элементу DOM. В applyChildrenDiff мы перебираем операции и применяем их к текущему дочернему элементу. Основная сложность здесь связана с offset, который используется для определения того, к какому элементу в DOM относится текущая операция. Важно помнить, что операций может быть гораздо больше, чем дочерних элементов. Применение набора операций к дочерним элементам HTML-элемента. С помощью этой функции мы завершили создание VDOM и всего, что с ней связано. Следующим этапом нашего исследования будет добавление стейтфул-компонентов и объединение их в настоящее реактивное приложение. Знакомые с React, знают что компоненты - это то, что содержит состояние. Хотя с точки зрения пользователей это действительно важная часть компонентов, в исходном коде реактивного фреймворка основная задача компонента - управлять реактивной областью. Что такое реактивная область? Это часть дерева VDOM, которая отвечает за свои собственные обновления. То есть компонент имеет ссылку на html-элемент в DOM, где он установлен, и создает diffs для этого элемента на основе состояния и пропсов. Даже если компонент принадлежит родителю, он все равно будет отвечать за свой собственный рендеринг и diffing. Мы начнем с создания базового класса для всех компонентов. Этот класс будет хранить текущий пропс, состояние, корневой элемент и VDOM. Базовый набросок такого класса показан ниже. Многие атрибуты и методы знакомы по библиотекам типа React, например, props, state, setState, componentDidMount и render. Существуют методы, которые выделены только для внутреннего использования. setProps будет вызываться во время обновления родителя, чтобы предоставить этому компоненту новые пропсы. Эта функция возвращает разницу между его VDOM и новой VDOM родителя. initProps вызывается во время процесса монтирования и возвращает начальную VDOM, которая будет рендериться в реальной DOM. После этого будет вызван notifyMounted, и компонент будет полностью смонтирован в DOM. Кроме того, у нас есть метод unmount, используемый при удалении компонента из DOM. Набросок для класса Component. Еще вы видите пустые хук-методы, которые отдельные компоненты могут переопределять. Подключим их к внутренним компонентам нашей библиотеки по ходу работы. Первым шагом к использованию компонентов внутри функции рендеринга является расширение VDOM с помощью типа узла для компонентов. Это позволит нам использовать компонент внутри функции рендеринга другого компонента и, таким образом, создать настоящее дерево с множеством стейтфул-компонентов. Внутри функции разработчик будет указывать, какой компонент должен рендериться и с какими пропсами. Определение типа для такого узла показано ниже. Расширение для определения VDOM для компонентов. Свойство instance предназначено только для внутреннего использования. В нем будет храниться реальный экземпляр компонента, с состоянием и т.д. внутри. В функции diffing мы обязательно скопируем существующие инстансы компонентов из старого дерева в новое, чтобы они не потерялись между ними. Важно отметить, что функция рендеринга компонента не создаст никаких инстансов дочерних компонентов. Вместо этого она вернет узел со ссылкой на класс компонента и значением пропса. Создание компонента будет происходить "за кулисами". Функция для построения узла компонента в рендер-функции . Первый сценарий использования компонента в VDOM - это начальный рендеринг. Такое происходит, когда компонент является корневым в приложении или присутствует при первом рендеринге его родителя. Позже мы рассмотрим, что происходит, если компонент заменяет существующий элемент в VDOM. Когда дело доходит до "монтирования" компонента, необходимо рассмотреть еще два сабкейса: компонент уже инстанцирован или его еще нужно создать. Если компонент уже имеет инстанс, процесс довольно прост: мы вызываем функцию render для получения текущего представления состояния VDOM, создаем из него HTML-элемент и вызываем notifyMounted для компонента. Когда компонент все же нужно создать, необходимо выполнить еще несколько шагов. Сначала создадим инстанс компонента с помощью ключевого слова new. Он будет назначен пропсу instance VDOM. Таким образом, он сохраняется для использования при следующем рендеринге, поэтому нам не придется заново создавать стейтфул-компоненты. После этого мы инициализируем пропс компонента. Это вернет первоначальную VDOM компонента, которую можно отрендерить в DOM. Наконец, вызываем notifyMounted. Дополнительный код, имплементирующий данную логику для функции renderElement, показан ниже. Монтаж компонента в функции рендеринга. Чтобы это действительно работало, мы также должны имплементировать различные методы в классе компонента. Первый метод - initProps. Он отвечает за инициализацию props и выполнение первого render. Он сохранит и вернет результирующее дерево VDOM вызывающей стороне. Затем вызывающий будет отвечать за размещение его в DOM. Инициализация пропсов в компоненте. Другой метод, который нам нужен для завершения процесса монтирования, - это notifyMounted. Это обратный вызов (callback), который будет вызван render (потенциально через applyUpdate) в тот момент, когда мы создадим HTML-элемент для исходного дерева VDOM. Этот метод также вызовет componentDidMount, хук, который компоненты могут имплементировать, чтобы сделать что-нибудь после того, как произойдет рендеринг в DOM. Для этого используется setTimeout, чтобы гарантировать, что хук будет вызван после того, как текущая функция завершит монтаж компонента, а не во время него. Коллбэк для рендеринга и хук componentDidUpdate. Теперь, когда можно встраивать компоненты в DOM, займемся их обновлением. Обновление происходит двумя способами: компонент может обновить свое state или получить новые props от своего родителя. В первом случае именно компонент отвечает за выполнение обновления в DOM, в последнем - он вернет diff своему родителю. В обоих случаях процесс схож: мы рендерим компонент, создаем diff с этим новым деревом и сохраненным существующим, обновляем currentRootNode новым деревом VDOM и возвращаем diff. Реализация показана ниже. Метод getUpdateDiff будет использоваться как setState и setProps и выполнит тяжелую работу по управлению реактивной областью компонента. Именно здесь планируется вызов componentDidUpdate для выполнения, после того как обновление будет завершено. Генерирование обновления между текущим состоянием компонента и предыдущим результатом метода рендеринга. Здесь видно  callback для операций replace. Это необходимо для того, чтобы убедиться, что свойство mountedElement нашего компонента продолжает указывать на правильный HTML-элемент в случае замены корневого элемента. Для этого нам понадобится несколько дополнений к VDOM и рендерингу: Добавление обратных вызовов для операции replace и функции applyUpdate. Когда компонент обновляется с помощью setState, нам нужно: установить свойство state; получить diff с помощью getUpdateDiff; применить этот diff к mountedElement. Кроме того, я добавил if-оператор, чтобы при попытке обновить не смонтированный компонент выкидывалась ошибка. Или можно обновить состояние и вернуться, тогда новое состояние будет использовано во время первого рендеринга в initProps. Можно распознать это как предупреждение от React. Поскольку мы уже реализовали всю логику обновления компонентов, этот метод стал довольно коротким и простым: Метод setState для наших компонентов. Другим способом обновления компонента является метод setProps. Он очень похож на setState. Его полное определение найдете ниже. Здесь мы также выкидываем ошибку, если компонент еще не смонтирован. Одно отличие от классических хуков жизненного цикла React в том, что мы позволяем componentWillRecieveProps возвращать новое состояние, которое будет использоваться до того, как мы заново отрендерим компонент с новыми пропсами. Метод, который родитель будет использовать для обновления пропсов компонента. В отличие от setState, setProps вызывается в функции diffing, когда пропсы узла компонента изменились. Чтобы осуществить это на практике, добавим поддержку компонентов в diffing. В процессе работы с компонентом могут произойти 3 вещи: мы обновляем существующий компонент, демонтируем его или монтируем новый. Сценарий, который мы будем реализовывать в первую очередь, - это обновление существующего компонента. Первым шагом будет копирование существующего инстанса из oldNode в newNode. После этого мы проверяем, изменились ли props, если да, то устанавливаем props компонента. Полученный в результате diff и есть тот diff, который мы непосредственно возвращаем из нашей функции. В случае, если props не изменился, мы возвращаем операцию skip . Обновление props для существующего компонента внутри diffing-кода. Второй сценарий, который мы реализуем, - это замена любого узла компонентом. Это весьма похоже на уже рассмотренный нами монтаж, но только теперь он выполняется внутри функции diffing для генерации операции replace. Примечательно то, что мы используем callback операции replace, чтобы убедиться, что notifyMounted все еще вызывается внутри функции рендеринга. Монтаж компонента внутри diffing. Последний сценарий - это замена компонента на другой. Это приводит к "демонтажу" компонента. Должно произойти несколько вещей: мы вызываем unmount  на компонент, компонент вызывает хук componentWillUnmount, компонент обнуляет свою ссылку на DOM и мы удаляем HTML-элемент из DOM. Обновление diffing-кода для демонтажа компонентов, которые будут удалены. Последний метод, который необходимо реализовать в нашем компоненте, - это unmount. Этот метод вызывает хук componentWillUnmount, на этот раз без setTimeout, потому что хук запускается до начала фактического демонтажа. Далее мы устанавливаем для mountedElement значение null. Это делается по двум причинам: любые обновления этого компонента теперь будут вызывать ошибку, и обеспечивается освобождение HTML-элемента для сборки мусора. Демонтаж компонента. На этом код нашей библиотеки реактивного рендеринга завершен. Используя все имеющиеся у нас части, можно создавать настоящие современные одностраничные приложения. Я покажу пример приложения, созданного с помощью библиотеки из этой статьи. Как и в любом другом примере приложения, это, конечно, список задач (todo-list). Первый компонент этого приложения показывает вам стандартную форму контроллера. Второй компонент использует первый в своей функции рендеринга и рендерит список со всеми добавленными элементами. Компонент управляемой формы с нашей библиотекой реактивного рендеринга. Простое todo-приложение с нашей библиотекой реактивного рендеринга. За исключением отсутствующей поддержки JSX, этот код знаком тем, кто работал с React. Наша библиотека поддерживает большинство основных функций React и способна работать с небольшими приложениями. Надеюсь, вам удалось понять, как работает реактивный рендеринг под капотом. Полный исходный код можно найти в этом репозитории. React-hooks появились в React с версии 16.8, сегодня они используются уже повсеместно. Приглашаем всех заинтересованных на интенсив, на котором разберемся, как работать с React-hooks, создадим компонент с использованием hooks, а также научимся делать кастомные hooks. Поработаем с react-testing-library и научимся тестировать компоненты и кастомные hooks. Регистрация по ссылке.  Переводчик, редактор