フロントエンドエンジニアの小林です。
今回は、UIコンポーネントのアンチパターンな利用を検出するESLintのカスタムルールを作成した話をご紹介いたします。
背景と問題
YappliのCMS画面におけるテキストフィールドは、入力項目に対してバリデーションエラーが発生した場合、
- テキストフィールドの枠をアラート色にする
- テキストフィールドの下にアラート色でエラーメッセージを表示する
という挙動が一般的であり、プロダクトデザイナーも同様の認識を持っています。
一方で、プロダクトデザイナーから「一部の箇所では、テキストフィールドの枠がアラート色になっていない」というフィードバックを貰いました。
まずは、テキストフィールドのコンポーネントがどのような仕様なのかを調査します。要点のみを抜粋すると、subject
/ message
slotが定義されています。また、propsとしてerror
を受け取り、テキストフィールドの枠色を変更しているようです。
// TextField.vue <script> export default { name: 'TextField', props: { error: Boolean } } </script> <template> <label :class="{ '-error': error }"> <slot name="subject" /> <input type="text"> <slot name="message" /> </label> </template>
親コンポーネントでは、TextField
コンポーネントにerrorフラグを渡し、message
slotとして表示している Message
コンポーネントにもerrorフラグを渡しています。
// Parent.vue <script> export default { name: 'Parent' } </script> <template> <TextField :error="hasError"> <span slot="subject">入力項目</span> <Message v-if="hasError" slot="message" :error="hasError"> 入力内容に誤りがあります。 </Message> </TextField> </template>
Message
コンポーネントもTextField
コンポーネントと同様に、propsとしてerror
を受け取り、メッセージの文字色を変更しています。
// Message.vue <script> export default { name: 'Message', props: { error: Boolean } } </script> <template> <span :class="{ '-error': error }"> <slot /> </span> </template>
これらの仕様から、コンポーネント(TextField
/ Message
)と 色(normal
/ alert
)を掛け合わせた4パターンが表現できることが分かりました。デザイン設計として想定されないパターンもありますが、先人のフロントエンドエンジニアが拡張性を持たせた設計をしたのだと思われます。
一方で、この仕様を理解していなければ、前述の異常とされる挙動を生み出してしまいます。
検討
TextField
コンポーネントの仕様変更をしてしまうことも可能ですが、異常とされる挙動を正として扱っている箇所があるかもしれません。したがって、まずはTextField
コンポーネントの利用箇所を洗い出す必要があります。
AST(abstract syntax tree)
目視で洗い出すことも可能ですが、汎用的なコンポーネントは利用箇所も多く、現実的ではありません。また、コンポーネントテストを記述することも考えましたが、コンポーネントの使用側で生み出されるアンチパターンであるため、そう簡単には実現できなさそうです。
そこで、ASTを利用します。ASTとは、単純な文字列としてのソースコードを、プログラムが扱いやすいデータ構造(ツリー構造)に変換したものです。例えば、ESLintでアンチパターンな構文を検知・修正する場合などに使用されていたりします。
今回は、ESLintのルールを独自に作成して、アンチパターンの検出と自動修正に取り組みます。
まずは、どのようなパターンがNGなのかをMessageIdとして定義します。合わせて、このNGルールを元にしてテストも書きましょう。
// src/rules/my-rule import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; type RuleModule = TSESLint.RuleModule< | 'TextFieldはnormal色だが、Messageはalert色である' | 'TextFieldはalert色だが、Messageがslotとして定義されていない' | 'TextFieldはalert色だが、Messageはnormal色である', [] >;
// src/rules/__tests__/my-rule import { TSESLint } from '@typescript-eslint/experimental-utils'; import dedent from 'dedent'; import * as rule from '../my-rule'; const ruleTester = new TSESLint.RuleTester({ parser: require.resolve('vue-eslint-parser'), parserOptions: { ecmaVersion: 11, sourceType: 'module', }, }); ruleTester.run('my-rule', rule, { valid: [ /* 今回は割愛 */ ], invalid: [ { code: dedent` <template> <TextField> <Subject slot="subject">subject</Subject> <Message slot="message" error>message</Message> </TextField> </template> `, errors: [{ messageId: 'TextFieldはnormal色だが、Messageはalert色である' }], }, { code: dedent` <template> <TextField :error="hasError"> <Message slot="message">subject</Message> </TextField> </template> `, errors: [{ messageId: 'TextFieldはalert色だが、Messageはnormal色である' }], }, { code: dedent` <template> <TextField :error="hasError"> <Subject slot="subject">subject</Subject> </TextField> </template> `, errors: [{ messageId: 'TextFieldはalert色だが、Messageがslotとして定義されていない' }], }, ] });
準備が完了したので、実際に検知ロジックを実装します。 この記事では「TextFieldはnormal色だが、Messageはalert色である 」をサンプルとして記載します。
尚、具体的にどのNodeが検知対象なのかを判定するにあたり、AST Explorerを使用しました。多くの言語・パーサーに対応しているため、非常に便利です。また、VueのTemplate内のNodeに関しては、vuejs/vue-eslint-parserのドキュメントを参照しました。
// src/rules/my-rule export const create: RuleModule['create'] = context => { return utils.defineTemplateBodyVisitor(context, { 'VElement[name=textfield]'(node) { // TextFieldにerrorがbindされている const canBeError = node.startTag.attributes.some(attr => { return ( attr.key.type === 'VDirectiveKey' && attr.key.argument?.type === 'VIdentifier' && attr.key.argument?.name === 'error' ); }); // slot="message"が子要素に存在する const messageSlot = node.children.find((child): child is VElement => { if (child.type !== 'VElement' || child.name !== 'message') return false; return child.startTag.attributes.some(attr => { return ( attr.key.name === 'slot' && attr.value?.type === 'VLiteral' && attr.value.value === 'message' ); }); }); // slot="message"が子要素に存在しており、errorがbindされている const hasMessageSlotWithError = messageSlot?.startTag.attributes.some(attr => { const hasErrorAsNotDirective = attr.directive === false && attr.key.name === 'error' && attr.value === null; const hasErrorAsDirective = attr.directive === true && attr.key.argument?.type === 'VIdentifier' && attr.key.argument.name === 'error'; return hasErrorAsNotDirective || hasErrorAsDirective; }); if (canBeError) { if (messageSlot && !hasMessageSlotWithError) { context.report({ node, loc: node.loc, messageId: 'TextFieldはnormal色だが、Messageはalert色である', }); } } } }) }
これで自動でアンチパターンの検知が可能になりました!
まとめ
今回は簡易的に洗い出すアンチパターンを洗い出す目的でESLintのルールを作成しました。しかし、本来であればコンポーネントの設計時に気づくべき事項です。
Yappliではこの時点でコンポーネントカタログは存在しましたが、閲覧はローカル環境のみに限定されており、プロダクトデザイナーは閲覧できませんでした。また、このコンポーネントカタログは独自のアーキテクチャで実装されており、運用保守に大きなコストがありました。
今回の件を踏まえ、まずは既存コンポーネントの洗い出しや負債払拭を目的に、Storybookへの移行を進めています。次のステップでは、豊富なアドオンを利用した生産性向上、UI自体の再設計などを進めていきたいと考えています!