Миграции версий React: проект — это аудит зависимостей
Мажоры React — проекты про зависимости: createRoot включает автоматический батчинг везде, двойной прогон StrictMode вскрывает и так сломанные эффекты, 19 удаляет легаси-API. Сначала аудит deps, потом codemod-ы, потом канареечный раскат с RUM. Недели-месяцы на большом приложении.
Оценка в Jira гласила: две недели. «Апгрейд React 17 → 18, поменять ReactDOM.render на createRoot, готово». Код приложения занял четыре дня — один прогон codemod, обёртка flushSync вокруг двух хаков с измерением скролла, зелёная сборка. Потом команда запустила тесты: 4 100 enzyme-тестов, а у enzyme нет адаптера под React 18 — его собственный мейнтейнер публично объявил проект завершённым, вместо того чтобы гнаться за новыми внутренностями React. Настоящей миграцией стало переписывание четырёх тысяч тестов на Testing Library, вывод из эксплуатации внутренней библиотеки дропдаунов на легаси-контексте и перевендоривание обёртки над чартами, которая звала ReactDOM.findDOMNode. Одиннадцать недель, из которых сам React стоил четыре дня. Это соотношение — правило, а не исключение. Мажор React — проект про граф зависимостей в костюме фреймворка, и команда, которая первым делом аудирует собственный код, старательно проверяет дешёвую часть, пока дорогая ждёт в node_modules.
17 → 18: createRoot меняет поведение, а не только API
Оставить ReactDOM.render на React 18 — значит запустить приложение в легаси-режиме с предупреждением об устаревании и без какого-либо нового поведения; настоящий апгрейд — переход на createRoot, и он несёт одно тихое семантическое изменение, породившее целый класс продакшен-багов: автоматический батчинг везде. В 17 несколько вызовов setState сливались в один рендер только внутри обработчиков событий React; в таймаутах, промисах и нативных слушателях каждый setState синхронно прогонял собственный рендер. Код незаметно зависел от этого промежуточного коммита:
// React 17, легаси-рут: setOpen коммитится синхронно прямо здесь,
// поэтому к следующей строке модалка уже в DOM.
setTimeout(() => {
setOpen(true);
const h = panelRef.current.offsetHeight; // меряет настоящую панель
setHeight(h);
}, 0);
// React 18, createRoot: оба апдейта сливаются в ОДИН рендер.
// panelRef.current — всё ещё СТАРОЕ дерево, offsetHeight читает мусор.
// Аварийный люк, когда коммит правда нужен немедленно:
flushSync(() => setOpen(true)); // коммитит до следующей строкиКласс багов коварен тем, что ничего не падает: измерение читает устаревший layout, аналитика стреляет один раз вместо двух, тест проверяет промежуточный рендер, которого больше не существует. flushSync возвращает семантику 17 для одного апдейта ценой синхронного коммита — применяйте его к считанным настоящим случаям «измерить после обновления», а не как ковровое лечение.
После миграции с ReactDOM.render на createRoot позиционер модалки, который вызывает setOpen(true) внутри setTimeout и на следующей строке меряет offsetHeight, стал возвращать 0. Ничего не падает. Что изменилось?
StrictMode — линтер миграции
Dev-режим StrictMode в React 18 прогоняет каждое монтирование как setup → cleanup → setup: эффекты срабатывают, их cleanup выполняется, затем они срабатывают снова. Команды, столкнувшись с этим при миграции, видят удвоенные WebSocket-соединения, удвоенные события аналитики, удвоенные подписки — и соблазнительный диагноз звучит как «StrictMode сломал приложение, выключим его». Правильное прочтение обратное: эффекты, ломающиеся под двойным прогоном, были сломаны и до него. Любой эффект, чей cleanup не отменяет setup в точности, неверно ведёт себя и в продакшене — при ремаунте роутов, при навигации назад/вперёд и под любой будущей фичей React, сохраняющей и восстанавливающей состояние компонентов. StrictMode — это фаззер контракта setup/cleanup, и потому самый дешёвый инструмент миграции: включите его в dev до апгрейда, почините каждый удвоенный сайд-эффект — и вы заранее оплатили класс багов, который автоматический батчинг и ремаунты иначе вскрывали бы в продакшене по одному тикету за раз.
▸Почему это работает
Почему React намеренно гоняет эффекты дважды в dev, вместо того чтобы просто задокументировать контракт? Потому что контракт непроверяем статически — действительно ли cleanup отменяет setup, зависит от рантайм-поведения произвольного кода (сокеты, кэши, обсерверы, сторонние SDK). Двойной прогон — исполняемый тест на устойчивость к ремаунту: если mount → unmount → mount даёт корректный экран и ни одного утёкшего ресурса, компонент переживёт всё, что React планирует делать с переиспользованием состояния. Цена — шум только в dev; альтернативой было бы каждой команде открывать свои текущие эффекты в продакшене, по инциденту за раз, когда такие фичи приедут.
React 19: удаления, ref как prop и путь к Компилятору
React 19 — релиз удалений. API, годами выдававшие предупреждения, исчезли: defaultProps у функциональных компонентов (используйте параметры по умолчанию), строковые refs, легаси-контекст (contextTypes / getChildContext), propTypes (теперь полностью игнорируются) и старые точки входа DOM — ReactDOM.render, hydrate, unmountComponentAtNode, findDOMNode. react-test-renderer объявлен устаревшим. Единственное добавление, меняющее повседневный код: ref — обычный prop функционального компонента, так что forwardRef превращается в церемонию, которую можно удалить:
// 18: церемония-обёртка
const Input = forwardRef((props, ref) => <input ref={ref} {...props} />);
// 19: ref приходит как любой другой prop
function Input({ ref, ...props }) { return <input ref={ref} {...props} />; }React Compiler едет следом за 19 как опциональный шаг сборки: он автоматически мемоизирует компоненты, соблюдающие правила, и путь его внедрения повторяет форму самой миграции — сначала линт (расширенные правила eslint-plugin-react-hooks показывают, какие файлы нарушают Rules of React), затем включение каталог за каталогом, а не большим взрывом. Компонент, ломающийся под Компилятором, как и эффект, ломающийся под StrictMode, нарушал правила и раньше — инструмент лишь сделал это видимым.
Процесс: сначала deps, codemod-ы честно, канарейка с RUM
Аудит зависимостей идёт первым, потому что зависимости доминируют в сроках. Перечислите каждый пакет с peer-зависимостью react и рассортируйте по категориям смертного списка: тестовые фреймворки, пришитые к внутренностям React (похороны enzyme — смерть тестового фреймворка — самая частая скрытая стоимость), библиотеки на удалённых API (легаси-контекст, строковые refs, findDOMNode) и пакеты, чьи поддерживаемые версии требуют нового мажора React, пока остальные ваши deps требуют старого. По каждому пакету варианты: совместимый мажор существует (обновить), не существует (заменить, форкнуть или выпилить фичу), пакет внутренний (заложить переписывание). Этот аудит — таблица, собираемая за два дня, и именно она превращает «недели две, наверное» в честный план.
Codemod-ы (автоматизированные скрипты переименования кода) покрывают синтаксис, а не семантику. Официальная коллекция react-codemod (плюс миграционный рецепт React 19) выполняет механические переименования — обновление импортов, ReactDOM.render → createRoot, замену строковых refs, defaultProps → параметры по умолчанию. Чего ни один codemod не чинит: эффект, опирающийся на небатченные коммиты; тест, проверяющий промежуточные рендеры; подписку без cleanup. Прогоните codemod-ы — и заложите настоящее инженерное время на поведенческую разницу.
Раскатывайте как любой рискованный деплой. Отправьте обновлённую сборку на канареечное кольцо в 1% с RUM, следящим за тремя сигналами против контрольного кольца: частота JS-ошибок, частота расхождений гидрации и p75 INP/LCP. Наращивайте 1 → 10 → 50 → 100 за несколько дней; откат — перепиновка предыдущей сборки. Команды, которые «обновляют в ветке и мёржат, когда зелено», ставят на своё тестовое покрытие всю пользовательскую базу; команды с постепенным раскатом — один её процент.
Вместе эти четыре шага — аудит, codemod-ы, обкатка StrictMode, канареечный раскат — образуют единый конвейер: пропустите аудит и смертный список обнаружится в середине раската; пропустите канарейку и тихая батчинг-регрессия доедет до 100% пользователей прежде, чем кто-то заметит.
Цена отставания накапливается. Старый мажор не получает фич и на практике не получает патчей; диапазоны peer-зависимостей экосистемы уезжают вперёд, и ваше дерево зависимостей замерзает на последних версиях, поддерживающих ваш React, — вместе с их исправлениями безопасности. Найм усугубляет: кандидаты знают актуальный React. Честные сроки для большого приложения — недели-месяцы, и доминируют в них зависимости; и сроки растут, чем дольше ждать, потому что каждый пропущенный мажор добавляет собственный смертный список.
Во время обкатки 18 StrictMode в dev заставляет дашборд открывать два WebSocket-соединения и дублировать каждое сообщение чата. Коллега предлагает выкатиться без StrictMode — продакшен ведь монтирует только один раз. Каков senior-вердикт?
- 01Назовите поведенческое изменение createRoot, порождаемый им класс багов и аварийный люк.
- 02Изложите процесс миграции по порядку и обоснуйте, почему зависимости идут первыми.
Мажорный апгрейд React — проект про граф зависимостей, в котором ваш собственный код обычно дешёвая часть. Теперь, когда в роадмапе появится мажор React, запустите аудит зависимостей до того, как изменена хоть одна строка кода, — эта таблица и есть план проекта. Шаг 17 → 18 определяется одним семантическим изменением — createRoot включает автоматический батчинг вне обработчиков событий React, поэтому код, зависевший от синхронного коммита на каждый setState (измерения DOM между апдейтами, аналитика на счётчиках рендеров, тесты промежуточных рендеров), ломается тихо, а точечный люк — flushSync. Dev-цикл StrictMode setup → cleanup → setup — линтер миграции: каждый удвоенный сокет или событие — это эффект, чей cleanup не отменял setup, то есть готовый продакшен-баг при любом ремаунте; чините cleanup и никогда не выключайте линтер и не обманывайте его ref-флагами. React 19 удаляет давно устаревшую поверхность — defaultProps у функций, строковые refs, легаси-контекст, propTypes, старые точки входа ReactDOM — и делает ref обычным prop, а Компилятор идёт следом как опциональный шаг с путём внедрения «линт, затем включение каталог за каталогом». Процесс, выживающий при встрече с реальностью: сначала аудит зависимостей (категории смертного списка — умирающие тестовые фреймворки, библиотеки на удалённых API, конфликты peer deps — потому что сроки задают deps, а не код), затем codemod-ы для механических переименований с честным признанием, что они чинят синтаксис и никогда семантику, затем обкатка StrictMode, затем канареечный раскат с RUM по частоте ошибок, расхождениям гидрации и p75 INP на кольцо, где откат — перепиновка. Отставание не нейтрально: замёрзшие деревья peer-зависимостей перестают получать исправления безопасности, а каждый пропущенный мажор добавляет собственный смертный список. Для большого приложения закладывайте недели-месяцы — и знайте, что почти ничего из них не есть сам React.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.