В проектах старше шести лет почти всегда живёт технология, давно вычеркнутая её создателями из планов: AngularJS, чей финальный релиз вышел уже после конца официальной поддержки, jQuery 1.x, Gulp 3, Node.js 14, покинувший график обновлений 30 апреля 2023 года. Команда смотрит на этот архив, прикидывает переписать всё заново за пару месяцев и попадает в ловушку: полтора года тишины, старый код работает и ветшает, новый не готов, заказчик теряет терпение. Рабочая альтернатива - долгосрочная миграция по шагам, при которой старая и новая кодовые базы живут рядом, а граница между ними двигается небольшими маршрутами месяцами и годами. Такой переезд не выглядит эффектно на схемах, зато каждый его отрезок заканчивается релизом, и именно его маршруту посвящён разбор ниже.
Аудит унаследованного кода и карта зависимостей до первого шага миграции
Переезд начинается не с выбора блестящего фреймворка, а с честной инвентаризации. Три команды пакетного менеджера дают первую карту за десять минут:
npm outdated
npm ls --depth=0
npm audit --audit-level=moderate
Первая команда выводит таблицу отставания пакетов от свежих релизов, вторая показывает дерево прямых зависимостей, третья считает известные дыры в защите. Типичная картина проекта 2019 года рождения выглядит примерно так:
Package Current Wanted Latest
angular 1.5.8 1.8.3 1.8.3
vue 2.6.14 2.7.16 3.5.44
webpack 4.47.0 4.47.0 5.112.0
typescript 3.9.4 3.9.4 7.0.2
Здесь видны три разных диагноза. AngularJS обновился до финального релиза 1.8.3, но дальше дороги нет: линия закрыта, это кандидат номер один на вынос в новый стек. Vue внутри ветки 2.x поднимется до 2.7.16 малой кровью, и этот шаг стоит сделать первым, потому что версия 2.7 принесла в старую ветку Composition API и снизила цену прыжка на третью. Webpack застрял на четвёрке: пятая мажорная версия меняет конфигурацию, обновление станет отдельной мини-миграцией. TypeScript отстал на четыре мажора, самое коварное отставание: код продолжает собираться и однажды просто перестаёт.
Второй слой аудита - карта горячих зон. Статистика изменений из системы контроля версий показывает, какие модули правят каждую неделю, а какие не трогали годами. Замороженные модули не переносят вовсе: их архивируют или вырезают до переезда. Для живых зон измеряют покрытие тестами: если дымовой набор закрывает меньше половины сценариев, миграция модуля начинается не с кода, а с тестов, иначе сравнивать будет не с чем. Третий слой - границы: до первого шага фиксируют, какие модули обмениваются данными и в каком формате, по этим швам систему будут резать.
Паттерн удушающей лозы и шлюз между старым и новым кодом
Название паттерна strangler fig взято у тропического фикуса: тот прорастает рядом со старым деревом, оплетает его и замещает крону, пока от прежнего растения не остаётся трухлявый ствол. В веб-разработке аналогия буквальна. Перед старым приложением ставят обратный прокси, который развозит входящие адреса по двум кодовым базам. Сначала новый стек обслуживает один некритичный маршрут, например страницу вакансий, остальное отдаёт унаследованное приложение. Когда маршрут приживается, шлюз переключает следующий, и так до последнего. Минимальная конфигурация шлюза на nginx умещается в несколько строк:
server {
listen 443 ssl;
location /vacancies {
proxy_pass http://127.0.0.1:8100;
}
location / {
proxy_pass http://127.0.0.1:8080;
}
}
У лозы есть тактические помощники. Антикоррупционный слой - тонкая прослойка перевода данных на границе двух миров: новый код получает свои чистые структуры, а не сырые форматы 2016 года. Ветвление через абстракцию позволяет менять реализацию ещё до появления нового стека: сначала интерфейс платежей выносят в отдельный модуль, потом пишут вторую реализацию, потом переключают флагом. Канареечный запуск повышает долю трафика шагами: 5 процентов, затем 10, затем половина, и метрики ошибок решают, ехать дальше или откатываться.
Календарь поддержки Node.js и перевод рантайма на свежие версии
Рантайм тянут из прошлого первым, потому что от него зависит всё остальное: сборщики, тестовые раннеры, нативные модули. Node.js живёт по опубликованному графику: каждая чётная мажорная версия выходит весной и осенью, в октябре получает статус LTS и окно поддержки на 30 месяцев - год активной фазы с регулярными обновлениями, затем полтора года режима обслуживания, где остаются только исправления безопасности.
Даты графика говорят сами за себя. Node.js 18 закончился 30 апреля 2025 года, Node.js 20 - 30 апреля 2026 года, Node.js 22 перешёл в режим обслуживания 21 октября 2025 года и закроется 30 апреля 2027 года, Node.js 24 получил LTS 28 октября 2025 года, ушёл в обслуживание 20 октября 2026 года и закончится 30 апреля 2028 года, Node.js 26 выходит на LTS 28 октября 2026 года с финалом 30 апреля 2029 года. Проект на Node.js 14 или 16 лежит вне графика уже третий год, и это не абстрактная угроза: нативные зависимости и тестовые инструменты на новых машинах перестают собираться.
Ожидаемую версию фиксируют заранее. Поле engines в package.json отклоняет запуск на неподдерживаемом рантайме ещё до установки зависимостей:
{
"engines": {
"node": ">=22.12.0 <23"
}
}
Матрица непрерывной интеграции гоняет тесты на двух живых версиях сразу, чтобы скачок не пришёлся на день большого релиза. Заодно переезд рантайма вскрывает сломанные зависимости: классический пример - препроцессор node-sass, давно уступивший место чистой реализации sass, такое наследие меняют одной операцией.
Мажорные переходы React, Vue и Angular с датами окончания поддержки
За одну осеннюю неделю react из публичного реестра npm скачивают около 116 миллионов раз, typescript - около 184 миллионов, vite - около 119 миллионов, vue - около 10 миллионов, jQuery - около 8,4 миллиона, а пакет angular, то есть старый AngularJS, - около 275 тысяч, в четыреста с лишним раз меньше react.
React сейчас живёт на ветке 19, актуальный релиз - 19.3.1. Мажор 19 вышел 5 декабря 2024 года и сломал на удивление мало: точку входа нужно перевести на createRoot из react-dom/client, а ref теперь передаётся как обычный проп без обёртки forwardRef.
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);
Отдельного маршрута заслуживает судьба Create React App: 14 февраля 2025 года команда React свернула инструмент, утилита заморожена на релизе 5.1.0, новые приложения рекомендуют собирать на Vite, Parcel или RSBuild. Смена сборки и смена кода - два разных маршрута лозы, совмещать их в одном шаге не стоит.
Поддержка Vue второй ветки прекратилась 31 декабря 2023 года, финальный релиз - 2.7.16. Обновление до 2.7 внутри старой ветки само по себе половина пути: Composition API доступен уже в ней, компоненты переводят на новый синтаксис ещё до смены версии. Переходный режим @vue/compat запускает приложение на третьей версии в режиме совместимости и гасит предупреждения об устаревшем коде по одному файлу. Актуальный релиз третьей ветки - 3.5.44, версия 3.6 дошла до релиз-кандидатов.
Angular живёт по строгому календарю: мажор раз в 12 месяцев, окно поддержки каждой мажорной версии - 24 месяца, из них 12 активных обновлений и 12 только критических исправлений. Текущий мажор - 22.2.3, предыдущий сидит на LTS-релизе 21.2.25, следующий ожидается около июня 2027 года. Устаревшие API держат не меньше 12 месяцев после объявления deprecation, поэтому путь по одной мажорной версии за раз проходит спокойно. У предка, AngularJS, всё иначе: поддержка закончилась 31 декабря 2021 года, финальный релиз 1.8.3 вышел 7 апреля 2022 года, а для гибридного периода существует модуль UpgradeModule, монтирующий компоненты нового Angular внутри старого приложения.
Сборка на Vite и постепенное ужесточение проверки типов
Webpack 5.112.0 по-прежнему рабочий инструмент, но миграции почти всегда смотрят в сторону Vite: версия 8.3.5 требует Node.js не ниже 20.19 или 22.12, так что свежий рантайм сам открывает дорогу к новой сборке. Переход со старого конфигурационного файла редко делают за раз, практичнее переносить сборку постранично:
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
'@legacy': '/src/legacy',
jquery: '/src/shims/dom-helpers.js'
}
}
});
Алиас на jquery - типовой миграционный приём: старые модули продолжают импортировать привычное имя, а под ним живёт обёртка над querySelectorAll и fetch. По мере переезда импорты вычищают, и к концу миграции шим уходит вместе с последним зависимым модулем.
Типы - второй фронт без большой переписи. TypeScript остаётся самым скачиваемым инструментом фронтенда, актуальная ветка - седьмая, релиз 7.0.2. Строгий режим включают по каталогам: новые модули рождаются под строгими флагами, старые живут с мягкими настройками до своей очереди.
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
}
}
Флаг noUncheckedIndexedAccess помечает любое обращение к элементу массива как возможно неопределённое, и старый код с ходу такую проверку не переживёт. Поэтому порядок обратный: сначала strictNullChecks в новых каталогах, потом строгий режим целиком, в конце чистка остатков.
Характеризационные тесты и контракты как страховка переезда
Миграция без страховки оставляет одни догадки: после переключения маршрута ошибки выросли, но кто виноват - новый код, старые данные или совпадение, остаётся спорить. Первый слой страховки - характеризационные тесты. Перед изменением старого модуля его поведение фиксируют автотестами, включая крайние случаи: миграция не чинит бизнес-логику, а сохраняет поведение, и если старый код годами отвечал кривым JSON, тест закрепит именно его. Сознательные исправления делают отдельным шагом после переезда, иначе поломку миграции не отличить от изменения замысла.
Второй слой - контрактные тесты на стыке двух стеков: потребитель описывает ожидания от полей и форматов, обе реализации обязаны их выполнять, расхождение ловится в непрерывной интеграции задолго до эксплуатации. Третий слой - параллельный прогон: один и тот же запрос уходит в обе системы, совпадение ответов проверяется автоматически:
import { test, expect } from '@playwright/test';
test('новый сервис отвечает тем же телом заказа', async ({ request }) => {
const legacy = await (await request.get('/api/orders/1001')).json();
const modern = await (await request.get('/api/v2/orders/1001')).json();
expect(modern).toEqual(legacy);
});
Кода в таких проверках немного, а спокойствия они дают на месяцы. Дымовой набор из пары десятков сценариев гоняют перед каждым переключением маршрута: корзина, оплата, вход, фильтры.
Темп миграции, метрики и признаки завершённого переезда
Долгий переезд держится на жёстких правилах, и их стоит записать до первой миграционной ветки:
- каждая итерация заканчивается слиянием в основную ветку и выпуском, а не копится в параллельной разработке на год;
- маршрут переезжает вместе со своими тестами и метриками, без страховки новый участок в эксплуатацию не попадает;
- старый код в мигрированной зоне замораживается, изменения проходят только через новый стек;
- у каждого шага есть откат, флаг выключает новый маршрут за минуты, а не за часы;
- схема переезда обновляется тем же коммитом, что и код, иначе через полгода детали маршрута забудутся.
Метрики говорят громче слов. У проекта должны остаться в норме или улучшиться три вещи: скорость, надёжность и вес. Пороговые значения Core Web Vitals фиксированы: LCP не хуже 2,5 секунды, INP не выше 200 миллисекунд, CLS не больше 0,1. Долю ошибок и время ответа p95 сравнивают до и после каждого переключённого маршрута, а размер главной страницы в килобайтах - самая честная диаграмма прогресса, её вывешивают на общий дашборд.
Завершённым переезд считается не тогда, когда весь код переписан, а когда от старого стека не остаётся рабочих следов: последний маршрут переведён, контейнер унаследованного образа остановлен, тег удалён из реестра, аудит зависимостей не видит пакетов вне поддержки. Главная добыча миграции лежит даже не в коде: команда, однажды прошедшая такой путь, получает привычку обновляться малыми шагами, и следующая смена мажорной версии фреймворка превращается из проекта на квартал в плановую работу на два спринта. Устаревание никуда не исчезает, у него лишь появляется расписание, бюджет и ответственный, а это и есть разница между проектом, который медленно ветшает, и проектом, который переезжает налегке.