はじめに
想定読者
- jQueryなどで構築したMPA(Multi-Page Application)システムのTypeScript移行を検討している方
自分でやってみて、ReactやVue.jsなどSPAの情報は豊富にあるけど、MPAの情報が少ないと感じたのもあり記事にしてみます。
今回の対応の前提事項
- 対象のjsは約100ファイル
- IE11のサポートは必須
- 私を含めて3人で作業(基本的にみんなTS未経験)
- あまり工数は掛けない
- jsファイルの単位は変えない
- any型を使ってもOK
- グローバル変数はゼロには出来ないし目指さない
- TS化以外の修正は同時にやらない
作業をしていると既存ソースの粗が気になってしまいますが、そこの修正はぐっと堪えました。
やったこと
- Study
- @types/xxxのインストール
- ts-migrateを使って機械的にts化
- webpackを設定
- ts-migrateが吐き出した
//@ts-expect-error
の箇所をひたすら直していく
2〜4は準備を除くと実施だけでしたら1日で終わるレベルの作業です。
一応、4.までやると一応TS化したことになります。
5.についてはゆっくりやっても大丈夫ですのでうちのチームでは毎週時間を決めて少しずつ対応していく方針を取りました。
続けて具体的にやったことを書いていきます。
1. Study
チーム全員にTypeScriptの概要レベルを押さえてもらため勉強会を開催しました。
下記のスライドを教材にさせてもらいましたが、内容が浅すぎず深すぎず絶妙なバランスの神スライドでした。多謝!
https://speakerdeck.com/rtechkouhou/typescript-bootcamp-2020
また、チーム外の人を含めた有志でプログラミングTypeScript読書会も開催しています。
こちらは言語仕様が深く書かれていて難易度がちょっと高いですが、すごく勉強になります。
TS移行を担当するメンバーはこちらにも全員参加して日々TS力を高めています。
2. 型情報(@types/xxx)のインストール
TSの概要を押さえたところで具体的な作業に入っていきます。
jQueryなど、Definition Typedで公開されている型情報をインストールしておきます。
これでエディタ上での外部ライブラリの使用箇所のエラーが出なくなります。
$ npm install -D @types/jquery @types/jqueryui
2. ts-migrateを使って機械的にts化
.js
から.ts
への拡張子のリネームなどは手動でやろうと思ってましたが、ts-migrateというAirbnbが公開しているツールを上司が教えてくれたのでそれを使用しました。
$ npm install --save-dev ts-migrate
$ npx ts-migrate-full src/js
ツールを流すと以下のことを一気にやってくれます。
- tsconfig.jsonを生成
- 拡張子jsをtsにリネーム
- tsファイルの中身を一括変換
上記の単位でgitへのコミットまでしてくれるので後から変更が追いやすいです。
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を設定しています。
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)に設定されるようです。
// 変数をエクスポートすると...
export let hoge = 1;
// 裏ではこんな感じで値がself(windowと同じ)に設定されている
self.hoge = exports.hoge = 1;
その後、値を更新するのですが下記のように値が反映しません。
// hogeの値を更新
hoge = 2;
// 裏ではこんな感じに値を更新している
exports.hoge = 2;
これは、self.hoge = eports.hogeのようにアドレスを共有しているのですが
プリミティブで値を上書きしてリンクが切れてしまいself.hoge(window.hoge)の値が変わらないことが原因でした。
(hogeがオブジェクトだったら中身を書き換える分には問題ないのです)
このケースは、window.hogeのようにグローバルであることを明示してアクセスするようにしました。
thisの型が合わなくて怒られる
例えば、以下のようにjQueryのonイベントのコールバック内でthisに対して何か操作をする時にエラーになりました。
$('#hoge').on('click', function() {
// TS2339: Property 'checked' does not exist on type 'HTMLElement'.
// TSはthisをHTMLElementだと推論している
if (this.checked) {
このケースは、HTMLInputElementのように、より具体的な型を関数の第一引数に自分で指定する必要がありました。
$('#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に宣言を追加しました。
interface Window {
globalValue: number;
}
Definition Typedに存在しないライブラリの扱い
それほど数が多くなかったので必要なものだけ自分で定義しました。
今後、使用箇所を増やす予定はないので中身は頑張らずanyとしました。
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でも解釈できる形式で出力するようにしました。
"browserslist": [
"IE >= 11"
],
モジュールとスクリプトが混在しているファイルの扱い
もともと、下記のような感じで共通関数とスクリプトが混在しているファイルがあり、
import { commonFunc } from './common'
を複数のファイルからすると
importした分だけスクリプトの部分が実行されてしまう不都合がありました。
export const commonFunc = () => {
...
}
$(function() {
$("#btn").click(...)
});
回避策としては、モジュール部分とスクリプト部分でファイルを分割するか、それが難しければexportで共有するのを諦めてスクリプトモードで使うというのもあると思います。
変更前のトランスパイル結果と比較する
途中で気づいたんですが、ts-migrateした直後のコードをtscでトランスパイルした結果を取っておいて、
TS対応後のトランスパイル結果とdiffがなければOKみたいにすると、動作確認はかなり省略できるんじゃないかと思います。
おわりに
以上で型の支援がある開発環境を手に入れることができました。
副次的な効果として既存ソースの課題も見えて来たので
機能開発で手を入れる時などにコツコツとリファクタリングしていこうと思います
最後にts-migrateはもう使わないで消しておきましょう。