Как работает React: подробное руководство

image-loader.svg


Привет, друзья!

В этой статье я покажу вам, с чего начинается React.

Что это означает? Это означает, что мы разработаем мини-версию React, которая сможет выполнять следующий код:

import '../style.scss'
import MyReact from './my-react'

const buttonStyles = {
 border: 'none',
 outline: 'none',
 padding: '0.3rem 0.5rem',
 marginLeft: '0.5rem',
 backgroundImage: 'linear-gradient(yellow, orange)',
 borderRadius: '2px',
 boxShadow: '0 1px 2px rgba(0, 0, 0, 0.2)',
 cursor: 'pointer'
}

/** @jsx MyReact.createElement */
function Counter() {
 const [value, setValue] = MyReact.useState(1)
 const [count, setCount] = MyReact.useState(1)

 return (
   

Hello from MyReact!

{ setValue(Number(e.target.value)) }} />

Count: {count}

    {['React', 'from', 'scratch'].map((item) => (
  • {item}
  • ))}
) } MyReact.render(, document.getElementById('app'))

Как вы могли догадаться, наша версия будет называться MyReact.

Исходный код проекта.

Скриншот:


image-loader.svg

Песочница:

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

Основным источником вдохновения для меня послужила эта замечательная статья.

В конце статьи я покажу вам, как запустить проект, в котором используются ES6-модули и SASS с помощью Snowpack.


Введение

const element = 

Hello from MyReact!

const container = document.getElementById("root") ReactDOM.render(element, container)

Что здесь происходит?

На первой строке мы определяем React-элемент. На второй — получаем ссылку на DOM-элемент. На последней — рендерим React-элемент — помещаем его в container.

Заменим код на React обычным JavaScript.

const element = 

Hello from MyReact!

На первой строке у нас имеется элемент, определенный с помощью JSX.

JSX трансформируется в JS с помощью таких инструментов как Babel. Трансформация, обычно, включает в себя следующее: замена кода внутри тегов на вызов функции createElement, которой в качестве аргументов передаются название тега (type), свойства (props) и дочерние элементы (children). Процесс трансформации JSX в JS называется транспиляцией (transilation).

const element = React.createElement(
 "h1",
 { title: "hello" },
 "Hello from MyReact!"
)

Функция React.createElement создает объект на основе переданных ей аргументов. Не считая некоторой валидации, это все, что делает данная функция.

const element = {
 type: "h1",
 props: {
   title: "hello",
   children: "Hello from MyReact!"
 }
}

Таким образом, элемент — это объект с 2 свойствами: type и props. На самом деле, свойств больше, но нас пока интересуют только эти.

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

props — это объект, содержащий все ключи и значения атрибутов JSX. Он также содержит специальное свойство children.

В данном случае children — это строка, но, как правило, значением этого свойства является массив элементов. Вот почему элементы — это деревья (tree) с точки зрения структуры.

ReactDOM.render(element, container)

render — это то «место», где React изменяет DOM.

const node = document.createElement(element.type)
node["title"] = element.props.title

Сначала мы создаем узел (node) (во избежание путаницы я буду использовать слово «элемент» для обозначения элементов React, а слово «узел» — для обозначения элементов DOM) на основе типа (type) — в данном случае h1.

Затем мы присваиваем узлу все пропы (props). В данном случае у нас имеется только заголовок (title).

const text = document.createTextNode("")
text["nodeValue"] = element.props.children

Далее мы создаем узлы для дочерних элементов. В данном случае у нас имеется только один такой элемент — строка. Поэтому мы создаем текстовый узел.

Использование nodeValue вместо innerText позволит нам одинаково обрабатывать все элементы. Обратите внимание, что мы устанавливаем nodeValue так, как если бы строка имела props: { nodeValue: "Hello from MyReact!" }.

node.append(text)
container.append(node)

Наконец, мы добавляем textNode в h1, а h1 в container.

const element = {
 type: "h1",
 props: {
   title: "hello",
   children: "Hello from MyReact!"
 }
}

const container = document.getElementById("root")

const node = document.createElement(element.type)
node["title"] = element.props.title

const text = document.createTextNode("")
text["nodeValue"] = element.props.children

node.append(text)
container.append(node)

На выходе мы получили аналогичный код, но без React.


Функция createElement

const element = (
 

Hello from MyReact!

React from scratch

) const container = document.getElementById("root") ReactDOM.render(element, container)

