mizdra's blog

ぽよぐらみんぐ

TypeScript 4.5 Beta で実装された Node.js ESM 対応を試してみた

ご存じの方もいるかもしれませんが、TypeScript 4.5 Beta で遂に Node.js ESM がサポートされました。まだ Stable に来ていない実験的な機能なのですが、どういうものなのか気になったので、先日趣味で作っているプロダクトに導入してみました。で、この記事はその備忘録です。実験的な機能で、これから状況もどんどん変わっていくので、数カ月後にはこの記事の内容も古くなっているかもしれません。未来から来た人がこの記事を読んでいる場合は、注意して読んで下さい。

今回 TypeScript の Node.js ESM 対応を導入してみたのはこちらの eslint-interactive というプロダクトです。以前このブログでも紹介した ESLint の API を使った CLI ツールです。

github.com

www.mizdra.net

www.mizdra.net

どうせ物好きな人しか読まないと思うので、雑にサクサク書いていきます。

最初に結論

どういう手順で進めて、どんな障害があって、それをどうやって解決していったかは全部 PR に詳しく書いたので、これ見てもらえれば大体分かります。

github.com

という訳で、以下はその PR の補足です。PR で書ききれなかったこととか、面白かった部分について書いていきます。

まずガイドを読む

JavaScript エコシステムを取り巻くモジュールシステムの事情にもそれなりに知っておかないと、かなり進めるのが難しいです (というか知っていてもムズいです)。手を加えなければならない場所も多岐にわたり、作業量自体も大きいです。そこで、まずは手を動かす前に参考になる資料を読んで、どういったことをしていくことになりそうか、目処をつけることにしました。

ありがたいことに @teppeis さんが TypeScript の Node.js ESM 対応についてまとめた非常に素晴らしい記事を書かれています。まずはこれを読みましょう。

zenn.dev

もし ESM 化するプロジェクトがライブラリの場合、sindresorhus 氏 *1 が書かれている記事も読むと良いです。ライブラリを ESM 化するとユーザ側にどんな影響があるかがまとまっています。

toolchain のサポート状況を確認する

Node.js と TypeScript が ESM をサポートしていても、Jest などの toolchain がサポートしていなくて ESM 化できない、という可能性もあるので、 toolchain の対応状況は調べておきます。大体 toolchain ごとに公式ドキュメントがあったり、 ESM 用のトラッキング Issue があるので、それを読んだり、トラッキング Issue にリンクされている Issue や PR のタイトルを見て気になるものがあったらクリックして、我々の作業のブロッカーになりそうな話題がないか確認していきます。

Jest のサポートは絶賛進行中で、モジュールの mock 周りのサポートが結構欠けているので気をつけて下さい。jest.mockjest.requireActual はまだ機能しません。 結構使われている機能なので、ここがブロッカーになることが多いと思います。eslint-interactive でも使っていましたが、別に mock 使わなくても良い箇所だったので、mock を使わない形に書き換えて乗り切ることにしました。

モジュールファイルを動的に import する機構を持つライブラリに気をつける

これはプロダクトによるのですが、「モジュールファイルを動的に import するような機構を持つライブラリを使っている場合、そのモジュールファイルを ESM で書けるようライブラリがサポートしているか」がブロッカーになることがあります。ライブラリが CJS なモジュールファイルしかサポートしていない場合、モジュールファイルを ESM で書くことができません。

例えば、eslint-interactive では ESLint が開発者向けに提供している API を使い、ESLint に自作のカスタムルールを動的に import させていました。ESLint のドキュメントを読む限りはカスタムルールは CJS でしか書けなさそうだったので、仕方なく当初はカスタムルールは CJS、それ以外は ESM で書く、という戦略で ESM 化を進めようと考えていました。dynamic import を使うと CJS から ESM を import できるので、これで部分的に CJS を使い、コードベースの他では ESM を使う、というハイブリットな構成を実現できます。

// src/rules/custom-rule.cts
import { type Rule } from 'eslint';

const rule: Rule.RuleModule = {
  create(context: Rule.RuleContext) {
    // ESM なモジュールを dynamic import で import する
    // 本当は import statement (`import ... from '...';`) で import したいけど、CJS => ESM だと
    // できなくて、dynamic import が唯一の import 方法。
    const { foo } = await import('./util.mts');

    // foo など ESM で書かれたモジュールの utility を使ってルールを実装
  },
}

普通はこれで問題ないのですが、ESLint のカスタムルールでは、ESLint 側の実装上の都合により、カスタムルールを非同期で記述できないことが分かりました。つまり dynamic import は使えません。CJS から ESM を読み込むには、dynamic import が唯一の方法なので、カスタムルールから事実上 ESM を読み込めないということを意味します。詰んだ…

仕方がないので eslint/eslint に issue を書いて、ESM のカスタムルールサポートして欲しいと Feature Request を送りました。

github.com

