既存のMPAなシステムをTypeScriptに移行した時のメモ

# はじめに

## 想定読者
- jQueryなどで構築したMPA(Multi-Page Application)システムのTypeScript移行を検討している方

自分でやってみて、ReactやVue.jsなどSPAの情報は豊富にあるけど、MPAの情報が少ないと感じたのもあり記事にしてみます。

## 今回の対応の前提事項
- 対象のjsは約100ファイル
- IE11のサポートは必須
- 私を含めて3人で作業（基本的にみんなTS未経験）
- あまり工数は掛けない
  - jsファイルの単位は変えない
  - any型を使ってもOK
  - グローバル変数はゼロには出来ないし目指さない
  - TS化以外の修正は同時にやらない

作業をしていると既存ソースの粗が気になってしまいますが、そこの修正はぐっと堪えました。

# やったこと
1. Study
2. @types/xxxのインストール
3. ts-migrateを使って機械的にts化
4. webpackを設定
5. ts-migrateが吐き出した`//@ts-expect-error`の箇所をひたすら直していく

2〜4は準備を除くと実施だけでしたら1日で終わるレベルの作業です。
一応、4.までやると一応TS化したことになります。  
5.についてはゆっくりやっても大丈夫ですのでうちのチームでは毎週時間を決めて少しずつ対応していく方針を取りました。

続けて具体的にやったことを書いていきます。

## 1. Study
チーム全員にTypeScriptの概要レベルを押さえてもらため勉強会を開催しました。
下記のスライドを教材にさせてもらいましたが、内容が浅すぎず深すぎず絶妙なバランスの神スライドでした。多謝！
https://speakerdeck.com/rtechkouhou/typescript-bootcamp-2020

