5万行弱あるSPA の JavaScript を TypeScript に移行した話

はじめに

デジタル作品を販売・閲覧するオンライン EC サービスでフロントエンドエンジニアをしております、surumebeerと申します。

現在は主に上記サービスで提供される購入した作品の閲覧機能についての開発改善を担当しています。 この閲覧機能は React / Redux / React-Router を用いた 中規模〜大規模の SPA で構成されています。

上記の機能は 2018 年頃から 2022 年までの約 4 年間フロントエンド専任のエンジニアが不在だったため、 エコシステムやパッケージのアップデート等のメンテナンスが行われていない状態になっていました。 開発効率、開発コストの点で問題が生じており、これらの問題が肥大していく一方であったため、 アプリケーションの開発と並行しながら、負債脱却と開発改善を行っています。

移行の目的

本アプリケーションは、API との通信を頻繁に行う SPA であり、JavaScript を Babel と Webpack によりビルドする構成になっていました。

React を用いた開発ではコンポーネント間の値の受け渡しを props で行い、Redux を用いた開発では、API からのレスポンスを中心に state の更新と管理をしています。
上記のような構成では、コンポーネントや関数間で複雑な値の受け渡しをする箇所が多く、静的解析による型の有無が保守性と開発効率に大きく影響することを実感していました。

実際に、存在しないオブジェクトのキーを参照してしまい、予期しない不具合が発生したこともありました。
そうした経験から、「型が存在していれば防げた」と感じる場面が多々あり、まずは TypeScript の導入に踏み切ることに決めました。

移行計画

ts-migrateを使用しての一括移行も検討しましたが、既存コードとの相性が悪く、any や ts-ignore の付与も正確でなかったりしたため、手動での TypeScript への移行することにしました。

移行にあたり、以下のような手順で計画を立てました。

  1. トランスパイラの変更
  2. tsconfig.json の作成
  3. 拡張子の変更
  4. 使用しているパッケージの型解決
  5. CSS の named export に型付け
  6. グローバルな変数や定数の宣言を型定義ファイルに記述
  7. any と ts-ignore でコンパイルエラーを解消
  8. any と ts-ignore を詳細な型付けで解消

移行作業

トランスパイラの変更

まず、Babel を用いたトランスパイルから、TypeScript のコンパイラによるトランスパイルに変更しました。
Webpack をバンドラとして使用しているため、従来使用していた babel-loader から ts-loader へ移行する形で変更しました。
出力には若干の違いがありますが、TypeScript によるトランスパイルが可能な構成へと変更しました。

