Хабрахабр

Опыт перевода большого проекта с Flow на TypeScript

Логотип Directum

Такие языки удобны для быстрой разработки приложений, но когда несколько команд берутся за разработку одного большого проекта, лучше с самого начала выбрать один из инструментов для проверки типов. JavaScript – это один из языков с динамической типизацией.

TypeScript – это компилируемая версия JavaScript, разработанная компанией Microsoft. Можно начать разрабатывать код на TypeScript или включить в проект Flow. В сети можно найти множество статей и видео об этих подходах, а также руководство по тому, как начать использовать типизацию. Flow, в отличие от TypeScript, это не язык, а инструмент, который позволяет анализировать код и проверять типы. В этой статье мы бы хотели рассказать, почему нам не подошел Flow, и как мы начали переходить на Typescript.

Немного истории

В 2016 году мы начали разрабатывать веб-клиент на базе React/Redux для нашей ECM системы. Для проверки типизации был выбран Flow по следующим причинам:

  1. React и Flow – это продукты одной компании Facebook.
  2. Flow более активно развивался.
  3. Flow легко интегрируется в проект.

Но проект рос, количество команд-разработки увеличилось, и проявился ряд проблем при использовании Flow:

  1. Фоновый режим проверки типов Flow использовал слишком много ресурсов ПК. В результате некоторые разработчики отключали его и запускали проверку по необходимости.
  2. Возникали ситуации, когда для приведения кода в соответствие с Flow тратилось столько же времени, сколько и на написание самого кода.
  3. В проекте стал появляться код, необходимый только для прохождения проверки Flow. Например, двойная проверка на null:

    foo() } }

  4. Большинство разработчиков использовало редактор кода Visual Studio Code, в котором у Flow не такая хорошая поддержка, как у TypeScript. Во время разработки не всегда срабатывало автодополнение (IntelliSense), а также нестабильно работала навигация по коду. Хотелось бы иметь такое же удобство разработки, как при написании на С# в Visual Studio.

У некоторых разработчиков появилась идея попробовать перейти на TypeScript. Для того чтобы проверить идею перехода и убедить руководство, решили попробовать прототип.

Прототип

На прототипах мы хотели проверить две идеи:

  1. Попробовать перевести весь проект целиком.
  2. Настроить проект так, чтобы можно было использовать параллельно и Flow, и Typescript.

Для первой идеи нужна была утилита, которая сконвертировала бы все файлы проекта. В сети нашли одну из таких. Судя по описанию, она смогла бы перевести большую часть, но часть изменений пришлось бы править самим, либо дописать саму утилиту. Нам удалось сконвертировать тестовый проект с небольшим количеством файлов. Реальный же проект скомпилировать так и не удалось, пришлось бы править слишком большое количество файлов. Решили не продолжать в этом направлении, так как:

  1. Доделывать предстояло еще много! И пока мы будем дорабатывать проект, остальные команды будут продолжать разрабатывать новую функциональность, править баги, писать тесты. К тому же пришлось бы потратить немало времени для слияния файлов.
  2. Даже если бы мы перевели таким способом проект, то какой объем работы пришлось бы проделать нашим тестировщикам!

Хотя мы и отказались от этого варианта, на нем мы получили полезный опыт. Стал ясен примерный объем работ, который нужно проделать для перевода каждого файла. Вот как примерно выглядит перевод простого React-компонента.
Сравнение кода на Flow и TypeScript

В основном, они заключаются в следующем: Как видно, изменений не так много.

  • убрать //@flow;
  • заменить type на более привычный interface;
  • добавить модификаторы доступа;
  • заменить типы на типы из ts-библиотек (из примера на картинке: обработчики событий и сами события).

Реализация по второй идее позволила бы продолжить разработку, но уже на TypeScript, и в фоновом режиме потихоньку переводить существующую кодовую базу. Это давало ряд преимуществ:

  1. Легко переводить, без страха что-то упустить.
  2. Легко тестировать.
  3. Легко сливать изменения.

Но было не до конца ясно, можно ли настроить проект для работы с двумя видами типизации параллельно. Поиск в интернете ни к чему конкретному не привел, поэтому стали разбираться сами. В теории, анализатор Flow проверяет только файлы с расширением js/jsx и содержащие комментарий:

//@flow
или
/* @flow */