また、チーム外の人を含めた有志で[プログラミングTypeScript](https://www.oreilly.co.jp/books/9784873119045/)読書会も開催しています。
こちらは言語仕様が深く書かれていて難易度がちょっと高いですが、すごく勉強になります。
TS移行を担当するメンバーはこちらにも全員参加して日々TS力を高めています。

## 2. 型情報（@types/xxx）のインストール
TSの概要を押さえたところで具体的な作業に入っていきます。  

jQueryなど、[Definition Typed](https://github.com/DefinitelyTyped/DefinitelyTyped)で公開されている型情報をインストールしておきます。  
これでエディタ上での外部ライブラリの使用箇所のエラーが出なくなります。

```
$ npm install -D @types/jquery @types/jqueryui
```

## 2. ts-migrateを使って機械的にts化
`.js`から`.ts`への拡張子のリネームなどは手動でやろうと思ってましたが、[ts-migrate](https://medium.com/airbnb-engineering/ts-migrate-a-tool-for-migrating-to-typescript-at-scale-cd23bfeb5cc)というAirbnbが公開しているツールを上司が教えてくれたのでそれを使用しました。

```
$ npm install --save-dev ts-migrate
$ npx ts-migrate-full src/js
```

ツールを流すと以下のことを一気にやってくれます。

- tsconfig.jsonを生成
- 拡張子jsをtsにリネーム
- tsファイルの中身を一括変換

上記の単位でgitへのコミットまでしてくれるので後から変更が追いやすいです。

```sh
d2e91aeb77e04ec5cb449b5687b4d435f74f4975 (HEAD -> master) [ts-migrate][src] Run TS Migrate
5dbe3669ec05232824dcb42c321d18983e451a54 [ts-migrate][src] Rename files from JS/JSX to TS/TSX
bae0dfb6790808310865ceb7f00ab0c0f161013a [ts-migrate][src] Init tsconfig.json file
```

また、個別に実行するオプションもあるのでツールが何をやってるか理解しながら進めることもできます。
jQueryなプロジェクトだと自動変換してくれることは少ないですが、関数の引数の型をanyでアノーテートしたり、varをletに置換するなど退屈な作業をやらないで済むのは良かったです。
ちなみに、git上でリネームされるので.js時代の古いgit logも引き継いで見れるので安心です。

## 4. webpackを設定
このタイミングでwebpackを導入しました。
ts-loaderでトランスパイルするのですが、MPAなのでエントリーポイントを沢山宣言する必要があります。
そうすると今までとJSの出力単位が変わらないので、HTML側の修正が必要なく影響を極小に抑えることができます。
libraryTargetはIE11でも動くようにumdを設定しています。

```js:webpack.config.js
module.exports = {
  // 既存のjsの数だけエントリーポイントを定義
  // 実際はここに約100個記述します
  entry: {
    'a.js': '/src/a.ts',
    'b.js': '/src/b.ts'
  },
  module: {
    rules: [
      {
        // ts-loaderでトランスパイルする
        test: /\.ts$/,
        exclude: /node_modules/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true,
          }
        }
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  output: {
    // IE11でも動かすためumdを指定する
    libraryTarget: 'umd',
    // entryに指定した名前でファイルを出力する設定
    filename: '[name]',
    path: `${__dirname}/webapp/htdocs/js`
  }
}
```
100ファイルぐらいでしたらビルド時間は大したことありませんでした。

## 5. ts-migrateが吐き出した`//@ts-expect-error`の箇所をひたすら直していく
ひとまずanyを使っても良いので、`@ts-expect-error`エラーコメントをひたすら直していきます。
前述した通り、時間を決めて少しずつ直していきます。

# つまずいたこと
## グローバル変数の値が更新されない

exportした変数を呼び先で変更してるのに変更が反映されませんでした。
調べてみるとumdだとこんな感じでexportした変数がself(window)に設定されるようです。

```ts
// 変数をエクスポートすると...
export let hoge = 1;

// 裏ではこんな感じで値がself(windowと同じ)に設定されている
self.hoge = exports.hoge = 1; 
```

その後、値を更新するのですが下記のように値が反映しません。

```ts
// hogeの値を更新
hoge = 2;

// 裏ではこんな感じに値を更新している
exports.hoge = 2;
```

これは、self.hoge = eports.hogeのようにアドレスを共有しているのですが
プリミティブで値を上書きしてリンクが切れてしまいself.hoge（window.hoge）の値が変わらないことが原因でした。
（hogeがオブジェクトだったら中身を書き換える分には問題ないのです）
このケースは、window.hogeのようにグローバルであることを明示してアクセスするようにしました。

## thisの型が合わなくて怒られる
例えば、以下のようにjQueryのonイベントのコールバック内でthisに対して何か操作をする時にエラーになりました。

```ts
$('#hoge').on('click', function() {
  // TS2339: Property 'checked' does not exist on type 'HTMLElement'.
  // TSはthisをHTMLElementだと推論している
  if (this.checked) {
```

このケースは、HTMLInputElementのように、より具体的な型を関数の第一引数に自分で指定する必要がありました。  

```ts
$('#hoge').on('click', function(this: HTMLInputElement) {
  if (this.checked) {
```

関数の第一引数のthisはTSに型を伝えるための特別な扱いになっていて、トランスパイル後のjsには出力されません。
他の言語にはない概念なので少し戸惑いました。

## どうしてもグローバル依存がはずせないものがある

外部ライブラリと共有する変数など、どうしてもグローバル依存が外せないものがありました。
その場合は諦めて`window.globalValue = 1`のように明示するようにしました。
`TS23339: Property 'globalValue' does not exist on type 'Window & type of globalThis'`のエラーになるため、globals.d.tsというファイルを作ってWindowに宣言を追加しました。

```ts:globals.d.ts
interface Window {
    globalValue: number;
}
```

## [Definition Typed](https://github.com/DefinitelyTyped/DefinitelyTyped)に存在しないライブラリの扱い
それほど数が多くなかったので必要なものだけ自分で定義しました。
今後、使用箇所を増やす予定はないので中身は頑張らずanyとしました。

```ts
interface JQuery {
    // jquery.datePicker.js
    datePicker(options: any);

    // jquery.ajaxfileupload.js
    ajaxfileupload(options: any);
}
```

## webpackで出力したファイルがIE11で動かない
tsconfig.jsonのtargetにES5を指定して安心していると、webpackが出力するファイルの中でアロー関数が使われているためIE11での実行時にエラーになりました。
アロー関数はES6(ES2015)で追加されたもので、IE11ではサポートしていないためです。
対策として、package.jsonに`"browserslist": ["IE >= 11"]`を追加してIE11でも解釈できる形式で出力するようにしました。  

```json:package.json
  "browserslist": [
    "IE >= 11"
  ],
```

## モジュールとスクリプトが混在しているファイルの扱い
もともと、下記のような感じで共通関数とスクリプトが混在しているファイルがあり、
`import { commonFunc } from './common'`を複数のファイルからすると
importした分だけスクリプトの部分が実行されてしまう不都合がありました。

```ts:common.ts
export const commonFunc = () => {
...
}

$(function() {
  $("#btn").click(...)
});
```

回避策としては、モジュール部分とスクリプト部分でファイルを分割するか、それが難しければexportで共有するのを諦めてスクリプトモードで使うというのもあると思います。


## 変更前のトランスパイル結果と比較する
途中で気づいたんですが、ts-migrateした直後のコードをtscでトランスパイルした結果を取っておいて、
TS対応後のトランスパイル結果とdiffがなければOKみたいにすると、動作確認はかなり省略できるんじゃないかと思います。

# おわりに
以上で型の支援がある開発環境を手に入れることができました。
副次的な効果として既存ソースの課題も見えて来たので
機能開発で手を入れる時などにコツコツとリファクタリングしていこうと思います
最後にts-migrateはもう使わないで消しておきましょう。