Создание собственного React с нуля

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Демистификация 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 из состояния одного синтаксического дерева в другое.

Связь между VDOM-деревьями, diffing-алгоритмом и утилитой сравнения (diff) 
Связь между VDOM-деревьями, diffing-алгоритмом и утилитой сравнения (diff) 

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 элемента: предполжим, что если ключи совпадают, то и элементы одинаковы (но могут быть изменены). С помощью ключей определим, что первый элемент в текущем дереве соответствует третьему в новом дереве, потому что добавилось два элемента впереди. Без ключей это практически невозможно узнать.

Пример с diff-операциями между двумя коллекциями элементов VDOM
Пример с diff-операциями между двумя коллекциями элементов VDOM

Сделаем немного упрощенную имплементацию, не поддерживающую переупорядочивание ключей (в случае переупорядочивания она будет выполнять 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. Можно поддерживать сортировку элементов по ключам, что важно для некоторых приложений.

Пример пересортированных ключей. B будет удален и вставлен. Если B стейтфул-компонент, то B возвращается в исходное состояние 
Пример пересортированных ключей. B будет удален и вставлен. Если B стейтфул-компонент, то B возвращается в исходное состояние 

4. Соединение виртуальной и реальной DOM

Теперь есть готовая виртуальная DOM, пора соединить ее с реальной. Для этого понадобятся две отдельные части: функция, которая берет VDOM и рендерит ее в DOM, и функция, которая принимает diff и применяет его к элементу в DOM.

Связь между diff, элементом HTML и функцией apply 
Связь между diff, элементом HTML и функцией apply 

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 для компонента.

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

Когда компонент все же нужно создать, необходимо выполнить еще несколько шагов. Сначала создадим инстанс компонента с помощью ключевого слова 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 вещи: мы обновляем существующий компонент, демонтируем его или монтируем новый.

Обзор того, что происходит при обновлении компонента с дочерним. Родитель не делает diff VDOM дочернего компонента, но при этом несет ответственность за применение diff'ов, сгенерированных дочерним компонентом.
Обзор того, что происходит при обновлении компонента с дочерним. Родитель не делает diff VDOM дочернего компонента, но при этом несет ответственность за применение diff'ов, сгенерированных дочерним компонентом.

Сценарий, который мы будем реализовывать в первую очередь, - это обновление существующего компонента. Первым шагом будет копирование существующего инстанса из 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. Регистрация по ссылке.

Источник: https://habr.com/ru/company/otus/blog/653861/


Интересные статьи

Интересные статьи

React Router — это не то же самое, что маршрутизатор, направляющий сетевые данные — к счастью! Однако между сетевым маршрутизатором и React Router есть много общего. React Router помогает нам направля...
Однажды, в понедельник, мне пришла в голову мысль — "а покопаюсь ка я в новом ядре" (новым относительно, но об этом позже). Мысль не появилась на ровном месте, а предпосылками для нее стали: ...
В статье описаны необходимые параметры сервера для оптимальной работы сайта на платформе 1С-Битрикс.
Как выдумаете, сложно ли написать на Python собственного чатбота, способного поддержать беседу? Оказалось, очень легко, если найти хороший набор данных. Причём это можно сделать даже без нейросет...
Всем привет! На повестке дня интересная тема — будем создавать с нуля собственную нейронную сеть на Python. В ее основе обойдемся без сложных библиотек (TensorFlow и Keras). Основное, о че...