実装の許可が貰えれば実装するつもりでいたのですが、実は (.eslintrc.js とか一般ユーザからはまだ利用できないけど) ESLint が開発者向けに提供している API ならもう ESM なカスタムプラグイン/ルール読み込めるよ (ref1, ref2, ref3)、と教えてもらえたので、それを使う形で乗り切ることにしました。聞いてみてよかった。

pkg.bin で参照している実行可能ファイルには拡張子が必須

eslint-interactive は CLI ツールです。npm i @mizdra/eslint-interactive すると eslint-interactive というコマンドがインストールされ、このコマンドを介してツールを利用できるようになります。このコマンドのインストールには package.jsonbin というフィールド (以下 pkg.bin) を使っています。

numb86-tech.hatenablog.com

当初、eslint-interactive の package.json では、以下のように pkg.bin を記述し、bin/eslint-interactive にリンクさせていました。

  "bin": {
    "eslint-interactive": "bin/eslint-interactive"
  },

bin/eslint-interactive は shebang を書いて node コマンドで実行するようにシステムに指示していました。拡張子がないのですが、中に書いてあるのは JavaScript (厳密に言うと shebang はまだ ECMAScript 的に valid ではないですが…) なので、これで問題なく実行できます。

#!/usr/bin/env -S node -r source-map-support/register

import { run } from '../dist/index.js';

run({
  argv: process.argv,
}).catch((error) => {
  process.exitCode = 1;
  console.error(error);
});

で、本題なのですが、ESM 化するとこれ壊れます。厳密に言うと、package.json"type": "module" が書かれた状態で、bin/eslint-interactive を実行すると Unknown file extension "" という実行時エラーが出ます。

$ bin/eslint-interactive
TypeError: Unknown file extension "" for /Users/mizdra/src/github.com/mizdra/eslint-interactive/bin/eslint-interactive
    at new NodeError (node:internal/errors:371:5)
    at Object.file: (node:internal/modules/esm/get_format:72:15)
    at defaultGetFormat (node:internal/modules/esm/get_format:85:38)
    at defaultLoad (node:internal/modules/esm/load:22:14)
    at ESMLoader.load (node:internal/modules/esm/loader:353:26)
    at ESMLoader.moduleProvider (node:internal/modules/esm/loader:274:58)
    at new ModuleJob (node:internal/modules/esm/module_job:66:26)
    at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:291:17)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:255:34)
    at async Promise.all (index 0)

多分 Node.js が拡張子を元に ESM として扱うか CJS として扱うかを決定しているので、拡張子がないとそこがうまく動かないんでしょうね。node --eval "..." で文字列で JS コードを渡したときも、拡張子がないので似たような問題が発生します。

何か良い回避策無いかなあと Node.js のドキュメントを探してみたところ、--input-type=module というオプションがあることに気づきました。どうやらこれを使うと node --eval "..." で渡した文字列を ESM として処理するか CJS として処理するか、Node.js ランタイムに指示できるらしいです。

// from: https://nodejs.org/api/packages.html#--input-type-flag
node --input-type=module --eval "import { sep } from 'path'; console.log(sep);"

これ pkg.bin にも使えないかな? と思って試してみたんですが、駄目でした。--eval オプション OR --print オプションとの併用が必須らしいです。

github.com

打つ手がなく途方に暮れていたところ、id:rinsuki さんに拡張子を付けてみてはどうか、と教えていただきました。試しに .jsを つけてみたところ、なんと無事回避できました。

github.com

勘の良い人ならコマンドのインストール時に拡張子が外れた状態で node_modules/.bin に symlink が置かれるので、動かないのでは? と思うかもしれませんが、意外と何事もなく動きました。気づけば簡単だったけど、気づくまで結構時間掛かった… 何はともあれ id:rinsuki さんありがとうございました!

特定のディレクトリ配下のファイルを全部 CJS にする

eslint-interactive ではテストで使う fixtures があって、fixtures/ ディレクトリに配置しています。この中に .js を含む静的リソースを入れているのですが、プロジェクトのルートの package.json"type": "module" を加えたせいで、fixtures/ 配下の *.js も ESM として扱われるようになってしまいました。eslin-interactive のテストの都合上、fixtures/ 配下は CJS である必要があったので、これによってテストが壊れてしまいました。

今回は { "type": "commonjs" } とだけ書かれた package.jsonfixtures/package.json に配置して回避しました。これで fixtures/ 配下の *.js が CJS として扱われます。ESM 化する上で便利なテクなので覚えておくと良いです。

github.com

Jest が pkg.exports を解決できるようにする

Jest はまだ pkg.exports を解決してくれません (pkg.exports って何? という方はこちらを参照)。

github.com

一部のパッケージは pkg.exports を使って ESM モジュールを export しているので、これを解決できないと、パッケージを ESM で利用することができません。sindresorhus 氏のパッケージもこれを使っています。

Jest は割とモジュールの resolution をユーザ側で制御できるので、色々な workaround があります。例えば以下は moduleNameMapper を使って手動で解決する workaround です。こことかこことかここ とか見ながら合わせていきます。職人技です。

github.com

