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

в 13:57, , рубрики: javascript

Логотип 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-правил.

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

Автор: teager

Источник


* - обязательные к заполнению поля