Вокруг все говорят о серверных компонентах реакта, о серверном рендеринге, и разных новшествах в мире фронтенде. Как будто 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 запросы. Мы добавили реакт роутер, и для каждой фичи создаём песочницу по отдельному урлу.
Заключение
Если подводить итог, то с уверенностью можно сказать, что код стало писать в разы легче.
Но важно помнить, что если в вашей команде не все знакомы с реакт или с другой новой технологией, вам нужно быть очень осторожными и тщательно делать код ревью. Миграция также нужна и для подхода, который используют люди. Если вы пишете на реакте код автоматически не становится идеальным, очень легко написать плохой код на любой технологии.
Если у вас есть опыт миграции на реакт или идеи как это можно было сделать лучше — расскажите в комментариях 🙏.
Код из статьи можно найти на гитхабе.
Если вам понравилась статья подпишитесь на мой телеграмм канал о программировании и не только.