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

в 11:44, , рубрики: createelement, javascript, jsx, parcel, React, react-dom, react.js, ReactJS, render, tsx, TypeScript, usestate
Создаем свой React с рендером и useState за 30 минут - 1

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

Исходники можно найти по ссылке.

Автор: Смолин Павел

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js