Модель Fiber без зависимости от деталей реализации, планировщик, классовые компоненты, переход с React 18 на 19 и проверка кода старшего уровня.
Внутренности полезны как модель диагностики, пока вы не выдаёте детали конкретной версии за публичный контракт.
Вы проведёте миграцию на основе измерений, объясните планировщик и идентичность на уровне гарантий и распознаете устаревшие шаблоны без полного одномоментного переписывания.
Понадобится весь маршрут: отрисовка, конкурентность, серверные границы, производительность и эксплуатация. Итог урока — проект миграции React 18→19 и React Compiler с диагностическими проверками, матрицей совместимости, постепенным включением и откатом.
Публично можно опираться на чистоту отрисовки, связь состояния с компонентом, жизненный цикл эффекта и документированные API. Узлы Fiber, внутренние флаги, номера полос (lanes) и точный порядок планирования — детали реализации. Диагностическая проверка и фиксация версии допустимы, но корректность продукта от них не зависит.
Разница видна по имени того, к чему вы обращаетесь:
// ❌ внутреннее устройство: имя ключа и структура меняются между версиями
const fiberKey = Object.keys(node).find((key) => key.startsWith('__reactFiber$'))!;
const props = (node as any)[fiberKey].memoizedProps; // сломается на минорном обновлении
// ✅ публичный контракт: документированный API и данные из атрибутов
const value = (node as HTMLInputElement).value;
const ticketId = node.dataset.ticketId;Первый вариант встречается в самописных инструментах отладки и в тестах, «которым нужно посмотреть состояние». Он превращает обновление React в исследовательскую задачу: приложение собирается, тесты проходят, а инструмент молча возвращает undefined.
Проверьте себя. Пометьте утверждения как гарантию, наблюдение или предположение.
Частая ошибка. Читать внутренний Fiber из DOM-узла.
Обновления имеют разную срочность; React планирует вычисление дерева и может прервать переход срочным вводом. Фиксация остаётся согласованной, а эффекты относятся только к зафиксированному дереву. Не обещайте точные миллисекунды или порядок между независимыми корнями. Долгая задача в основном потоке браузера всё равно блокирует React.
Конкурентность — это не второй поток, а возможность прервать работу между единицами:
// ❌ 200 мс синхронной работы: прервать нечем, React ждёт вместе с браузером
function Results({ items }: { items: Item[] }) {
const sorted = heavySortInPlace(items); // блокирует основной поток целиком
return <List items={sorted} />;
}Прерываемость даёт только разбиение работы и отметка её несрочной:
// ✅ несрочное обновление можно прервать вводом пользователя
const [isPending, startTransition] = useTransition();
function handleFilter(next: Filter) {
startTransition(() => setFilter(next)); // React вправе отложить и переработать
}Даже так React не спасёт от долгой синхронной функции внутри одного компонента: пока она выполняется, основной поток занят, и прервать её нечем. Обещания вида «переход займёт не больше 50 мс» также не выполняются — планировщик даёт приоритеты, а не гарантии времени.
Проверьте себя. Снимите трассировку срочного ввода и перехода.
Частая ошибка. Считать конкурентный режим отдельным фоновым потоком.
componentDidMount, componentDidUpdate и componentWillUnmount часто объединяют разные виды синхронизации. При миграции разделите эффекты по внешним ресурсам, производные значения верните в отрисовку, логику событий — в обработчики. Границы ошибок пока обычно остаются классовым компонентом или частью фреймворка. Не переносите весь жизненный цикл в один огромный эффект.
Типичный componentDidUpdate содержит три разные обязанности в одном месте:
// ❌ было: подписка, производное значение и аналитика в одном методе
componentDidUpdate(prevProps: Props) {
if (prevProps.roomId !== this.props.roomId) {
this.connection?.close();
this.connection = connect(this.props.roomId);
}
this.setState({ fullName: `${this.props.firstName} ${this.props.lastName}` });
analytics.track('room_updated', { roomId: this.props.roomId });
}После разделения каждая обязанность попадает туда, где ей место:
// ✅ стало: внешний ресурс — эффект, производное — отрисовка, событие — обработчик
useEffect(() => {
const connection = connect(roomId);
return () => connection.close(); // симметричная очистка
}, [roomId]);
const fullName = `${firstName} ${lastName}`; // вычисляется во время отрисовки
function handleRoomChange(next: string) {
analytics.track('room_updated', { roomId: next }); // вызвано событием, не отрисовкой
onRoomChange(next);
}Механический перенос всего метода в один useEffect без зависимостей сохраняет исходную путаницу и добавляет новую: производное значение начинает отставать на одну фиксацию, а подписка пересоздаётся при любом изменении свойств.
Проверьте себя. Разделите устаревший componentDidUpdate по обязанностям.
Частая ошибка. Эффект без массива зависимостей как копия всего жизненного цикла.
Перед обновлением изучите официальное руководство: требование нового преобразования JSX, удалённые API, очистку ref, удаление ReactDOM.render и изменения типов. Сначала устраните предупреждения последней версии 18.3, затем применяйте React 19, кодовые преобразования (codemods) и тесты. Не смешивайте крупное обновление фреймворка, React и архитектуры в одном развёртывании.
Порядок шагов важнее скорости, и его стоит зафиксировать явно:
# 1. предупреждения текущей ветки — на 18.3 они те же, что станут ошибками в 19
npm i react@18.3 react-dom@18.3 && npm test
# 2. автоматические преобразования кода
npx codemod@latest react/19/migration-recipe
# 3. только теперь обновление мажорной версии
npm i react@19.2 react-dom@19.2 && npm run typecheck && npm testМатрица совместимости важна не меньше: библиотеки с собственными peerDependencies на React часто отстают от мажорного релиза.
| Зависимость | Текущая | Поддерживает React 19 | Действие |
|---|---|---|---|
| react-router | 8.3 | да | обновить вместе с React |
| @testing-library/react | 15.x | с 16.0 | обновить до 16.x |
| старый UI-набор | 3.x | нет | заменить или изолировать адаптером |
Обновление диапазонов в package.json без файла блокировки и прогона тестов даёт худший вариант: сборка проходит, а несовместимость обнаруживается у пользователя.
Проверьте себя. Составьте матрицу совместимости зависимостей.
Частая ошибка. Обновить диапазоны в package.json без файла блокировки и тестов.
Для строковых ref, findDOMNode, ReactDOM.render и значений propTypes по умолчанию документированы пути миграции. Сохраняйте поведение через createRoot или hydrateRoot, свойство или обратный вызов ref, явное владение DOM и параметры по умолчанию. findDOMNode особенно нарушает инкапсуляцию и допущения конкурентной отрисовки.
findDOMNode находит узел в обход контракта компонента — и потому ломается, как только структура разметки меняется:
// ❌ было: обёртка достаёт чужой DOM-узел
class FocusWrapper extends React.Component<Props> {
componentDidMount() {
const node = ReactDOM.findDOMNode(this) as HTMLElement;
node.querySelector('input')?.focus(); // предполагает вёрстку потомка
}
}Явное владение узлом делает связь видимой в типах: компонент получает ref тем же способом, что и любое другое свойство:
// ✅ стало: узел передан явно, обёртка не знает про вёрстку
function FocusOnMount({ children, targetRef }: { children: React.ReactNode; targetRef: React.RefObject<HTMLInputElement | null> }) {
useEffect(() => {
targetRef.current?.focus();
}, [targetRef]);
return <>{children}</>;
}
// в React 19 ref передаётся обычным свойством, forwardRef не нужен
function TitleInput({ ref }: { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} />;
}Замена findDOMNode на глобальный querySelector не решает проблему, а расширяет её: теперь компонент зависит не от разметки потомка, а от разметки всей страницы, и найдёт чужое поле при первом же переиспользовании.
Проверьте себя. Удалите обёртку с findDOMNode.
Частая ошибка. Глобальный querySelector как замена.
Сначала обновите eslint-plugin-react-hooks, исправьте нарушения и запустите проверку готовности. Включайте компиляцию по каталогам или компонентам, фиксируйте версию и сравнивайте тесты и производительность. Существующую ручную мемоизацию оставляйте до измеренного удаления. panicThreshold и режим компиляции управляют внедрением, а не скрывают ошибки.
Ограничение области — это настройка сборки, а не правка кода:
// vite.config.ts — компилируем только уже проверенный каталог
react({
babel: {
plugins: [['babel-plugin-react-compiler', {
target: '19',
sources: (filename: string) => filename.includes('/src/features/tickets/'),
}]],
},
});Дальше маршрут включают части пользователей и сравнивают метрики с контрольной группой:
// src/app/routes.ts
const TicketsRoute = flags.compiledTickets ? CompiledTickets : LegacyTickets; // 10% выкаткиУдаление ручной мемоизации — отдельный шаг после измерения. Один коммит «скомпилировали всё и убрали memo» соединяет два независимых источника регрессии: если производительность упадёт, вы не узнаете, кто виноват. panicThreshold при этом не «исправляет» проблемные компоненты — он лишь решает, падать сборке или пропускать такой компонент без компиляции.
Проверьте себя. Включите скомпилированный маршрут для 10% пользователей.
Частая ошибка. Скомпилировать всё и удалить memo одним изменением.
Мигрируйте один маршрут или сценарий вместе с тестами, границами и наблюдаемостью, оставляя адаптеры к старому коду. Вертикальный срез даёт пользовательскую ценность и реальный сигнал; слой «перепишем все утилиты» долго не поставляется. После переноса потребителей временный адаптер удаляют.
Адаптер — временный мост между новым и старым владельцем данных, и он должен быть помечен как временный:
// src/features/tickets/legacy-bridge.ts
// TODO(migration): удалить после переноса /reports и /admin на useTickets (срок: 2026-10-01)
export function subscribeLegacyStore(onChange: (tickets: Ticket[]) => void): () => void {
const unsubscribe = legacyStore.subscribe(() => onChange(legacyStore.getTickets()));
return unsubscribe;
}Срез выбирают по двум осям одновременно: польза для пользователя и ограниченность риска. Маршрут заявок с высокой посещаемостью и понятными тестами — хороший кандидат; страница биллинга с денежными операциями — плохой первый шаг. Отдельная ветка полного переписывания на месяцы не даёт ни обратной связи, ни возможности откатиться частями: к моменту слияния она конфликтует со всем, что делала команда.
Проверьте себя. Выберите срез с высокой пользой и ограниченной областью риска.
Частая ошибка. Отдельная ветка полного переписывания на месяцы.
Проект решения содержит цель и ограничения, перечень кода, матрицу совместимости, альтернативы, этапы, метрики, влияние на безопасность и производительность, откат и открытые риски. «Современнее» не аргумент. После пробного включения сравните ошибки, Web Vitals, успешность сценариев и обращения в поддержку. Иногда правильное решение — пока не мигрировать.
Проект решения умещается на страницу, и его проверяют по наличию каждого пункта:
Цель: сократить INP p75 на /tickets с 420 до ≤200 мс, снять ручную мемоизацию
Ограничения: без остановки поставки, откат в течение 10 минут, старый UI-набор 3.x не поддерживает React 19
Этапы: 18.3 + предупреждения → codemods → React 19.2 → Compiler на /tickets (10/50/100%)
Метрики: INP p75, доля ошибок границ, успешность сохранения заявки, обращения в поддержку
Альтернативы: точечная виртуализация без миграции (дешевле, не снимает мемоизацию);
остаться на 18.3 (нулевой риск, не решает задачу)
Откат: флаг compiledTickets → выключение без развёртывания; артефакт N-1 хранится
Остаточный риск: UI-набор 3.x изолирован адаптером, но блокирует обновление до 20.xРаздел с альтернативами и остаточным риском — то, что отличает проект от намерения. Успешная сборка не является критерием: она не говорит ни о задержке ввода, ни о доле ошибок, ни о том, стало ли пользователю лучше. И вывод «пока не мигрируем, потому что блокирующая зависимость не готова» — это полноценный результат проверки.
Проверьте себя. Защитите проект миграции перед критически настроенными коллегами.
Частая ошибка. Считать успешную сборку единственным критерием успеха.
Проверьте старую панель: классы, findDOMNode, строковые ref, побочные эффекты во время отрисовки, ручную мемоизацию, загрузку данных эффектами и старый корень. Перенесите один вертикальный срез на React 19, действия, ref как свойство и React Compiler, сохранив тесты поведения и план пробного включения.
Работа готова, когда проект решения отделяет публичные гарантии от деталей реализации, миграция идёт поэтапно, метрики и откат определены, а тесты старого поведения остаются зелёными.