// webpack.config.js(抜粋)
module.exports = {
  module: {
    rules: [
      {
        test: /\.(ts|tsx|js|jsx)$/,
        loader: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
};

構文変換のルートを TypeScript に変更したことで、以降のアプリケーションのコード自体の TypeScript への移行作業を進められる基盤が整いました。

tsconfig.json の作成

TypeScript 導入の後は、tsconfig.json の設定をしました。 既存のアプリケーション構成に沿った形を取るため、下記のような設定をしました。

// tsconfig.json抜粋
{
  "compilerOptions": {
    "allowJS": true,
    "jsx": "react-jsx"
  }
}

ファイルの拡張子の変換はまだ行っておらず、構文も TypeScript になっていないため、allowJs の設定をしました。 react での JSX 構文を適切に解釈させるために jsx の設定をしました。

拡張子を変更

拡張子の変更無しでは TypeScript のコンパイラの型チェックの対象にならないため、拡張子を変換しました。

find src -name '*.js' -exec rename 's/\.js$/.ts/' {} \;
find src -name '*.jsx' -exec rename 's/\.jsx$/.tsx/' {} \;

これで src ディレクトリ配下のファイルが型チェックの対象になりました。 型チェックの対象になったことにより、ファイル内で型エラーが出るようになったため、この後の工程ではエラーの解消が主になってきます。

使用しているパッケージの型解決

TypeScript では、すべてのパッケージに型情報が必要です。
型を同梱しているものもありますが、そうでないものに関しては、バージョンに合わせた DefinitelyTyped のパッケージをインストールして導入しました。

npm install -D @types/lodash @types/react @types/react-dom

型定義が同梱されていないパッケージについては、コンパイル時のエラーを参考にして都度追加していきました。

CSS の named export に型付け

本アプリケーションでは、CSSModules を採用してのスタイリングを行っており、TypeScript ではこれら CSS のクラス名のモジュールにも型定義が必要になります。 これらを手作業で行うのは骨が折れるため、typed-css-modulesを使用しました。 しかし、本アプリケーションは postcss-loader の postcss-nested を使用してネストされたセレクタとスタイルの変換をする設定になっていました。 typed-css-modules はネストされた CSS に関しては出力しないため、この点に関しては手動で記載する必要がありました。

/* ネストされたCSS例 */
.container {
  width: auto;

  .title {
    color: red;
  }
}
// tsxに記述されているimport
import { container, title } from "./style.module.css";

.container.title を必要としていますが、後者の .title は出力されません。

// styles.d.ts
declare const styles: {
  container: string;
  title: string; // ここを手動で追加
};
export = styles;

このようなネストされたセレクタはアプリケーションのバンドラの設定に沿って手動で行う必要がありました。

グローバルな変数や定数の宣言を型定義ファイルに記述

グローバルで定義されていた変数や定数が多数存在していたため、global.d.ts を作成し、アプリケーション全体から参照できるようにまとめて定義しました。 たとえば、window オブジェクトに追加されているサードパーティ製の変数などは、以下のように明示的に型を定義しています。

// global.d.ts の一例
interface Window {
  hoge: any;
}

any と ts-ignore でコンパイルエラーを解消

ここからは、実際のアプリケーションのコードで発生しているコンパイルエラーを、any// @ts-ignore のコメントによって一時的に抑制していく対応になります。 作業自体は、IDE やビルドのエラーを拾って ts-ignore コメントをつけるという単純な流れにはなりますが、進めていく中で以下のような問題に直面しました。

バンドラの設定と tsconfig.json の resolve が合わない

実装当時からアプリケーションは以下のようなディレクトリ配置になっています。

src
├ pc
│ ├ components
│ └ ...etc
├ sp
│ ├ components
│ └ ...etc
├ tsconfig.json
├ package.json
├ webpack.config.json
└ ...etc

ビルド時にコマンドで指定された環境変数による振り分けを受けて、Webpack は以下のような参照解決を行っていました。

const current = process.cwd();
const device = process.env.DEVICE;

const rootConfig = {
  pc: {
    root: [
      path.join(current, 'src/pc'),
    ],
  },
  sp: {
    root: [
      path.join(current, 'src/sp'),
    ],
  },
}

module.exports = {
  ...
  resolve: {
    modules: [
      ...rootConfig[device].root,
    ],
  },
  ...
}

例えば、以下のような import 文があったとします。

import { Fuga } from "components/Fuga";

このコードは、ビルド時のコマンドの指定が device=pc の時は src/pc/components/Fuga.tsx を、device=sp のときは src/sp/components/Fuga.tsx を参照するという仕組みになっています。 この Webpack でのバンドラ時の参照解決に関する設定を見落とし、TypeScript の tsconfig.json 側では、以下のような記述をしていました。

{
  "compilerOptions": {
    "paths": {
      "components/*": ["pc/components/*", "sp/components/*"],
      ...
    }
  }
}

この設定では、components/* の解決時に、pc/components/* を優先して解決しようとします。つまり、次のようなケースで不一致が発生します。

src
├ pc
│ └ components
│   ├ Hoge.tsx
│   └ Fuga.tsx
└ sp
  └ components
    ├ Hoge.tsx
    └ Fuga.tsx

例えば上記のようなディレクトリ構成で、src/sp/components/Fuga.tsx に次のようなコードが書かれているとします。

import { Hoge } from "components/Hoge.tsx";

バンドラは sp/components/Hoge.tsx を参照しますが、TypeScript は paths の定義に従って pc/components/Hoge.tsx を優先的に解決してしまいます。 (※両方存在する場合は、最初のマッチで確定します) このように、TypeScript がビルド時と異なるモジュールを参照してしまうという不一致が起きていました。

エイリアスの付与と import 文の変更

この問題を解消するため、Webpack の resolve.alias を導入し、各モジュールのルートを統一しました。

resolve: {
+  alias: {
+      "@": path.resolve(__dirname, "src"),
+  },
-  modules: [
-    ...rootConfig[device].root,
-  ],
}

また、アプリケーション内の import 文も @/pc/components/Hoge@/sp/components/Fuga のように明示的にディレクトリを含める形へと変更しました。

import { Fuga } from "@/sp/components/Fuga.tsx";

TypeScript 側でも以下のように paths を統一し、Webpack 側と整合性が取れるようにしました。

{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

このように設定することで、TypeScript の型解決と Webpack のバンドル解決が一致し、両環境で同じファイルを正しく扱えるようになりました。

導入の完了

これらの段階的な変更を経て、無事 TypeScript の導入が完了しました。
現在、新規の開発はすべて TypeScript(.ts / .tsx)で行われており、型チェックを活用した開発効率の向上が実感できています。

作業後での気づき

実際に既存のファイルの拡張子を変更し十分な型付けが行えていなくても、TypeScript のトランスパイルチェックを IDE 上で受けられるようになり、以下のようなメリットを得られていると感じました。

  • 不要な引数を関数に渡している箇所でエラーが出るようになった
  • Props で必要なキーが抜けている UI コンポーネントが検知できた
  • 使われていない import 文が自動で警告され、クリーンなコードを維持しやすくなった

たとえ段階的な導入であっても、IDE 上でも最低限のチェックが入るだけで構文エラーの発見や意図しない不整合に気づくことができ、保守性の向上と開発効率の改善の両面で、十分な効果があると実感しています。

最後に

複雑かつ大規模であったため、1 つ 1 つを適切に対応できていたかは自信がありませんが、アプリケーションのコードに TypeScript を導入できました。 ただ、現在は一部のコードでは、やむを得ず any// @ts-ignore を用いて型チェックをスキップしている箇所が残っています。 型付けのアプローチについては、ドメイン層から全体を設計していくようなトップダウンの進め方と、実際の開発の流れに沿って必要なところから徐々に型を補完していくボトムアップの進め方を、並行して進めている最中です。 いずれの方法も状況に応じて使い分けながら進めていますが、まだまだ整備が追いついていない部分もあり、課題は残っています。 今後もコードベース全体の型カバレッジを少しずつ向上させ、型の恩恵をより広く享受できる状態を目指していきます。

我々は、デジタル作品を販売するオンライン EC サービスを共に成長させていく仲間を募集しています。 ご興味があればご覧いただければと思います。 dmm-corp.com