Создаем свой React с рендером и useState за 30 минут
- воскресенье, 20 февраля 2022 г. в 00:34:30
Понимание работы процессов приходит с изучением механизмов, которые приводят в движение мелкие части большого пазла. Если представить, что Вам дали задачу объяснить, что такое React за полчаса, скорее всего, Вы бы выбрали один из двух вариантов:
пересказать все то, что изложено на первой странице официальной документации reactjs.org
либо прокомментировать каждый из импортов в репозитории react
Разумеется, можно попробовать скомбинировать оба шага, но есть ли варианты интереснее?
Давайте создадим пустой проект, в который установим две dev зависимости:
yarn add -D parcel typescript
В нашем проекте parcel будет использоваться в качестве бандлера, который не требует настройки (как раз то, что нам нужно), а typescript (точнее typescript compiler - tsc) понадобится для легкого компилирования jsx в js. Для решения второй задачи можно было бы использовать babel, но используя typescript мы дополнительно получим статическую типизацию. Выполним следующую команду:
yarn tsc --init
При установке пакетов, 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. Далее нам необходимо сделать несколько косметических изменений:
Раскомментируем строку "jsx": "preserve"
- и заменим "preserve"
на значение "react"
. Таким образом мы указываем, какой тип output в случае появления jsx мы получим (подробнее поговорим о jsx в следующем разделе). Все варианты можно рассмотреть по ссылке.
Изменим значение флага "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
Рассмотрим объявление переменной в следующем блоке кода:
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?");
Рассмотрим подробнее данную запись.
Очевидно, что первый параметр - это тег, в нашем случае 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) и в результате мы увидим следующий вывод в консоль:
Целью же этой статьи является разбор базовых концепции реакта, поэтому мы пойдем другим путем и напишем свою реализацию 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?
Вызовы createElement рекурсивны. Если взять родительский тег div, то в процессе вызова сначала выполнится вложенный вызов React.createElement("header", null, "Header")
и другие вложенные вызовы, а только потом закончит работу первоначальный вызов React.createElement("div", …);
Настоящий вызов 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.
Но для того, чтобы приложение появилось на экране браузера, необходимо в файле 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 отдельно:
В случае, когда элемент, пришедший к нам является примитивом (строкой или числом) - мы создаем текстовую ноду и добавляем ее к контейнеру.
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 элемент в контейнер.
В итоге на экране появится ожидаемый результат - наша разметка!
Конечно, можно было бы просто создать .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);
Ключевой особенностью 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 - создадим отдельный компонент счетчика (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.
Если Вас заинтересовал процесс воссоздания реакта - обязательно загляните в исходный код. Там Вы найдете много чего полезного для понимания устройства библиотеки.
Целью данной статьи было показать альтернативный вариант знакомства с React. Буду благодарен обратной связи!
Исходники можно найти по ссылке.