mizdra's blog

ぽよぐらみんぐ

ネイティブモジュールに依存しない node-canvas 代替ライブラリを使う

Web フロントエンドにて、Canvas を使った View のテストを書きたいことがたまにあります。ブラウザであれば以下のようにして Canvas を利用できますが、テストが実行される Node.js ではそのような API は生えていません。

const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

そこで Node.js では、node-canvas という npm パッケージがよく使われます。これを使うと、Web Canvas API 互換な API を用いて、Node.js でも Canvas を利用できます。

import { createCanvas } from 'canvas';

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

余談ですが、Node.js に DOM API の互換実装を提供するライブラリ「jsdom」では、HTMLCanvasElementHTMLImageElement の実装に、この node-canvas を利用しています。

ネイティブモジュールの依存問題

そんな node-canvas ですが、実はネイティブモジュールに依存しています。そのため、いくつかのモジュールがマシンにインストールされていなければ実行できません。

一度マシンにインストールしてしまえば良いものではありますが、複数人で開発するようなプロジェクトで node-canvas に依存していると、新しくチームメンバーがやってくる度にインストール方法を案内する必要があります。加えて、CI 上でテストを走らせる際も CI 環境にインストールが必要になってきます。

ちょっとした手間ではあるのですが、面倒なのでなんとかして解消できると嬉しいです。

@napi-rs/canvas を使う

@napi-rs/canvas という、同じく Web Canvas API 互換な API を実装したライブラリがあります。このパッケージはネイティブモジュールに依存しない作りになっていて、ネイティブモジュールをインストールせずとも node-canvas と同等のことができます。

$ npm i -D @napi-rs/canvas
import { createCanvas } from '@napi-rs/canvas';
//                            ^^^^^^^^^^^^^^^
//                            ここが canvas から変わっただけ

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

jsdom と組み合わせるにはもう一手間必要

先程ちょっと触れましたが、jsdom からは const Canvas = require("canvas"); で node-canvas が参照されています。従って、jsdom の HTMLCanvasElement を呼び出すと node-canvas が使われてしまう…ということになってしまいます。

そこで id:mizdra@napi-rs/canvas を以下のようなコマンドでインストールしています。

$ npm i -D canvas@npm:@napi-rs/canvas

このコマンドを実行すると、package.json 上では以下のような依存関係の情報が書き込まれます。

{
  // ...
  "devDependencies": {
    // ...
    "canvas": "npm:@napi-rs/canvas@^0.1.34",
  }
}

これで require('canvas') というコードが require('@napi-rs/canvas') へと解決され、node-canvas の代わりに @napi-rs/canvas が利用されるようになります。jsdom の HTMLCanvasElement からも @napi-rs/canvas が使われるようになり、ネイティブモジュール無しでテストを実行できるようになります。

こことかここを見るとわかるのですが、実際には @napi-rs/canvasnode-canvas の API を完全に模倣している訳ではないので、もしかしたらうまく動かないこともあるかもしれません… とりあえず手元の HTMLCanvasElement を使ったテストケースは正常に動作していますが、凝ったテストを書いてると動かないとかあるかもです。

ある Web ページに関するリソースのうち、ブラウザにリークしているものを調べる

最近の Web フロントエンドの開発では、JavaScript/CSS ファイルを bundler (webpack, vite, ...) でバンドルして、それをブラウザに配信することが多いと思います。

例えば以下のようなコードを bundler でバンドルすると、react + react-dom/client + ./locales/ja-JP.json が結合された bundle.js というファイルが生成されます (出力されるファイル名は bundler によって異なるのでイメージです)。

import React from 'react';
import { createRoot } from 'react-dom/client';
import locale from './locales/ja-JP.json';

function App() {
  return <div>{locale['Hello World!']}</div>; // => 'ようこそ、世界へ!'
}

const root = createRoot(document.getElementById('app')!);
root.render(<App />);

一方で、 bundler はモジュールから辿れるものを片っ端からバンドルするので、時として「本当はバンドルされて欲しくないもの」までバンドルされてしまうことがあります。例えば ./locales/ja-JP.json に入っている文言に未解禁情報が含まれていて、それが bundle.js に含まれてしまい、ユーザにリークしてしまう… そんな状況が考えられます。

ブラウザにリークしているリソースを調べる方法

どうコードを書き換えたらリークを防げるのか、リークを未然に防ぐ仕組みはどうやったら作れるか、などこのテーマについて話せることは色々ありますが、今回は「ブラウザにリークしているリソースを調べる方法」について紹介します。

