Как мы мигрируем с JQuery на React

от автора

Вокруг все говорят о серверных компонентах реакта, о серверном рендеринге, и разных новшествах в мире фронтенде. Как будто JQuery в один миг взял и исчез. Несмотря ни на что он всё ещё остаётся самой популярной библиотекой 😅.

Сегодня я вам расскажу, как мы постепенно мигрируем с JQuery на React.

Если вам понравится эта статья, загляните в мой Telegram-канал — там я делюсь полезными материалами и мыслями о программировании.

Почему мигрируем

Тут всё довольно стандартно и понятно:

  • Разработка идёт медленно

  • Код сложно читать и поддерживать

  • XSS уязвимости подстерегают на каждом шагу

  • Сложно полноценно использовать NPM из-за ограничений пространств имён (namespace) в TypeScript

Исходный стек

Миграция началась примерно в 2019 году. Наш стек выглядел так:

Весь этот стек важен, ведь каждая из его частей накладывает некоторые ограничения, от которых зависят принимаемые решения.

Прежде чем говорить о миграции, давайте расскажу, как мы писали код до 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 варианта:

  1. Переписываем существующий код на ES-модули и интегрируем React

  2. Создать отдельный проект, где писать будем только на реакте. Никакого 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" ] }
  • declarationemitDeclarationOnly — указываем, что нам нужно сгенерировать только файлы типов

  • outFile — путь, по которому будет сгенерирован файл с типами

  • moduleResolutionmodule — нужны для корректной обработки импортов

Файл с типами будет выглядеть вот так:

/// <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 об этом ничего не знает. Как временное решение, мы просто взяли и с помощью регулярок:

  1. удалили } declare module "path/to/mo"

  2. оставшийся импорт (перед которым нем }) заменили на declare module myLib

  3. удалили вообще все импорты

В итоге получили:

/// <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/


Комментарии

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

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