とはいえ #ansi-styles とかが chalk 以外で使われていると普通に壊れるので、オススメしません。オススメはカスタムリゾルバによる workaround です。どれくらいちゃんと動くものなのか分かりませんが、id:mizdra が確認した限りでは期待通りに動いてそうです。eslint-interactive の ESM 化でも最終的にこれを使いました。Jest の公式のサポートが来るまでのつなぎなので、一旦雑に回避しました。

github.com

github.com

package/path/to/module.mjs 形式の import がうまく動かない

3rd-party パッケージの中には、パッケージ名だけでなくパスまで指定させて import してもらうタイプのモジュールを提供しているものがあります。例えば comlink とかです。

import { Worker } from 'worker_threads';
import { wrap } from 'comlink';
// `package/path/to/module.mjs` 形式で import
import nodeEndpoint from 'comlink/esm/node-adapter.mjs';

const worker = new Worker('./worker.js');
const remoteFn = wrap(nodeEndpoint(worker));
console.log(remoteFn());

tsconfig.json"module": "node12" にすると、この形式で import するとモジュールに対応する型定義が見つからないとコンパイルエラーになることがあります。

$ tsc -p tsconfig.src.json --noEmit
src/index.ts:5:26 - error TS7016: Could not find a declaration file for module 'comlink/dist/esm/node-adapter.mjs'. '/Users/mizdra/src/github.com/mizdra/eslint-interactive/node_modules/comlink/dist/esm/node-adapter.mjs' implicitly has an 'any' type.
  If the 'comlink' package actually exposes this module, try adding a new declaration (.d.ts) file containing `declare module 'comlink/dist/esm/node-adapter.mjs';`

5 import nodeEndpoint from 'comlink/dist/esm/node-adapter.mjs';
                           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

comlink には node_modules/comlink/dist/esm/node-adapter.d.ts にちゃんと型定義があるのですが、tsc がこれをうまく見てくれません。これは node-adapter.mjs に対応する型定義が node-adapter.d.mts であるためです。*.d.ts ではなく *.d.mts です。試しに *.d.mts を用意してみると型検査に pass します。

$ cp node_modules/comlink/dist/esm/node-adapter.d.{ts,mts}
$ tsc -p tsconfig.src.json --noEmit && echo passed
passed

仕方がないので eslint-interactive では tsconfig.jsonpaths オプションを使い、TypeScript の世界でのみ comlink/dist/esm/node-adapter.mjs を無理やり comlink/dist/esm/node-adapter.js にマップさせ、comlink/dist/esm/node-adapter.d.ts の型定義を使わせる、ということをしています。

github.com

けど comlink/dist/esm/node-adapter.d.ts は CJS な型定義ファイルなので、default の挙動がおかしくなります。TypeScript の型上では nodeEndpoint.default に default export されたものが詰め込まれているように見えるのですが、ランタイム上では nodeEndpoint に詰め込まれています。どうしようもないので as any してます。難しい。

github.com

所感

各ツールチェインで ESM のサポートが進みつつあるとはいえ、まだまだ workaround を駆使しないと難しい、というのが所感です。comlink のように 3rd-party の型定義が合わない問題も結構悩ましいです。こういう形式でモジュールを export しているパッケージは沢山あるので、至るところで同様の問題に遭遇しそうです。TypeScript の Node.js ESM が Stable に来たタイミングで、皆で協力して型定義を直していく、みたいな感じになるのかな?

Pure ESM なパッケージを自然に import できるようになったりといったメリットがあるので、業務のプロダクトでも入れられると良いかなと思っていたのですが、実際に ESM 対応をしてみて、業務のプロダクトへの導入はまだ先になるかなと思いました。eslint-interactive は最小限のツールチェインしか使っていなかったので、何とか ESM 化できましたが、業務のプロダクトのような大規模のものになると、ここで紹介していない問題も色々出てきそうな予感がします。webpack とか babel の ESM のサポート状況とかも関わってきそうです。とはいえまだ実験的な機能ですからね。導入できる日が来るのを楽しみに待ちましょう。

あと今回の作業で気づいたこととかあれば、適時ツールチェイン側に issue を立てたりしようと思います。ちゃんとコミュニティにもフィードバックしていきたい。それに同じことをしようとした人がいた時に、参考にしてもらえますしね。

*1:sindresorhus 氏ってどんな人? という方はこちら: https://github.com/sponsors/sindresorhus

vscode-jest を導入してテストの開発体験を向上させる

この記事は JavaScript Advent Calendar 2021 の 12日目です。11日目は なかがわはじめ さんの 「Array.prototype.reduce()とかのprototypeってなに?」 でした。


Jest は JavaScript のテスティングフレームワークです。Node.js で動くアプリケーションのテストや、React を使った Web アプリケーションのテストなど、様々なプロジェクトのテストをサポートしています。また、エディタ向けに拡張機能やプラグインも公開されています。

(各拡張機能のサポート状況によりますが) これらの拡張機能を使うと、エディタ上から Jest によるテストの実行やエディタへのテスト結果の表示、breakpoint を使ったテストの実行などができるようになります。こうした便利な機能を活用することで、テストの開発体験が大きく向上します。

