Демистификация React путем создания собственных компонентов, включая виртуальный DOM, стейтфул компоненты и хуки жизненного цикла
В последнее время видны масштабные изменения в способах разработки веб-приложений. Если раньше интерфейс создавался на сервере, а на стороне клиента выполнялись лишь незначительные сценарии, то в наши дни стандартом является использование какой-либо из различных библиотек реактивного рендеринга для создания сложных стейтфул клиентских приложений.
Хотя многие разработчики успешно применяют такие библиотеки как React или Vue, понимание их точной внутренней работы не слишком широко изучено. В этой статье я расскажу о создании собственной библиотеки реактивного рендеринга, и разъясню, что происходит под капотом.
Но прежде чем мы начнем, небольшое предупреждение. Хотя наша библиотека вполне рабочая (см. примеры приложений в конце), это, прежде всего, демонстрационный инструмент, а не легковесная альтернатива React. Весь проект можно найти на GitHub.
1. Архитектура библиотеки реактивного рендеринга
Главное в любой библиотеке реактивного рендеринга - это компоненты. Работа компонента заключается в управлении так называемым "реактивным пространством". Компонент содержит переменную ("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. В заключение я покажу вам библиотеку в действии с помощью реальных примеров приложений.
2. Виртуальная DOM
По своей сути виртуальная DOM состоит из двух структур данных: дерева синтаксиса HTML и diff-дерева. Дерево HTML представляет состояние HTML в JavaScript-объекте . Это возвращаемый тип метода рендеринга компонентов и то, с чем будет взаимодействовать разработчик, использующий библиотеку. Diff-дерево описывает, какие шаги необходимо предпринять для обновления элемента HTML из состояния одного синтаксического дерева в другое.
2.1 Дерево синтаксиса HTML
Эта структура данных будет хранить состояние нашей виртуальной DOM. Пока мы будем поддерживать только обычные элементы и текстовые узлы. Позже эта структура расширится, для того чтобы использовать стейтфул-компоненты внутри дерева. Определение типа приведено ниже. Свойство key будет использоваться при сравнении дочерних элементов, те, кто знаком с React, уже поняли это.
export type VDOMAttributes = { [_: string]: string | number | boolean | Function }
export interface VDOMElement {
kind: 'element'
tagname: string
childeren?: VDomNode[]
props?: VDOMAttributes
key: string | number
}
export interface VDOMText {
kind: 'text',
value: string
key: string | number
}
export type VDomNode =
| VDOMText
| VDOMElement
Определение типа VDOM.
Наша библиотека не будет поддерживать JSX, и поэтому нам нужны некоторые функции-хелперы для создании элементов VDOM удобным способом. Они будут использоваться в методах рендеринга наших компонентов и являются аналогом вызовов React.createElement, с которыми вы, возможно, сталкивались в своих пакетах JavaScript.
export const createElement = (tagname: string, props: VDOMAttributes & { key: string }, ...childeren: VDomNode[]): VDOMElement => {
const key = props.key
delete props.key
return ({ kind: 'element',
tagname, props,
childeren, key
})
}
export const createText = (value: string | number | boolean, key: string = '') : VDOMText => ({
key, kind: 'text', value: value.toString()
})
Функции для создания элементов VDOM.
2.2 Структура данных diff
Начнем с механизма сравнения (diffing), который работает с одним элементом, а затем расширим его за счет поддержки дочерних. Сначала создадим тип со всеми операциями. Операция - это мутация DOM, которая будет произведена с одним элементом HTML. Основными операциями являются update и replace. Также есть skip, указывающая на отказ от изменений.
type AttributesUpdater = {
set: VDOMAttributes
remove: string[]
}
interface UpdateOperation {
kind: 'update',
attributes: AttributesUpdater,
childeren: ChildUpdater[]
}
interface ReplaceOperation {
kind: 'replace',
newNode: VDomNode
}
interface SkipOperation {
kind: 'skip'
}
export type VDomNodeUpdater =
| UpdateOperation
| ReplaceOperation
| SkipOperation
Типы для операций, применяемых к одному элементу.
Операция update фактически представляет собой diff-дерево и состоит из небольших шагов, которые необходимо предпринять для обработки результата большинства вызовов setState. Также здесь содержится ссылка на обновления для дочерних элементов. Операции для дочерних элементов немного отличаются, потому что они будут применяться к коллекции элементов. Помимо тех операций, которые мы уже определили, они также включают insert и remove:
interface RemoveOperation {
kind: 'remove'
}
interface InsertOperation {
kind: 'insert',
node: VDomNode
}
export type ChildUpdater =
| UpdateOperation
| ReplaceOperation
| RemoveOperation
| SkipOperation
| InsertOperation
Типы для операций, которые могут быть применены к дочерним элементам.
3. Создание diff'ов
Теперь нам удалось построить виртуальную DOM и diff, далее создадим то, что делает VDOM стоящим усилий: diffing-алгоритм . Это позволит нашим компонентам эффективно переходить из одного состояния в другое, это важно, когда речь о плавной работе реактивного приложения. Медленный или даже прерывистый diffing-алгоритм полностью уничтожает пользовательский опыт любого приложения, независимо от того, насколько тщательно проработаны его компоненты.
Мы разложим данный фрагмент на две части: сначала diffing отдельного элемента, потом diffing коллекции (дочерних) элементов, так же, как это делалось для определений типов.
3.1 Создание diff'а элемента
При diffing'е VDOM мы имеем дело с двумя различными вещами, которые могут обновляться: текстовые узлы и HTML-элементы. Начнем с текстовых узлов, т.к. они довольно просты. Если оба элемента являются текстовыми узлами и их значение не изменилось, мы возвращаем операцию skip. В любом другом случае когда есть текстовый узел, нам придется заменить старый элемент новым:
export const createDiff = (oldNode: VDomNode, newNode: VDomNode): VDomNodeUpdater => {
// skip over text nodes with the same text
if (oldNode.kind == 'text' && newNode.kind == 'text' && oldNode.value == newNode.value) {
return skip()
}
// If a textnode is updated we need to replace it completly
if (oldNode.kind == 'text' || newNode.kind == 'text') {
return replace(newNode)
}
// rest of the diffing
}
Diffing текстовых узлов.
После выполнения проверок мы убедились, что оба узла являются обычными элементами. Но остался один случай, где необходимо полностью заменить узел: когда tagname старого и нового элемента отличаются. Это следующее,что мы добавим в нашу функцию:
export const createDiff = (oldNode: VDomNode, newNode: VDomNode): VDomNodeUpdater => {
// diff text nodes
// If the tagname of a node is changed we have to replace it completly
if (oldNode.tagname != oldNode.tagname) {
return replace(newNode)
}
// diff elements
}
Обновленное tagname
означает, что весь компонент должен быть смонтирован заново.
Два имеющихся у нас элемента имеют одинаковый тип и могут быть обновлены из одного состояния в другое без создания нового элемента. Для этого создадим: атрибуты, которые были удалены; атрибуты, которые должны быть установлены; и обновления для дочерних элементов:
export const createDiff = (oldNode: VDomNode, newNode: VDomNode): VDomNodeUpdater => {
// diff text nodes and replaced elements
// get the updated and replaced attributes
const attUpdater: AttributesUpdater = {
remove: Object.keys(oldNode.props || {})
.filter(att => Object.keys(newNode).indexOf(att) == -1),
set: Object.keys(newNode.props || {})
.filter(att => oldNode.props[att] != newNode.props[att])
.reduce((upd, att) => ({ ...upd, [att]: newNode.props[att] }), {})
}
const childsUpdater: ChildUpdater[] = childsDiff((oldNode.childeren || []), (newNode.childeren || []))
return update(attUpdater, childsUpdater)
}
Генерация всех данных для обновления.
Мы завершили логику diffing'а для отдельных элементов. Как и в случае с деревом синтаксиса HTML, позже добавим поддержку diffing'а и монтирования компонентов. Переходим к логике diffing'а для дочерних элементов.
3.2 Создание diff для дочерних элементов
Последней частью diffing-алгоритма является diffing для дочерних элементов. Это, самая сложная часть VDOM, потому что существует множество вариантов того, что может произойти с дочерними элементами. Можно вставить новый элемент в начало, в середину или в конец; удалить, обновить или пересортировать существующие элементы. Здесь в игру вступает key элемента: предполжим, что если ключи совпадают, то и элементы одинаковы (но могут быть изменены). С помощью ключей определим, что первый элемент в текущем дереве соответствует третьему в новом дереве, потому что добавилось два элемента впереди. Без ключей это практически невозможно узнать.
Сделаем немного упрощенную имплементацию, не поддерживающую переупорядочивание ключей (в случае переупорядочивания она будет выполнять remove и insert дочерних элементов, которые находятся не по порядку). Это экономит много кода. Начнем функцию с создания двух стеков из оставшихся необработанными дочерних элементов для старого и нового дерева:
const childsDiff = (oldChilds: VDomNode[], newChilds: VDomNode[]): ChildUpdater[] => {
const remainingOldChilds: [string | number, VDomNode][] = oldChilds.map(c => [c.key, c])
const remainingNewChilds: [string | number, VDomNode][] = newChilds.map(c => [c.key, c])
const operations: ChildUpdater[] = []
return operations
}
Базовая структура для функции childsDiff.
В строке 6 добавим логику, которая обрабатывает все дочерние элементы и заполняет operations массив операциями для преобразования старого дерева в новое. Этот алгоритм будет сосредоточен на поиске элементов (= ключей), которые присутствуют как в старой, так и в новой VDOM. Для них будем генерировать обновления, а все остальные элементы будут удалены или вставлены. Ниже показана эта часть логики. Пока в стеках остаются обновленные дочерние элементы, мы продолжаем генерировать для них update операции.
const childsDiff = (oldChilds: VDomNode[], newChilds: VDomNode[]): ChildUpdater[] => {
const remainingOldChilds: [string | number, VDomNode][] = oldChilds.map(c => [c.key, c])
const remainingNewChilds: [string | number, VDomNode][] = newChilds.map(c => [c.key, c])
const operations: ChildUpdater[] = []
// find the first element that got updated
let [ nextUpdateKey ] = remainingOldChilds.find(k => remainingNewChilds.map(k => k[0]).indexOf(k[0]) != -1) || [null]
while(nextUpdateKey) {
// process other children here
// create the update
operations.push(createDiff(remainingOldChilds.shift()[1], remainingNewChilds.shift()[1]))
// find the next update
; [ nextUpdateKey ] = remainingOldChilds.find(k => remainingNewChilds.map(k => k[0]).indexOf(k[0]) != -1) || [null]
}
// process the remaining children here
return operations
}
При diffing'е дочерних элементов мы заботимся о генерации операций обновления для новых компонентов, чтобы предотвратить перемонтирование существующих.
В этой функции важно отметить, что nextUpdatedKey
не обязательно должен быть первым из элементов как в remainingOldChilds
, так и в remainingNewChilds
. Перед ними в стеках могут быть элементы, которые были удалены или вставлены и потому не содержатся одновременно в обеих коллекциях. Сначала надо удалить все старые элементы и вставить новые, прежде чем мы сможем создать обновление.
// create stacks and the first update
while(nextUpdateKey) {
// first remove all old childs before the update
removeUntilkey(operations, remainingOldChilds, nextUpdateKey)
// then insert all new childs before the update
insertUntilKey(operations, remainingNewChilds, nextUpdateKey)
// generate update
}
const removeUntilkey = (operations: ChildUpdater[], elems: [string | number, VDomNode][], key: string | number) => {
while(elems[0] && elems[0][0] != key) {
operations.push(remove())
elems.shift()
}
}
const insertUntilKey = (operations: ChildUpdater[], elems: [string | number, VDomNode][], key: string | number) => {
while(elems[0] && elems[0][0] != key) {
operations.push(insert(elems.shift()[1]))
}
}
Старые элементы надо удалить, а новые - вставить, прежде чем добавлять операцию обновления к результату.
После обработки всех обновленных узлов все еще могут оставаться элементы. Это происходит, когда в нижней части дерева добавляются или удаляются дочерние элементы. Смотрите ниже:
while(nextUpdateKey) {
// generate updates here
}
// remove all remaing old childs after the last update
removeUntilkey(operations, remainingOldChilds, undefined)
// insert all remaing new childs after the last update
insertUntilKey(operations, remainingNewChilds, undefined)
return operations
}
Обработаем оставшиеся операции remove
и insert
после завершения всех обновлений.
Теперь есть полноценный diffing-алгоритм, способный генерировать ключевые diffs сложных деревьев и позволит получить приемлемый эффективный diff. Возможны оптимизации, каждая пара remove и insert выполняется с помощью одной replace. Можно поддерживать сортировку элементов по ключам, что важно для некоторых приложений.
4. Соединение виртуальной и реальной DOM
Теперь есть готовая виртуальная DOM, пора соединить ее с реальной. Для этого понадобятся две отдельные части: функция, которая берет VDOM и рендерит ее в DOM, и функция, которая принимает diff и применяет его к элементу в DOM.
4.1 Рендеринг виртуальной DOM
Чтобы начать использовать новый виртуальный DOM, - надо осуществить его рендеринг в реальный. Это будет рендеринг первой версии компонента. После этого функция applyDiff станет отвечать за соединение виртуальной и реальной DOM друг с другом. По сути, она сводится к вызову document.createElement для каждого элемента в дереве:
const renderElement = (rootNode: VDomNode): HTMLElement | Text => {
if (rootNode.kind == 'text') {
return document.createTextNode(rootNode.value)
}
const elem = document.createElement(rootNode.tagname)
for (const att in (rootNode.props || {})) {
(elem as any)[att] = rootNode.props[att]
}
(rootNode.childeren || []).forEach(child =>
elem.appendChild(renderElement(child))
)
return elem
}
Преобразование VDOM в HTML-элемент.
Мы присваиваем каждое свойство непосредственно элементу. Конечно, в React эта логика реализована не так. Правильной будет установка атрибутов с помощью setAttribute и использование синтетической системы для связи событий внутри DOM и VDOM. Здесь я сократил часть статьи, чтобы не увеличивать ее в размерах...
4.2 Применение diff к элементу HTML
Другая половина рендеринга - это применение diff. Для этого функция берет HTMLElement и diff и применяет его к элементу. Здесь вы видите те же операции, которые мы определили ранее. Для краткости я опустил все валидации, которые позволяет TypeScript, единственное, что мы проверяем, это то, что мы присваиваем атрибуты действительному элементу, а не текстовому узлу.
export const applyUpdate = (elem: HTMLElement | Text, diff: VDomNodeUpdater): HTMLElement | Text => {
if (diff.kind == 'skip') return elem
if (diff.kind == 'replace') {
const newElem = renderElement(diff.newNode)
elem.replaceWith(newElem)
return newElem
}
if('wholeText' in elem) throw new Error('invalid update for Text node')
for (const att in diff.attributes.remove) {
elem.removeAttribute(att)
}
for (const att in diff.attributes.set) {
(elem as any)[att] = diff.attributes.set[att]
}
applyChildrenDiff(elem, diff.childeren)
return elem
}
Применение diff между двумя элементами VDOM к элементу DOM.
В applyChildrenDiff
мы перебираем операции и применяем их к текущему дочернему элементу. Основная сложность здесь связана с offset, который используется для определения того, к какому элементу в DOM относится текущая операция. Важно помнить, что операций может быть гораздо больше, чем дочерних элементов.
const applyChildrenDiff = (elem: HTMLElement, operations: ChildUpdater[]) => {
let offset = 0
for (let i = 0; i < operations.length; i++) {
const childUpdater = operations[i]
if (childUpdater.kind == 'skip') continue
if (childUpdater.kind == 'insert') {
if (elem.childNodes[i + offset - 1]) elem.childNodes[i + offset - 1].after(renderElement(childUpdater.node))
else elem.appendChild(renderElement(childUpdater.node))
continue
}
const childElem = elem.childNodes[i + offset]
if (childUpdater.kind == 'remove') {
childElem.remove()
offset -= 1
continue
}
applyUpdate(childElem, childUpdater)
}
}
Применение набора операций к дочерним элементам HTML-элемента.
С помощью этой функции мы завершили создание VDOM и всего, что с ней связано. Следующим этапом нашего исследования будет добавление стейтфул-компонентов и объединение их в настоящее реактивное приложение.
5. Компоненты и реактивные области применения
Знакомые с React, знают что компоненты - это то, что содержит состояние. Хотя с точки зрения пользователей это действительно важная часть компонентов, в исходном коде реактивного фреймворка основная задача компонента - управлять реактивной областью. Что такое реактивная область? Это часть дерева VDOM, которая отвечает за свои собственные обновления. То есть компонент имеет ссылку на html-элемент в DOM, где он установлен, и создает diffs для этого элемента на основе состояния и пропсов. Даже если компонент принадлежит родителю, он все равно будет отвечать за свой собственный рендеринг и diffing.
5.1 Создание класса компонента
Мы начнем с создания базового класса для всех компонентов. Этот класс будет хранить текущий пропс, состояние, корневой элемент и VDOM. Базовый набросок такого класса показан ниже. Многие атрибуты и методы знакомы по библиотекам типа React, например, props, state, setState, componentDidMount и render.
Существуют методы, которые выделены только для внутреннего использования. setProps будет вызываться во время обновления родителя, чтобы предоставить этому компоненту новые пропсы. Эта функция возвращает разницу между его VDOM и новой VDOM родителя. initProps вызывается во время процесса монтирования и возвращает начальную VDOM, которая будет рендериться в реальной DOM. После этого будет вызван notifyMounted, и компонент будет полностью смонтирован в DOM. Кроме того, у нас есть метод unmount, используемый при удалении компонента из DOM.
export abstract class Component<P, S> {
protected props: P
protected state: S
private currentRootNode: VDomNode
private mountedElement: HTMLElement | Text
protected setState(updater: (s:S) => S) { }
// called when the mounted element recieves new props
public setProps(props: P): VDomNodeUpdater { }
// called when mounting the element to generate the first VDOM state
public initProps(props: P): VDomNode { }
// this gets called when the component is mounted in the real DOM
public notifyMounted(elem: HTMLElement | Text) { }
// this gets called when the component will be unmounted in the real DOM
public unmount() { }
// hooks
public componentDidMount() {}
public componentWillRecieveProps(props: P, state: S): S { return state }
public componentDidUpdate() { }
public componentWillUnmount() { }
// the render function each component has to implement
public abstract render(): VDomNode
}
Набросок для класса Component.
Еще вы видите пустые хук-методы, которые отдельные компоненты могут переопределять. Подключим их к внутренним компонентам нашей библиотеки по ходу работы.
5.2 Монтаж компонентов
Первым шагом к использованию компонентов внутри функции рендеринга является расширение VDOM с помощью типа узла для компонентов. Это позволит нам использовать компонент внутри функции рендеринга другого компонента и, таким образом, создать настоящее дерево с множеством стейтфул-компонентов. Внутри функции разработчик будет указывать, какой компонент должен рендериться и с какими пропсами. Определение типа для такого узла показано ниже.
export interface VDOMComponent {
kind: 'component'
instance?: Component<any, any>
props: object
component: { new(): Component<any, any> }
key: string | number
}
export type VDomNode =
| VDOMText
| VDOMElement
| VDOMComponent
Расширение для определения VDOM для компонентов.
Свойство instance предназначено только для внутреннего использования. В нем будет храниться реальный экземпляр компонента, с состоянием и т.д. внутри. В функции diffing мы обязательно скопируем существующие инстансы компонентов из старого дерева в новое, чтобы они не потерялись между ними. Важно отметить, что функция рендеринга компонента не создаст никаких инстансов дочерних компонентов. Вместо этого она вернет узел со ссылкой на класс компонента и значением пропса. Создание компонента будет происходить "за кулисами".
export const createComponent = <P extends object>(component: { new(): Component<P, any> }, props: P & { key: string }): VDOMComponent => {
const key = props.key
delete props.key
return ({
component, props, key, kind: 'component'
})
}
Функция для построения узла компонента в рендер-функции .
Первый сценарий использования компонента в VDOM - это начальный рендеринг. Такое происходит, когда компонент является корневым в приложении или присутствует при первом рендеринге его родителя. Позже мы рассмотрим, что происходит, если компонент заменяет существующий элемент в VDOM.
Когда дело доходит до "монтирования" компонента, необходимо рассмотреть еще два сабкейса: компонент уже инстанцирован или его еще нужно создать. Если компонент уже имеет инстанс, процесс довольно прост: мы вызываем функцию render для получения текущего представления состояния VDOM, создаем из него HTML-элемент и вызываем notifyMounted для компонента.
Когда компонент все же нужно создать, необходимо выполнить еще несколько шагов. Сначала создадим инстанс компонента с помощью ключевого слова new. Он будет назначен пропсу instance VDOM. Таким образом, он сохраняется для использования при следующем рендеринге, поэтому нам не придется заново создавать стейтфул-компоненты. После этого мы инициализируем пропс компонента. Это вернет первоначальную VDOM компонента, которую можно отрендерить в DOM. Наконец, вызываем notifyMounted
. Дополнительный код, имплементирующий данную логику для функции renderElement
, показан ниже.
const renderElement = (rootNode: VDomNode): HTMLElement | Text => {
// render text nodes
if(rootNode.kind == 'component') {
if(rootNode.instance) {
const elem = renderElement(rootNode.instance.render())
rootNode.instance.notifyMounted(elem as HTMLElement)
return elem
}
rootNode.instance = new rootNode.component()
const elem = renderElement( rootNode.instance.initProps(rootNode.props))
rootNode.instance.notifyMounted(elem as HTMLElement)
return elem
}
// render elements
}
Монтаж компонента в функции рендеринга.
Чтобы это действительно работало, мы также должны имплементировать различные методы в классе компонента. Первый метод - initProps. Он отвечает за инициализацию props и выполнение первого render. Он сохранит и вернет результирующее дерево VDOM вызывающей стороне. Затем вызывающий будет отвечать за размещение его в DOM.
Инициализация пропсов в компоненте.
Другой метод, который нам нужен для завершения процесса монтирования, - это notifyMounted
. Это обратный вызов (callback
), который будет вызван render (потенциально через applyUpdate
) в тот момент, когда мы создадим HTML-элемент для исходного дерева VDOM. Этот метод также вызовет componentDidMount
, хук, который компоненты могут имплементировать, чтобы сделать что-нибудь после того, как произойдет рендеринг в DOM. Для этого используется setTimeout
, чтобы гарантировать, что хук будет вызван после того, как текущая функция завершит монтаж компонента, а не во время него.
export abstract class Component<P, S> {
public notifyMounted(elem: HTMLElement) {
this.mountedElement = elem
setTimeout(() => this.componentDidMount())
}
public componentDidMount() {}
}
Коллбэк для рендеринга и хук componentDidUpdate
.
5.3 Обновление компонента
Теперь, когда можно встраивать компоненты в DOM, займемся их обновлением. Обновление происходит двумя способами: компонент может обновить свое state
или получить новые props от своего родителя. В первом случае именно компонент отвечает за выполнение обновления в DOM, в последнем - он вернет diff
своему родителю.
В обоих случаях процесс схож: мы рендерим компонент, создаем diff с этим новым деревом и сохраненным существующим, обновляем currentRootNode
новым деревом VDOM и возвращаем diff. Реализация показана ниже. Метод getUpdateDiff
будет использоваться как setState
и setProps
и выполнит тяжелую работу по управлению реактивной областью компонента. Именно здесь планируется вызов componentDidUpdate
для выполнения, после того как обновление будет завершено.
export abstract class Component<P, S> {
private getUpdateDiff() : VDomNodeUpdater {
const newRootNode = this.render()
const diff = createDiff(this.currentRootNode, newRootNode)
if(diff.kind == 'replace') elem => this.mountedElement = elem
this.currentRootNode = newRootNode
setTimeout(() => this.componentDidUpdate())
return diff
}
}
Генерирование обновления между текущим состоянием компонента и предыдущим результатом метода рендеринга.
Здесь видно callback
для операций replace
. Это необходимо для того, чтобы убедиться, что свойство mountedElement
нашего компонента продолжает указывать на правильный HTML-элемент в случае замены корневого элемента. Для этого нам понадобится несколько дополнений к VDOM и рендерингу:
export const applyUpdate = (elem: HTMLElement | Text, diff: VDomNodeUpdater): HTMLElement | Text => {
if (diff.kind == 'skip') return elem
if (diff.kind == 'replace') {
const newElem = renderElement(diff.newNode)
elem.replaceWith(newElem)
if(diff.callback) diff.callback(newElem)
return newElem
}
// rest of the function
}
interface ReplaceOperation {
kind: 'replace',
newNode: VDomNode
callback?: (elem: HTMLElement | Text) => void
}
Добавление обратных вызовов для операции replace
и функции applyUpdate
.
5.4 Обновление компонентов с помощью setState
Когда компонент обновляется с помощью setState
, нам нужно: установить свойство state; получить diff с помощью getUpdateDiff
; применить этот diff к mountedElement
. Кроме того, я добавил if-оператор, чтобы при попытке обновить не смонтированный компонент выкидывалась ошибка. Или можно обновить состояние и вернуться, тогда новое состояние будет использовано во время первого рендеринга в initProps
. Можно распознать это как предупреждение от React. Поскольку мы уже реализовали всю логику обновления компонентов, этот метод стал довольно коротким и простым:
export abstract class Component<P, S> {
protected setState(updater: (s:S) => S) {
if(this.mountedElement == undefined) throw new Error("you are updating an unmounted component")
this.state = updater(this.state)
applyUpdate(this.mountedElement, this.getUpdateDiff())
}
}
Метод setState
для наших компонентов.
5.5 Обновление компонентов с помощью setProps
Другим способом обновления компонента является метод setProps
. Он очень похож на setState
. Его полное определение найдете ниже. Здесь мы также выкидываем ошибку, если компонент еще не смонтирован. Одно отличие от классических хуков жизненного цикла React в том, что мы позволяем componentWillRecieveProps
возвращать новое состояние, которое будет использоваться до того, как мы заново отрендерим компонент с новыми пропсами.
export abstract class Component<P, S> {
public setProps(props: P): VDomNodeUpdater {
if(this.mountedElement == null)
throw new Error("You are setting the props of an inmounted component")
this.state = this.componentWillRecieveProps(props, this.state)
this.props = props
return this.getUpdateDiff()
}
}
Метод, который родитель будет использовать для обновления пропсов компонента.
В отличие от setState
, setProps
вызывается в функции diffing, когда пропсы узла компонента изменились. Чтобы осуществить это на практике, добавим поддержку компонентов в diffing. В процессе работы с компонентом могут произойти 3 вещи: мы обновляем существующий компонент, демонтируем его или монтируем новый.
Сценарий, который мы будем реализовывать в первую очередь, - это обновление существующего компонента. Первым шагом будет копирование существующего инстанса из oldNode
в newNode
. После этого мы проверяем, изменились ли props, если да, то устанавливаем props компонента. Полученный в результате diff и есть тот diff, который мы непосредственно возвращаем из нашей функции. В случае, если props не изменился, мы возвращаем операцию skip .
export const createDiff = (oldNode: VDomNode, newNode: VDomNode): VDomNodeUpdater => {
// diff text nodes
if(oldNode.kind == 'component' && newNode.kind == 'component' && oldNode.component == newNode.component && oldNode.instance) {
newNode.instance = oldNode.instance
if(isEqual(oldNode.props, newNode.props)) return skip()
return newNode.instance.setProps(newNode.props)
}
// diff elements
}
Обновление props для существующего компонента внутри diffing-кода.
Второй сценарий, который мы реализуем, - это замена любого узла компонентом. Это весьма похоже на уже рассмотренный нами монтаж, но только теперь он выполняется внутри функции diffing для генерации операции replace
. Примечательно то, что мы используем callback
операции replace
, чтобы убедиться, что notifyMounted
все еще вызывается внутри функции рендеринга.
export const createDiff = (oldNode: VDomNode, newNode: VDomNode): VDomNodeUpdater => {
// updating components
if(newNode.kind == 'component') {
newNode.instance = new newNode.component()
return {
kind: 'replace',
newNode: newNode.instance.initProps(newNode.props),
callback: e => newNode.instance.notifyMounted(e)
}
}
// diffing elements
}
Монтаж компонента внутри diffing.
5.6 Демонтаж компонентов
Последний сценарий - это замена компонента на другой. Это приводит к "демонтажу" компонента. Должно произойти несколько вещей: мы вызываем unmount
на компонент, компонент вызывает хук componentWillUnmount
, компонент обнуляет свою ссылку на DOM и мы удаляем HTML-элемент из DOM.
const removeUntilkey = (operations: ChildUpdater[], elems: [string | number, VDomNode][], key: string | number) => {
while(elems[0] && elems[0][0] != key) {
if(elems[0][1].kind == 'component') {
elems[0][1].instance.unmount()
elems[0][1].instance = null
}
operations.push(remove())
elems.shift()
}
}
export const createDiff = (oldNode: VDomNode, newNode: VDomNode): VDomNodeUpdater => {
// update component props
if(oldNode.kind == 'component') {
oldNode.instance.unmount()
oldNode.instance = null
return replace(newNode)
}
// mount new components
}
Обновление diffing-кода для демонтажа компонентов, которые будут удалены.
Последний метод, который необходимо реализовать в нашем компоненте, - это unmount. Этот метод вызывает хук componentWillUnmount
, на этот раз без setTimeout
, потому что хук запускается до начала фактического демонтажа. Далее мы устанавливаем для mountedElement
значение null. Это делается по двум причинам: любые обновления этого компонента теперь будут вызывать ошибку, и обеспечивается освобождение HTML-элемента для сборки мусора.
export abstract class Component<P, S> {
public unmount() {
this.componentWillUnmount()
this.mountedElement = null
}
public componentWillUnmount() { }
}
Демонтаж компонента.
На этом код нашей библиотеки реактивного рендеринга завершен. Используя все имеющиеся у нас части, можно создавать настоящие современные одностраничные приложения.
6. Создание примера приложения
Я покажу пример приложения, созданного с помощью библиотеки из этой статьи. Как и в любом другом примере приложения, это, конечно, список задач (todo-list). Первый компонент этого приложения показывает вам стандартную форму контроллера. Второй компонент использует первый в своей функции рендеринга и рендерит список со всеми добавленными элементами.
interface NewItemFormState {
name: string
}
interface NewItemFormProps {
addItem: (name: string) => void
}
class NewItemForm extends Component<NewItemFormProps, NewItemFormState> {
state = { name: '' }
render() {
return createElement(
'form',
{ key: 'f',
onsubmit: (e: Event) => {
e.preventDefault()
this.props.addItem(this.state.name)
this.setState(() => ({ name: '' }))
}
},
createElement('label', { key: 'l-n', 'for': 'i-n' }, createText('New item')),
createElement('input',
{ key: 'i-n', id: 'i-n',
value: this.state.name,
oninput: (e: any) => this.setState(s => ({...s, name: e.target.value})) }
),
)
}
}
Компонент управляемой формы с нашей библиотекой реактивного рендеринга.
class ToDoComponent extends Component<{}, ToDoState> {
state: ToDoState = { items: [] }
toggleItem(index: number) {
this.setState(s => ({items: s.items.map((item, i) => {
if(index == i) return { ...item, done: !item.done }
return item
})}))
}
render() {
return createElement('div', { key: 'root'},
createComponent(NewItemForm, {
key: 'form',
addItem: n => this.setState(s => ({ items: s.items.concat([{name: n, done: false}])}))
}),
createElement('ul', { key: 'items' },
...this.state.items.map((item: ToDoItem, i) =>
createElement( 'li', { key: i.toString()},
createElement('button', {
key: 'btn',
onclick: () => this.toggleItem(i)
},
createText(item.done ? 'done' : '-')),
createText(item.name, 'label')
))
)
)
}
Простое todo-приложение с нашей библиотекой реактивного рендеринга.
За исключением отсутствующей поддержки JSX, этот код знаком тем, кто работал с React. Наша библиотека поддерживает большинство основных функций React и способна работать с небольшими приложениями.
7. Заключение
Надеюсь, вам удалось понять, как работает реактивный рендеринг под капотом. Полный исходный код можно найти в этом репозитории.
React-hooks появились в React с версии 16.8, сегодня они используются уже повсеместно. Приглашаем всех заинтересованных на интенсив, на котором разберемся, как работать с React-hooks, создадим компонент с использованием hooks, а также научимся делать кастомные hooks. Поработаем с react-testing-library и научимся тестировать компоненты и кастомные hooks. Регистрация по ссылке.