Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

ESLintのカスタムルールを作成してアンチパターンを洗い出した話

フロントエンドエンジニアの小林です。

今回は、UIコンポーネントのアンチパターンな利用を検出するESLintのカスタムルールを作成した話をご紹介いたします。

背景と問題

YappliのCMS画面におけるテキストフィールドは、入力項目に対してバリデーションエラーが発生した場合、

  • テキストフィールドの枠をアラート色にする
  • テキストフィールドの下にアラート色でエラーメッセージを表示する

という挙動が一般的であり、プロダクトデザイナーも同様の認識を持っています。

f:id:ykob_yapp:20211026185204g:plain
正常とされる挙動

一方で、プロダクトデザイナーから「一部の箇所では、テキストフィールドの枠がアラート色になっていない」というフィードバックを貰いました。

f:id:ykob_yapp:20211026185646g:plain
異常とされる挙動

まずは、テキストフィールドのコンポーネントがどのような仕様なのかを調査します。要点のみを抜粋すると、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パターンが表現できることが分かりました。デザイン設計として想定されないパターンもありますが、先人のフロントエンドエンジニアが拡張性を持たせた設計をしたのだと思われます。

f:id:ykob_yapp:20211026195210p:plain
表現可能なスタイル

一方で、この仕様を理解していなければ、前述の異常とされる挙動を生み出してしまいます。

検討

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色である',
          });
        }
      }
    }
  })
}

これで自動でアンチパターンの検知が可能になりました!

f:id:ykob_yapp:20211111171012p:plain

まとめ

今回は簡易的に洗い出すアンチパターンを洗い出す目的でESLintのルールを作成しました。しかし、本来であればコンポーネントの設計時に気づくべき事項です。

Yappliではこの時点でコンポーネントカタログは存在しましたが、閲覧はローカル環境のみに限定されており、プロダクトデザイナーは閲覧できませんでした。また、このコンポーネントカタログは独自のアーキテクチャで実装されており、運用保守に大きなコストがありました。

今回の件を踏まえ、まずは既存コンポーネントの洗い出しや負債払拭を目的に、Storybookへの移行を進めています。次のステップでは、豊富なアドオンを利用した生産性向上、UI自体の再設計などを進めていきたいと考えています!