Вокруг все говорят о серверных компонентах реакта, о серверном рендеринге, и разных новшествах в мире фронтенде. Как будто JQuery в один миг взял и исчез. Несмотря ни на что он всё ещё остаётся самой популярной библиотекой 😅.
Сегодня я вам расскажу, как мы постепенно мигрируем с JQuery на React.
Если вам понравится эта статья, загляните в мой Telegram-канал — там я делюсь полезными материалами и мыслями о программировании.
Почему мигрируем
Тут всё довольно стандартно и понятно:
-
Разработка идёт медленно
-
Код сложно читать и поддерживать
-
XSS уязвимости подстерегают на каждом шагу
-
Сложно полноценно использовать NPM из-за ограничений пространств имён (namespace) в TypeScript
Исходный стек
Миграция началась примерно в 2019 году. Наш стек выглядел так:
-
ASP.NET Core
-
JQuery
-
TypeScript c пространствами имён вместо ESM
-
Немного Vanilla JS
-
LESS для стилей
Весь этот стек важен, ведь каждая из его частей накладывает некоторые ограничения, от которых зависят принимаемые решения.
Прежде чем говорить о миграции, давайте расскажу, как мы писали код до React. Если не интересно — сразу переходите к основной части.
Жизнь до реакта — JQuery
В упрощённом виде наши компоненты выглядели вот так:
namespace App.Components { export interface ComboBoxProps { container: JQuery; items: ComboBoxItem[]; renderOption: (item: ComboBoxItem) => JQuery; } export class ComboBox { constructor(private props: ComboBoxProps) { // создаём разметку и рендерим её в container this.container.append(this.render()); } private render(): JQuery { const viewItems = this.props.items.map(this.props.renderOption); // ... создаём разметку return view; } setItems() { /* ... */ } enable() { /* ... */ } disable() { /* ... */ } } }
Мы с завистью смотрели на React, поэтому писали компоненты в похожем стиле:
-
Интерфейс с пропсами
-
Метод render
-
Render Props функции
Что такое неймспейсы
Если вы знакомы с C# или давно используете TypeScript, то должны знать что такое неймспейсы. При компиляции они превращается в JavaScript объект, а все export
-элементы внутри пространства имён становятся свойствами этого объекта.
Например:
namespace Personnel { export class Employee { constructor(public name: string){ } } } let alice = new Personnel.Employee("Alice"); console.log(alice.name); // Alice
Компилируется в IIFE:
var Personnel; (function (Personnel) { class Employee { constructor(name) { this.name = name; } } Personnel.Employee = Employee; })(Personnel || (Personnel = {})); let alice = new Personnel.Employee("Alice"); console.log(alice.name); // Alice
React-like контекст
Мы даже изобрели что-то вроде React контекста. Данные сохраняются в DOM-элементе, с помощью JQuery метода $('.container').data(dataName, value)
. А достаются (аналогично React-контексту) из любого дочерного DOM узла с помощью метода findData
.
export function findData(element: JQuery, dataName: string) { if (!element) { return null; } const data = element.data(dataName); if (data) { return data; } const dataOwner = element.closest(`:data("${dataName}")`); if (dataOwner) { return dataOwner.data(dataName); } return null; }
Миграция
Почему React?
Мы не собирались переписывать всё с нуля, поэтому Angular нам точно не подходил — выбор стоял между React и Vue. Так как у нас в команде был разработчик с опытом миграции с JQuery на React, то выбор пал именно на него.
Подход к миграции
Дело в том, что в одном проекте нельзя использовать одновременно неймспейсы и ES-модули. Никакого инструмента для авто-конвертации тоже нет. Команда TypeScript писала внутренний инструмент для конвертации кодовой базы TypeScript’а (TypeScript написан на TypeScript’е!) на модули на основе AST.
В общем, не получалось просто взять и добавить React в существующий проект. У нас было 2 варианта:
-
Переписываем существующий код на ES-модули и интегрируем React
-
Создать отдельный проект, где писать будем только на реакте. Никакого JQuery!
Но существующая кодовая база достаточно большая, поэтому мы решили идти вторым путём.
После того как определились с подходом решили не писать webpack-конфиг с нуля — просто взяли Create React App (CRA) и сделали Eject. Потом мы сделали форк, чтобы было проще обновляться на новые версии, тогда CRA был ещё жив 🪦.
Отдельный проект — библиотека компонентов
Про реакт из каждого утюга говорят, что это просто View слой, поэтому его можно легко использовать в существующем проекте.
Мы решили, что наш реакт проект будет своего рода библиотекой компонентов. То есть все новые компоненты мы пишем в новом проекте и просто встраиваем в существующий. Для рендера реакт-компонентов в DOM-дерево, нам нужно использовать функцию createRoot
(до React18 — ReactDOM.render
).
Все вы видели следующий код:
import { createRoot } from 'react-dom/client'; const container = document.getElementById("root")!; const root = createRoot(container); root.render(<App />);
Именно он находится в index.tsx
файле вашего проекта. Точно также мы и будем встраивать наши компоненты в существующее приложение.
В React17 для рендера и обновления компонента можно было использовать ReactDOM.render
, главное передавать один и тот же DOM-элемент. При миграции на React 18 нам пришлось написать функцию-обёртку renderComponent
для удобного использования createRoot()
. Код целиком можно найти здесь.
import { Root } from 'react-dom/client'; const roots = new Map<HTMLElement, Root>(); export async function renderComponent({ container, component, autoUnmount }: IRenderComponentProps) { const { createRoot } = await import('react-dom/client'); const isUpdate = roots.has(container); const root = isUpdate ? roots.get(container)! : createRoot(container); if (!isUpdate) { roots.set(container, root); if (autoUnmount) { onDetach(container, () => unMount(container)); } } try { root.render(component); } catch (e) { } }
Весь публичный API нашей библиотеки находится в файле library.tsx
. В основном это функции-обёртки для рендера реакт компонентов, которые выглядят следующим образом:
export async function renderAppHeader(props: AppHeaderProps, container: HTMLElement) { const { AppHeader } = await import('./components/AppHeader'); return renderComponent({ container, component: <AppHeader {...props} />, autoUnmount: true }); }
Тут же мы используем динамические импорты для код сплитинга, чтобы подгружать код по мере необходимости.
Недавно наткнулся на статью The anatomy of a React Island, где описывается такой же подход.
Конфигурация Webpack
По умолчанию Create React App — приложение, и чтобы сделать из него библиотеку мы немного изменили webpack-конфиг. Возможность запускать CRA как приложение мы также оставили, но для чего — немного позже.
Упрощенно конфиг библиотеки выглядит так:
const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); module.exports = { entry: { 'lib': './src/library.tsx' }, output: { filename: '[name].[contenthash:8].js', library: { name: 'myLib', type: 'umd', }, clean: true, }, plugins: [ new WebpackManifestPlugin({ fileName: 'asset-manifest.json', publicPath: './', }), ], };
Быстро пробежимся по конфигу.
entry: { 'lib': './src/library.tsx' }
— src/library.tsx
— основной файл нашей библиотеки. Тут мы указываем, что будет доступно в существующем проекте. После билда мы получим файл с именем lib.[contenthash].js
(например, lib.94c4847c.js
), который нужно будет подгрузить в основное приложение.
output.library.name: 'myLib'
— имя объекта, в котором будет доступно всё, что экспортируется из library.tsx
.
output.library.type
: ‘umd’, тип модулей совместимый с большинством популярных загрузчиков. Нас интересует только возможность работать с библиотекой как с глобальной переменной, поэтому значения window или var тоже бы подошли.
Проще говоря, всё, что мы экспортируем из src/library.tsx
будет упаковано в объект myLib
и доступно глобально. В существующем проекте мы сможем вызвать renderAppHeader
вот так:
myLib.renderAppHeader(document.getElementById('header-container'), { title: 'Cool App', // остальные пропсы ... })
Интегрируем
Библиотека компонентов есть, но как её интегрировать в существующий проект?
Обычно для загрузки скриптов в index.html
используется HtmlWebpackPlugin
, это очень удобно, а когда мы используем contenthash
— жизненно необходимо.
Но мы не разрабатываем приложение с нуля. Мы интегрируем реакт в существующее ASP.NET приложение, где для написания разметки используются шаблонизатор Razor, а файлы имеют расширение cshtml. При компиляции ASP.NET приложения, cshtml файлы будут включены в dll сборку.
Мы могли бы генерировать cshtml файл с помощью HtmlWebpackPlugin
’а и затем подключать его через Html.PartialAsync
.
@await Html.PartialAsync("~/Views/load-lib.cshtml")
Но тогда, на каждый билда фронта, нам придётся запускать и билд ASP.NET приложения. Всё из-за того, что имена js
файлов будут всё время меняться из-за использования contenthash’а. Избежать этого нам поможет “манифест”, для этого нам и нужен WebpackManifestPlugin
в конфиге выше.
Манифест выглядит примерно вот так (на реальном проекте он будет намного больше):
{ "lib.js": "./lib.016f9cc5.js", "app-header.js": "./app-header.3d395df7.js", "react-dom.js": "./react-dom.491b536c.js", "index.html": "./index.html" }
Он содержит название нашего основного бандла lib.js
и путь с самому файлу ./lib.016f9cc5.js
. С помощью манифеста мы можем получить название основного бандла и подгрузить его.
// load-lib.cshtml @using Newtonsoft.Json.Linq @{ const string manifestPath = "./path/to/dist/asset-manifest.json"; string assetManifestString = await System.IO.File.ReadAllTextAsync(manifestPath); JObject assetManifest = JObject.Parse(assetManifestString); string myLibChunkName = assetManifest.SelectToken("['lib.js']")?.Value<string>(); } <script src="~/@myLibChunkName"></script>
В итоге, интеграция проектов выглядит вот так
Нельзя просто так взять и мигрировать
К сожалению, от легаси «не спрятаться не скрыться, …». Например, иногда приходится использовать сложный компонент написанный на JQuery внутри React компонента.
В этом случае мы просто пишем компоненты-обёртки. Упрощённо они выглядят вот так.
function Calendar(props: CalendarProps) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { // компонент из существующего проекта, написанный на JQuery Components.Common.createCalendar({ container: ref.current, ...props }); }, []) return <div ref={ref}></div>; }
Кастомный генератор типов
Теперь можно сказать, что всё работает и мы можем использовать нашу библиотеку в существующем проекте. Но про кое-что мы забыли. Мы забыли про типы!
Мы любим TypeScript, и не любим писать код без автодополнений и проверки типов. CRA использует babel под капотом, в котором нет проверки типов и потому нет возможности генерировать .d.ts
файлы. Поэтому во время сборки мы запускаем tsc
для генерации типов.
tsc -p generate-types-tsconfig.json
В принципе, такой гибридный подход и рекомендуется в документации TypeScript — Babel for transpiling, tsc for types.
Конфиг выглядит примерно вот так:
// generate-types-tsconfig.json { "include": [ "src/library.tsx" ], "compilerOptions": { "declaration": true, "emitDeclarationOnly": true, "moduleResolution": "bundler", "module": "es2022", "outFile": "./dist/types.d.ts", "jsx": "react" }, "exclude": [ "node_modules" ] }
-
declaration
,emitDeclarationOnly
— указываем, что нам нужно сгенерировать только файлы типов -
outFile
— путь, по которому будет сгенерирован файл с типами -
moduleResolution
,module
— нужны для корректной обработки импортов
Файл с типами будет выглядеть вот так:
/// <reference types="react" /> declare module "components/AppHeader" { import React from "react"; export interface AppHeaderProps { title: string; } export function AppHeader({ title }: AppHeaderProps): React.JSX.Element; } declare module "library" { import { type AppHeaderProps } from "components/AppHeader"; export function renderAppHeader(props: AppHeaderProps, container: HTMLElement): Promise<void>; }
Но это ещё не всё. Помните, мы указали имя библиотеки myLib
? Так tsc
об этом ничего не знает. Как временное решение, мы просто взяли и с помощью регулярок:
-
удалили
} declare module "path/to/mo"
-
оставшийся импорт (перед которым нем
}
) заменили наdeclare module myLib
-
удалили вообще все импорты
В итоге получили:
/// <reference path="react.d.ts" /> declare module myLib { export interface AccountDialogProps { firstName: string; lastName: string; } export function AccountDialog({ firstName, lastName }: AccountDialogProps): React.JSX.Element; export function renderAlertDialog(): void; export function renderAccountDialog(props: AccountDialogProps, container: HTMLElement): Promise<void>; }
Но нет ничего более постоянного чем временное, поэтому ничего менять в итоге не стали. Генерируемые типы не совсем корректны, но нам главное, что он даёт базовые автокомплит и проверку типов.
Управление состоянием — Zustand
Изначально у нас вообще не было стейт менеджера, весь код мы писали на useState/useReducer + useContext. Но в этом подходе есть несколько проблем:
-
useContext не поддерживает атомарные обновления
-
В useReducer нельзя вынести асинхронную логику
В качестве стейтменеджера мы выбрали Zustand. Подробнее почему именно его — можно почитать здесь, но основная причина — его можно использовать вне React, в существующей части проекта.
Выглядит это так:
// src/stores/AppStore.ts import { create } from 'zustand' const useBearStore = create((set) => ({ bears: 0, increasePopulation: () => set((state) => ({ bears: state.bears + 1 })), removeAllBears: () => set({ bears: 0 }), })) // library.tsx // ... // При экспорте AppStore из library.tsx попадёт в основной бандл export { useAppStore as AppStore } from './stores/AppStore'; //...
Теперь в существующей части проекта можно использовать AppStore:
myLib.AppStore.increasePopulation();
Стили
В новом компоненте мы используем компонентный подход — всё, что относится к компоненту — кладём рядом:
-
код компонента
-
стили
-
тесты
-
истории сторибука
Чтобы использовать существующие LESS-переменные и миксины в новом проекте мы используем pnpm-монорепозиторий. Для этого мы создали package.json
в папке со стилями в существующем проекте и добавили зависимость в реакт проекте:
... "legacy-styles": "workspace:*" ...
И далее просто импортируем нужный нам файл в стилях.
@import (reference) '~legacy-styles/themes/tokens.less';
Тильда ~
перед legacy-styles
говорит вебпаку, что стили находятся в папке node_modules
.
Песочница
Помните я говорил, что мы оставили возможность запускать CRA как приложение? Проблема в том, что в существующем приложении нет Hot Reload’а 😮. Эту ситуацию мы также смогли немного улучшить.
Когда мы запускаем pnpm run build
происходит всё то, что я описал выше. Но при pnpm start
запускается стандартное реакт приложение. Мы называем его песочницей. По той же причине мы используем сторибук, но в нём нельзя выполнять API запросы. Мы добавили реакт роутер, и для каждой фичи создаём песочницу по отдельному урлу.
Заключение
Если подводить итог, то с уверенностью можно сказать, что код стало писать в разы легче.
Но важно помнить, что если в вашей команде не все знакомы с реакт или с другой новой технологией, вам нужно быть очень осторожными и тщательно делать код ревью. Миграция также нужна и для подхода, который используют люди. Если вы пишете на реакте код автоматически не становится идеальным, очень легко написать плохой код на любой технологии.
Если у вас есть опыт миграции на реакт или идеи как это можно было сделать лучше — расскажите в комментариях 🙏.
Код из статьи можно найти на гитхабе.
Если вам понравилась статья подпишитесь на мой телеграмм канал о программировании и не только.
ссылка на оригинал статьи https://habr.com/ru/articles/866158/
Добавить комментарий