Pull to refresh

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

Level of difficultyMedium
Reading time10 min
Views4.1K

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

Заключение

Если подводить итог, то с уверенностью можно сказать, что код стало писать в разы легче.

Но важно помнить, что если в вашей команде не все знакомы с реакт или с другой новой технологией, вам нужно быть очень осторожными и тщательно делать код ревью. Миграция также нужна и для подхода, который используют люди. Если вы пишете на реакте код автоматически не становится идеальным, очень легко написать плохой код на любой технологии.

Если у вас есть опыт миграции на реакт или идеи как это можно было сделать лучше — расскажите в комментариях 🙏.

Код из статьи можно найти на гитхабе.

Если вам понравилась статья подпишитесь на мой телеграмм канал о программировании и не только.

Tags:
Hubs:
+4
Comments13
1

Articles