bundler の成果物を grep する

一番シンプルです。./dist 配下などを未公開情報をキーワードにして grep するだけです。

./dist 配下が全てブラウザに配信される」ことが分かっているのならこの手法で良いですが、場合によっては「./dist 配下にサーバーサイドで使われるものとブラウザに配信されるものが混在していて、単純に grep できない」こともあるかもしれません。bundler の出力先のディレクトリ構成を見直して grep しやすい形に整理したり、あるいは他の手法で調べたりすると良いと思います。

ちなみに Next.js は .next 配下 にサーバーサイドで使われるリソースとブラウザに配信されるリソースが混在している典型的な例ですが、このうちブラウザに配信されるリソースは .next/static 配下に集められています (ただし undocumented な挙動なので今後変わりうるかも)。Next.js なら .next/static 配下に対して grep すれば良さそうです。

この記事ではあまり深入りしませんが、CI 上で next build 後に .next/static 配下を grep すれば、デプロイ前にリークに気づけるような仕組みを作れます。興味があれば試してみてください。

Chrome devtools の Network パネルの検索機能を使う

Chrome devtools の Network パネルを使うと、ページ表示中に発生した全てのネットワークリクエストを覗き見ることができます。実はこの Network パネルには検索機能がついており、ページ表示中に発生した全てのネットワークリクエストを対象に、平文検索を実行できます。これを使うと、未公開情報のリークの有無を確認できます。

  1. Network パネルを開いて Command + F キーを押す (macOS の場合 / 他の OS でも検索ショートカット相当のやつでいけるはず)
    • 検索欄が出てくるはず
  2. 検索欄にリークの有無を確認したいキーワードを入力
    • 検索結果に結果が表示されればリークしてる
  3. 検索結果をクリックして、ファイルを開く
    • キーワードが埋め込まれたファイルの中身が表示される
    • minify されてるファイルだと読みづらいかも
      • 左下の {} ボタンを押すと、ファイルがフォーマットされてちょっと読みやすくなる

Network パネルを使ったリークの有無の確認方法の図解

一点注意しておきたいこととして、実際にページ表示中にブラウザで発生したネットワークリクエストのみを対象に検索するため、リクエストされなかったリソースは検索対象に含まれません。つまり、実行時に特定の条件に応じて fetch でリソースを DL してきて、それを表示するようなコンテンツがあった際に、そのコンテンツに非公開情報が含まれていても、ヒットしない可能性があります。ヒットしないものの、bundle.js 中にはそのリソースの URL が埋め込まれているはずで、ユーザその URL を直接アドレスバーに打ち込んでアクセスすれば、非公開情報を閲覧できてしまいます。

クライアントサイドで動的に出し分けているリソースについては、この手法ではリークの有無を完全に確認出来るわけではない、ということに気をつけましょう。


他にもリークの有無を確認する便利な方法があれば教えて下さい!

next lint は lint 対象のディレクトリが制限されている

ということに next lint を触っていて気づきました。公式ドキュメントにもそう書いてありました。

next lint runs ESLint for all files in the pages, components, and lib directories. It also provides a guided setup to install any required dependencies if ESLint is not already configured in your application.

