Создаем свой React с рендером и useState за 30 минут

Kate

Administrator
Команда форума
Понимание работы процессов приходит с изучением механизмов, которые приводят в движение мелкие части большого пазла. Если представить, что Вам дали задачу объяснить, что такое React за полчаса, скорее всего, Вы бы выбрали один из двух вариантов:

  • пересказать все то, что изложено на первой странице официальной документации reactjs.org
  • либо прокомментировать каждый из импортов в репозитории react
Разумеется, можно попробовать скомбинировать оба шага, но есть ли варианты интереснее?

Подготовка​

Давайте создадим пустой проект, в который установим две dev зависимости:

yarn add -D parcel typescript
В нашем проекте parcel будет использоваться в качестве бандлера, который не требует настройки (как раз то, что нам нужно), а typescript (точнее typescript compiler - tsc) понадобится для легкого компилирования jsx в js. Для решения второй задачи можно было бы использовать babel, но используя typescript мы дополнительно получим статическую типизацию. Выполним следующую команду:

yarn tsc --init
Подробнее про файл node_modules/.bin/tsc
При установке пакетов, yarn (или npm) проверяет, есть ли у зависимости исполняемый файл через поле bin в файле package.json.
Когда мы устанавливали typescript, бинарных файла было сразу два:
// node_modules/typescript/package.json
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
Если поле bin заполнено, то пакетный менеджер создает symlink (символическую ссылку) на указанный путь и помещает ее в директорию node_modules/.bin
Таким образом node_modules/.bin/tsc - это символическая ссылка на файл node_modules/typescript/bin/tsc
Когда мы запускаем инструкцию yarn <bin_name> - пакетный менеджер проверит наличие <bin_name> по адресу node_modules/.bin и если таковой найден, то он исполняется.
Это поможет нам сгенерировать файл конфигурации для typescript - tsconfig.json. Далее нам необходимо сделать несколько косметических изменений:

  1. Раскомментируем строку "jsx": "preserve" - и заменим "preserve" на значение "react". Таким образом мы указываем, какой тип output в случае появления jsx мы получим (подробнее поговорим о jsx в следующем разделе). Все варианты можно рассмотреть по ссылке.
  2. Изменим значение флага "strict" с "true" на "false". Сделаем это, чтобы не отвлекаться на предупреждения во время работы над нашей версией React.
    В итоге, изменения в tsconfig.json будут выглядеть следующим образом:
// tsconfig.json
- // "jsx": "preserve" /* Specify what JSX code is generated. */,
+ "jsx": "react" /* Specify what JSX code is generated. */,
- "strict": true /* Enable all strict type-checking options. */,
+ "strict": false /* Enable all strict type-checking options. */,
Все готово для начала работы! Чтобы убедиться, что мы готовы писать код, предлагаю начать с создания index.html со следующим содержимым:

// index.html
<script src="index.tsx" type="module"></script>
Соответсвенно, следующим шагом будет создание index.tsx, в котором мы выведем сообщение в консоль console.log("hello react");

// index.tsx
console.log("hello react");
Для того, чтобы запустить веб-сервер, добавим следующий блок в файл package.json в корне нашего проекта:

// package.json
"licence": "MIT",
"scripts": {
"start": "parcel index.html"
},
Таким образом, после запуска yarn start в терминале, мы запустим приложение на 1234 порту локалхоста http://localhost:1234, при этом страница будет совершенно пустой, но в консоли будет выведено приветствие из файла index.tsx

JSX & React Elements​

Рассмотрим объявление переменной в следующем блоке кода:

const element = <h1>React, what are you?</h1>;
Официальная документация React (в переводе которой на русский язык принял участие в том числе Ваш покорный слуга), начинает объяснение JSX с фразы:

Этот странный тег — ни строка, ни фрагмент HTML
🤔 Интересное начало!

На мой взгляд, самым наглядным объяснением будет пример из песочницы typescript или babel (для babel не забудьте отжать галочку react слева!), где наше выражение <h1>React, what are you?</h1> превращается в следующую запись.

"use strict";