この記事では vscode-jest を対象に、実際にどのように導入して、どういった機能が利用できるのかについて紹介します。

前提

まず vscode-jest を導入する前に、Jest が導入済みのプロジェクトを用意して下さい。この記事では以下のようなjest.config.jsで構成されるプロジェクトを例に解説していきます。

// @ts-check

/** @typedef {import('ts-jest/dist/types')} */
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  preset: 'ts-jest',
  // test/ 配下にテストファイルがある想定
  testMatch: ['<rootDir>/test/**/*.test.ts?(x)'],
  globals: {
    'ts-jest': {
      tsconfig: 'tsconfig.test.json',
    },
  },
};

また、 Jest の テストを実行する npm-scripts も用意しておいて下さい。あとで使います。script 名はtestでもjestでも何でも良いです。

{
  "scripts": {
    // `npm run test` or `yarn test` で実行できる npm-scripts
    "test": "jest"
  },
}

vscode-jest の導入

準備ができたので、早速導入してみます。まずは以下のリンクから拡張機能をインストールしましょう。

marketplace.visualstudio.com

続けて jest.jestCommandLineというオプションを.vscode/settings.jsonに追加して下さい。これは Jest の起動方法を vscode-jest に教えてやるためのものです。事前に準備した Jest を起動する npm-script を書きましょう。yarn を使っていて、Jest を起動する npm-script の名前がtestなら、以下のように書きます。

// .vscode/settings.json
{
    "jest.jestCommandLine": "yarn run test",
}

vscode-jest はこのコマンドを使い、--watchオプションなどを付けてテストの自動実行を実現しています。そのため、npm を利用している場合は--を末尾に付けたコマンドを記入しなければなりません。ハマりやすいので注意して下さい。

// .vscode/settings.json
{
    // vscode-jest に jest を実行するコマンドを教えてやる
    // NOTE: `vscode-jest` はこのコマンドに `--watch` などのオプションを渡して実行するので、
    // `--` を明示してオプションが続けて渡せるようにしておく必要がある。
    "jest.jestCommandLine": "npm run test --",
}

これで完了です! ファイルの変更を保存して、VSCode を再起動してみて下さい。

各種機能の説明

インストールが完了すると、テストファイルの行番号の左にチェックマークが表示されたり、サイドバーに三角フラスコのメニューが追加されているはずです。1つずつ説明していきます。

エディタパネル

まず右側のエディタパネルから。行番号横の左にあるチェックマークは、テストの実行結果を示しています。passed なら緑のマークが、failed なら赤色のマークが表示されます。VSCode 起動時に自動的にテストが実行されるようになっており、起動した直後かつすべて pass していれば、すべてのテストケースに緑のマークが表示されているはずです。failed の場合は、assertion に失敗した行に赤の下線が引かれます。その行にマウスでホバーすると、失敗した原因のレポートを確認できます。

vscode-jest はテストの自動実行をサポートしています。そのためファイルを変更すると、関連するテストが自動的に実行されます。試してみましょう。

youtu.be

ちなみに全てのテストが自動実行される訳ではなく、モジュール同士の依存関係を理解して必要なテストファイルのみ実行してくれるようになっています。内部的には Jest の--watchオプションを用いているため、基本的にはjest --watchと同じ挙動をします。

Test Explorer

サイドバーに追加された三角フラスコアイコンのボタンを押すと、Test Explorer という機能が利用できます。これを使うと Jest のテストケースを一覧できます。

各テストケースの名前にホバーすると、「テストのデバッグ」ボタンと、「テストに移動」ボタンが表示されます。「テストに移動」ボタンを押すと、エディタパネルのほうで当該テストケースのあるファイルを開いて、その行にフォーカスを当ててくれます。これが地味に便利です。「テストのデバッグ」ボタンについては後で説明します。

上のほうにあるコンソールのようなアイコンのボタンを押すと、Jest のテストレポートをコンソール上で見ることができます。jest--watchで表示されるようなログがそのまま流れてきます。先程紹介した赤線にホバーした時に表示されるレポートはただのプレーンなテキストによるものでしたが、こちらは色付されているので、ちょっと見やすいです。かゆいところに手が届く感じがして良いですね。

デバッガーを利用する

vscode-jest は VSCode の組み込みデバッガーとの連携をサポートしています。これを使うと、breakpoint を使ったテストの実行などができます。

vscode-jest からデバッガーを利用するには、VSCode のデバッガー向けの設定ファイル (.vscode/launch.json) が必要です。vscode-jest にはこのファイルを自動生成する機能が実装されており、F1 > Jest Setup Extension (Beta) > Setup Jest Debug Config > Generateとするだけで簡単に用意できます。

www.youtube.com

これで以下のように、Test Explorer にある「テストのデバッグ」ボタンから breakpoint などを使ったデバッグが利用できるようになります。エディタパネルから変数をホバーして格納されている値を参照したり、ローカル変数一覧を参照したり、デバッグコンソールでローカル変数を使ってちょっとしたコードの評価をしたりできます。

www.youtube.com

