В Airbnb для фронтенд-разработки официально применяется TypeScript (TS). Но процесс внедрения TypeScript и перевода на этот язык зрелой кодовой базы, состоящей из тысяч JavaScript-файлов, это — не дело одного дня. А именно, внедрение TS происходило в несколько этапов. Сначала это было предложение, через некоторое время язык начали применять во множестве команд, потом внедрение TS вышло в бета-фазу. В итоге же TypeScript стал официальным языком фронтенд-разработки Airbnb. Подробнее о процессе внедрения TS в Airbnb рассказано здесь.
Этот материал посвящён описанию процессов перевода больших проектов на TypeScript и рассказу о специализированном инструменте, ts-migrate, разработанном в Airbnb.
Стратегии миграции
Перевод крупномасштабного проекта с JavaScript на TypeScript — это сложная задача. Мы, приступая к её решению, исследовали две стратегии перехода с JS на TS.
▍1. Гибридная стратегия миграции
При таком подходе осуществляется постепенный, пофайловый перевод проекта на TypeScript. В ходе этого процесса редактируют файлы, исправляют ошибки типизации и работают так до тех пор, пока на TS не будет переведён весь проект. Параметр allowJS позволяет иметь в проекте и TypeScript-файлы и JavaScript-файлы. Благодаря этому такой подход к переводу JS-проектов на TS вполне жизнеспособен.
При использовании гибридной стратегии миграции не нужно приостанавливать процесс разработки, можно постепенно, файл за файлом, переводить проект на TypeScript. Но, если говорить о крупномасштабном проекте, этот процесс может занять много времени. Кроме того, это потребует обучения программистов, работающих в разных отделах организации. Программистов нужно будет знакомить с особенностями проекта.
▍2. Всеобъемлющая стратегия миграции
При таком подходе берётся проект, написанный исключительно на JavaScript, или такой, часть которого написана на TypeScript, и полностью преобразовывается в TypeScript-проект. При этом понадобится использовать тип any
и комментарии @ts-ignore
, что позволит проекту компилироваться без ошибок. Но со временем код можно отредактировать и перейти к использованию более подходящих типов.
У всеобъемлющей стратегии миграции на TypeScript есть несколько серьёзных преимуществ перед гибридной стратегией:
- Единообразие в устройстве всех частей проекта. Применение всеобъемлющей стратегии миграции гарантирует то, что состояние каждого файла проекта будет таким же, как состояние других файлов. Программистам не нужно будет помнить о том, где они могут использовать TypeScript, и о том, где компилятор способен обнаружить основные ошибки.
- Исправление одного типа легче, чем переработка целого файла. Внесение исправлений в целый файл может вылиться в крайне сложную задачу, так как код, расположенный в файле, может иметь множество зависимостей. При использовании гибридной миграции сложнее оценивать реальный объём завершённых работ и состояние каждого файла.
Если учесть вышесказанное, то может показаться, что всеобъемлющая миграция по всем показателям превосходит гибридную миграцию. Но всеобъемлющий перевод зрелой кодовой базы на TypeScript — это очень трудная задача. Для её решения мы решили прибегнуть к скриптам для модификации кода, к так называемым «кодмодам» (codemods). Когда мы только начали перевод проекта на TypeScript, делая это вручную, мы обратили внимание на повторяющиеся операции, которые можно было бы автоматизировать. Мы написали кодмоды для каждой из таких операций и объединили их в единый конвейер миграции.
Опыт подсказывает нам, что нельзя быть на 100% уверенным в том, что после автоматического перевода проекта на TypeScript в нём не будет ошибок. Но мы выяснили, что комбинация шагов, описанная ниже, позволила нам добиться наилучших результатов и, в итоге, получить TypeScript-проект, лишённый ошибок. Мы, используя кодмоды, смогли перевести на TypeScript проект, содержащий более 50000 строк кода и представленный более чем 1000 файлами. На это у нас ушёл один день.
На основе конвейера, показанного на следующем рисунке, мы создали инструмент ts-migrate.
Кодмоды ts-migrate
В Airbnb значительная часть фронтенда написана с использованием React. Именно поэтому некоторые части кодмодов имеют отношение к концепциям, специфичным для React. Средство ts-migrate может быть использовано и с другими библиотеками или фреймворками, но это потребует его дополнительной настройки и тестирования.
Обзор процесса миграции
Пройдёмся по основным шагам, которые нужно выполнить для перевода проекта с JavaScript на TypeScript. Поговорим и о том, как именно реализованы эти шаги.
▍Шаг 1
Первое, что создают в каждом TypeScript-проекте, это — файл tsconfig.json
. Ts-migrate может, если нужно, сделать это самостоятельно. Существует стандартный шаблон этого файла. Кроме того, имеется система проверок, которая позволяет обеспечить единообразную конфигурацию всех проектов. Вот пример базовой конфигурации:
{
"extends": "../typescript/tsconfig.base.json",
"include": [".", "../typescript/types"]
}
▍Шаг 2
После того, как файл tsconfig.json
находится там, где он должен быть, выполняется переименование файлов с исходным кодом. А именно, расширения .js/.jsx меняются на .ts/.tsx. Этот шаг очень легко автоматизировать. Это позволяет избавиться от большого объёма ручного труда.
▍Шаг 3
А теперь пришло время запускать кодмоды! Мы называем их «плагинами». Плагины для ts-migrate — это кодмоды, у которых есть доступ к дополнительной информации через языковой сервер TypeScript. Плагины принимают на вход строки и выдают изменённые строки. Для выполнения трансформаций кода может быть использован набор инструментов jscodeshift, API TypeScript, средства обработки строк или другие инструменты для модификации AST.
После выполнения каждого из вышеописанных шагов мы проверяем, имеются ли в истории Git какие-то изменения, ожидающие включения в проект, и включаем их в проект. Это позволяет разделить миграционные PR на коммиты, что облегчает понимание происходящего и помогает отслеживать изменения в именах файлов.
Обзор пакетов, из которых состоит ts-migrate
Мы разделили ts-migrate на 3 пакета:
Поступив так, мы смогли отделить логику трансформации кода от ядра системы и смогли создать множество конфигураций, рассчитанных на решение разных задач. Сейчас у нас есть две основных конфигурации: migration и reignore.
Цель применения конфигурации migration
заключается в переводе проекта с JavaScript на TypeScript. А конфигурация reignore
применяется для того чтобы сделать возможной компиляцию проекта, просто игнорируя все ошибки. Эта конфигурация полезна в случаях, когда имеется большая кодовая база и с ней выполняют различные действия наподобие следующих:
- Обновление версии TypeScript.
- Внесение в код серьёзных изменений или рефакторинг кодовой базы.
- Улучшение типов некоторых широко используемых библиотек.
При таком подходе мы можем перевести проект на TypeScript даже в том случае, если при его компиляции выдаются ошибки, с которыми мы не планируем разбираться немедленно. Это упрощает и обновление TypeScript или используемых в коде библиотек.
Обе конфигурации работают на сервере ts-migrate-server
, который состоит из двух частей:
- TSServer: эта часть сервера очень похожа на то, что используется в VSCode для организации взаимодействия редактора и языкового сервера. Новый экземпляр языкового сервера TypeScript запускается в отдельном процессе. Инструменты разработки взаимодействуют с ним, используя языковой протокол.
- Средство для выполнения миграции: это — код, который выполняет процесс миграции и координирует этот процесс. Это средство принимает следующие параметры:
interface MigrateParams {
rootDir: string; // Путь к корневой директории.
config: MigrateConfig; // Настройки миграции, включая список
// плагинов.
server: TSServer; // Экземпляр форка TSServer.
}
Это средство выполняет следующие действия:
- Разбор файла
tsconfig.json
. - Создание .ts-файлов с исходным кодом.
- Отправка каждого файла языковому серверу TypeScript для диагностики этого файла. Есть три типа диагностики, которые даёт нам компилятор:
semanticDiagnostics
,syntacticDiagnostics
иsuggestionDiagnostics
. Мы используем эти проверки для нахождения в исходном коде проблемных мест. Основываясь на уникальном коде диагностики и на номере строки в файле, мы можем идентифицировать возможный тип проблемы и применить необходимые модификации кода. - Обработка каждого файла всеми плагинами. Если текст в файле изменился по инициативе плагина, мы обновляем содержимое исходного файла и уведомляем языковой сервер о том, что файл был изменён.
Примеры использования ts-migrate-server
можно найти в пакете examples или в пакете main. В ts-migrate-example
, кроме того, содержатся базовые примеры плагинов. Они делятся на 3 основные категории:
- Плагины, основанные на jscodeshift.
- Плагины, основанные на абстрактном синтаксическом дереве (AST, Abstract Syntax Tree) TypeScript.
- Плагины, основанные на обработке текста.
В репозитории имеется набор примеров, направленных на демонстрацию процесса создания простых плагинов всех этих видов. Там же показано и их использование в комбинации c ts-migrate-server
. Вот пример конвейера миграции, преобразующего код. На его вход поступает такой код:
function mult(first, second) {
return first * second;
}
А выдаёт он следующее:
function tlum(tsrif: number, dnoces: number): number {
console.log(`args: ${arguments}`);
return tsrif * dnoces;
}
В этом примере ts-migrate выполнил 3 трансформации:
- Обратил порядок символов во всех идентификаторах:
first -> tsrif
. - Добавил сведения о типах в объявление функции:
function tlum(tsrif, dnoces) -> function tlum(tsrif: number, dnoces: number): number
. - Добавил в код строку
console.log(‘args:${arguments}’);
Плагины общего назначения
Настоящие плагины расположены в отдельном пакете — ts-migrate-plugins. Взглянем на некоторые из них. У нас имеются два плагина, основанных на jscodeshift: explicitAnyPlugin
и declareMissingClassPropertiesPlugin
. Набор инструментов jscodeshift позволяет преобразовывать AST в обычный код, используя пакет recast. Мы можем, воспользовавшись функцией toSource()
, напрямую обновлять исходный код, содержащийся в наших файлах.
Плагин explicitAnyPlugin берёт с языкового сервера TypeScript информацию обо всех ошибках semanticDiagnostics
и о строках, в которых выявлены эти ошибки. Затем в эти строки добавляется аннотация типа any
. Этот подход позволяет исправлять ошибки, так как использование типа any
позволяет избавиться от ошибок компиляции.
Вот пример кода до обработки:
const fn2 = function(p3, p4) {}
const var1 = [];
Вот — тот же код, обработанный плагином:
const fn2 = function(p3: any, p4: any) {}
const var1: any = [];
Плагин declareMissingClassPropertiesPlugin берёт все диагностические сообщения с кодом ошибки 2339
(можете догадаться о том, что значит этот код?) и, если может найти объявления классов с пропущенными идентификаторами, добавляет их в тело класса с аннотацией типа any
. Из названия плагина можно сделать вывод о том, что он применим только к ES6-классам.
Следующая категория плагинов основана на AST TypeScript. Обрабатывая AST, мы можем сгенерировать массив обновлений, которые нужно внести в файл с исходным кодом. Описания этих обновлений выглядят так:
type Insert = { kind: 'insert'; index: number; text: string };
type Replace = { kind: 'replace'; index: number; length: number; text: string };
type Delete = { kind: 'delete'; index: number; length: number };
После генерирования сведений о необходимых обновлениях остаётся лишь внести их в файл в обратном порядке. Если, выполнив эту операцию, мы получим новый программный код, мы соответствующим образом обновим файл с исходным кодом.
Взглянем на следующую пару плагинов, основанных на AST. Это — stripTSIgnorePlugin
и hoistClassStaticsPlugin
.
Плагин stripTSIgnorePlugin — это первый плагин, используемый в конвейере миграции. Он убирает из файла все комментарии @ts-ignore
(эти комментарии позволяют нам сообщать компилятору о том, что он должен игнорировать ошибки, происходящие в следующей строке). Если мы занимаемся переводом на TypeScript проекта, написанного на JavaScript, то этот плагин не будет выполнять никаких действий. Но если речь идёт о проекте, который частично написан на JS, а частично — на TS (несколько наших проектов пребывали в подобном состоянии), то это — первый шаг миграции, без которого нельзя обойтись. Только после удаления комментариев @ts-ignore
компилятор TypeScript сможет выдавать диагностические сообщения об ошибках, которые нужно исправлять.
Вот код, поступающий на вход этого плагина:
const str3 = foo
? // @ts-ignore
// @ts-ignore comment
bar
: baz;
Вот что получается на выходе:
const str3 = foo
? bar
: baz;
После избавления от комментариев @ts-ignore
мы запускаем плагин hoistClassStaticsPlugin. Он проходится по всем объявлениям классов. Плагин определяет возможность поднятия идентификаторов или выражений и выясняет, поднята ли уже некая операция присвоения на уровень класса.
Для того чтобы обеспечить высокую скорость разработки и избежать вынужденных возвратов к предыдущим версиям проекта, мы снабдили каждый плагин и ts-migrate набором модульных тестов.
Плагины, имеющие отношение к React
Плагин reactPropsPlugin, основанный на этом замечательном инструменте, преобразует информацию о типах из формата PropTypes в объявления типов TypeScript. С помощью этого плагина нужно обрабатывать исключительно .tsx-файлы, содержащие хотя бы один React-компонент. Этот плагин ищет все объявления PropTypes и пытается разобрать их с использованием AST и простых регулярных выражений наподобие /number/
, или с привлечением более сложных регулярных выражений вроде /objectOf$/. Когда обнаруживается React-компонент (функциональный, или основанный на классах), он трансформируется в компонент, в котором для входных параметров (props) используется новый тип: type Props = {…};
.
Плагин reactDefaultPropsPlugin отвечает за реализацию в React-компонентах паттерна defaultProps. Мы используем особый тип, представляющий входные параметры, которым заданы значения, применяемые по умолчанию:
type Defined<T> = T extends undefined ? never : T;
type WithDefaultProps<P, DP extends Partial<P>> = Omit<P, keyof DP> & {
[K in Extract<keyof DP, keyof P>]:
DP[K] extends Defined<P[K]>
? Defined<P[K]>
: Defined<P[K]> | DP[K];
};
Мы пытаемся найти входные параметры, которым назначены значения, применяемые по умолчанию, после чего объединяем их с типом, описывающим входные параметры компонента, созданным на предыдущем шаге.
В экосистеме React широко применяются концепции состояния и жизненного цикла компонентов. Мы решаем задачи, относящиеся к этим концепциям, в следующей паре плагинов. Так, если компонент имеет состояние, то плагин reactClassStatePlugin генерирует новый тип (type State = any;
), а плагин reactClassLifecycleMethodsPlugin аннотирует методы жизненного цикла компонента соответствующими типами. Функционал этих плагинов может быть расширен, в том числе — за счёт оснащения их возможностью заменять any
на более точные типы.
Эти плагины можно улучшать, в частности, за счёт расширения поддержки типов для состояния и свойств. Но и их существующие возможности, как оказалось, являются хорошей отправной точкой для реализации нужного нам функционала. Мы, кроме того, не работаем тут с React-хуками, та как в начале миграции в нашей кодовой базе использовалась старая версия React, не поддерживающая хуки.
Проверка правильности компиляции проекта
Наша цель заключается в том, чтобы TypeScript-проект, оснащённый базовыми типами, скомпилировался бы, и чтобы при этом не изменилось бы поведение программы.
После всех трансформаций и модификаций наш код может оказаться неоднородно отформатированным, что способно привести к тому, что некоторые проверки кода линтером выявят ошибки. В кодовой базе нашего фронтенда используется система, основанная на Prettier и ESLint. А именно, Prettier применяется для автоматического форматирования кода, а ESLint помогает проверять код на предмет его соответствия рекомендованным подходам к разработке. Всё это позволяет нам быстро справляться с проблемами форматирования кода, появившимися в результате ранее выполненных действий, просто воспользовавшись соответствующим плагином — eslintFixPlugin
.
Последним шагом конвейера миграции является проверка того, что решены все проблемы компиляции TypeScript-кода. Для того чтобы находить и исправлять потенциальные ошибки плагин tsIgnorePlugin берёт сведения семантической диагностики кода и номера строк, а после этого добавляет в код комментарии @ts-ignore
с объяснениями ошибок. Например, это может выглядеть так:
// @ts-ignore ts-migrate(7053) FIXME: No index signature with a parameter of type 'string...
const { field1, field2, field3 } = DATA[prop];
// @ts-ignore ts-migrate(2532) FIXME: Object is possibly 'undefined'.
const field2 = object.some_property;
Мы оснастили систему и поддержкой синтаксиса JSX:
{*
// @ts-ignore ts-migrate(2339) FIXME: Property 'NORMAL' does not exist on type 'typeof W... */}
<Text weight={WEIGHT.NORMAL}>
some text
</Text>
<input
id="input"
// @ts-ignore ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'string'.
name={getName()}
/>
То, что в нашем распоряжении имеются осмысленные сообщения об ошибках, упрощает исправление ошибок и поиск фрагментов кода, на которые нужно обратить внимание. Соответствующие комментарии, в комбинации с $TSFixMe
, позволяют нам собирать ценные данные о качестве кода и находить потенциально проблемные фрагменты кода. $TSFixMe
— это созданный нами псевдоним для типа any
. А для функций это — $TSFixMeFunction = (…args: any[]) => any;
. Рекомендуется избегать использования типа any
, но его применение помогло нам упростить процесс миграции. Использование этого типа помогало нам точно узнавать о том, какие именно фрагменты кода нуждаются в доработке.
Стоит отметить, что плагин eslintFixPlugin
запускается два раза. Первый раз — до применения tsIgnorePlugin
, так как форматирование способно подействовать на сообщения о том, где именно происходят ошибки компиляции. Второй раз — после применения tsIgnorePlugin
, так как добавление в код комментариев @ts-ignore
может привести к появлению ошибок форматирования.
Дополнительные замечания
Мы хотели бы обратить ваше внимание на пару особенностей миграции, которые мы заметили в ходе работы. Возможно, вам знание об этих особенностях пригодится при работе с вашими проектами.
- В TypeScript 3.7 появились комментарии @ts-nocheck, которые можно добавлять на верхнем уровне TypeScript-файлов для отключения семантических проверок. Мы эти комментарии не использовали, так как раньше они подходили только для .js-файлов, но не для .ts/.tsx-файлов. В современных условиях эти комментарии могут представлять собой отличный вспомогательный механизм, применяемый на промежуточных стадиях миграции.
- В TypeScript 3.9 появилась поддержка комментариев @ts-expect-error. Если перед строкой кода есть префикс в виде такого комментария, TypeScript не будет сообщать о соответствующей ошибке. Если же в подобной строке ошибки нет, TypeScript сообщит о том, что в комментарии
@ts-expect-error
необходимости нет. В кодовой базе Airbnb осуществлён переход с комментариев@ts-ignore
на комментарии@ts-expect-error
.
Итоги
Миграция кодовой базы Airbnb с JavaScript на TypeScript всё ещё продолжается. У нас есть некоторые старые проекты, которые всё ещё представлены JavaScript-кодом. В нашей кодовой базе всё ещё часто встречаются $TSFixMe
и комментарии @ts-ignore
.
JavaScript и TypeScript в Airbnb
Но нужно отметить, что применение ts-migrate очень сильно ускорило процесс перевода наших проектов с JS на TS и сильно улучшило продуктивность нашего труда. Благодаря ts-migrate программисты смогли сосредоточить усилия на улучшении типизации, а не на ручной обработке каждого файла. В настоящее время примерно 86% нашего фронтенд-монорепозитория, в котором имеется около 6 миллионов строк кода, переведено на TypeScript. Мы, к концу этого года, ожидаем достичь показателя в 95%.
Здесь, на главной странице репозитория проекта, вы можете узнать о том, как установить и запустить ts-migrate. Если вы найдёте в ts-migrate какие-то проблемы, или если у вас будут идеи по улучшению этого инструмента — приглашаем вас присоединиться к работе над ним!
Доводилось ли вам переводить большие проекты с JavaScript на TypeScript?
Автор: ru_vds