ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

jscodeshift で Moment.js を Day.js に一括置換した話

※ こちらはミツモアAdvent Calendar 2021の3日目の記事です。

こんにちはミツモアでテックリードをしている白柳(@yanaemon)です。

ミツモアは「リモートワークが増えてエアコンを綺麗にしたい」「引っ越しで出た不用品を回収してもらいたい」といった生活のあらゆるシーンであなたにぴったりの専門家を無料で探せるサービスですので、ぜひ気軽に使ってみてください!

meetsmore.com

Moment.js の問題点

ミツモアでは、Moment.js を利用していたのですが、下記の課題がありました。

Day.js への置換

そこで、いくつかのライブラリを比較し、Moment.js とも互換性があり、かつ軽量な Day.js に切り替えることにしました。 ただ、Moment.js はいたる所で利用されていて、リプレイスがとても大変です。

こういう時皆さんどうしていますか?よくやるのは、

  1. VS Code の一括置換機能 (or sed コマンド) で一括置換
  2. うまく置換できない場合があるので、それを手動で一つ一つ修正

置換がとてもシンプルなものや、修正範囲が狭いものは正直これが最速です。 が、対象が数百ファイルあり、一括置換だとどうしてもできない場合はどうでしょう? これらを、jscodeshift というツールで一括置換しましたよというお話です。

ということで早速本題ですが、 moment to dayjs を置換してくれる codemod がちょうどなかったので作成しました。

github.com

実行方法は下記

$ 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 とは?

github.com

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 はさまざまな置換ライブラリが開発されていて、他にも下記のような置換も過去には実施しました。

使いこなせると、古いコードのリファクタリングも捗るのでぜひ活用してみてください。

現在、事業拡大を進めておりエンジニア・デザイナー・PdMを積極採用中です。ぜひWantedlyのリンクからカジュアル面談をしましょう。TwitterのDMでもお待ちしております。

www.wantedly.com