以上が vscode-jest の各種機能の紹介でした。ちょっとした設定を書くだけで、これだけの機能が使えるのは驚きですね。エディタを離れることなくテスト結果が即参照できるというというのは思いの外体験が良くて、これだけでも十分導入する価値があります。またデバッガーを使いこなせれば、非常に効率よくテストをデバッグできます。こちらも是非導入してみてください。

本当はデバッガーの使い方などについてもっと丁寧に解説したかったのですが、非常に機能が豊富で説明しきれないので、都度他の資料を当たって下さい。

Tips: VSCode の起動時にテストを自動実行しないようにする

VSCode を起動すると、vscode-jest は自動的にテストを実行して、結果を表示してくれます。これは大変便利なのですが、VSCode の起動時に行われる VSCode 本体の初期化や、他の拡張機能の初期化のためのリソースを奪ってしまいます。個人的にはエディタが立ち上がったら素早くファイルを編集できるようになってほしいという思いがあり、起動時のテストの自動実行はおせっかいに感じています。

そこで id:mizdra は以下のような設定を.vscode/settings.jsonに書き、起動時にテストが自動実行されないようにしています。VSCode 上でファイルが変更されたらテストが自動実行がされる点は変わらないので、普段の開発体験への影響もほとんどありません。オススメです。

// .vscode/settings.json
{
  // VSCode の起動時に自動で jest のテストを走らせないように。
  // VSCode 起動時は色々な拡張機能の初期化処理が走っているので、
  // あまりカジュアルに起動時に処理を実行したくない、という狙いがある。
  // *.ts ファイルが変更されたら jest のテストが自動実行がされる点は変わらないので、
  // これで開発体験が損なわれることはないはず。
  "jest.autoRun": { "watch": true }
}

追記 (2022-11-17)

場合によっては Jest の起動がマシンの負荷になって、開発体験が悪くなることがあります (ファイルを保存する度に CPU が 100% に張り付くなど / Developer: Open Process Explorer を見よう!)。そこで id:mizdra は最近はテストファイルを保存した時のみテストを走らせるようにしています。

// .vscode/settings.json
{
  // VSCode の起動時に自動で jest のテストを走らせないように。
  // VSCode 起動時は色々な拡張機能の初期化処理が走っているので、
  // あまりカジュアルに起動時に処理を実行したくない、という狙いがある。
  // *.ts ファイルが変更されたら jest のテストが自動実行がされる点は変わらないので、
  // これで開発体験が損なわれることはないはず。
  "jest.autoRun": {
      "watch": false,
      // 加えて、普段のコーディングの時にいちいちテストが走っていては開発体験が悪いので、
      // テストファイルを保存したときにのみテストを走らせるように。
      "onSave": "test-file"
  },
}

逆にテストを先に書いて、後から実装を書く開発方法 (テスト駆動開発など) とは相性悪いので、そういう場合はこのオプションは不要かもしれません。もしくはテスト駆動開発したければ npm run test -- --watch するとかでも良いかもです。

あと VSCode 起動時に jest のログを表示するターミナルが自動に開いてわずらわしいので、設定で off にしています。

// .vscode/settings.json
{
  // vscode 起動時に jest の実行結果を表示するターミナルを自動で開かないように
  "jest.showTerminalOnLaunch": false
}

まとめ

この記事では vscode-jest を使ったテストの開発体験の改善について紹介しました。ちょっとした設定を書くだけで非常に強力な機能が使えるので、皆さんも是非導入してみてください。実際に紹介した手法で vscode-jest を導入してみた PR も貼っておきますので、参考にしてみてください。

github.com

JavaScript Advent Calendar 2021 の明日の担当は @youtoy さんです!

Babel をリファクタリングツールとして使う

この記事は はてなエンジニア Advent Calendar 2021 の 5 日目の記事です。

4 日目は id:anatofuz さんの 「入社してから書いていた分報の行数を眺めてみる」 でした。日報に書き込んだ行数を可視化するというアイデアが面白い! 僕も日報書いているので今度可視化してみようと思います。

anatofuz.hatenablog.com

本題

さて今回はタイトルにもある通り、Babel の話をします。Babel というのは JavaScript のトランスパイラです。 JavaScript のソースコードを入力として受け取り、適切な変換を施し、JavaScript のソースコードを出力する (トランスパイルする) ツールです。主に新しい構文で書かれた JavaScript を、古いブラウザなどでも動くよう、古い構文で書かれた JavaScript に変換するために使われています。

実はあまり知られていないのですが、 Babel は旧構文への変換以外にも、ソースコードを機械的に書き換えるためのリファクタリングツールとしても利用できます。通常 Babel を利用する際は、src/配下にある JavaScript ファイルを、Babel で変換してdist/に出力しますが、Babel をリファクタリングツールとして利用する場合は、src/配下にある JavaScript ファイルを、Babel で変換してsrc/に出力することになります。

f:id:mizdra:20211205173833p:plain
旧構文への変換ツールとしての利用した時と、リファクタリングツールとしての利用した時の違い

JavaScript におけるリファクタリングツールとなると、codemods や JSCodeshift が有名で、Babel の名前は聞き慣れないと思います。しかし、公式ドキュメントにも Babel がリファクタリングツールとして利用できることが明記されており、きちんとユースケースの一部としてカバーされていることが分かります。

Here are the main things Babel can do for you:

  • Transform syntax
  • Polyfill features that are missing in your target environment (through a third-party polyfill such as core-js)
  • Source code transformations (codemods)
  • And more! (check out these videos for inspiration)

https://babeljs.io/docs/en/

リファクタリングツールとして利用する方法

Babel をリファクタリングツールとして利用する方法については、Babel のメンテナーである Nicolò Ribaudo 氏が発表されているので、その資料を参考にするのが良いです。

発表では React の Class Component を Function Component を書き換える例が紹介されています。合わせてリファクタリングのデモに使ったコード郡も以下のリポジトリで公開されており、ここから簡単に Babel を使ったリファクタリングを試せます。

github.com

デモを動かしてみる

まずはデモを動かしてみましょう。README に起動方法が書いてあるので、その手順通りにやれば良いです。

$ # リポジトリを clone
$ git clone https://github.com/nicolo-ribaudo/conf-holyjs-moscow-2020
$ cd conf-holyjs-moscow-2020

$ # ディレクトリ構成を確認
$ ls -1F
Babel_ A refactoring tool.pdf
README.md
codemod/ ... Babel を使ったリファクタリングツールのコード
todomvc/ ... Class Component を含むリファクタリング対象のアプリケーションのコード

$ # codemod/ 配下に package.json があるので、`npm ci` で依存パッケージをインストールしておく
$ cd codemod
$ npm ci

$ # プロジェクトルートに戻る
$ cd ..

$ # リファクタリングツールを起動し、アプリケーションコードをリファクタリング
$ node codemod/run.js todomvc/js/*.{ts,tsx}

リファクタリングツールの実行が終わると、以下のように Class Component が Function Component に書き変わっているはずです。

$ git diff
diff --git a/todomvc/js/app.tsx b/todomvc/js/app.tsx
index 6fc2842..ded2b9c 100644
--- a/todomvc/js/app.tsx
+++ b/todomvc/js/app.tsx
@@ -7,167 +7,172 @@
 /// <reference path="./interfaces.d.ts"/>

 declare var Router;
-import React from "react";
+import React, { useState, useEffect } from "react";
 import ReactDOM from "react-dom";
 import { TodoModel } from "./todoModel";
 import { TodoFooter } from "./footer";
 import { TodoItem } from "./todoItem";
 import { ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS, ENTER_KEY } from "./constants";

-class TodoApp extends React.Component<IAppProps, IAppState> {
+const TodoApp = props => {
+  const [nowShowing, setNowShowing] = useState(ALL_TODOS);
+  const [editing, setEditing] = useState(null);
...

リファクタリングツールの仕組み

codemod/配下のコードを見てみると分かるのですが、リファクタリングツールの実装はcodemod/codemod.jscodemod/run.jsのたった 2 つの JS ファイルから構成されています。codemod/codemod.jsは実行したい変換処理を表す Babel Plugin です。ここに Class Component を Function Component に変換する処理が書かれています。一方codemod/run.jsはコマンドライン引数で渡されたファイルを1ファイルずつ AST に parse し (ステップ1)、前述のBabel Plugin を使って AST を変換して (ステップ2)、AST を文字列化して元のファイルに上書きする (ステップ3) コードになっています。

// https://github.com/nicolo-ribaudo/conf-holyjs-moscow-2020/blob/7c2b597f284f0fbafe3f823aba0e6bc2f0e46154/codemod/run.js#L20 より一部引用
function transform(source, filename, codemod) {
  // ステップ1
  const ast = recast.parse(source, {
    parser: {
      parse(source) {
        return parser.parse(source, {
          filename,
          tokens: true,
          sourceType: "module",
          // typescript や jsx が扱えるように
          plugins: ["typescript", "classProperties", "jsx"],
        });
      },
    },
  });

  // ステップ2
  babel.transformFromAstSync(ast, source, {
    code: false,
    cloneInputAst: false,
    configFile: false,
    // 注: ここで plugin を差し込んでいる
    plugins: [codemod],
  });

  // ステップ3
  return recast.print(ast).code;
}

任意のリファクタリングができるように処理を差し替える

何となく気づいていると思いますが、codemod/codemod.jsの実装を変更すれば、任意のリファクタリングを実行できるようになります。先程も触れましたが、codemod/codemod.jsの実態はただの Babel Plugin なので、Babel Plugin のルールに沿ってコードを書いていけば良いです。

// @ts-check
// ts-check を使って書くと、型検査が効いてお得
module.exports = function plugin(babel) {
  /** @type {import('@babel/core').types} */
  const t = babel.types;
  /** @type {import('@babel/core').template} */
  const template = babel.template;

  return {
    visitor: {
      StringLiteral(path) {
        // シングルクオートで囲んでいるstring literal をダブルクオートで囲むように
        path.node.extra.raw = path.node.extra.raw.replaceAll("'", '"');
      },
    },
  };
};

使い所

正直なところ、単にリファクタリングしたいだけであれば JSCodeshift を使えば事足りるので、Babel をリファクタリングツールとして使う場面はそうないと思います。JSCodeshift はリファクタリングに特化したツール故に、リファクタリングに特化した utility が用意されていて、公式 example やも豊富で、使い方を解説した資料も沢山あります。一方で、Babel を使った手法では、Babel Plugin 向けに提供されている API をそのままリファクタリングに利用できたり、Babel Plugin のノウハウをそのまま利用できるという利点があります。これは Nicolò Ribaudo 氏の発表スライドでも触れられています。

f:id:mizdra:20211205164558p:plain
Babel: A refactoring tool 63 ページより引用

特に Babel Plugin のノウハウをそのまま活かせる、というのは大きなメリットだと考えています。ちょっと Babel Plugin を書いたことのある人なら、簡単に扱えますし、codemod.js を実装するときも既存の Babel Plugin のコードを参考にしたり、そのまま流用できます。

実際に id:mizdra は社内でとあるリファクタリングを行う際に、既存の Babel Plugin の一部コードをそのまま流用してリファクタリングをする、ということをやっていました。Babel Plugin 向けに書かれたコードを一切変更せずそのまま使えるので、(JSCodeshift 向けの codemod を新規に書くよりは) 簡単にリファクタリングを実施できました。

まとめ

今回は Babel を使ったリファクタリング手法について紹介しました。JSCodeshift という良い代替ツールがあるので、出番はそうないと思いますが、覚えておくとどこかで役に立つかもしれません。

明日は id:yutailang0119 さんです!

1Password のカスタムフィールドを autofill に利用する

1Password にはカスタムフィールドという機能があります。これを使うと、ログインのためのちょっとしたメモや、秘密の質問の答えなど、好きな情報を id/pass とともに記録できます。

f:id:mizdra:20211005001726p:plain
秘密の質問の答えを記録している様子

ところでこのカスタムフィールドは、実はログインフォームなどの autofill に活用することもできます。

具体例

例えば以下のような HTML Form があると仮定します *1

See the Pen by mizdra (@mizdra) on CodePen.

こうしたフォームがある時に以下のように 1Password を設定しておくと、所々の入力欄を autofill してくれます。

f:id:mizdra:20211005002513p:plain

f:id:mizdra:20211005003109g:plain

id:mizdra が調べた限りでは、以下の規則で「ラベル」や「新規フィールド」を設定しておくと、autofill してくれるようです。undocumented な機能だったので、正確な仕様は分かりません *2

  • ラベル: 以下のいずれかにマッチするもの
    • autofill したい入力欄のname属性の値に部分一致する文字列
    • autofill したい入力欄のid属性の値に部分一致する文字列
    • autofill したい入力欄に対応するlabel要素のテキストに部分一致する文字列
  • 新規フィールド
    • autofill したい値 (value属性の値)

別解

実はカスタムフィールドとは別に、form の autofill 用の設定を記入する欄が 1Password に用意されています。非常に分かりにくいのですが、Mac 版の 1Password 7 では「保存済みのフォームの詳細を表示」 から、その入力欄を表示できます。

f:id:mizdra:20211005003643p:plain

f:id:mizdra:20211005003850p:plain

専用の入力欄があるならそっちを使えば良さそうと思いつつ、入力欄が下の方にあって使い勝手が悪そうな感じもして難しいですね。皆さんはどうしてますか?

*1:説明のために誕生年を要求するフォームを挙げていますが、勿論こういったログインフォームを作るべきではありません。

*2:1Password のブラウザ拡張機能のソースコードを見ればなにか分かるかも

Sentry で IP アドレスの収集をやめる

@sentry/browser を使うと、ブラウザでエラーが発生した時にそのエラーを Sentry の集計サーバに送信して記録してくれます。送信されたエラーはエラーの種類ごとに Issue という単位にグルーピングされ、Issue ごとに何件発生しているのか、何人のユーザで発生しているのか、過去2週間にどれぐらいのエラー数の増減があったのか、などと簡潔に表示してくれます。便利ですね。セットアップも非常に簡単で、十数行程度のセットアップコードを書くだけで使い始めることができます。

エラーが Issue ごとにグルーピングされている様子。画像は https://docs.sentry.io/product/issues/ から引用。

IP アドレス の収集をやめる

ところでこのエラーが発生したユーザ数 (画像の USERS のカラムの部分) なのですが、デフォルトではエラーの送信元の IP アドレスを元に割り出しています。ブラウザから Sentry の集計サーバにエラーが送信されると、集計サーバ側で送信元の IP アドレスを取得し、それをキーにユーザを識別しています *1。何もしなくても勝手にユーザ識別してくれて便利ですね。

