スマホからターミナルを操作するアプリに、ワンタップの「定型文チップ」を足しました。作業自体は小さいのですが、チップの行が増えてページが lint の 300 行上限に当たり、チップ 5 種をまとめて子コンポーネントへ切り出すことになりました。
切り出し自体は正しい判断です。ただ、切り出した瞬間にそれまで存在しなかった継ぎ目が生まれます。親は @quick-command と書き、子は $emit('quickCommand') と書く。この 2 つが噛み合わなかったとき、Vue は例外も警告も出さず、ただチップをタップしても何も起きないだけです。
なぜ「たぶん大丈夫」で進めたくないか
Vue 3 のテンプレートコンパイラはイベント名を camelize してから on を付けるので、@quick-command は onQuickCommand になり、$emit('quickCommand') の探すキーと一致します。知識としては知っていても、綴りを一文字間違えたときに何も起きないのが問題です。
しかもこの手のミスは型でもテストでも落ちにくい。子の props はすべて default 付き(=必須ではない)で、余分な属性やリスナはフォールスルーとして許されます。加えて、このリポジトリのコンポーネントテストは Vue ランタイムを持たず、ソースファイルを読んで正規表現でアサートする方式です。つまり分割した直後は、継ぎ目を見ている人が誰もいない状態になります。
10 秒で確かめる
推測をやめて、テンプレート片を実際にコンパイラに通せば済みます。@vue/compiler-dom は Vue の依存として既に入っています。
node -e "
const { compile } = require('@vue/compiler-dom');
const out = compile('<TerminalChips @quick-command=\"useQuickCommand\" @ghost=\"useGhost\" />', { mode: 'module' });
console.log(out.code.split('\n').filter(l => l.includes('on')).join('\n'));
"
return (_openBlock(), _createBlock(_component_TerminalChips, {
onQuickCommand: _ctx.useQuickCommand,
onGhost: _ctx.useGhost
}, null, 8 /* PROPS */, ["onQuickCommand", "onGhost"]))
onQuickCommand が出ていれば、子の $emit('quickCommand') と確実に噛み合います。ドキュメントを読み直すより速く、しかも自分のバージョンでの実測です。
継ぎ目に見張りを置く
一度確かめても、あとで片方だけリネームされたら同じ穴が開きます。そこで、子の宣言から props と emits を抜き出し、親側に対応する束縛があるかを突き合わせるテストを足しました。
const kebab = (name: string): string => name.replace(/[A-Z]/gu, (upper) => `-${upper.toLowerCase()}`);
test("every chip prop and event meets its other half on the page", () => {
const view = read("src/views/Terminal.vue");
const props = [...(chipRows().match(/props: \{[\s\S]*?\n \},/u)?.[0] ?? "").matchAll(/^\s{4}(?<name>\w+): \{/gmu)].map((prop) => prop.groups?.name ?? "");
assert.ok(props.length > 0, "the component must declare its props");
for (const prop of props.filter((name) => name !== "disabled")) {
assert.match(view, new RegExp(`:${kebab(prop)}="`, "u"), `${prop} must be passed from the page`);
}
const emits = (chipRows().match(/emits: \[(?<names>[^\]]*)\]/u)?.groups?.names ?? "").split(",").map((name) => name.trim().replaceAll('"', ""));
assert.ok(emits.length > 0, "the component must declare its events");
for (const event of emits) {
assert.match(chipRows(), new RegExp(`\\$emit\\('${event}'`, "u"), `${event} must actually be emitted`);
assert.match(view, new RegExp(`@${kebab(event)}="`, "u"), `${event} must be handled by the page`);
}
});
宣言をハードコードせず子から読み出しているのが肝で、prop を 1 つ足せばテストも自動で 1 つ増えます。
コピペする前に知っておくと良いこと
- 件数のアサーションを先に置く。正規表現での抽出が失敗すると配列が空になり、
forが一度も回らずテストは「通って」しまいます。assert.ok(props.length > 0)があるだけで、この空振りを検出できます。テキストを機械的に読むテストで一番危ないのはこの vacuous pass です。 - 同名が他所にも出る prop は誤って通る。上の例で
disabledを除外しているのはそのためで、ページ側には入力欄自身の:disabled="があるので、突き合わせが成功したように見えてしまいます。除外するか、束縛先まで含めて照合するかのどちらかが要ります。 emitsに宣言したイベントはフォールスルーしない。宣言を忘れるとリスナがルート要素まで漏れるので、この突き合わせは「宣言されていること」自体の担保にもなっています。
分割は正しい、継ぎ目は見張る
ファイルが大きくなったら分割する、という判断はほぼいつも正しいです。ただ分割の瞬間、それまで 1 ファイルの中で閉じていた対応関係が、誰も検査しない 2 ファイル間の口約束に変わります。10 秒のコンパイル確認と 15 行のテストで、その口約束を落とせないところに置き直せます。