React.createElement("h1", null, "React, what are you?");
Первое знакомство с React.createElement
Рассмотрим подробнее данную запись.
Очевидно, что первый параметр - это тег, в нашем случае h1.
Второй параметр равен null, потому что мы не передали атрибуты. Если передать один или несколько атрибутов, второй параметр превратится в объект, в качестве ключей/значений которого будут имена и значения атрибутов. Такой объект в реакте называется пропс.
Третий параметр - это содержимое нашего тега, обычная строка. Если бы вложением была не строка или число, а другая разметка, то мы бы получили новый вызов React.createElement.
Важно! Если внутри тега будет вложено сразу несколько дополнительных тегов, число параметров может увеличиться с трех до n + 2, где n - это количество вложенных тегов одного уровня. Таким образом:
<div>
<p>1</p>
<p>2</p>
</div>
преобразуется в
React.createElement("div", null,
React.createElement("p", null, "1"),
React.createElement("p", null, "2"));
где у начального вызова React.createElement можно насчитать 4 параметра.
Получается, что после транспайлинга <h1>React, what are you?</h1> вместо верстки в переменную element запишется результат вызова React.createElement с тремя параметрами.

Убедимся в этом сами, в файле index.tsx добавим следующее содержимое:

// index.tsx
const element = <h1>React, what are you?</h1>;
console.log(element);
Сохраним изменения и проверим сообщение в консоли на странице http://localhost:1234

Uncaught ReferenceError: React is not defined
Ошибка вызвана тем, что TypeScript compiler выполнил свою работу как надо и в результате в переменную element должен попасть результат работы React.createElement, но проблема в том, что мы нигде не определили переменную React.

А что в обычной жизни?
В обычной жизни (до появления 17-ой версии react) можно было бы просто установить npm пакет react и добавить в начало файла index.tsx следующий импорт:
// index.tsx
import React from "react"
Действительно, это решит проблему (убедитесь, что у Вас добавлен атрибут type="module" тегу script в index.html) и в результате мы увидим следующий вывод в консоль:
Результат вывода в консоль переменной element в файле index.tsx
Результат вывода в консоль переменной element в файле index.tsx
Целью же этой статьи является разбор базовых концепции реакта, поэтому мы пойдем другим путем и напишем свою реализацию React.
В качестве быстрого решения, создадим переменную React и присвоим ей пустой объект:

// index.tsx
+ const React = {};

const element = <h1>React, what are you?</h1>;
console.log(element);
Теперь ошибка в консоли примет другой вид:

Uncaught TypeError: React.createElement is not a function
Что выглядит вполне логично, поэтому создадим метод createElement и выведем в консоль передаваемые параметры:

// index.tsx
const React = {
createElement: (...params) => {
console.log(params);
},
};

// ...
Ошибка повержена, и мы наблюдаем массив из трех параметров, в точности как те, что мы видели в песочнице TypeScript!
Чтобы лучше разобраться, как работает React.createElement - усложним разметку. В итоге файл index.tsx примет следующий вид:

// index.tsx
const React = {
createElement: (...params) => {
console.log(params);
},
};

const element = (
<div>
<header>Header</header>
<main>
<h1>Page title</h1>
<p>lorem...</p>
</main>
<footer>Footer</footer>
</div>
);

console.log(element);
Если посмотреть, что выведет консоль - получим интересную картину:

['header', {…}, 'Header']
['h1', {…}, 'Page title']
['p', {…}, 'lorem...']
['main', {…}, undefined, undefined]
['footer', {…}, 'Footer']
['div', {…}, undefined, undefined, undefined]
У неискушенного читателя может возникнуть несколько вопросов:

  • Чем обусловлен именно такой порядок вызовов?
  • Откуда взялись параметры undefined при вызовах для тегов main и div?
  1. Вызовы createElement рекурсивны. Если взять родительский тег div, то в процессе вызова сначала выполнится вложенный вызов React.createElement("header", null, "Header") и другие вложенные вызовы, а только потом закончит работу первоначальный вызов React.createElement("div", …);
  2. Настоящий вызов React.createElement (из npm пакета react) возвращает объект React элемента, а наша же функция пока только выводит параметры в консоль и ничего не возвращает (undefined). Исправим это! 🧑🏻‍💻
