Vue のコンポーネント分割で残る継ぎ目 — @kebab-case と $emit('camelCase') を実際にコンパイルして確かめる

Vue のコンポーネント分割で残る継ぎ目 — @kebab-case と $emit('camelCase') を実際にコンパイルして確かめる

スマホからターミナルを操作するアプリに、ワンタップの「定型文チップ」を足しました。作業自体は小さいのですが、チップの行が増えてページが lint の 300 行上限に当たり、チップ 5 種をまとめて子コンポーネントへ切り出すことになりました。

切り出し自体は正しい判断です。ただ、切り出した瞬間にそれまで存在しなかった継ぎ目が生まれます。親は @quick-command と書き、子は $emit('quickCommand') と書く。この 2 つが噛み合わなかったとき、Vue は例外も警告も出さず、ただチップをタップしても何も起きないだけです。

なぜ「たぶん大丈夫」で進めたくないか

Vue 3 のテンプレートコンパイラはイベント名を camelize してから on を付けるので、@quick-commandonQuickCommand になり、$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 つ増えます。

コピペする前に知っておくと良いこと

分割は正しい、継ぎ目は見張る

ファイルが大きくなったら分割する、という判断はほぼいつも正しいです。ただ分割の瞬間、それまで 1 ファイルの中で閉じていた対応関係が、誰も検査しない 2 ファイル間の口約束に変わります。10 秒のコンパイル確認と 15 行のテストで、その口約束を落とせないところに置き直せます。

この記事をシェア

関連記事

記事一覧に戻る