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

от автора

Логотип Directum

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

Можно начать разрабатывать код на TypeScript или включить в проект Flow. TypeScript – это компилируемая версия JavaScript, разработанная компанией Microsoft. 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() {     if (this.activeFormContainer != null) {       // to do something         if (this.activeFormContainer != null) // only for Flow           this.activeFormContainer.style.minWidth = '100px';       }     }   } 
  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 и так указывает на ошибки во время написания кода.

Но когда приступили к переводу наших 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. Вот несколько из них:

  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 < 50%), например для небольших по объему файлов. Для решения этой проблемы пробовали использовать:

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

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

Итоги

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/company/directum/blog/462055/


Комментарии

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

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