Приступим к реализации функции createElement.

Если мы трансформируем JSX в JS, то получим следующее:

const element = React.createElement(
 "section",
 { id: "welcome" },
 React.createElement(
   "h1",
   { title: "hello", className: "title" },
   "Hello from MyReact!"
 ),
 React.createElement(
   "p",
   { style: "color: green;" },
   React.createElement(
     "span",
     null,
     "React"
   )
   " from scratch"
 )
)

Как мы выяснили, элемент — это объект с type и props. Следовательно, наша функция должна создавать такие объекты.

function createElement(type, props, ...children) {
 return {
   type,
   props: {
     ...props,
     children,
   }
 }
}

Мы используем операторы spread для props и rest для children (поэтому children всегда будет массивом).

createElement("section") вернет:

{
 type: "section",
 props: {
   children: []
 }
}

createElement("section", null, "hello") вернет:

{
 type: "section",
 props: {
   children: ["hello"]
 }
}

createElement("section", { title: "hello" }, "hello", "world") вернет:

{
 type: "section",
 props: {
   title: "hello",
   children: [
     "hello",
     "world"
   ]
 }
}
function createElement(type, props, ...children) {
 return {
   type,
   props: {
     ...props,
     // ! - здесь и далее так будут отмечены вносимые в код изменения
     children: children.map(child =>
       typeof child === "object"
         ? child
         : createTextElement(child)
     )
   }
 }
}

function createTextElement(nodeValue) {
 return {
   type: "TEXT_ELEMENT",
   props: {
     nodeValue,
     children: []
   }
 }
}

Массив children может содержать примитивные значения, такие как строки или числа. Поэтому для значений с типом, отличающимся от объекта, требуется специальная функция, создающая особый тип элемента: TEXT_ELEMENT.

React не оборачивает примитивы и не создает пустые массивы при отсутствии children. Мы жертвуем производительностью ради простоты кода.

const MyReact = {
 createElement
}

const element = MyReact.createElement(
 "section",
 { id: "welcome" },
 MyReact.createElement(
   "h1",
   { title: "hello", className: "title" },
   "Hello from MyReact!"
 ),
 MyReact.createElement(
   "p",
   { style: "color: green;" },
   MyReact.createElement(
     "span",
     null,
     "React"
   )
   " from scratch"
 )
)

Заменяем React на MyReact.

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

/** @jsx MyReact.createElement */
const element = (
 

Hello from MyReact!

React from scratch

)

Комментарий /** @jsx MyReact.createElement */ сообщает Babel о нашем желании использовать собственную версию createElement для создания элементов.


Функция render

ReactDOM.render(element, container)

Далее нам необходимо реализовать собственную версию функции ReactDOM.render.

Мы начнем с добавления узлов в DOM, а их обновление и удаление рассмотрим позже.

function render(element, container) {
 const node = document.createElement(element.type)
​
 container.append(node)
}

Создаем новый узел на основе типа элемента и добавляем его в контейнер.

function render(element, container) {
 const node = document.createElement(element.type)
​
 // !
 element.props.children.forEach(child =>
   render(child, node)
 )
​
 container.appendChild(node)
}

Затем мы делаем тоже самое для каждого потомка узла рекурсивно.

function render(element, container) {
 // !
 const node =
   element.type == "TEXT_ELEMENT"
     ? document.createTextNode("")
     : document.createElement(element.type)
​
 element.props.children.forEach(child =>
   render(child, node)
 )
​
 container.appendChild(node)
}

Если типом элемента является TEXT_ELEMENT, вместо обычного узла создается текстовый.

function render(element, container) {
 const node =
   element.type == "TEXT_ELEMENT"
     ? document.createTextNode("")
     : document.createElement(element.type)

 // !
 const isProperty = key => key !== "children"
 Object.keys(element.props)
   .filter(isProperty)
   .forEach(key => {
     node[key] = element.props[key]
   })
​
 element.props.children.forEach(child =>
   render(child, node)
 )
​
 container.appendChild(node)
}

И последнее, что нам нужно сделать, это присвоить узлу пропы элемента.

function createElement(type, props, ...children) {
 return {
   type,
   props: {
     ...props,
     children: children.map(child =>
       typeof child === "object"
         ? child
         : createTextElement(child)
     )
   }
 }
}

function createTextElement(nodeValue) {
 return {
   type: "TEXT_ELEMENT",
   props: {
     nodeValue,
     children: [],
   }
 }
}

function render(element, container) {
 const node =
   element.type == "TEXT_ELEMENT"
     ? document.createTextNode("")
     : document.createElement(element.type)

 const isProperty = key => key !== "children"
 Object.keys(element.props)
   .filter(isProperty)
   .forEach(key => {
     node[key] = element.props[key]
   })
​
 element.props.children.forEach(child =>
   render(child, node)
 )
​
 container.appendChild(node)
}

const MyReact = {
 createElement,
 render
}

/** @jsx MyReact.createElement */
const element = (
 

Hello from MyReact!

React from scratch

) const container = document.getElementById("root") MyReact.render(element, container)

Самым простой способ запустить этот пример (и другие):




Конкурентный режим (Concurrent Mode)

Перед тем, как мы продолжим веселиться, придется сделать небольшой рефакторинг кода.

element.props.children.forEach(child =>
 render(child, node)
)

В чем проблема этого рекурсивного вызова? (Представим, что вы проходите собеседование для устройства на работу в Facebook;))

   .  .
    \/
   (@@)
g/\_)(_/\e
g/\(=--=)/\e
   //\\
  _|  |_

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

let nextUnitOfWork = null
​
function workLoop(deadline) {
 while (nextUnitOfWork && deadline.timeRemaining() > 0) {
   nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
 }

 requestIdleCallback(workLoop)
}
​
requestIdleCallback(workLoop)

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

Мы используем requestIdleCallback для создания бесконечного цикла. requestIdleCallback похож на setTimeout, но вместо того, чтобы выполнять задачу через определенное время, браузер запускает функцию обратного вызова (в данном случае workLoop), когда основной поток свободен от выполнения других задач (период простоя или режим ожидания браузера — отсюда idle).

В React больше не используется requestIdleCallback. Теперь там применяется библиотека scheduler. По всей видимости, это объясняется тем, что requestIdleCallback является экспериментальной технологией и поддерживается не всеми браузерами. В частности, Safari поддерживает requestIdleCallback только в экспериментальном режиме.

Подстраховаться на случай отсутствия поддержки requestIdleCallback можно так:

window.requestIdleCallback =
 window.requestIdleCallback ||
 function (handler) {
   const start = Date.now()

   return setTimeout(() => {
     handler({
       didTimeout: false,
       timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
     })
   }, 1)
 }

Подробнее о requestIdleCallback можно почитать здесь и здесь.

function performUnitOfWork(nextUnitOfWork) {
 // TODO
}

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


Волокно (Fiber)

Для организации правильного взаимодействия между единицами работы нам нужна подходящая структура данных. Одной из таких структур является fiber tree (условно можно перевести как «древесное волокно»).

У нас имеется одно волокно для каждого элемента и каждое волокно представляет собой единицу работы.

Рассмотрим на примере.


image-loader.svg


Предположим, что мы хотим отрендерить такое дерево элементов:

MyReact.render(
 

, container )

В методе render мы создаем корневое волокно (root fiber) и устанавливаем его в качестве nextUnitOfWork. Остальная работа выполняется в функции performUnitOfWork. Там происходит 3 вещи:


  1. Добавление элемента в DOM
  2. Создание волокон для потомков элемента
  3. Выбор следующей единицы работы


image-loader.svg

Одной из задач этой структуры данных является упрощение определения следующей единицы работы. Вот почему каждое волокно имеет ссылки на первого потомка (child), сиблинга (sibling) и предка (parent).

После обработки волокна, если у него есть child, он становится следующей единицей работы.

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

Если волокно не имеет child, следующей единицей работы становится sibling.

Например, волокно p не имеет child, поэтому следующей единицей работы становится a.

Наконец, если волокно не имеет ни child, ни sibling, следующей единицей работы становится sibling предка волокна (parent).

Если parent не имеет sibling, мы поднимаемся к следующему parent и так до тех пор, пока не достигнем корневого волокна. Если мы достигли такого волокна, значит, работа для данного цикла render закончена.

function createNode(fiber) {
 const node =
   fiber.type == "TEXT_ELEMENT"
     ? document.createTextNode("")
     : document.createElement(fiber.type)
​
 const isProperty = key => key !== "children"
 Object.keys(fiber.props)
   .filter(isProperty)
   .forEach(key => {
     node[key] = fiber.props[key]
   })
​
 return node
}

function render(element, container) {
 // TODO
}

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

function render(element, container) {
 nextUnitOfWork = {
   node: container,
   props: {
     children: [element]
   }
 }
}

В функции render мы присваиваем nextUnitOfWork корневой узел fiber tree.

nextUnitOfWork = performUnitOfWork(
 nextUnitOfWork
)

Когда браузер будет готов, он запустит «колбек» workLoop и начнется обработка корневого узла.

function performUnitOfWork(fiber) {
 if (!fiber.node) {
   fiber.node = createNode(fiber)
 }
​
 if (fiber.parent) {
   fiber.parent.node.append(fiber.node)
 }
}

Сначала мы создаем новый узел и добавляем его в DOM.

Узлы содержатся в свойстве fiber.node.

const elements = fiber.props.children
let index = 0
let prevSibling = null
​
while (index < elements.length) {
 const element = elements[index]
​
 const newFiber = {
   type: element.type,
   props: element.props,
   parent: fiber,
   node: null
 }
}

Затем для каждого потомка создается волокно.

const elements = fiber.props.children
let index = 0
let prevSibling = null
​
while (index < elements.length) {
 const element = elements[index]
​
 const newFiber = {
   type: element.type,
   props: element.props,
   parent: fiber,
   node: null,
 }
​
 // !
 // первый потомок?
 if (index === 0) {
   // свойство предка
   fiber.child = newFiber
 } else {
   // свойство текущего волокна
   prevSibling.sibling = newFiber
 }
​
 prevSibling = newFiber
 index++
}

Новое волокно добавляется в fiber tree либо как child, если оно является первым потомком, либо как sibling.

const elements = fiber.props.children
let index = 0
let prevSibling = null
​
while (index < elements.length) {
 const element = elements[index]
​
 const newFiber = {
   type: element.type,
   props: element.props,
   parent: fiber,
   node: null,
 }
​
 if (index === 0) {
   fiber.child = newFiber
 } else {
   prevSibling.sibling = newFiber
 }
​
 prevSibling = newFiber
 index++
}

// !
// есть потомок?
if (fiber.child) {
 return fiber.child
}

let nextFiber = fiber
while (nextFiber) {
 // есть сиблинг?
 if (nextFiber.sibling) {
   return nextFiber.sibling
 }

 // спросим у предка
 nextFiber = nextFiber.parent
}

Наконец, мы определяем и возвращаем следующую единицу работы. Сначала мы возвращаем потомка. Если потомок отсутствует, возвращается сиблинг. Если сиблинга нет, поднимаемся к предку и возвращаем его сиблинга и т.д.

function performUnitOfWork(fiber) {
 if (!fiber.node) {
   fiber.node = createNode(fiber)
 }
​
 if (fiber.parent) {
   fiber.parent.node.append(fiber.node)
 }

 const elements = fiber.props.children
 let index = 0
 let prevSibling = null
 ​
 while (index < elements.length) {
   const element = elements[index]
 ​
   const newFiber = {
     type: element.type,
     props: element.props,
     parent: fiber,
     node: null,
   }
 ​
   if (index === 0) {
     fiber.child = newFiber
   } else {
     prevSibling.sibling = newFiber
   }
 ​
   prevSibling = newFiber
   index++
 }

 if (fiber.child) {
   return fiber.child
 }

 let nextFiber = fiber
 while (nextFiber) {
   if (nextFiber.sibling) {
     return nextFiber.sibling
   }

   nextFiber = nextFiber.parent
 }
}

Вот как выглядит наша функция performUnitOfWork.


Этапы рендеринга и фиксации результатов (Commit)

if (fiber.parent) {
 fiber.parent.node.append(fiber.node)
}

В чем проблема этого блока кода? (Второй вопрос из 100;))

   o   o
    )-(
   (O O)
    \=/
   .-"-.
  //\ /\\
_// / \ \\_
=./ {,-.} \.=
   || ||
   || ||
 __|| ||__
`---" "---'

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

Поэтому часть, мутирующую DOM, из функции performUnitOfWork мы удаляем.

function render(element, container) {
 // !
 workingRoot = {
   node: container,
   props: {
     children: [element]
   }
 }

 // !
 nextUnitOfWork = workingRoot
}
​
let nextUnitOfWork = null
// !
let workingRoot = null

Вместо этого, мы следим за корнем fiber tree.


function workLoop(deadline) {
 // ...
​
 if (!nextUnitOfWork && workingRoot) {
   requestAnimationFrame(commitRoot)
 }
​
 // ...
}

После выполнения всей работы (это определяется по отсутствию следующей единицы работы) мы фиксируем (commit) fiber tree, т.е. добавляем его в DOM (рендерим).

О том, почему мы используем здесь requestAnimationFrame описывается в материалах, посвященным requestIdleCallback, по приведенным выше ссылкам. Отличное объяснение разницы между rAF и rIC на Stack Overflow.

function commitRoot() {
 commitWork(workingRoot.child)
 workingRoot = null
}
​
function commitWork(fiber) {
 if (!fiber) {
   return
 }

 const parentNode = fiber.parent.node
 parentNode.append(fiber.node)

 commitWork(fiber.child)
 commitWork(fiber.sibling)
}

Мы делаем это в функции commitRoot. Здесь мы рекурсивно добавляем все узлы в DOM.


Согласование (Reconcilation)

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

Этим мы сейчас и займемся. Нам необходимо сравнивать элементы, полученные функцией render с последним fiber tree, которое мы зафиксировали в DOM.

function commitRoot() {
 commitWork(workingRoot.child)
 // !
 currentRoot = workingRoot
 workingRoot = null
}

function render(element, container) {
 workingRoot = {
   dom: container,
   props: {
     children: [element],
   },
   // !
   alternate: currentRoot,
 }
 nextUnitOfWork = workingRoot
}

let nextUnitOfWork = null
// !
let currentRoot = null
let workingRoot = null

Нам нужно сохранять ссылку на последнее fiber tree после фиксации результатов. Назовем ее currentRoot.

Мы также добавляем каждому волокну свойство alternate. Данное свойство — это ссылка на старое волокно, волокно, зафиксированное в DOM на предыдущей стадии рендеринга.

function performUnitOfWork(fiber) {
 // ...
​
 const elements = fiber.props.children
 // !
 reconcileChildren(fiber, elements)
​
 if (fiber.child) {
   return fiber.child
 }
 // ...
}

function reconcileChildren(workingFiber, elements) {
 let index = 0
 let prevSibling = null
​
 while (index < elements.length) {
   const element = elements[index]
​
   const newFiber = {
     type: element.type,
     props: element.props,
     parent: workingFiber,
     node: null,
   }
​
   if (index === 0) {
     workingFiber.child = newFiber
   } else {
     prevSibling.sibling = newFiber
   }
​
   prevSibling = newFiber
   index++
 }
}

Извлекаем код для создания новых волокон из performUnitOfWork в новую функцию reconcileChildren.

Здесь мы будем сравнивать старые волокна с новыми элементами.

function reconcileChildren(workingFiber, elements) {
 let index = 0
 let oldFiber =
   workingFiber.alternate && workingFiber.alternate.child
 let prevSibling = null
​
 while (
   index < elements.length ||
   oldFiber !== null
 ) {
   // TODO
 }
}

Мы одновременно перебираем потомков старого волокна (workingFiber.alternate) и массив новых элементов для сравнения.

Если мы опустим код для одновременной итерации по массиву и связному списку, то у нас останется 2 вещи: oldFiber и element. element — это то, что мы хотим отрендерить в DOM, а oldFiber — это то, что рендерилось в последний раз.

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

const element = elements[index]
let newFiber = null

const sameType =
 oldFiber &&
 element &&
 element.type == oldFiber.type
​
if (sameType) {
 // TODO обновляем узел
}
if (element && !sameType) {
 // TODO добавляем узел
}
if (oldFiber && !sameType) {
 // TODO удаляем узел
}

if (oldFiber) {
 oldFiber = oldFiber.sibling
}

Для их сравнения мы используем тип:


  • если старое волокно и новый элемент имеют одинаковый тип, мы сохраняет узел и только обновляем его новыми пропами
  • если типы разные и имеется новый элемент, мы создаем новый узел
  • если типы разные и имеется старое волонко, мы удаляем узел

Здесь React также использует ключи (keys) в целях лучшего согласования. Например, с помощью ключей определяется изменение порядка элементов в списке.

if (sameType) {
 newFiber = {
   type: oldFiber.type,
   props: element.props,
   node: oldFiber.node,
   parent: workingFiber,
   alternate: oldFiber,
   action: "UPDATE",
 }
}

Когда старое волокно и новый элемент имеют одинаковый тип, мы создаем новое волокно, сохраняя узел из старого волокна и добавляя пропы из нового элемента.

Мы также добавляем в волокно новое свойство action (в React используется название effectTag). Это свойство будет использоваться на стадии фиксации.

if (element && !sameType) {
 newFiber = {
   type: element.type,
   props: element.props,
   node: null,
   parent: workingFiber,
   alternate: null,
   action: "ADD",
 }
}

Индикатором необходимости создания нового узла является action: "ADD".

if (oldFiber && !sameType) {
 oldFiber.action = "REMOVE"
 nodesToRemove.push(oldFiber)
}

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

Но когда мы фиксируем fiber tree в DOM, мы делаем это с помощью (из) workingRoot, которое не имеет старых волокон.

function render(element, container) {
 workingRoot = {
   dom: container,
   props: {
     children: [element],
   },
   alternate: currentRoot,
 }
 // !
 nodesToRemove = []
 nextUnitOfWork = workingRoot
}
​
let nextUnitOfWork = null
let currentRoot = null
let workingRoot = null
// !
let nodesToRemove = null

Поэтому нам нужен массив для узлов, подлежащих удалению.

function commitRoot() {
 // !
 nodesToRemove.forEach(commitWork)
 commitWork(workingRoot.child)
 currentRoot = workingRoot
 workingRoot = null
}

Мы используем этот массив при фиксации результатов.

В функции commitWork заменяем parentNode.append(fiber.node) на следующее:

switch (fiber.action) {
 case 'ADD':
   fiber.node && parentNode.append(fiber.node)
   break
 case 'REMOVE':
   fiber.node.remove()
   break
 case 'UPDATE':
   fiber.node && updateNode(fiber.node, fiber.alternate.props, fiber.props)
   break
 default:
   return
}

Если fiber.action имеет значение ADD, мы помещаем новый узел в родительский узел. Если fiber.action имеет значение REMOVE, мы удаляем узел. Если fiber.action имеет значение UPDATE, мы обновляем узел новыми пропами.

function updateNode(node, prevProps, nextProps) {
 // TODO
}

Это происходит в функции updateNode.

const isProperty = key => key !== "children"
const wasAdded = (prev, next) => key =>
 prev[key] !== next[key]
const wasRemoved = (prev, next) => key => !(key in next)

function updateNode(node, prevProps, nextProps) {
 // удаляем старые свойства
 Object.keys(prevProps)
   .filter(isProperty)
   .filter(wasRemoved(prevProps, nextProps))
   .forEach(key => {
     dom[key] = ""
   })
​
 // добавляем новые или изменившиеся свойства
 Object.keys(nextProps)
   .filter(isProperty)
   .filter(wasAdded(prevProps, nextProps))
   .forEach(key => {
     dom[key] = nextProps[key]
   })
}

Мы сравниваем пропы старого и нового волокон, удаляем отсутствующие пропы и добавляем новые или изменившиеся пропы.

const isEvent = key => key.startsWith("on")
const isProperty = key =>
 key !== "children" && !isEvent(key)

Одним из особых пропов являются обработчики событий (event listeners). Поэтому, если название пропа начинается с on, такой проп следует обрабатывать отдельно.

Object.keys(prevProps)
 .filter(isEvent)
 .filter(
   key =>
     !(key in nextProps) ||
     wasAdded(prevProps, nextProps)(key)
 )
 .forEach(key => {
   const eventType = key
     .toLowerCase()
     .substring(2) // onClick -> click

   node.removeEventListener(
     eventType,
     prevProps[key]
   )
 })

Если обработчик отсутствует или изменился, его нужно удалить.

Object.keys(nextProps)
 .filter(isEvent)
 .filter(wasAdded(prevProps, nextProps))
 .forEach(key => {
   const eventType = key
     .toLowerCase()
     .substring(2)

   node.addEventListener(
     eventType,
     nextProps[key]
   )
 })

Затем мы добавляем новые обработчики.


Функциональные компоненты (Functional Components)

/** @jsx MyReact.createElement */
function App(props) {
 return (
   

Hello from {props.who}!

React from {props.what}

) } const element = const container = document.getElementById("root") MyReact.render(element, container)

Добавим поддержку функциональных компонентов.

Если мы трансформируем строку const element = в JS, то получим следующее:

const element = MyReact.createElement(App, {
 who: "MyReact",
 what: "scratch"
})

Функциональные компоненты отличаются от обычных элементов следующим:


  • волокно функционального компонента не имеет узла
  • дочерние элементы являются результатом вызова функции
function performUnitOfWork(fiber) {
 // !
 const isFunctionComponent =
   fiber.type instanceof Function

 if (isFunctionComponent) {
   updateFunctionComponent(fiber)
 } else {
   updateHostComponent(fiber)
 }

 // ...
}
​
function updateFunctionComponent(fiber) {
 // TODO
}
​
function updateHostComponent(fiber) {
 if (!fiber.dom) {
   fiber.dom = createDom(fiber)
 }

 reconcileChildren(fiber, fiber.props.children)
}

Мы проверяем, является ли тип волокна функцией, и на основе этой проверки запускам соответствующую функцию.

В функции updateHostComponent мы делаем тоже самое, что и раньше.

export function updateFunctionComponent(fiber) {
 const children = [fiber.type(fiber.props)]
 reconcileChildren(fiber, children)
}

А в updateFunctionalComponent мы запускаем переданную функцию для получения дочерних элементов.

В нашем случае fiber.type — это функция App, выполнение которой возвращает элемент section с потомками.

Логика согласования потомков остается прежней, нам не нужно ничего в ней изменять.

Однако, поскольку у нас появилось волокно без узлов, нам нужно поменять 2 вещи в функции commitWork.

let parentFiber = fiber.parent
while (!parentFiber.node) {
 parentFiber = parentFiber.parent
}
const parentNode = parentFiber.node

Во-первых, для того, чтобы найти предка текущего узла мы поднимаемся вверх по fiber tree до тех пор, пока не обнаружим волокно с узлом.

case 'REMOVE':
 return commitRemove(fiber)

function commitRemove(fiber) {
 if (fiber.node) {
   return fiber.node.remove()
 }
 commitRemove(fiber.child)
}

А при удалении узла мы двигаемся вниз, пока не найден потомка с узлом. Кроме того, поскольку удаление элемента делегируется commitRemove, мы не должны запускать commitWork для старых узлов.


Хуки (Hooks)

Последнее, что нам осталось сделать, это добавить в функциональные компоненты состояние.

/** @jsx MyReact.createElement */
function Counter() {
 const [state, setState] = MyReact.useState(1)

 return (
   

setState(c => c + 1)}> Count: { state }

) } const container = document.getElementById("root") MyReact.render(, container)

Здесь у нас имеется простой компонент счетчика. При клике по заголовку значение счетчика увеличивается на 1.

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

// Функция принимает начальное состояние
function useState(initialState) {
 // TODO
}

Мы вызываем функцию Counter, внутри которой вызывается функция useState.

// !
let workingFiber = null
let hookIndex = null
​
function updateFunctionComponent(fiber) {
 // !
 workingFiber = fiber
 hookIndex = 0
 workingFiber.hooks = []
 // end !
 const children = [fiber.type(fiber.props)]
 reconcileChildren(fiber, children)
}

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

Сначала мы определяем рабочее волокно (workingFiber).

Затем мы добавляем массив hooks в волокно для того, чтобы иметь возможность вызывать useState несколько раз в одном компоненте. Также мы фиксируем индекс текущего хука.

function useState(initialState) {
 const oldHook =
   workingFiber.alternate &&
   workingFiber.alternate.hooks &&
   workingFiber.alternate.hooks[hookIndex]

 const hook = {
   state: oldHook ? oldHook.state : initialState,
 }
​
 workingFiber.hooks.push(hook)
 hookIndex++

 return [hook.state]
}

При вызове useState мы проверяем, имеется ли у нас старый хук. Для этого мы заглядываем в свойство alternate волокна, используя индекс хука.

Если старый хук есть, мы копируем его состояние в новый хук, иначе инициализируем состояние начальным значением (в данном случае примитивом).

Затем мы добавляем новый хук в волокно, увеличиваем значение индекса на 1 и возвращаем состояние.

const hook = {
   state: oldHook ? oldHook.state : initialState,
   // !
   queue: [],
 }
​
// !
const setState = action => {
 hook.queue.push(action)

 workingRoot = {
   node: currentRoot.node,
   props: currentRoot.props,
   alternate: currentRoot,
 }

 nextUnitOfWork = workingRoot
 nodesToRemove = []
}
​
workingFiber.hooks.push(hook)
hookIndex++

// !
return [hook.state, setState]

useState также должна возвращать функцию для обновления состояния, поэтому мы определяем функцию setState, принимающую операцию (в Counter операция — это функция, которая увеличивает значение счетчика на 1).

Мы помещаем эту операцию в очередь (queue) хука.

Затем мы повторяем логику функции render: новый workingRoot становится следующей единицей работы, что приводит к запуску новой стадии рендеринга.

const hook = {
 state: oldHook ? oldHook.state : initial,
 queue: [],
}
​
// !
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
 hook.state = action(hook.state)
})

Операции выполняются при следующем рендеринге компонента. Мы получаем все операции из очереди старого хука и применяем их по одной к состоянию хука. После этого мы возвращаем обновленное состояние.

Пожалуй, на этом мы остановимся. Теперь вы знаете, с чего начать разработку собственной версии React.

Но прежде, чем мы закончим, внесем еще несколько мелких правок.


  • initialState может быть функцией:
// useState
const hook = {
 state: oldHook
   ? oldHook.state
   // !
   : initialState instanceof Function
   ? initialState()
   : initialState,
 queue: []
}


  • action может быть примитивом:
// useState
actions.forEach((action) => {
 // !
 hook.state = action instanceof Function ? action(hook.state) : action
})


  • значением пропа style может быть объект:
// updateNode
Object.keys(nextProps)
 .filter(isProperty)
 .filter(wasAdded(prevProps, nextProps))
 .forEach((key) => {
   // !
   if (key === 'style' && typeof nextProps[key] === 'object') {
     Object.assign(node.style, nextProps[key])
   } else {
     node[key] = nextProps[key]
   }
 })


  • children может содержать массив:
// createElement
children: children
 .flat()
 .map((child) =>
   typeof child === 'object' ? child : createTextElement(child)
 )


Запуск проекта с помощью Snowpack

Весь код MyReact содержится в одном файле. Это не очень удобно. Но если попытаться разделить код на модули, то начнутся проблемы. Сначала мы получим ошибку, связанную с тем, что инструкция import может использоваться только в модулях. Затем исключения начнет выбрасывать Babel, потому что он не понимает синтаксис модулей — для этого ему требуется специальный плагин. Подключить плагин к Babel с помощью одного только babel.config.json не получится. Здесь нужна помощь сборщика (бандлера).

Когда речь заходит о сборщиках, я, обычно, использую Webpack. Но недавно на Хабре вышло 2 хорошие статьи, в которых создатель snowpack делится своим опытом разработки открытого проекта. Поэтому я решил использовать этот «сборщик для сборщиков».

Структура проекта:


image-loader.svg


Инициализируем проект, находясь в корневой директории:

yarn init -yp

Устанавливаем snowpack, 2 плагина для него и еще один для babel:

yarn add -D snowpack @snowpack/plugin-babel @snowpack/plugin-sass @babel/preset-react

Настраиваем snowpack (snowpack.config.json):

{
 "plugins": [
   "@snowpack/plugin-babel",
   "@snowpack/plugin-sass"
 ]
}

Настраиваем babel (babel.config.json):

{
 "presets": [
   "@babel/preset-react"
 ]
}

Определяем команду для запуска snowpack в package.json:

"scripts": {
 "start": "snowpack dev"
}

Запускаем проект в режиме для разработки:

yarn start

Красота:


image-loader.svg

Заключение

Итак, в этой статье мы с вами реализовали мини-версию React. Она представляет собой довольно наивную имплементацию ≈0.1% исходного кода React. Но, как говорится, главное — начать ;)

Полезные ссылки для тех, кому, как и мне, всегда мало:


  • React — исходный код React
  • JSX Runtime — попытка реализовать парсинг JSX с помощью tagged template literals
  • Fre — более продвинутое согласование, в том числе, с использованием ключей
  • @dbarone/didact — реализация фрагментов и хуков useEffect, useMemo и useCallback

Надеюсь вам было интересно и вы не жалеете потраченного времени.

Благодарю за внимание и хорошего дня!


image-loader.svg

© Habrahabr.ru