В энтерпрайз-монорепозиториях объемом от 100 000 до 5 000 000 строк кода регулярное обновление мажорных версий фреймворка сталкивается с экспоненциальным ростом затрат. Релиз React 19 официально объявил устаревшими (deprecated) паттерны, которые годами считались стандартом индустрии:
React.forwardRef: заменена на передачу ref как стандартного пропса.defaultProps для функциональных компонентов: удалено в пользу стандартных дефолтных значений параметров ES6.React.FC (или React.FunctionComponent): признан антипаттерном из-за неявного добавления children и оверхеда типизации.Попытка выполнить подобную миграцию вручную силами продуктовых команд неизбежно приводит к краху:
null / undefined.Единственный масштабируемый подход — автоматизированная трансформация исходного кода через AST-кодмоды с использованием фреймворка jscodeshift и библиотеки бережного синтаксического форматирования recast.
В отличие от стандартных компиляторов (Babel, SWC), которые генерируют полностью новый исходный код из AST без сохранения исходного стиля форматирования, инструмент рефакторинга обязан сохранять нетронутые участки файла в первозданном виде (включая отступы, пустые строки, кавычки и комментарии JSDoc).
Эту задачу решает recast — синтаксический процессор, создающий гибридное дерево AST. Каждый узел recast сохраняет точные токены и ссылки на оригинальный исходный текст. При модификации конкретного узла recast форматирует только измененную подветвь, оставляя 99% исходного файла байт-в-байт идентичным оригиналу.
jscodeshift оборачивает recast в декларативный API, вдохновленный jQuery, и запускает параллельную обработку файлов в пуле воркеров Node.js:
[ CLI: jscodeshift --transform codemod.ts src/ ]
│
┌──────────────────────────┴──────────────────────────┐
▼ ▼
[ Worker 1 (CPU 0) ] [ Worker N (CPU N) ]
│ │
▼ ▼
[ recast.parse() ] [ recast.parse() ]
(Сохранение токенов и (Сохранение токенов и
форматирования) форматирования)
│ │
▼ ▼
[ jscodeshift Collection API ] [ jscodeshift Collection API ]
root.find(j.CallExpression) root.find(j.CallExpression)
.replaceWith(...) .replaceWith(...)
│ │
▼ ▼
[ recast.print() ] [ recast.print() ]
(Минимальный точный diff) (Минимальный точный diff)
│ │
└──────────────────────────┬──────────────────────────┘
│
▼
[ Атомарный Git Commit / Diff ]Проверка чистоты git status. Запуск линтера и тестов для фиксации базового зеленого состояния.
Выполнение jscodeshift с флагами --dry --print для инспекции AST-диффов в терминале без записи на диск.
jscodeshift распределяет десятки тысяч файлов по ядрам CPU, выполняя разбор через парсер @babel/parser или flow.
Поиск целевых узлов (например, MemberExpression с defaultProps), перенос логики в параметры и удаление старых узлов.
Анализ оставшихся ссылок на идентификатор forwardRef; если ссылок нет, удаление спецификатора из декларации React.
Запуск точечного форматирования измененных файлов и создание атомарных коммитов по доменам фич.
Почему для масштабного рефакторинга кодовой базы используется именно связка jscodeshift + recast, а не обычная генерация кода через Babel или SWC?
React.forwardRef для React 19#В кодовой базе из 400 компонентов дизайн-системы каждый компонент, работающий с DOM-узлами, обернут в forwardRef((props, ref) => ...). В React 19 ref передается как стандартный пропс. Кодмод автоматически:
forwardRef;ref в список аргументов функции или деструктуризацию пропсов;Props, добавляя ref?: React.Ref<T>;forwardRef из import React, { forwardRef } from 'react'.defaultProps на дефолтные параметры ES6#Паттерн Button.defaultProps = { variant: 'primary', size: 'md' } полностью удален из функциональных компонентов в React 19. Кодмод находит присваивания defaultProps, считывает объектные литералы, сопоставляет ключи со списком деструктурированных параметров функции ({ variant = 'primary', size = 'md' }) и безопасно удаляет выражение Component.defaultProps.
React.FC#Тип const Button: React.FC<ButtonProps> = ... долгое время критиковался архитекторами за неявный полиморфизм. Кодмод преобразует переменную в именованную декларацию функции export function Button(props: ButtonProps), повышая читаемость стека вызовов в DevTools и оптимизируя анализ для React Compiler.
Напишем рабочий кодмод, выполняющий сразу три задачи:
forwardRef на нативный ref.defaultProps в аргументы функции.forwardRef.import type { API, FileInfo, Options } from 'jscodeshift';
export default function transformer(file: FileInfo, api: API, options: Options) {
const j = api.jscodeshift;
const root = j(file.source);
let hasModifications = false;
// 1. Поиск и трансформация вызовов forwardRef
root
.find(j.CallExpression, {
callee: {
type: 'Identifier',
name: 'forwardRef',
},
})
.forEach((path) => {
const renderFn = path.node.arguments[0];
// Проверяем, что аргумент — это стрелочная функция или FunctionExpression
if (
j.ArrowFunctionExpression.check(renderFn) ||
j.FunctionExpression.check(renderFn)
) {
// В forwardRef сигнатура: (props, ref) => ...
const propsParam = renderFn.params[0];
const refParam = renderFn.params[1];
if (refParam && j.Identifier.check(refParam)) {
// Если первый параметр — деструктуризация ({ className, ...rest })
if (j.ObjectPattern.check(propsParam)) {
// Добавляем ref в деструктуризацию: ({ className, ref, ...rest })
propsParam.properties.unshift(
j.objectProperty.from({
shorthand: true,
key: j.identifier(refParam.name),
value: j.identifier(refParam.name),
})
);
} else if (j.Identifier.check(propsParam)) {
// Если пропсы именованы целиком: (props) => ...
// Оставляем второй параметр или объединяем параметры
}
// Удаляем второй параметр ref из списка аргументов функции
renderFn.params.splice(1, 1);
}
// Заменяем вызов forwardRef(fn) на саму чистую функцию fn
j(path).replaceWith(renderFn);
hasModifications = true;
}
});
// 2. Поиск и миграция AssignmentExpression: Component.defaultProps = { ... }
root
.find(j.AssignmentExpression, {
left: {
type: 'MemberExpression',
property: {
type: 'Identifier',
name: 'defaultProps',
},
},
})
.forEach((path) => {
const componentIdent = path.node.left;
if (
j.MemberExpression.check(componentIdent) &&
j.Identifier.check(componentIdent.object)
) {
const componentName = componentIdent.object.name;
const rightValue = path.node.right;
// Если присваивается объектный литерал
if (j.ObjectExpression.check(rightValue)) {
const defaultPropsMap = new Map<string, any>();
rightValue.properties.forEach((prop) => {
if (
j.ObjectProperty.check(prop) &&
j.Identifier.check(prop.key)
) {
defaultPropsMap.set(prop.key.name, prop.value);
}
});
// Ищем объявление соответствующего функционального компонента
root
.find(j.FunctionDeclaration, { id: { name: componentName } })
.forEach((fnPath) => {
const firstParam = fnPath.node.params[0];
if (j.ObjectPattern.check(firstParam)) {
firstParam.properties.forEach((patternProp) => {
if (
j.ObjectProperty.check(patternProp) &&
j.Identifier.check(patternProp.key) &&
defaultPropsMap.has(patternProp.key.name)
) {
const defaultVal = defaultPropsMap.get(patternProp.key.name);
// Преобразуем { prop } в AssignmentPattern: { prop = defaultValue }
patternProp.value = j.assignmentPattern(
j.identifier(patternProp.key.name),
defaultVal
);
}
});
}
});
// Удаляем старую инструкцию Component.defaultProps = { ... }
j(path.parentPath).remove();
hasModifications = true;
}
}
});
// 3. Очистка неиспользуемого импорта forwardRef
if (hasModifications) {
root
.find(j.ImportDeclaration, {
source: { value: 'react' },
})
.forEach((importPath) => {
// Фильтруем спецификаторы, исключая forwardRef
importPath.node.specifiers = importPath.node.specifiers?.filter(
(spec) =>
!(
j.ImportSpecifier.check(spec) &&
spec.imported.name === 'forwardRef'
)
);
// Если импорт стал пустым, удаляем строку целиком
if (importPath.node.specifiers?.length === 0) {
j(importPath).remove();
}
});
}
// Возвращаем модифицированный исходный код только при наличии изменений
return hasModifications ? root.toSource(options.printOptions || { quote: 'single' }) : null;
}jscodeshift сканирует директории src/ по маске расширений .tsx, отсекая node_modules и .git.
Используется многопоточный сканер с минимальным потреблением дескрипторов файлов.
Каждый файл парсится в изолированном воркере Node.js через @babel/parser с поддержкой TSX.
recast сохраняет точную карту смещений символов и комментариев (leading/trailing comments).
Выполнение логики кодмода: преобразование параметров функций, замена узлов и вырезание устаревших свойств.
API jscodeshift автоматически обновляет обратные ссылки узлов на их родительские контексты (parentPath).
recast.print() перепечатывает только дельту измененных узлов. Файлы атомарно перезаписываются на диске.
Неизмененные файлы пропускаются (0 байт дисковых операций I/O).
Что произойдет с комментариями JSDoc и инлайн-аннотациями типов TypeScript, если кодмод удалит узел функционального компонента целиком через j(path).remove() вместо модификации его свойств?
Если вызвать метод root.toSource() с жесткими параметрами форматирования или пропустить AST через стандартный генератор кода Babel (@babel/generator), инструмент переформатирует 100% строк файла. В результате Pull Request будет содержать 10 000 строк изменений вместо 20. Это разрушит историю git blame и сделает код-ревью невозможным. Используйте настройки по умолчанию recast.
Запуск кодмода jscodeshift -t transform.ts src/ напрямую на рабочем дереве без предварительного прогона с флагом --dry может необратимо испортить сотни файлов в случае логической ошибки в AST-селекторе. Всегда выполняйте тестовый прогон с проверкой одного файла: npx jscodeshift -t codemod.ts src/components/Button.tsx -d -p.
Вызов path.replaceWith() или удаление элементов массива params.splice() внутри прямого цикла без учета сдвига индексов приводит к пропуску соседних узлов AST (эффект скачущего индекса). Используйте метод forEach библиотеки jscodeshift, которая создает стабильную копию коллекции перед обходом.