Модифицируем нашу функцию createElement, чтобы она возвращала элемент следующего вида:

// index.tsx
const React = {
createElement: (tag, props, ...children) => {
return {
tag,
props: {
...props,
children,
},
};
},
};

// ...
В реакте содержимое элемента автоматически доступно как проп children. Вложенных элементов может быть много, а может и не быть совсем, поэтому собираем все вложенные элементы в массив с помощью rest оператора.

После проделанных манипуляций в консоли можно увидеть древовидную структуру, которая полностью соответствует нашей разметке!
Но как же вывести результат на экран? 🤔

Перед тем как сделать это, заменим в файле index.tsx элемент на компонент.

Подробно разница между элементом и компонентом разобрана в официальном блоге на reactjs.org.

С практической точки зрения, вместо переменной element мы создадим функцию App, которая будет возвращать элемент:

const App = () => (
<div>
<header>Header</header>
<main>
<h1>Page title</h1>
<p>lorem...</p>
</main>
<footer>Footer</footer>
</div>
);
Чтобы научить нашу версию метода React.createElement работать с компонентами, добавим следующую проверку:

// index.tsx

const React = {
createElement: (tag, props, ...children) => {
+ if (typeof tag === "function") {
+ return tag({ ...props, children });
+ }

return {
tag,
props: {
...props,
children,
},
};
},
};