Для компилятора TypeScript файлы должны иметь расширение ts/tsx. Из чего следует, что оба подхода к типизации должны работать одновременно и не мешать друг другу. На основании этого мы настроили окружение проекта. Используя опыт от первого прототипа, перевели пару файлов. Скомпилировали проект, запустили клиент — всё заработало как раньше!

Зеленый свет

И вот в один прекрасный день — день планирования спринта, у нашей команды в бэклоге появляется User Story “Начать переход на TypeScript”, с следующим перечнем работ:

  1. Настроить webpack.
  2. Настроить tslint.
  3. Настроить тестовое окружение.
  4. Перевести файлы на 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 и так указывает на ошибки во время написания кода.

Этот плагин добавляет всем классам статическое свойство displayName. Но когда приступили к переводу наших Redux-экшенов, стало ясно, что для их работы необходим плагин babel-plugin-transform-class-display-name. В результате, мы отказались от ts-loader и расширили существующее правило для js/jsx, добавив babel/preset-typescript: Экшены после перевода стали обрабатываться только ts-loader, а это не позволило применить к ним плагины babel.

// 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-компонент, который используется по всему проекту. Выбор пал на компонент “Кнопка”.

Кнопки в проекте

На ресурсе TypeSearch от Microsoft библиотеку типов для нее найти не удалось. В процессе перевода столкнулись с проблемой: не все сторонние библиотеки имеют типизацию TypeScript, например, bem-cn-lite. Одним из решений было подключение через require: почти для всех необходимых библиотек мы нашли и подключили ts-библиотеки типов.

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.

Как и сам проект, он собирается с помощью webpack и имеет свой конфигурационный файл. Storybook используется для визуального регрессионного тестирования (статья). Поэтому для работы с ts/tsx-файлами его нужно было сконфигурировать по аналогии с конфигурацией самого проекта.

Для решения этой проблемы в тестовое окружение необходимо добавить ts-node: Пока мы использовали ts-loader для сборки проекта, у нас перестали запускаться тесты Mocha.

// 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. Вот несколько из них:

  1. Импорт компонентов/функций из разных типов файлов.
  2. Перевод компонентов высшего порядка.
  3. Потеря истории изменений.

Импорт компонентов/функций из разных типов файлов

При использовании компонентов/функций из разных типов файлов появилась необходимость указывать расширение файла:

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 не ругался при использовании компонентов, полученных с помощью таких функций, нужно «вырезать» свойства, которые добавляет функция из возвращаемого типа.

Для этого нужно:

  1. Вынести в интерфейс эти свойства:

    interface IWithSelectItem { selectedItem: number; handleSelectedItemChange: (id: number) => void;
    }

  2. Удалить все свойства, которые входят в интерфейс IWithSelectItem из интерфейса компонента. Для этого можно воспользоваться операцией Diff<T, U> из библиотеки utility-types.

    React.ComponentType<Diff<TPropsComponent, IWithSelectItem>>

Потеря истории изменений

Для работы с исходниками, например, выполнение code review, мы используем Team Foundation Server. При переводе файлов мы столкнулись с одной неприятной особенностью. В пул реквестах вместо одного измененного файла появляется два:

  • удаленный – старая версия файла;
  • созданный – новая версия.

    Как это выглядит в Pull Request

Такое поведение наблюдается, если изменений в файле много (similarity

  • команду git mv;
  • выполнять два коммита: первый – это изменение расширения файла, второй — с непосредственными исправлениями.

Но, к сожалению, оба подхода нам так и не помогли.

Итоги

Использовать Flow или же TypeScript — решает каждый для себя сам, оба подхода имеют свои плюсы и минусы. Мы для себя выбрали TypeScript. И на своем опыте убедились: если вы выбрали один из подходов и вдруг осознали, даже спустя три года, что он вам не подходит, то всегда можно его поменять. А для более гладкого перехода можно настроить проект, как и мы, на параллельную работу.

В кодовой базе можно найти примеры перевода всех видов файлов, начиная от простого react-компонента и заканчивая компонентами высшего порядка. На момент написания статьи мы еще не полностью перешли на TypeScript, но основную часть — «ядро» проекта – мы уже переписали. Также было проведено обучение среди всех команд разработчиков, и теперь каждая команда в рамках своей задачи на тех долг переводит часть проекта.

Мы планируем завершить переход до конца года, перевести тесты и storybook, и, возможно даже написать несколько своих tslint-правил.

По личным ощущениям могу сказать, что разработка стала занимать меньше времени, проверка типов делается на лету, при этом не нагружая систему, а сообщения об ошибках лично для меня стали более понятными.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть