😺

Ubie における ESLint 活用

2022/12/04に公開

Ubie では JavaScript や TypeScript で開発されているプロジェクトに対して、静的解析のために ESLint を導入しています。

この記事では Ubie での ESLint を活用事例を紹介します

ESLint を活用する目的

まず私が ESLint を活用する目的は、コーディング規約やベストプラクティスを強制することで、コードレビューの手間を省き、結果として本番環境でのエラーやパフォーマンスの悪化を減らすことです。

この記事で紹介するいくつかの設定もその目的を達成するためのものです。

no-restricted-syntax でアンチパターンを禁止する

ESLint には no-restricted-syntax というルールがあります。

このルールはセレクタで指定した構文を禁止できます。簡単に言えば、簡易的に独自ルールを作成できます。

たとえば次のように設定すると関数式を禁止できます。ここで指定する構文の名前(この例では FunctionExpression)はパーサーに依存しますが、基本的には ESTree で定義されているものをそのまま使えます。

{
    "rules": {
        "no-restricted-syntax": [
            "error",
            {
                "selector": "FunctionExpression",
                "message": "関数式は使うな!"
            }
        ]
    }
}

さらに ESLint ルールを定義するときに使われているセレクタを使うことで、より詳細に構文を指定できます。

たとえば次のように設定すると"hello"と名付けられた関数宣言を禁止できます。

{
    "rules": {
        "no-restricted-syntax": [
            "error",
            {
                "selector": "FunctionDeclration[id.name='hello']",
                "message": "関数に hello という名前をつけるな!"
            }
        ]
    }
}

このセレクタは https://eslint.org/docs/latest/developer-guide/selectors で説明されています。またこのセレクタの機能は内部的には esquery というライブラリで実現されていて http://estools.github.io/esquery/ で試すこともできます。

Ubie で実際に使っている no-restricted-syntax の設定を一つ紹介します。

{
    "rules": {
        "no-restricted-syntax": [
            "error",
            {
                "selector":
                    "ExportNamedDeclaration > VariableDeclaration[declarations.length=1][kind='const'] > VariableDeclarator[id.name='getServerSideProps'] BlockStatement ThrowStatement",
                "message":
                    "`getServerSideProps`の中で`Error`を投げるのは意図しないInternal Server Errorを発生させるので推奨できません。エラーではなく 404 扱いすることを検討してください。本当に必要な場合はこの警告を eslint-disable で無視してください。"
            }
        ]
    }
}

セレクタもメッセージも長いので説明します。

このセレクタをわかりやすく書くとこうなっています。

ExportNamedDeclaration >
VariableDeclaration[declarations.length=1][kind='const'] > 
VariableDeclarator[id.name='getServerSideProps']
BlockStatement
ThrowStatement

で、これがなにかというと Next.js の getServerSideProps の中のthrow文を指しています。

つまり、こういうコードを禁止します。

export const getServerSideProps = async (context) => {
  if (!context.params?.foo) {
    // セレクタが指しているのはここ
    throw new Error("クエリパラメータ foo がありません")
  }
  return {
    props: {
      foo: context.params.foo
    }
  };
}

以前は Next.js のページコンポーネントにこういうコードが記述されがちでした。このコードだと、クエリパラメータ foo がない状態で該当ページにアクセスするとサーバー側がステータスコード 500 を返します。しかし多くの場合、実際には 500 でなく 404 で十分です。

そこで、このような構文を見つけたときに次のようなメッセージを出します。

`getServerSideProps`の中で`Error`を投げるのは
意図しないInternal Server Errorを発生させるので推奨できません。
エラーではなく 404 扱いすることを検討してください。
本当に必要な場合はこの警告を eslint-disable で無視してください。

つまり、こういう書き換えを推奨しているわけです。

export const getServerSideProps = async (context) => {
  if (!context.params?.foo) {
-   throw new Error("クエリパラメータ foo がありません")
+   return { notFound: true };
  }
  return {
    props: {
      foo: context.params.foo
    }
  };
}

このように、no-restricted-syntax を使うと自分のチームで書かれがちなアンチパターンを先回りして禁止できます。

@typescript-eslint/no-restricted-imports でサイズの大きなライブラリの静的 import を禁止する

ESLint には no-restricted-imports というルールがあります。そして typescript-eslint には ESLint の no-restricted-imports をラップした @typescript-eslint/no-restricted-imports というルールがあります。

(Ubie では TypeScript を使っているのでここでは typescript-eslint のルールを使っていますが、JavaScript を使っている場合は ESLint のルールをそのまま使えます)

このルールは名前の通り指定したモジュールの import を禁止します。

たとえば次のように設定すると import foo from "foo"; のような foo の import を禁止します。

{
    "rules": {
        "@typescript-eslint/no-restricted-imports": [
            "error",
            {
                "paths": [
                    {
                        "name": "foo",
                        "message": "パッケージ`foo`は使わないで!"
                    }
                ]
            }
        ]
    }
}

Ubie で使っている @typescript-eslint/no-restricted-imports の設定を一つ紹介します。

{
    "rules": {
        "@typescript-eslint/no-restricted-imports": [
            "error",
            {
                "paths": [
                    {
                        "name": "@holiday-jp/holiday_jp"
                        "allowTypeImports": true
                    }
                ]
            }
        ]
    }
}

@holiday-jp/holiday_jp というモジュールの import を禁止しています。

// これはだめ
import { isHoliday } from '@holiday-jp/holiday_jp';

// これはよい
import("@holiday-jp/holiday_jp").then(({ isHoliday }) => {
  // noop
})

私が関わっているプロジェクトでは特定の日付が祝日かどうか判定するために @holiday-jp/holiday_jp を使っています。しかし @holiday-jp/holiday_jp はサイズが大きいのです(bundlephobia によると Minified + gzipped で 13.4 kb)。

なので雑に import してしまうと場合によってはプロジェクト全体のバンドルサイズを無駄に増やすことに繋がります。

そこで @typescript-eslint/no-restricted-imports を設定することで @holiday-jp/holiday_jp を import するときは dynamic import を使うように強制できます。

また、allowTypeImports オプションを true に指定すると型のみの import は許容します。

// `allowTypeImports` を `true` にしておけばこういうのはセーフ
import type { Holiday } from '@holiday-jp/holiday_jp';

@typescript-eslint/no-restricted-imports を適切に設定しておけば、サイズの大きなライブラリによってプロジェクト全体のバンドルサイズが無駄に増えるのを回避できます(もちろん静的 import する必要があるときもあるのでそのへんはトレードオフですね)。

さらなる活用

最近では no-restricted-syntax を使うだけではカバーできないケースも出てきたので、プロジェクト用に独自の ESLint プラグインを開発することを考えています。

Ubie でのことではありませんが、OSS である Prettier や Babel ではプロジェクト独自の ESLint プラグインが実装されていて、上手く機能しているように思います。

Ubie テックブログ

Discussion