一方でプロダクトによっては諸事情で IP アドレスの収集をやめたいということもあると思います。実は管理画面から IP アドレスの収集をやめるオプションがちゃんと用意されています。

  • プロジェクトごとに収集停止する場合
    • Settings -> オーガニゼーション -> プロジェクト -> セキュリティとプライバシー > Prevent Storing of IP Addresses を ON に
  • オーガニゼーション全体で収集停止する場合
    • Settings -> オーガニゼーション -> セキュリティとプライバシー > Prevent Storing of IP Addresses を ON に
    • オーガニゼーション全体で収集停止すると、オーガニゼーション全体で強制的に収集停止になりプロジェクトごとに ON/OFF の切り替えさえできなくなってしまうので注意してください

IP アドレスの収集停止による弊害

IP アドレスの収集をやめると、Sentry からユーザを識別できなくなってしまいます。その結果として当然といえば当然なのですが、Issue ごとのユーザ数を見ることができなくなってしまいます。

ユーザが識別できなくて、ユーザ数が 0 埋めされてしまっている様子

ユーザ数はその Issue の深刻度を判断する上で非常に重要な情報ですから、これが見れないとなると Issue のトリアージが困難になってしまいます。IP アドレスの収集をやめたいとはいえ、流石にこれだと困りますね。

手動でユーザ情報を設定する

実は @sentry/browser では任意のユーザ情報を設定する Sentry.setUser という API が用意されています。これを使うと IP アドレス以外の任意のデータを、ユーザを区別するための情報として設定できるようになります。

例えば、以下のようにページアクセスの度にランダムで生成した文字列を id として設定しておけば、とりあえずユーザ数は表示されるようになります。もしセッションを跨いでも同じユーザであると判定したければ、localStorageid を保存しておけば良いです。

/** 0 以上 max 未満の整数をランダムで返す */
function getRandomInt(max: number): number {
  return Math.floor(Math.random() * max);
}

// Sentry SDK の初期化
Sentry.init({
  // ...
});

// ユーザ情報をセット
Sentry.setUser({ id: getRandomInt(0xffff_ffff).toString() });

もし IP アドレスを収集していないプロジェクトをやっていて、同様の問題でお困りの方が居ましたら、ぜひお試しください。

巨大なコードベースに対して段階的に新しい ESLint rule を導入する

背景

  • 既存の巨大なコードベースに対して新しい ESLint rule を導入したいことがある
    • ESLint を導入した段階では厳しすぎて OFF にしていたけど、やっぱり便利なので ON にしたい、みたいなケース
    • 例えば @typescript-eslint/no-floating-promises とか
  • しかし既存のコードベースはそのルールに従っていないため、ON にすると大量に lint エラーが出てしまう
    • 例えば数百件とか
  • 手で修正するのは現実的ではない、eslint --fix で修正できる rule でもない、けど便利な rule なので有効化したい
    • さてどうしよう

解決策

以前このブログでも紹介した eslint-interactive というツールに、lint エラーが出ている行に一括で // eslint-disable-next-line xxx を挿入する機能があります。これを使うと、ひとまず既存のコードでは該当 rule は無視して、新規のコードでは rule を有効化する、といったことが簡単に実現できます。

youtu.be

github.com

eslint-interactive とは何か、については以前記事を書いたので是非そちらをお読み下さい。

www.mizdra.net

使ってみてね。

補足: suppress-eslint-errors との違いについて

同等の機能を持ったツールに suppress-eslint-errors というものがあって、これでも全く同じことができます。

github.com

じゃあ何で eslint-interactive 側に rule を無効化する機能を追加したかというと、良いユーザ体験を提供したかったからです。元々 eslint-interactive にはコードベース中の lint エラーを見やすくまとめてくれる機能があったり、rule ごとに eslint --fix できる機能があって、それと一緒になっていたほうが何かとユーザ的には嬉しいだろう、みたいな狙いがあります。1つのツールで完結する嬉しさを実現したかった。

2021/9/8 追記

v2.0.0 をリリースして、 suggestion *1 も機械的に適用できるようになりました。

svh という策定中の CSS Unit について

CSS Values and Units Module Level 4 という提案にて、 svh という新たな CSS Unit の導入が検討されています。簡単に言うと、ブラウザのナビゲーションバーやオムニボックスの高さを除外した vh です。

今までは実際にユーザがブラウザで見ている範囲内に要素を収めるには JS で動的に可視領域の高さを計算する必要がありましたが、これが導入されると CSS だけで実現できます。従来の JS を使ったハックでは CSS が適用されてから JS の実行が行われて高さが変更されるため、Layout Shift が発生していましたが、svh を使うとこの Layout Shift を回避できます。Above the fold に動画や画像を画面いっぱいに表示したい時などに便利そうですね。

ブラウザベンダの実装状況

そもそもまだ仕様がドラフトの段階で、実装は全く進んでいません。一応 Issue は出ているので、興味があればトラッキングしてみましょう。早く使えるようになると良いですね *1


以前 Twitter を見ていたら目に入ったので、調べて書いてみました。

追記 (2022/3/15)

Safari 15.4 でサポートが入りました。

*1:もちろんまだドラフト段階なのでどうなるか分かりませんが

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.