JavaScript – это один из языков с динамической типизацией. Такие языки удобны для быстрой разработки приложений, но когда несколько команд берутся за разработку одного большого проекта, лучше с самого начала выбрать один из инструментов для проверки типов.
Можно начать разрабатывать код на TypeScript или включить в проект Flow. TypeScript – это компилируемая версия JavaScript, разработанная компанией Microsoft. Flow, в отличие от TypeScript, это не язык, а инструмент, который позволяет анализировать код и проверять типы. В сети можно найти множество статей и видео об этих подходах, а также руководство по тому, как начать использовать типизацию. В этой статье мы бы хотели рассказать, почему нам не подошел Flow, и как мы начали переходить на Typescript.
Немного истории
В 2016 году мы начали разрабатывать веб-клиент на базе React/Redux для нашей ECM системы. Для проверки типизации был выбран Flow по следующим причинам:
- React и Flow – это продукты одной компании Facebook.
- Flow более активно развивался.
- Flow легко интегрируется в проект.
Но проект рос, количество команд-разработки увеличилось, и проявился ряд проблем при использовании Flow:
- Фоновый режим проверки типов Flow использовал слишком много ресурсов ПК. В результате некоторые разработчики отключали его и запускали проверку по необходимости.
- Возникали ситуации, когда для приведения кода в соответствие с Flow тратилось столько же времени, сколько и на написание самого кода.
- В проекте стал появляться код, необходимый только для прохождения проверки Flow. Например, двойная проверка на null:
foo() { if (this.activeFormContainer != null) { // to do something if (this.activeFormContainer != null) // only for Flow this.activeFormContainer.style.minWidth = '100px'; } } }
- Большинство разработчиков использовало редактор кода Visual Studio Code, в котором у Flow не такая хорошая поддержка, как у TypeScript. Во время разработки не всегда срабатывало автодополнение (IntelliSense), а также нестабильно работала навигация по коду. Хотелось бы иметь такое же удобство разработки, как при написании на С# в Visual Studio.
У некоторых разработчиков появилась идея попробовать перейти на TypeScript. Для того чтобы проверить идею перехода и убедить руководство, решили попробовать прототип.
Прототип
На прототипах мы хотели проверить две идеи:
- Попробовать перевести весь проект целиком.
- Настроить проект так, чтобы можно было использовать параллельно и Flow, и Typescript.
Для первой идеи нужна была утилита, которая сконвертировала бы все файлы проекта. В сети нашли одну из таких. Судя по описанию, она смогла бы перевести большую часть, но часть изменений пришлось бы править самим, либо дописать саму утилиту. Нам удалось сконвертировать тестовый проект с небольшим количеством файлов. Реальный же проект скомпилировать так и не удалось, пришлось бы править слишком большое количество файлов. Решили не продолжать в этом направлении, так как:
- Доделывать предстояло еще много! И пока мы будем дорабатывать проект, остальные команды будут продолжать разрабатывать новую функциональность, править баги, писать тесты. К тому же пришлось бы потратить немало времени для слияния файлов.
- Даже если бы мы перевели таким способом проект, то какой объем работы пришлось бы проделать нашим тестировщикам!
Хотя мы и отказались от этого варианта, на нем мы получили полезный опыт. Стал ясен примерный объем работ, который нужно проделать для перевода каждого файла. Вот как примерно выглядит перевод простого React-компонента.
Как видно, изменений не так много. В основном, они заключаются в следующем:
- убрать //@flow;
- заменить type на более привычный interface;
- добавить модификаторы доступа;
- заменить типы на типы из ts-библиотек (из примера на картинке: обработчики событий и сами события).
Реализация по второй идее позволила бы продолжить разработку, но уже на TypeScript, и в фоновом режиме потихоньку переводить существующую кодовую базу. Это давало ряд преимуществ:
- Легко переводить, без страха что-то упустить.
- Легко тестировать.
- Легко сливать изменения.
Но было не до конца ясно, можно ли настроить проект для работы с двумя видами типизации параллельно. Поиск в интернете ни к чему конкретному не привел, поэтому стали разбираться сами. В теории, анализатор Flow проверяет только файлы с расширением js/jsx и содержащие комментарий:
//@flow
или
/* @flow */
Для компилятора TypeScript файлы должны иметь расширение ts/tsx. Из чего следует, что оба подхода к типизации должны работать одновременно и не мешать друг другу. На основании этого мы настроили окружение проекта. Используя опыт от первого прототипа, перевели пару файлов. Скомпилировали проект, запустили клиент — всё заработало как раньше!
Зеленый свет
И вот в один прекрасный день — день планирования спринта, у нашей команды в бэклоге появляется User Story “Начать переход на TypeScript”, с следующим перечнем работ:
- Настроить webpack.
- Настроить tslint.
- Настроить тестовое окружение.
- Перевести файлы на TypeScript.
Настройка webpack
Первым делом нужно научить webpack обрабатывать файлы с расширением ts/tsx. Для этого добавили правило в секцию rules конфигурационного файла. Изначально использовался ts-loader:
// webpack.config.js
const rules = [
...
{
test: /.(ts|tsx)?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
];
Чтобы ускорить сборку, отключили проверку типов: transpileOnly: true
, т.к. IDE и так указывает на ошибки во время написания кода.
Но когда приступили к переводу наших Redux-экшенов, стало ясно, что для их работы необходим плагин babel-plugin-transform-class-display-name. Этот плагин добавляет всем классам статическое свойство displayName. Экшены после перевода стали обрабатываться только ts-loader, а это не позволило применить к ним плагины babel. В результате, мы отказались от ts-loader и расширили существующее правило для js/jsx, добавив babel/preset-typescript:
// webpack.config.js
const rules = [
{
test: /.(ts|tsx|js|jsx)?$/,
exclude: /node_modules|lib/,
loader: 'babel-loader?cacheDirectory=true'
},
...
];
// .babelrc.js
const presets = [
[
"@babel/preset-env",
{
"modules": !isTest ? false : 'commonjs',
"useBuiltIns": false
}
],
"@babel/typescript",
"@babel/preset-react",
];
Для правильной работы компилятора TypeScript нужно добавить конфигурационный файл tsconfig.json, он был взят из документации.
Настройка Tslint
Написанный с использованием Flow код дополнительно проверялся с помощью eslint. Для TypeScript есть его аналог — tslint. Изначально хотелось все правила из eslint перенести в tslint. Была попытка синхронизации правил через плагин tslint-eslint-rules, но большинство правил не поддерживается. Также есть возможность использовать eslint для проверки ts-файлов с помощью typescript-eslint-parser. Но, к сожалению, к eslint-у можно подключить только один парсер. Если использовать только ts-parser для всех видов файлов, появляется много непонятных ошибок как в js-файлах, так и в ts. В результате, использовали рекомендуемый набор правил, расширенный под наши требования:
// tslint.json
"extends": ["tslint:recommended", "tslint-react"]
Перевод файла на TypeScript
Теперь все готово, и можно приступать к переводу файлов. Для начала решили перевести небольшой React-компонент, который используется по всему проекту. Выбор пал на компонент “Кнопка”.
В процессе перевода столкнулись с проблемой: не все сторонние библиотеки имеют типизацию TypeScript, например, bem-cn-lite. На ресурсе TypeSearch от Microsoft библиотеку типов для нее найти не удалось. почти для всех необходимых библиотек мы нашли и подключили ts-библиотеки типов. Одним из решений было подключение через require:
const b = require(‘bem-cn-lite’);
Но при этом проблема с отсутствием типов не решилась. Поэтому мы сгенерировали «заглушку» для типов самостоятельно, воспользовавшись утилитой dts-gen:
dts-gen -m bem-cn-lite
Утилита сгенерировала файл с расширением *.d.ts. Файл поместили в папку @types и настроили tsconfig.json:
// tsconfig.json
"typeRoots": [
"./@types",
"./node_modules/@types"
]
Далее, по аналогии с прототипом, мы перевели компонент. Скомпилировали проект, запустили клиент — всё заработало! Но сломались тесты.
Настройка тестового окружения
Для тестирования приложения мы используем Storybook и Mocha.
Storybook используется для визуального регрессионного тестирования (статья). Как и сам проект, он собирается с помощью webpack и имеет свой конфигурационный файл. Поэтому для работы с ts/tsx-файлами его нужно было сконфигурировать по аналогии с конфигурацией самого проекта.
Пока мы использовали ts-loader для сборки проекта, у нас перестали запускаться тесты Mocha. Для решения этой проблемы в тестовое окружение необходимо добавить ts-node:
// mocha.opts
--require @babel/polyfill
--require @babel/register
--require test/index.js
--require tsconfig-paths/register
--require ts-node/register/transpile-only
--recursive
--reporter mochawesome
--reporter-options reportDir=../../bin/TestResults,reportName=js-test-results,inlineAssets=true
--exit
Но после перехода на Babel от этого можно было избавиться.
Проблемы
В процессе перевода мы столкнулись с большим количеством проблем различной степени сложности. В основном они были связаны с отсутствием у нас опыта работы с TypeScript. Вот несколько из них:
- Импорт компонентов/функций из разных типов файлов.
- Перевод компонентов высшего порядка.
- Потеря истории изменений.
Импорт компонентов/функций из разных типов файлов
При использовании компонентов/функций из разных типов файлов появилась необходимость указывать расширение файла:
import { foo } from ‘./utils.ts’
Избавиться от этого позволяет добавление допустимых расширений в конфигурационные файлы webpack и eslint:
// webpack.config.js
resolve: {
…
extensions: [ '.tsx', '.ts', '.js' ]
}
// .eslintrc.js
"import/resolver": {
"node": {
"extensions": [
".js",
".jsx",
".ts",
".tsx",
".json"
]
}
}
Перевод компонентов высшего порядка
Из всех типов файлов больше всего проблем вызвал перевод компонентов высшего порядка (Higher-Order Component, HOC). Это функция, которая на вход принимает компонент и возвращает новый компонент. Применяется в основном для повторного использования логики, например, это может быть функция, добавляющая возможность выделять элементы:
const MyComponentWithSeletedItem = withSelectedItem(MyComponent);
Или наиболее известная connect, из библиотеки Redux. Типизация таких функций не тривиальная и требует подключения дополнительной библиотеки для работы с типами. Подробно описывать процесс перевода не буду, так как в сети можно найти много руководств на эту тему. Если вкратце, то проблема заключается в том, что такая функция – абстрактная: на вход может принять любой компонент, с любым набором свойств. Это может быть компонент «Кнопка» со свойствами title и onClick или компонент «Картинка» со свойствами alt и imgUrl. Набор этих свойств нам заранее не известен, известны лишь те свойства, которые добавляет сама функция. Для того, чтобы компилятор TypeScript не ругался при использовании компонентов, полученных с помощью таких функций, нужно «вырезать» свойства, которые добавляет функция из возвращаемого типа.
Для этого нужно:
- Вынести в интерфейс эти свойства:
interface IWithSelectItem { selectedItem: number; handleSelectedItemChange: (id: number) => void; }
- Удалить все свойства, которые входят в интерфейс IWithSelectItem из интерфейса компонента. Для этого можно воспользоваться операцией Diff<T, U> из библиотеки utility-types.
React.ComponentType<Diff<TPropsComponent, IWithSelectItem>>
Потеря истории изменений
Для работы с исходниками, например, выполнение code review, мы используем Team Foundation Server. При переводе файлов мы столкнулись с одной неприятной особенностью. В пул реквестах вместо одного измененного файла появляется два:
- удаленный – старая версия файла;
- созданный – новая версия.
Такое поведение наблюдается, если изменений в файле много (similarity < 50%), например для небольших по объему файлов. Для решения этой проблемы пробовали использовать:
- команду git mv;
- выполнять два коммита: первый – это изменение расширения файла, второй — с непосредственными исправлениями.
Но, к сожалению, оба подхода нам так и не помогли.
Итоги
Использовать Flow или же TypeScript — решает каждый для себя сам, оба подхода имеют свои плюсы и минусы. Мы для себя выбрали TypeScript. И на своем опыте убедились: если вы выбрали один из подходов и вдруг осознали, даже спустя три года, что он вам не подходит, то всегда можно его поменять. А для более гладкого перехода можно настроить проект, как и мы, на параллельную работу.
На момент написания статьи мы еще не полностью перешли на TypeScript, но основную часть — «ядро» проекта – мы уже переписали. В кодовой базе можно найти примеры перевода всех видов файлов, начиная от простого react-компонента и заканчивая компонентами высшего порядка. Также было проведено обучение среди всех команд разработчиков, и теперь каждая команда в рамках своей задачи на тех долг переводит часть проекта.
Мы планируем завершить переход до конца года, перевести тесты и storybook, и, возможно даже написать несколько своих tslint-правил.
По личным ощущениям могу сказать, что разработка стала занимать меньше времени, проверка типов делается на лету, при этом не нагружая систему, а сообщения об ошибках лично для меня стали более понятными.
Автор: teager