+ const App = () => (
- const element = (
<div>
<header>Header</header>
<main>
<h1>Page title</h1>
<p>lorem...</p>
</main>
<footer>Footer</footer>
</div>
);

+ console.log(<App />);
- console.log(element);
Если tag является функцией, createElement вернет результат ее вызова, передав props, в состав которых будет входить и children.

Эта особенность работы React.createElement частично объясняет ограничение JSX expressions must have one parent element.

ReactDOM render​

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

ReactDOM.render(<App />, document.getElementById("root"));
Сразу мы получим ожидаемую ошибку Uncaught ReferenceError: ReactDOM is not defined, чтобы обойти которую добавим заглушку вида:

const ReactDOM = {
render: (...params) => {
console.log(params);
},
};
В консоли пропала ошибка и мы видим массив из двух элементов. Первый - уже знакомое нам древовидное представление разметки, а второй null. Значение null появилось ожидаемо, ведь document.getElementById("root") не смог найти элемент с атрибутом id равным root. Чтобы такой элемент появился, добавим в index.html следующую строку:

// index.html
+ <div id="root"></div>
<script src="index.tsx" type="module"></script>
В качестве id элемента можно было выбрать любое значение, но root очень хорошо подчеркивает назначение только что добавленного тега. Это будет корневой контейнер, в который мы добавим наше дерево (jsx элемент - результат вызова App).

Далее напишем реализацию ReactDOM.render:

const ReactDOM = {
render: (element, container) => {
if (typeof element === "string" || typeof element === "number") {
container.appendChild(document.createTextNode(String(element)));

return;
}

const { props, tag } = element;
const domElement = document.createElement(tag);

if (props.children) {
for (const child of props.children) {
ReactDOM.render(child, domElement);
}
}

for (const prop in props) {
const value = props[prop];
if (prop !== "children") {
domElement[prop] = value;
}
}

container.appendChild(domElement);
},
};
Разберем каждый блок кода в ReactDOM.render отдельно:

  1. В случае, когда элемент, пришедший к нам является примитивом (строкой или числом) - мы создаем текстовую ноду и добавляем ее к контейнеру.
if (typeof element === "string" || typeof element === "number") {
container.appendChild(document.createTextNode(String(element)));

return;
}
2. Если элемент не является примитивом, то мы ожидаем объект, у которого есть поля tag и props. На основании поля tag создадим DOM элемент.

const { props, tag } = element;
const domElement = document.createElement(tag);

if (props.children) {
for (const child of props.children) {
ReactDOM.render(child, domElement);
}
}
В указанном выше блоке, мы проверяем наличие пропы children. Если она есть - то для каждого чайлда рекурсивно вызываем ReactDOM.render, где в качестве контейнера передается созданный DOM элемент.

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

Далее, для всех остальных пропов (кроме children) добавим соответствующие атрибуты DOM элементу.

for (const prop in props) {
const value = props[prop];
if (prop !== "children") {
domElement[prop.toLowerCase()] = value;
}
}

container.appendChild(domElement);
И в самом конце добавим полученный DOM элемент в контейнер.

В итоге на экране появится ожидаемый результат - наша разметка!

Результат работы ReactDOM.render
Результат работы ReactDOM.render
Конечно, можно было бы просто создать .html файлик и не мучаться, но такой разбор помог нам лучше понять как работает React.createElement и ReactDOM.render.
Следущий на очереди хук useState, но сначала произведем небольшой рефакторинг.

Добавляем типизацию + разносим код по отдельным файлам​

Следующий раздел можно пропустить, если Вы хотите сосредоточиться именно на том, что касается React.

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

Опишем типы и интерфейсы, которые мы используем. Для этого создадим отдельный файл types.ts:

// types.ts
export type Component<T = {}> = (props: IPropsWithChildren<T>) => JSX;
export type ReactTag = HTMLTag | Component;
type HTMLTag = keyof HTMLElementTagNameMap;
export type JSX = IElement | string | number;

interface IElement {
tag: HTMLTag;
props: IPropsWithChildren;
}

export type IPropsWithChildren<P = {}> = P & { children?: JSX[] };
Следующим шагом вынесем самописную версию ReactDOM в отдельный файл:

// react-dom.ts
import { JSX } from "./types";

const ReactDOM = {
render: (element: JSX, container: HTMLElement): void => {
if (typeof element === "string" || typeof element === "number") {
container.appendChild(document.createTextNode(String(element)));

return;
}

const { props, tag } = element;
const domElement = document.createElement(tag);

if (props.children) {
for (const child of props.children) {
ReactDOM.render(child, domElement);
}
}

for (const prop in props) {
const value = props[prop];
if (prop !== "children") {
domElement[prop.toLowerCase()] = value;
}
}

container.appendChild(domElement);
}
};

export default ReactDOM;
Аналогично создадим отдельный файл для React:

// react.ts
import { ReactTag, JSX, IPropsWithChildren } from "./types";

const React = {
createElement: (
tag: ReactTag,
props: IPropsWithChildren,
...children: JSX[]
): JSX => {
if (typeof tag === "function") {
return tag({ ...props, children });
}

return {
tag,
props: {
...props,
children,
},
};
}
};

export default React;
Отдельный файл для компонента App:

// App.tsx
import React from "./react";
import { Component } from "./types";

const App: Component = () => (
<div>
<header>Header</header>
<main>
<h1>Page title</h1>
<p>lorem...</p>
</main>
<footer>Footer</footer>
</div>
);

export default App;
В итоге index.tsx превратится в удобно читаемый файл:

// index.tsx
import React from "./react";
import ReactDOM from "./react-dom";
import App from "./App";

const rootContainer = document.getElementById("root");
ReactDOM.render(<App />, rootContainer);

ReactDOM rerender​

Ключевой особенностью this.setState в классовых компонентах и второго параметра из массива от useState в функциональных компонентах является возможность обновлять UI при изменении состояния. Такой результат достигается благодаря возможности вызывать ререндер. Давайте реализуем такую возможность как метод ReactDOM:

// react-dom.tsx
import { JSX } from "./types";
+ import React from "./react";
+ import App from "./App";

// ...

+ rerender: () => {
+ const rootContainer = document.getElementById("root");
+ rootContainer.removeChild(rootContainer.firstChild);
+ ReactDOM.render(<App />, rootContainer);
+ }
Сначала мы удаляем всё, что находится в root контейнере. Затем рендерим <App /> в контейнер.

☢️ Обратите внимание, что мы также изменяем расширение файла react-dom.ts на react-dom.tsx, поскольку при вызове ReactDOM.render первым параметром будет JSX element. Вдобавок к этому, мы добавляем import React from "./react".

На следующем шаге перейдем к стейту.

Самодельный useState​

Для того, чтобы показать работу useState - создадим отдельный компонент счетчика (Counter), который предсказуемо будет выводить значение на экран. Также у нас будет две кнопки для инкремента и декремента.

Как выглядит использование хука useState в React? Рассмотрим на примере нового компонента Counter:

// Counter.tsx
import React from "./react";
import { Component } from "./types";

interface ICounterProps {
initialValue: number;
}

export const Counter: Component<ICounterProps> = ({ initialValue }) => {
const [value, setValue] = React.useState(initialValue);

return (
<div>
<h2>Counter: {value}</h2>
<div>
<button onClick={() => setValue(value - 1)}>-</button>
<button onClick={() => setValue(value + 1)}>+</button>
</div>
</div>
);
};
Мы уже с Вами знаем, что в нашей версии React нет реализации useState, поэтому напишем свою версию.

Перед началом рассмотрения реализации хука, вспомним про важную особенность стейта. Она состоит в том, что useState сохраняет значения между рендерами. Чтобы добиться такого поведения - создадим объект globalState, в котором будем хранить массив всех стейтов + курсор. Изначально массив пуст, а курсор равен нулю:

// types.ts
export interface IGlobalState {
states: any[];
cursor: number;
}

// react.ts
import { IGlobalState, ReactTag, JSX, IPropsWithChildren } from "./types";

const globalState: IGlobalState = {
states: [],
cursor: 0,
};
Когда мы перейдем к реализации useState, станет понятно зачем нам нужен курсор.

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

На первом рендере useState возвращает начальное значение, но затем нам нужно проверять, есть ли уже значение для данного хука в globalState. Именно курсор поможет нам получить доступ к нужному элементу массива, где хранится значение текущего стейта. Реализация будет выглядеть следующим образом:

// react.ts
import ReactDOM from "./react-dom";

// ...

useState<T>(initialValue: T): [state: T, setState: (newState: T) => void] {
const currentCursor = globalState.cursor;
const state = globalState.states[currentCursor] || initialValue;
const setState = (newValue: T) => {
globalState.states[currentCursor] = newValue;

ReactDOM.rerender(globalState);
};

globalState.cursor += 1;

return [state, setState];
},
Наша задача - создать массив, состоящий из state и setState. На первой итерации массив globalState.states пуст, поэтому в качестве state вернется initialValue.

Также мы фиксируем globalState.cursor в локальной переменной currentCursor, т.к. затем глобальный курсор будет увеличен на единицу.

Может возникнуть вопрос, а как будет происходить сброс курсора?

Для этого нам необходимо добавить последний штрих. Вызывая метод ReactDOM.rerender из setState, мы передадим globalState в качестве параметра, чтобы затем установить глобальный курсор на ноль перед следующим рендером.

// react-dom.tsx
- import { JSX } from "./types";
+ import { IGlobalState, JSX } from "./types";

// ...

- rerender: () => {
+ rerender: (globalState: IGlobalState) => {
const rootContainer = document.getElementById("root");
rootContainer.removeChild(rootContainer.firstChild);
+ globalState.cursor = 0;
ReactDOM.render(<App />, rootContainer);
},
Вызовем компонент Counter в теле нашего главного компонента App с начальным значением 646:

// App.tsx
import React from "./react";
import { Component } from "./types";
+ import { Counter } from "./Counter";

const App: Component = () => (
<div>
<header>Header</header>
<main>
<h1>Page title</h1>
<p>lorem...</p>
+ <Counter initialValue={646} />
</main>
<footer>Footer</footer>
</div>
);

export default App;
Таким образом на экране мы увидим интерактивный счетчик, который под капотом использует API очень похожий на react и react-dom.

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

Выводы​

Целью данной статьи было показать альтернативный вариант знакомства с React. Буду благодарен обратной связи!

 
Сверху