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

от автора

Понимание работы процессов приходит с изучением механизмов, которые приводят в движение мелкие части большого пазла. Если представить, что Вам дали задачу объяснить, что такое 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. Буду благодарен обратной связи!
Исходники можно найти по ссылке — github.com/SmolinPavel/create-react.


ссылка на оригинал статьи https://habr.com/ru/post/652487/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *