※ こちらはミツモアAdvent Calendar 2021の3日目の記事です。
こんにちはミツモアでテックリードをしている白柳(@yanaemon)です。
ミツモアは「リモートワークが増えてエアコンを綺麗にしたい」「引っ越しで出た不用品を回収してもらいたい」といった生活のあらゆるシーンであなたにぴったりの専門家を無料で探せるサービスですので、ぜひ気軽に使ってみてください!
Moment.js の問題点
ミツモアでは、Moment.js を利用していたのですが、下記の課題がありました。
- Moment is in maintenance mode
- bigger bundle size
- mutable ❗
Day.js への置換
そこで、いくつかのライブラリを比較し、Moment.js とも互換性があり、かつ軽量な Day.js に切り替えることにしました。 ただ、Moment.js はいたる所で利用されていて、リプレイスがとても大変です。
こういう時皆さんどうしていますか?よくやるのは、
- VS Code の一括置換機能 (or sed コマンド) で一括置換
- うまく置換できない場合があるので、それを手動で一つ一つ修正
置換がとてもシンプルなものや、修正範囲が狭いものは正直これが最速です。 が、対象が数百ファイルあり、一括置換だとどうしてもできない場合はどうでしょう? これらを、jscodeshift というツールで一括置換しましたよというお話です。
ということで早速本題ですが、 moment to dayjs を置換してくれる codemod がちょうどなかったので作成しました。
実行方法は下記
$ npm install -g jscodeshift moment-to-dayjs-codemod # dry run $ jscodeshift -t node_modules/moment-to-dayjs-codemod/transform.js -d -p path/to/file.ts # exec $ jscodeshift -t node_modules/moment-to-dayjs-codemod/transform.js path/to/file.ts
これで一括置換をしてくれます。 あくまで構文を元に自動置換しているので、動作までは保証されていません。しっかりとテストをした上でリリースしてください。 また、coding style まで考慮できていませんが、その辺りは eslint や prettier で自動修正すると良いです。
で、このままだと完全にブラックボックスなので、中身はどういった挙動をしているのかについて説明していきます。
jscodeshift とは?
jscodeshift is a toolkit for running codemods over multiple JavaScript or TypeScript files. It provides: * A runner, which executes the provided transform for each file passed to it. It also outputs a summary of how many files have (not) been transformed. * A wrapper around recast, providing a different API. Recast is an AST-to-AST transform tool and also tries to preserve the style of original code as much as possible.
codemod というツールも別にあったりします。これはさまざまな言語に対して汎用的に使えるツール jscodeshift は js に特化してかつ複数ファイルへの操作を容易にしてくれるツールというイメージです。
何はともあれ、とりあえず動かしてみましょう。
install jscodeshift
$ mkdir jscodeshift-workshop $ cd jscodeshift-workshop # install jscodeshift $ yarn init -y $ yarn add -D jscodeshift typescript tsc @types/jscodeshift # generate tsconfig $ yarn tsc --init
ファイル構成
├── node_modules ├── package.json ├── tsconfig.json └── yarn.lock
変更対象のファイル例
# src/sample1.ts const foo = 'foo' console.log(foo)
myTransform.ts
import { API, FileInfo, Transform } from 'jscodeshift' const myTransform: Transform = (fileInfo: FileInfo, api: API) => { return api.jscodeshift(fileInfo.source) .findVariableDeclarators('foo') .renameTo('bar') .toSource() } export default myTransform
ファイル構成(変更後)
├── myTransform.ts ├── node_modules ├── package.json ├── src │ └── sample1.ts ├── tsconfig.json └── yarn.lock
実行
$ yarn jscodeshift -t myTransform.ts src/sample1.ts --parser ts -d -p yarn run v1.22.10 warning ../../package.json: No license field $ /Users/yanaemon/workspace/jscodeshift-workshop/node_modules/.bin/jscodeshift -t myTransform.ts src/sample1.ts --parser ts -p Processing 1 files... Spawning 1 workers... Sending 1 files to free worker... const bar = 'foo' console.log(bar) All done. Results: 0 errors 0 unmodified 0 skipped 1 ok Time elapsed: 0.802seconds ✨ Done in 1.13s.
-d
は dry-run で、-p
は結果を標準出力するオプション
jscodeshift の内部挙動
myTransform のそれぞれの行の詳細を記述すると下記になります。
const myTransform: Transform = (fileInfo: FileInfo, api: API) => { // これは plugin の interface なので固定 return api.jscodeshift(fileInfo.source) // ファイルから情報を読み取り、 .findVariableDeclarators('foo') // foo という変数を探して、 .renameTo('bar') // bar に置換し、 .toSource() // ファイルに書き出す (-p オプションが指定したら標準出力される)
FileInfo
は、引数で渡されたファイル情報で、下記のような情報が入っています。
{ path: 'src/sample1.ts', source: "const foo = 'foo'\nconsole.log(foo)\n" }
で?結局、内部的には何やっているの?
jscodeshift は recast というライブラリの wrapper で、recast は AST-to-AST 変換ツールです。
AST (Abstract Syntax Tree: 抽象構文木) とは?
Wikipedia: https://en.wikipedia.org/wiki/Abstract_syntax_tree
抽象構文木(英: abstract syntax tree、AST)は、通常の構文木(具象構文木あるいは解析木とも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報のみを取り出した(抽象した)木構造の木である。
はい...文章だけだと分からないですね。
百聞は一見に如かず。AST をいい感じに表示してくれるツールがあるので使います。
AST Explorer https://astexplorer.net/
ソースコードを解析して、JSON 形式で出力してくれます。
例: foo.bar() を foo.baz() に変換 参考: https://qiita.com/toshi-toma/items/c59aedfd75f5db33aee3
Source
# src/sample2.ts const foo = { bar: () => console.log('bar'), baz: () => console.log('baz'), } foo.bar()
Transform
import { API, FileInfo, Transform } from 'jscodeshift' const myTransform2: Transform = (fileInfo: FileInfo, api: API) => { const j = api.jscodeshift return j(fileInfo.source) .find(j.CallExpression, { callee: { object: { name: 'foo' }, property: { name: 'bar' }, }, }) .replaceWith((path) => { return j.callExpression( j.memberExpression( j.identifier('foo'), j.identifier('baz') ), path.value.arguments ); }) .toSource() } export default myTransform2
jscodeshift の Unit Test
参考: https://github.com/facebook/jscodeshift#unit-testing
install jest
$ yarn add -D jest ts-jest
# jest.config.js module.exports = { preset: 'ts-jest', }
Test Files
テスト本体は、__tests__
ディレクトリに
テストで利用する fixture は __testfixtures__
ディレクトリに xxx.input.ts, xxx.output.ts の形式で配置する
├── jest.config.js ├── myTransform2.ts ├── __testfixtures__ │ ├── bar2baz.input.ts │ └── bar2baz.output.ts └── __tests__ └── myTransform2.ts
それぞれの中身はこう
# __tests__/myTransform2.ts import { defineTest } from 'jscodeshift/dist/testUtils' describe('test', () => { defineTest(__dirname, 'myTransform2', null, 'foobar', { parser: 'ts' }) })
# __testfixtures__/bar2baz.input.ts const foo = { bar: () => console.log('bar'), baz: () => console.log('baz'), } foo.bar()
# __testfixtures__/bar2baz.output.ts const foo = { bar: () => console.log('bar'), baz: () => console.log('baz'), } foo.baz()
実行
$ yarn jest yarn run v1.22.10 warning ../../package.json: No license field $ /Users/yanaemon/workspace/jscodeshift-workshop/node_modules/.bin/jest PASS __tests__/myTransform2.ts test myTransform2 ✓ transforms correctly using "bar2baz" data (203 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 3.241 s Ran all test suites. ✨ Done in 4.25s.
という感じでテストしながら挙動確認ができます。
moment-to-dayjs-codemod で、一括置換でうまくいかなかったパターン
最後に、ミツモアでうまく動かなかったパターンも紹介します。
mutable use
moment は mutable ですが、dayjs は immutable のため、下記のような実装をしている無限ループに陥ります。
# moment for (let b = moment(); b.isBefore(moment().add(10, 'date')); b.add(1, 'date')) { console.log(d.toDate()); } # replaced by codemod for (let b = dayjs(); b.isBefore(dayjs().add(10, 'date')); b.add(1, 'date')) { console.log(b.toDate()); }
variable assign
変数に代入していると、現状はその変数をトレースして置換はしないので、予期せぬエラーが発生する可能性があります。
const d = moment(); // error unless you use objectSupport plugin console.log(d.add({ date: 1 }).toDate());
まとめ
jscodeshift はさまざまな置換ライブラリが開発されていて、他にも下記のような置換も過去には実施しました。
- ava から jest へ変換 : https://github.com/skovhus/jest-codemods
- JavaScript を TypeScript へ変換: https://github.com/airbnb/ts-migrate
使いこなせると、古いコードのリファクタリングも捗るのでぜひ活用してみてください。
現在、事業拡大を進めておりエンジニア・デザイナー・PdMを積極採用中です。ぜひWantedlyのリンクからカジュアル面談をしましょう。TwitterのDMでもお待ちしております。