(https://nextjs.org/docs/api-reference/cli#lint より引用)

これによると、next lintpages/, components/, lib/、そして上記ドキュメントでは漏れてますが src/ *1 を加えた 4 つのディレクトリが lint 対象となるようです。

これ以外のディレクトリを lint 対象に含めたい場合は、next lint --dir util/ のようにコマンドラインから渡すか、next.config.jseslint.dirs オプションにディレクトリを指定するよう案内されてます。

暗黙的に lint 対象を制限することの是非

実際のところこの挙動って嬉しいんでしょうかね。本当は util/ も lint されて欲しいのに何故か lint されなくて混乱する、みたいなことが直感的には起きそうな気がします。加えて vscode-eslint (ESLint の VSCode 拡張機能) は「4つのディレクトリ以外は lint 対象から外す」ことを知らないので、util/ 配下のファイルでも ESLint の警告を出してしまいます。エディタ上で見たときと、next lint で見たときで警告の内容に違いが出るので、これも混乱しそうです。

以上のような問題があるので、除外するファイルは .eslintignore に書きつつ、eslint . でカレントディレクトリ配下のファイル全てを lint 対象にするアプローチが id:mizdra は好きです。どのディレクトリが除外されるか明示的になりますし、vscode-eslint が .eslintignore を見て、どのファイルが lint 対象外か認識してくれるので、エディタ上/CLI上での警告に差がなくなります。

zenn.dev

まあでも予想外に lint 対象の範囲が広がるのを避けたいとか、ファイルが多くなりすぎて lint 速度が低下するのを避けたいみたいな事情があったりするのかな...? いやでも速度が問題になってから .eslintignore すれば良いような気も…

next lint の初期の PR のやり取りを見ると、Next.js アプリケーションで一般的なディレクトリに限って lint するという前提で最初から設計されているように見えるので、あまりそのあたりの拘りがないのかもしれません。

という訳で id:mizdra は以下のように、カレントディレクトリ配下のうち .eslintignore に含まれるものを除外したファイルを lint 対象とするアプローチが良いかなと思っています。

// package.json
{
  "scripts": {
    "lint": "next lint -d ."
  }
}

それよりも

デフォルトの lint 対象ディレクトリのうち、pages/, components/, src/ は順当だと思ったのですが、lib/ がそこに入っていたのはちょっとびっくりしました。Next.js 公式的には lib/ も「Next.js アプリケーションで一般的に見られるディレクトリ」ということなんですかね。id:mizdra はあんまりそういう印象なかったので「そうなの...?」となってます。公式ドキュメントでも lib/ が登場するのは next lint 関連での文脈でしかなさそうでした。

しかし Next.js の公式 Example 集を見ると、結構 lib/ が使われてるようでした。

公式ドキュメントでは触れられてないものの、公式 Example 集では使われているディレクトリみたいですね。

追記: 2023-01-14

Next.js v13 からサポートされた app/ ディレクトリも lint 対象に含めようとする PR が出ていました。この PR がマージされれば lint 対象のディレクトリが 5 つに増えることになりそうです。

github.com

最近書いた風変わりなテストコード 7 連発

テスト書く腕力鍛えるため & 個人開発のメンテナンスを楽にするために、最近なんでもテストコードを書きまくってます。あらゆるものをテストするぞという気概を持って手を動かした結果、ちょっと変わったテストコードを書いたりしてました。というわけで、この記事ではそれらを紹介していきます。

CLIツールの E2Eテスト

「eslint-interactive」という ESLint の error/warning を高速修正する CLI ツールがあります。

www.mizdra.net

このツールには、E2Eテストを導入しています。

E2E テストと聞くと、「GUI アプリケーションに対してタップ/クリックイベントをエミュレートして、アプリが期待通りの挙動をするかを検証するアレ」を思い浮かべることが多いと思います。しかし元の意味から考えると「E2E テスト」は「End-to-End テスト」のことで、つまりは「全部のレイヤーを結合した上でユーザが実際に利用するインターフェイスからソフトウェアを操作し、その振る舞いを検証するテスト」を意味しています。E2E テストは、GUI であっても CUI であっても適用できるテスト手法なのです。

実際に eslint-interactive ではどんな E2E テストを書いているかといいますと...

  1. npm install -g packed.zipnpm packしたパッケージをグローバルにインストール
  2. eslint-interactive ./fixtureでインストールした eslint-interactive を起動
  3. 標準入力から適当な入力を与えて eslint-interactive を操作
  4. 標準出力が期待通りかを assertion

spawnでプロセスを起動すると、stdin/stdout への binding が取れるので、それ経由で入出力のハンドリングをしてます。

eslint-interactive は諸事情あってローカルインストールされた時は上手く動きますが、グローバルインストールされた時は動かない、ということが起きやすい作りになっています。そのため、手間を掛けてグローバルインストールした時の E2E テストを整備しています。実際にこのテストがあったことで、グローバルインストールした時にだけ再現するバグなどが検出できたりしていて、あって良かったなと思ってます。

dotfiles のインストール可能性のテスト

バニラな macOS/Ubuntu に dotfiles がインストールできることを GitHub Actions でテストしてます。 sh -c "$(curl -fsLS chezmoi.io/get)" -- init --apply -S .がミソです。

github.com

id:mizdra は dotfiles は書くために、chezmoi というツールを利用してます。template という機能でマシンごとの設定の差分を上手く表現できて、簡単にマルチプラットフォームな dotfiles を作れます。

www.mizdra.net

また chezmoi はバイナリが配布されているため、インストールの待ち時間もほとんど掛かりません。CI 上でも高速にインストールでき、快適にテストできます。

ブラウザ拡張機能の content script の E2E テスト

now-playing-for-google-play-music という YouTube Music から NowPlaying ツイートできるブラウザ拡張機能があります *1

www.mizdra.net

content script で右下にボタンを挿入していて、これを押すと https://twitter.com/intent/tweet?text=ギターと孤独と蒼い惑星%20%2F%20結束バンド&hashtags=NowPlaying という URL が新しいタブで開かれてツイートできます (Twitter Web Intent という仕組みに乗っかっています)。

右下の矢印ボタンを押すと、NowPlaying ツイートができる。

この content script に以下のような E2E テスト を用意しています。

  1. playwright を使い、chrome をヘッドレスモードで起動
  2. YouTube Music の表示言語が日本語になるよう、それっぽい cookie をセットしておく
  3. YouTube Music の任意の曲の再生ページを chrome で開く
  4. page.waitForSelectorを使い、右下に拡張機能のボタンが挿入されるまで待機
  5. ボタンをクリックして、新しいタブで Twitter Intent が開かれることを assertion

--load-extensionで拡張機能を読み込み、context.waitForEvent('page')で新しく開かれたタブの binding を取ってきているところがミソです。

Scrapbox の UserScript の E2E テスト

風変わりな E2E テスト第3弾。Scrapbox というドキュメンテーションサービスには UserScript という仕組みがあり、ユーザが書いた JavaScript をページ表示完了時に実行できます。

scrapbox.io scrapbox.io

id:mizdra が作ったアイコン記法高速挿入ツール「icon-suggestion」も、UserScript として実装してます。

scrapbox.io

これにも E2E テスト があって、以下のようなことをやっています。

  1. playwright を使い、chrome をヘッドレスモードで起動
  2. Content Security Policy (CSP) を無効化する
  3. Scrapbox のページにアクセス
  4. page.waitForSelectorを使い、scrapbox 側のDOMの組み立てが終わるまで待機
  5. page.addScriptTagでUserScriptの<script>タグを挿入
    • 挿入した瞬間にブラウザが UserScript を実行してくれる
  6. page.keyboardで icon-suggestion を操作
  7. 結果が期待通りか assertion

Scrapbox では、許可されたドメイン以外で配信されている JavaScript が読み込めないよう、CSP で制限をしてます。そのため、素朴に <script>タグでローカルにあるビルド済みの.jsを挿入しただけでは上手く行きません (file:///.jsは CSP 違反によりブロックされてしまう)。そこで CSP を無効化するべくbypassCSP: trueを渡してます。

CSP 環境下で E2E テストするためにアレコレしてるのが面白いと思います。

VSCode のコードジャンプの振る舞いのテスト

happy-css-modules という CSS Modules で.tsx => .module.cssへのコードジャンプを実現するツールがあります。

www.mizdra.net

.module.css.d.ts.module.css.d.ts.mapという2つのファイルをツールでコード生成しておくと、VSCode がそれをヒントに.tsx => .module.cssへとコードジャンプできるという仕組みです。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

.module.css.d.ts.module.css.d.ts.map を駆使してコードジャンプする様子。

このツールには様々な面白テストがありますが、特に面白いのは「VSCode で.tsx上のstyles.buttonを押した時に .module.css上の.button { ... }にコードジャンプすること」のテストです。

VSCode では TypeScript の Language Server に「tsserver)」が使われているのですが、それを使ってコードジャンプの挙動のテストをしています。

github.com

tsserver とのやり取りは標準入出力を介して行います。しかし、自前で書こうとすると中々骨が折れます。そこで id:mizdra@typescript/server-harness という wrapper ライブラリを使ってます。

以下のようにserver.message(...)で tsserver に渡したい入力を渡すと、tsserver からの応答がその返り値で得られます。

import serverHarness from '@typescript/server-harness';
import url from 'node:url'; 
import path from 'node:path';
import _glob from 'glob';
import util from 'node:util';
import fs from 'node:fs';
import assert from 'node:assert/strict';
import type { UpdateOpenRequest, DefinitionResponse, DefinitionRequest } from 'typescript/lib/protocol.js';

const glob = util.promisify(_glob);

const server = serverHarness.launchServer(
  url.fileURLToPath(await path.resolve('typescript/lib/tsserver.js', import.meta.url)),
  // よくわからないけど、おまじないっぽい
  ['--disableAutomaticTypingAcquisition'],
);

// tsserver に読み込ませたい .ts ファイル一覧を準備。
// fixtures ディレクトリの中に `.ts` / `.module.css.d.ts` / `.module.css.d.ts.map` が存在するという設定。
const fixtureFilePaths = await glob(path.resolve('./fixtures/**/*.ts'), { dot: true });
const openFiles: UpdateOpenRequest['arguments']['openFiles'] =
  fixtureFilePaths.map((filePath) => ({
    file: filePath,
    fileContent: fs.readFileSync(filePath, 'utf-8'),
    projectRootPath: path.resolve('./fixtures'),
    scriptKindName: 'TS',
  }));

// tsserver に .ts ファイルを読み込ませる
await server.message({
  seq: 0,
  type: 'request',
  command: 'updateOpen',
  arguments: {
    changedFiles: [],
    closedFiles: [],
    openFiles,
  },
} as UpdateOpenRequest);

// index.ts の 2 行目 8列目の定義元 (ジャンプ先) の情報を取得
const response: DefinitionResponse = await server.message({
  seq: 1,
  type: 'request',
  command: 'definition',
  arguments: {
    file: path.resolve('./fixtures/index.ts'),
    line: 2,
    offset: 8,
  },
} as DefinitionRequest);

assert.deepEqual(response, ...); // コードジャンプ先が期待通りかを検証

本当は本物の VSCode を使ってテストしたかったのですが、CI 上でネイティブアプリを動かすのが大変そうなので諦めました。その点、Language Server を使うくらいであれば、標準入出力でプロセス間通信するだけなので、まあなんとか…みたいな感じです。

レンダリングパフォーマンスのリグレッションテスト

昔 rocketimer という 60fps で動作する高パフォーマンスなカウントダウンタイマーを作ってました (訳あって未完成のまま放置しちゃってますが)。

github.com

カウントダウンがどれくらいの速度で行えるかを検証するためのベンチマークがあって、これを CI で継続して走らせて、パフォーマンスのデグレが起きていないか検証してます。

github.com

ヘッドレス chrome を使って検証をしているのですが、いくつか面白いポイントがあって...

  • Chrome を起動する時に--disable-frame-rate-limitオプションを渡してる
    • requestAnimationFrameのコールバックは通常最大 60fps の間隔で呼ばれる
      • CPUにどんなに余裕があっても、最短で約 16ms に 1 回にスロットリングされる
    • --disable-frame-rate-limitを渡すとこの上限がなくなり、CPUリソースが許す限りrequestAnimationFrameのコールバックを次々呼び出す
      • つまり 60 fps 以上出せる
    • ベンチマークスクリプトからrequestAnimationFrameのコールバックを都度登録して、それが呼び出された回数を記録する
      • 1回レンダリングが走ったら、requestAnimationFrameのコールバックが1回呼び出されるはず
      • これでアプリケーションコードに手を加えることなく、ベンチマークスクリプトだけで fps を測定できる

60 fps で頭打ちになってしまうとパフォーマンスの変動が上手く測れないので、その制限を無効化し、真の fps でパフォーマンスを計測する、というのがミソです。

メモリリークのリグレッションテスト

rocketimer には長期間カウントダウンタイマーを稼働させた時に、メモリ使用量が増え続けていないかを検証するリグレッションテストもあります。

github.com

メモリの集計にはperformance.measureUserAgentSpecificMemoryという API を使ってます。performance.measureUserAgentSpecificMemoryは (ゴミがメモリ使用量に含まれないように) GC が実行された後にメモリ使用量を集計するようになっていますが、そのせいで GC が実行されるタイミングまで集計が遅延されてしまいます。それだと好きなタイミングで集計できなくて不便なので、chrome を起動する際に--enable-blink-features=ForceEagerMeasureMemoryオプションを渡しています。こうすると、performance.measureUserAgentSpecificMemoryを呼び出した瞬間に GC とメモリ使用量が集計されます。

ところで最近 Meta がメモリリークを検出する専用のテスティングライブラリをリリースしています。今だったらこれ使って作るのが簡単だと思います。

engineering.fb.com

おわりに

これらのテストコードを書くのは大変でした…がやってみて良かったなと思っています。実際色々なテストコードを書いたことで自信と腕力が付きましたし、多くのバグの検出にも役立っていて、「テスト書く腕力鍛えるため」「個人開発のメンテナンスを楽にするため」という当初の目的は達成できたと考えてます。

また、テストタブルなインターフェースを設計するためのモデリング力・リファクタリング力が身についたり、"ミソ"を考えるための発想力がついたり、今後テストコードを書く上での手札が増えたりと、色々な副産物もありました。

一方で、メンテナンスしやすいテストコードを書く難しさをひしひしと感じました。テストコードはぐちゃぐちゃになったり、実行結果が安定しなかったり (flaky test)... また、テストに使っているツールや手法に馴染みがないと、キャッチアップが大変です。チーム開発で今回紹介したようなテストを導入するなら、こうした問題と向き合っていかなければなりません。

少しずつ趣味で学んだことを業務でも活用していきたいですね。

*1:Google Play Music はとうの昔に終了したサービスで、YouTube Music はその後継です。拡張機能の名前はつまりそういうことです...

個人開発祭り #1 で LT しました

個人開発をテーマにしたこちらのイベントで、5 分の LT をしてきました。

nota.connpass.com

今回は Scrapbox で議事録を高速に取るための UserScript「icon-suggestion」を作った話をしました。

youtu.be

scrapbox.io

個人開発というと、限られた時間と人手で開発しなければならないため、ついつい動けば良い状態になってしまいがちです。テストコードがなかったり、リファクタリングされずにコードが継ぎ足しされた結果、インターフェイスがめちゃめちゃになっていたり... 個人開発では開発者が1人なので、自分がコードを理解できていればよく、コードの品質の優先度が相対的に低くなるので、ある意味当然と言えます。id:mizdra もよく動けば良いやと書き散らしてます。

icon-suggestion の開発はあえてその逆をいってて、拘ってテストコードを書いたり、内部インターフェイスを露出させてユーザがプラグインを書けるための仕組みを丁寧に提供しています。ここに情熱を注ぐ人はそう居ないので興味深く思ってもらえるのでは...そう考えてこの話をしたのですが、狙い通り良い反応をいただけたようでした。


イベント後半の質問タイムでは、カンバンの運用の仕方や、個人開発のルーティン、テストコードどれくらい書くか、熱量を高める工夫、個人開発の成果を発表するタイミングなどについて、登壇者同士であれこれ話してました。毎日個人開発する人がいれば、熱量が出た時にガッとやる人もいる / 最後まで成果を隠す人もいれば、途中で小出しにしていく人もいる...などなど、人によってやり方や考えがまちまちで面白かったです。こちらも是非動画でご覧ください。

youtu.be

Web フロントエンドにおけるコロケーション (co-location) という考え方について

Webフロントエンド界隈の文献などにあたっていると、「コロケーション (co-location)」という考え方が時々登場します。

コロケーションを簡単に説明すると、関連するリソース同士を近くに置いておく、という考え方です。

  • FooComponent.tsx と同じディレクトリに FooComponent.test.tsx を置く
  • GraphQL fragment は、クエリを発行するコンポーネントファイル (pages/user.tsx) ではなく、fragment を利用するコンポーネントファイル (components/UserInfo.tsx) の中で定義する
    • pages/user.tsx からはサブコンポーネントのファイルで定義されている fragment を import してきて、クエリを組み立てて発行する
  • API ドキュメントは API.md に書くのではなく、コードの中にドキュメンテーションコメントとして書く
    • API ドキュメントはドキュメンテーションコメントから自動生成する

関連するもの同士を近くに置いておくことで、以下のようなメリットが得られます。

  • テストファイルの場合
    • src/in/deep/directory/FooComponent.tsxtest/in/deep/directory/FooComponent.tsx を行ったり来たりする手間から開放されます
    • テストファイルが目につきやすくなります
      • 「テストコードを書く参考にするために、他のテストファイルの書き方を見に行く」といったことがやりやすくなります
      • 実装に対応するテストがないことが、src/in/deep/directory を見るだけでわかります
  • GraphQL fragment の場合
    • コンポーネントで新たな GraphQL field が必要になったら、そのファイル内にある fragment の定義を編集するだけで、field を取得できます
    • pages/user.tsxcomponents/UserInfo.tsx を行ったり来たりする手間から開放されます
  • API ドキュメントの場合
    • API ドキュメントが API.md に書かれていると、実装を進めている間に存在を忘れてしまい、実装とドキュメントが乖離しがちです
    • 実装とドキュメントをそばに置いておけば、乖離が発生しにくくなります

色々な記事でこの考え方が紹介・参照されています。

コロケーションは保守性や可読性を向上させるため、長期的に見れば良いメリットが多く得られます。また考え方を知っておけば、「このファイルをどこに置くか」などといった開発中の悩みも軽減されるため、開発速度の向上にも繋がります。

フロントエンド界隈で注目されている考えではありますが、その他の界隈でも適用できる普遍的な考え方だと思います。

コロケーションのさじ加減

とはいえコロケーションもノーコストでできる訳ではありません。本来ページコンポーネントにベタ書きで良かったものを GraphQL fragment に切り出して持ってきたり、ドキュメンテーションコメントからドキュメントファイルを生成するツールを導入したりと、ちょっとした手間が発生します。コロケーションにすることで長期的に得られるメリットと比較すれば大したコストではないですが、短期的に見ると無視できないコストです。書きなぐるようにサクサクコーディングしたい、というケースではかえってわずらわしく感じるかもしれません。

コロケーションはあくまでリソースの配置方法に関する1つの考え方であり、プロジェクトや状況によって適切な解決策を取り入れていくのが望ましいと思います。

コロケーション (co-location) の初出

この呼び方の初出がどこなのか、探してみたのですがよく分かりませんでした。少なくとも 2015/2 に React の公式ブログにて "Co-location" という語が登場していました。

github.com

それらしき初出が見つからないので、何となく人々の間でそう呼ばれてきたものなのかもしれません。少なくとも、"co-location" の意味が整理されたのは、Kent C. Dodds *1 氏が 2019/6 に自身のブログでそれを紹介した時のようです。

kentcdodds.com

*1:testing-library 開発者、Testing Trophy 提唱者

React コンポーネントの定義の仕方によって VSCode の定義元ジャンプの挙動が変わる

この記事は「はてなエンジニア Advent Calendar 2022」の3日目の記事です。2日目は id:pokutuna さんの「Slack チャンネルのロボット帝国化を防ぐ feed-pruning-proxy」でした。

blog.pokutuna.com


さて、TypeScript で React コンポーネントを定義する時、皆さんはどういう書き方をしてますか? 関数宣言/アロー関数どちらを使って書くか、React.FC を使うかどうか、など微妙に人によって書き方が異なると思います。

その中でも、よく使われるのは以下の 3 つのスタイルでしょうか。

import React from "react";

type ButtonProps = {
  children: React.ReactNode;
};

// 関数宣言。
function Button1({ children }: ButtonProps) {
  return <button>{children}</button>;
}

// アロー関数 + 引数リストに型注釈。
const Button2 = ({ children }: ButtonProps) => {
  return <button>{children}</button>;
}

// アロー関数 + 変数宣言に型注釈。
// 返り値の型が `JSX.Element | null` に制限されるという点で、
// `0` などを返される心配がなくて安全。
const Button3: React.FC<ButtonProps> = ({ children }) => {
  return <button>{children}</button>;
};

型の付け方や厳密さ、使っている構文が違うくらいで、どれも普通に Button コンポーネントとして使えます。最後の「アロー関数 + 変数宣言に型注釈」スタイルだと返り値の型が厳密になるので、このスタイルで書いている方は結構いらっしゃるのではないかと思います。

...しかしこの最後の「アロー関数 + 変数宣言に型注釈」スタイルは、VSCode の定義元へジャンプする機能の挙動が他と違います 。具体的には、他のスタイルと比べてジャンプ先を選択するステップが挟まり、ワンクリックでジャンプできないようになってます。

youtu.be

皆さんご存知でしたか?

お試し会場

(利用するエディタによって再現できるかどうか変わってくるのかもしれないですが) この問題は VSCode だけでなく、Codesandbox でも再現する問題です。

という訳で以下に Codesandbox で動く環境を用意してみました。こちらから皆さんもお試しください:

お手元の VSCode で試したい方は、以下のリポジトリから git clone してみてください。

github.com

一体何が起きているのか

定義元ジャンプをしようとした時の画面をよく見てみると、我々が期待しているジャンプ先 (const Button3 = ... の行) に加えて、 node_modules/@types/react にある FunctionComponent (React.FC の別名) の型定義の行も候補として出ています。

3番目の書き方で定義元が 2 つ表示されている様子

原因はよくわからないのですが、どうやら const Button3 = ...node_modules/@types/react 側の React.FC の型定義の両方が<Button3> の定義元として認識されてしまっているようです。定義元が複数あるので、どちらにジャンプするかを開発者に選択させる必要があり、あのようなステップが挟まってしまっているようです。

正直これがバグなのか、仕様なのか id:mizdra には判断つかなかったのですが、公式 Issue では Bug ラベルが付いていたので、一応バグとして扱われているようです。

github.com

回避策

実際のところ、node_modules/@types/react 側の React.FC の型定義を開発者が見たいことはないですし、この挙動は煩わしいでしょう。そこでこの問題の回避策をいくつか紹介したいと思います。

複数の定義元を選択させる UI が出たら、Enter を押す

回避策...というより対症療法的な話ですが、複数の定義元を選択させる UI が出た際に Enter を押せば、すぐに期待している定義元へとジャンプできます。

Enter を押すと即座に期待している定義元へとジャンプできる。

VSCode の Editor > Goto Location: Multiple Definitionsgoto にする

Editor > Goto Location: Multiple Definitions という設定を弄ると、複数の定義元があった時の VSCode の定義元ジャンプの挙動を変更できます。

デフォルトの設定値は peek となっていて、gotogotoAndPeek に変更すると、ワンクリックでジャンプできるようになります。

  • peek: どの定義元にジャンプするか選択する UI を表示 => クリックでジャンプ
  • goto: 最も優先度の高い定義元にジャンプする
    • const Button3 = ... が最も優先度の高い定義元なようで、ワンクリックでジャンプできる
  • gotoAndPeek: 最も優先度の高い定義元にジャンプし、ジャンプ先で peek と同じ UI を表示

この設定は React コンポーネント以外の定義元ジャンプでも使われる、影響範囲の広い設定です。もしかしたら goto にしてしまうことで、peek のように候補を選んでジャンプしたかったのに、それができない…というケースがあるかもしれません。

一応「左クリックメニュー > ピーク > 定義をここに表示」から、従来のように定義元を複数表示することができます。もし定義元を複数をたければ、これを使うと良いと思います。

「左クリックメニュー > ピーク > 定義をここに表示」から定義元を複数表示できる

vscode-tsx-arrow-definition を使う

node_modules/@types/react を候補先から除外して、ワンクリックでジャンプできるようにする VSCode 拡張機能があるようです。

marketplace.visualstudio.com

これだと手軽ですし、影響範囲も React コンポーネントの定義元ジャンプに絞れるので良さそうですね。

他のスタイルで React コンポーネントを定義する

VSCode の設定や VSCode の拡張機能に頼る形だと、特定のエディタでだけ対応する形になって好きじゃないな…という理由で id:mizdra は「関数宣言」スタイルで書くようにしています。「アロー関数 + 変数宣言に型注釈」スタイルと比較すると、返り値の型は厳密に扱えませんが...多くの場合コンポーネントを利用する側から型違反として検出できるので、問題ないと判断してます。

import React from 'react';

// 誤って number 型を返しているコンポーネント
function Button() {
    return 0;
}

function App() {
    return (
        <div>
            <Button />
//           ^^^^^^
// 'Button' cannot be used as a JSX component.
// Its return type 'number' is not a valid JSX element.(2786)
        </div>
    )
}

関数の返り値の型を明示的に書く規約を取り入れているプロジェクトでもこのスタイルで書いてますが、VSCode の Infer function return type で返り値の型を補完できるので、それほどストレスは感じてないです。

VSCode の Infer function return type で返り値の型を補完できる

おまけ: satisifies を使って React コンポーネントを定義する

TypeScript 4.9 で追加された satisifies を使うと、React.FC 型と互換性を持たせつつ、より厳密な型の値を定義できます (id:gfx さんより情報提供いただきました。ありがとうございます!)。

// アロー関数 + satisfiesで制約を加えつつ正確な型を表現する。
// 安全だが関数本体をカッコで囲む必要がある。また TypeScript 4.9 時点では定義元ジャンプもワンクッションあり。
// Button3 だと返り値の型が `JSX.Element | null` になったが、こちらの書き方だと返り値の型の推論結果が優先されて `JSX.Element` になる。
const Button4 = (({ children }) => {
  return <button>{children}</button>;
}) satisfies React.FC<ButtonProps>;

一見するとワンクリックで定義元へとジャンプできるように見えますが…実はできません。

satisfies を使っても、ワンクリックで定義元へとジャンプできない。

こちらは 「アロー関数 + 変数宣言に型注釈」 と違って、「Button4({ children }) => { ... } が定義元である」と認識されているようです。node_modules/@types/react の型定義は出てこなくなったものの、satisfies が新たに定義元を作り出してしまい、ワンクリック挟まる形になってしまいます。上手くいかないものですね。

まとめ

  • 「アロー関数 + 変数宣言に型注釈」スタイルだとワンクリックで定義元にジャンプできない
  • VSCode の設定を変える/VSCode 拡張機能を使う/他のスタイルでコンポーネントを定義する、などで回避可能

はてなエンジニア Advent Calendar 2022 の明日の担当は id:happy_siro さんです!

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

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