🐕

頼む、リアクティブな値に対して.valueを忘れたときに警告してくれ

2024/07/16に公開

はじめに

今回は、Vue のリアクティブな値に対して .value を忘れた場合に警告を出す ESLint のカスタムルールを作ってみた話です。
正直内容が拙いと思いますが、ぜひ目を通していただけると幸いです。

Vue や Nuxt ではリアクティブな値に対してアクセスする時は reactiveValue.value といった感じで、.value をつける必要があります。
これを忘れるとバグの原因になるのですが、よく忘れられるんですよね。私に。

リアクティブな値に対して .value をつけてアクセスしなければならないという仕様は、個人的にはバグの温床であると思っていて、これを防ぐために ESLint でエラーを出してほしいと思いました。
npm で探しましたが...

ありません!

そう、見つからなかったんです。
なので、作ってしまいました。

ESLint の plugin として作成して全世界に公開しても良かったんですが、リアクティブな値の扱いは比較的自由度が高く、すべてを制御することが難しいと感じ、カスタムルールを作成するにとどめました。

今回作成するカスタムルールは前述の通り「リアクティブな値に対して .value をつけることを強制するルール」です。
カスタムルールの実装自体は JavaScript で書いていきますが、TypeScript のシステムを活用してゆるく型の定義はしていきます。

一から開発順序を追うのではなく、ざっくりどんな処理を実装したか紹介します。

プロジェクトのセットアップ

まずは ESLint と TypeScript をインストールします。
プロジェクトを立ち上げた段階で ESLint と TypeScript はインストールされていることがほとんどなので、今回のカスタムルールを作るときに必要になるパッケージのみを追加します。

pnpm add -D @typescript-eslint/parser @typescript-eslint/utils @types/eslint @types/estree

ESLint のバージョンは 9 を使っていくので、FlatConfig となります。

eslint.config.mjsは下記のコードを参考にしてください。

eslint.config.mjs
export default withNuxt({
  languageOptions: {
    parserOptions: {
      parser: '@typescript-eslint/parser',
      project: './tsconfig.json',
    },
  },
});

withNuxt は Nuxt で ESLint のおすすめ設定を使うものなので、Nuxt を使っていない場合は適宜変更してください。
とにかく parser に @typescript-eslint/parser を指定して、project には tsconfig.json を指定してください。

tsconfig.json は特別な設定をしていません。

tsconfig.json
{
  // https://nuxt.com/docs/guide/concepts/typescript
  "extends": "./.nuxt/tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

ルールの設定

ルールの設定は rules に追加していきます。

eslint.config.mjs
import withNuxt from './.nuxt/eslint.config.mjs';
import eslintCustomRulesPlugin from './settings/rules/index.js';

export default withNuxt({
  languageOptions: {
    parserOptions: {
      parser: '@typescript-eslint/parser',
      project: './tsconfig.json',
    },
  },
  plugins: { 'coding-rules': eslintCustomRulesPlugin },
  rules: {
    'coding-rules/reactive-value-suffix': 'error',
  },
});

./settings/rules/index.js はカスタムルールを集約しているプラグイン用のファイルです。
今回は settings ディレクトリを作成し、その中に rules ディレクトリを作成し、その中でカスタムルールを定義していきますが、この辺りは自由に変更してください。

プラグインの設定は plugins に追加していきます。
今回は coding-rules という名前でカスタムルールを登録して、その中の reactive-value-suffix を使うようにしています。

このあたりは実際にカスタムルールが完成したあとに改めて確認してみてください。

カスタムルールの作成

カスタムルールファイルは settings/rules ディレクトリに作成します。
そのため、 settings/rules/reactive-value-suffix.js を作成してください。
そのディレクトリを起点にして、utils の関数や helper 関数などを作成していきます。

settings/rules/reactive-value-suffix.js
/**
 * @fileoverview リアクティブな値に ".value" を付けることを強制するESLintルール
 */

import { ESLintUtils } from '@typescript-eslint/utils';
import {
  addArgumentsToList,
  addReactiveVariables,
  addComposablesArgumentsToList,
  addSkipCheckFunctionsArgumentsToList,
} from './utils/reactiveVariableUtils.js';
import {
  COMPOSABLE_FUNCTION_PATTERN,
  REACTIVE_FUNCTIONS,
  SKIP_CHECK_ARGUMENT_FUNCTION_NAMES,
} from './utils/constant.js';
import { isArgumentOfFunction, isWatchArguments } from './utils/astNodeCheckers.js';

/**
 * @typedef {import('@typescript-eslint/utils/ts-eslint').RuleModule<'requireValueSuffix', []>} RuleModule
 * @typedef {import('@typescript-eslint/utils/ts-eslint').RuleContext} RuleContext
 * @typedef {import('estree').VariableDeclarator} VariableDeclarator
 * @typedef {import('estree').FunctionDeclaration} FunctionDeclaration
 * @typedef {import('estree').ArrowFunctionExpression} ArrowFunctionExpression
 * @typedef {import('estree').MemberExpression} MemberExpression
 * @typedef {import('estree').Identifier} Identifier
 * @typedef {import("estree").Node} Node
 * @typedef {import('typescript').TypeChecker} TypeChecker
 * @typedef {import('@typescript-eslint/utils').TSESTree.Node} TSESTreeNode
 */

/**
 * 指定したノードに対して型をチェックし、必要な修正を報告します
 * @param {TSESTreeNode} node - ASTノード
 * @param {string} name - 識別子の名前
 * @param {RuleContext} context - ESLintのコンテキスト
 * @param {ReturnType<typeof ESLintUtils.getParserServices>} parserServices - パーササービス
 * @param {TypeChecker} checker - TypeScriptの型チェッカー
 */
function checkNodeAndReport(node, name, context, parserServices, checker) {
  const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
  const type = checker.getTypeAtLocation(tsNode);
  const typeName = checker.typeToString(type);

  if (typeName.includes('Ref') && !typeName.includes('.value')) {
    context.report({
      node,
      messageId: 'requireValueSuffix',
      data: { name },
      // NOTE: 自動修正を行う場合はコメントアウトを外す
      // fix(fixer) {
      //   return fixer.insertTextAfter(node, '.value');
      // },
    });
  }
}

/**
 * 識別子ノードをチェックしてルールを適用
 * @param {Identifier} node - 識別子ノード
 * @param {string[]} variableFromReactiveFunctions - リアクティブ関数から取得された変数のリスト
 * @param {string[]} functionArguments - 関数の引数のリスト
 * @param {string[]} composablesArguments - Composables関数から取得された引数のリスト
 * @param {string[]} skipFunctionArgument - スキップする関数の引数のリスト
 * @param {RuleContext} context - ESLintのコンテキスト
 * @param {ReturnType<typeof ESLintUtils.getParserServices>} parserServices - パーササービス
 * @param {TypeChecker} checker - TypeScriptの型チェッカー
 */
function checkIdentifier(
  node,
  variableFromReactiveFunctions,
  functionArguments,
  composablesArguments,
  skipFunctionArgument,
  context,
  parserServices,
  checker,
) {
  /** @type {Node} */
  const parent = node.parent;
  const parentType = parent?.type;

  const isVariableDeclarator = parentType === 'VariableDeclarator';
  const isMemberExpression = parentType === 'MemberExpression';
  const isProperty = parentType === 'Property';
  const isPropertyValue = parent?.property?.name === 'value';
  const isFunctionArgument = functionArguments.includes(node.name);
  const isObjectKey = isProperty && parent.key.name === node.name;
  const isOriginalDeclaration = isMemberExpression || isProperty;
  const isComposablesArgument =
    parentType === 'CallExpression' &&
    parent.arguments.includes(node) &&
    parent.callee.type === 'Identifier' &&
    composablesArguments.includes(parent.callee.name);
  const isSkipFunctionArgument = skipFunctionArgument.includes(node.name);

  const shouldSkipCheck =
    isVariableDeclarator ||
    isMemberExpression ||
    isObjectKey ||
    isFunctionArgument ||
    isPropertyValue ||
    isOriginalDeclaration ||
    isComposablesArgument ||
    isSkipFunctionArgument ||
    isWatchArguments(node) ||
    isArgumentOfFunction(node, COMPOSABLE_FUNCTION_PATTERN); // NOTE: useから始まる関数名の引数は例外(composablesの関数など)

  if (!shouldSkipCheck && variableFromReactiveFunctions.includes(node.name)) {
    checkNodeAndReport(node, node.name, context, parserServices, checker);
  }
}

/**
 * メンバー式ノードをチェックしてルールを適用
 * @param {MemberExpression} node - メンバー式ノード
 * @param {string[]} variableFromReactiveFunctions - リアクティブ関数から取得された変数のリスト
 * @param {RuleContext} context - ESLintのコンテキスト
 * @param {ReturnType<typeof ESLintUtils.getParserServices>} parserServices - パーササービス
 * @param {TypeChecker} checker - TypeScriptの型チェッカー
 */
function checkMemberExpression(node, variableFromReactiveFunctions, context, parserServices, checker) {
  const isPropertyValue = node.property?.name === 'value';
  if (!isPropertyValue && variableFromReactiveFunctions.includes(node.object?.name)) {
    checkNodeAndReport(node.object, node.object.name, context, parserServices, checker);
  }
}

/** @type {RuleModule} */
export const reactiveValueSuffix = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'リアクティブな値に対して.valueでアクセスすることを強制する',
      recommended: true,
    },
    fixable: 'code',
    messages: {
      requireValueSuffix: 'リアクティブな値 "{{name}}" には "{{name}}.value" でアクセスする必要があります。',
    },
  },
  defaultOptions: [],
  create(context) {
    /** @type {string[]} */
    const variableFromReactiveFunctions = [];
    /** @type {string[]} */
    const functionArguments = [];
    /** @type {string[]} */
    const composablesArguments = [];
    /** @type {string[]} */
    const skipFunctionArgument = [];
    const parserServices = ESLintUtils.getParserServices(context);
    const checker = parserServices.program.getTypeChecker();

    return {
      VariableDeclarator(node) {
        addReactiveVariables(node, variableFromReactiveFunctions, REACTIVE_FUNCTIONS);
        addComposablesArgumentsToList(node, composablesArguments, COMPOSABLE_FUNCTION_PATTERN);
        addSkipCheckFunctionsArgumentsToList(node, skipFunctionArgument, SKIP_CHECK_ARGUMENT_FUNCTION_NAMES);
      },
      FunctionDeclaration(node) {
        addArgumentsToList(node, functionArguments);
      },
      ArrowFunctionExpression(node) {
        addArgumentsToList(node, functionArguments);
      },
      Identifier(node) {
        checkIdentifier(
          node,
          variableFromReactiveFunctions,
          functionArguments,
          composablesArguments,
          skipFunctionArgument,
          context,
          parserServices,
          checker,
        );
      },
      MemberExpression(node) {
        checkMemberExpression(node, variableFromReactiveFunctions, context, parserServices, checker);
      },
    };
  },
};
utils や helper の関数
settings/rules/utils/reactiveVariableUtils.js
import { addToList, extractPropertyNames } from './helpers/arrayHelpers.js';
import { isSpecificFunctionCall } from './helpers/specificFunctionChecks.js';

/**
 * @typedef {import('estree').VariableDeclarator} VariableDeclarator
 * @typedef {import('estree').FunctionDeclaration} FunctionDeclaration
 * @typedef {import('estree').ArrowFunctionExpression} ArrowFunctionExpression
 */

/**
 * リアクティブな識別子をリストに追加する関数
 * @param {VariableDeclarator} node - ASTのノード
 * @param {string[]} list - 変数名リスト
 */
function addReactiveIdentifier(node, list) {
  if (node.id.type === 'Identifier') {
    addToList(list, [node.id.name]);
  }
}

/**
 * リアクティブなオブジェクトパターンをリストに追加する関数
 * @param {VariableDeclarator} node - ASTのノード
 * @param {string[]} list - 変数名リスト
 */
function addReactiveObjectPattern(node, list) {
  if (node.id.type === 'ObjectPattern') {
    const properties = extractPropertyNames(node.id.properties);
    addToList(list, properties);
  }
}

/**
 * リアクティブ変数をリストに追加する関数
 * @param {VariableDeclarator} node - ASTのノード
 * @param {string[]} list - 変数名リスト
 * @param {string[] | RegExp} reactiveFunctions - リアクティブな関数名のリストまたはパターン
 */
export function addReactiveVariables(node, list, reactiveFunctions) {
  if (isSpecificFunctionCall(node, reactiveFunctions)) {
    addReactiveIdentifier(node, list);
    addReactiveObjectPattern(node, list);
  }
}

/**
 * composables関数の引数をリストに追加する関数
 * @param {VariableDeclarator} node - ASTのノード
 * @param {string[]} list - 引数リスト
 * @param {RegExp} composableFunctionPattern - composables関数名のパターン
 */
export function addComposablesArgumentsToList(node, list, composableFunctionPattern) {
  if (isSpecificFunctionCall(node, composableFunctionPattern) && node.id.type === 'ObjectPattern') {
    const properties = extractPropertyNames(node.id.properties);
    addToList(list, properties);
  }
}

/**
 * スキップする関数の引数をリストに追加する関数
 * @param {VariableDeclarator} node - ASTのノード
 * @param {string[]} list - 引数リスト
 * @param {string[]} skipFunctions - スキップする関数名のリスト
 */
export function addSkipCheckFunctionsArgumentsToList(node, list, skipFunctions) {
  if (isSpecificFunctionCall(node, skipFunctions) && node.id.type === 'ObjectPattern') {
    const properties = extractPropertyNames(node.id.properties);
    addToList(list, properties);
  }
}

/**
 * 関数の引数をリストに追加する関数
 * @param {FunctionDeclaration | ArrowFunctionExpression} node - ASTのノード
 * @param {string[]} list - 引数のリスト
 */
export function addArgumentsToList(node, list) {
  const args = node.params.reduce((acc, param) => {
    if (param.name) {
      acc.push(param.name);
    }
    return acc;
  }, []);
  addToList(list, args);
}

settings/rules/utils/constant.js
/**
 * リアクティブ関数名リスト
 * @type {('ref' | 'toRefs' | 'storeToRefs' | 'computed')[]}
 */
export const REACTIVE_FUNCTIONS = ['ref', 'toRefs', 'storeToRefs', 'computed'];

/**
 * composablesの慣習的な関数名のパターン
 */
export const COMPOSABLE_FUNCTION_PATTERN = /^use[A-Z]/;

/**
 * .valueチェックをスキップする関数の名前を格納するリスト
 *
 * このリストに含まれる関数名の引数は`.value`チェックがスキップされます。
 *
 * 自由に追加してください。
 *
 * @type {string[]}
 */
export const SKIP_CHECK_ARGUMENT_FUNCTION_NAMES = [];
settings/rules/utils/astNodeCheckers.js
import { findAncestorOfType } from './helpers/astHelpers.js';

/**
 * @typedef {import('estree').Identifier} Identifier
 * @typedef {import('estree').CallExpression} CallExpression
 */

/**
 * ノードが指定された関数の引数であるかを確認
 * @param {Identifier} node - 識別子ノード
 * @param {RegExp} functionNamePattern - 関数名のパターン
 * @returns {boolean} - ノードが指定された関数の引数であるかどうか
 */
export function isArgumentOfFunction(node, functionNamePattern) {
  /** @type {CallExpression} */
  const callExpression = findAncestorOfType(node, 'CallExpression');
  return (
    callExpression?.callee?.type === 'Identifier' &&
    functionNamePattern.test(callExpression.callee.name) &&
    callExpression.arguments.includes(node)
  );
}

/**
 * ノードがwatch関数の引数であるかを確認
 * @param {Identifier} node - 識別子ノード
 * @returns {boolean} - ノードがwatch関数の引数であるかどうか
 */
export function isWatchArguments(node) {
  /** @type {CallExpression} */
  const callExpression = findAncestorOfType(node, 'CallExpression');
  return (
    callExpression?.callee?.name === 'watch' &&
    (callExpression.arguments?.indexOf(node) === 0 ||
      (callExpression.arguments?.[0]?.type === 'ArrayExpression' &&
        callExpression.arguments?.[0]?.elements?.includes(node)))
  );
}
settings/rules/utils/helpers/specificFunctionChecks.js
/**
 * @typedef {import('estree').VariableDeclarator} VariableDeclarator
 */

/**
 * 特定関数呼び出しかどうかをチェックするヘルパー関数
 * @param {VariableDeclarator} node - ASTのノード
 * @param {string[] | RegExp} functions - チェックする関数名のリストまたはパターン
 * @returns {boolean} - 関数呼び出しかどうか
 */
export function isSpecificFunctionCall(node, functions) {
  if (node.init?.type !== 'CallExpression') return false;
  const functionName = node.init?.callee?.name;
  return Array.isArray(functions) ? functions.includes(functionName) : functions.test(functionName);
}

/**
 * storeToRefs関数の呼び出しかどうかをチェックするヘルパー関数
 * @param {VariableDeclarator} node - チェックするASTノード
 * @returns {boolean} - storeToRefs関数の呼び出しかどうか
 */
export function isStoreToRefsCall(node) {
  return (
    node.id.type === 'ObjectPattern' &&
    node.init &&
    node.init.type === 'CallExpression' &&
    node.init.callee.name === 'storeToRefs'
  );
}
settings/rules/utils/helpers/astHelpers.js
/**
 * @typedef {import('estree').Node} Node
 * @typedef {import('estree').VariableDeclarator} VariableDeclarator
 */

/**
 * ノードの祖先を探索し、指定されたタイプのノードを見つける
 * @param {Node} node - 開始ノード
 * @param {string} type - 探索するノードのタイプ
 * @returns {Node} - 見つかったノード
 */
export function findAncestorOfType(node, type) {
  let ancestor = node.parent;
  while (ancestor && ancestor.type !== type) {
    ancestor = ancestor.parent;
  }
  return ancestor;
}
settings/rules/utils/helpers/arrayHelpers.js
/**
 * @typedef {import('estree').Property} Property
 */

/**
 * 配列にアイテムを追加するヘルパー関数
 * @param {string[]} list - 追加先のリスト
 * @param {string[]} items - 追加するアイテムのリスト
 */
export function addToList(list, items) {
  list.push(...items);
}

/**
 * プロパティから有効な名前を抽出しリストに追加するヘルパー関数
 * @param {Property[]} properties - オブジェクトプロパティの配列
 * @returns {string[]} - プロパティ名のリスト
 */
export function extractPropertyNames(properties) {
  return properties.reduce((acc, property) => {
    if (property.value?.name) {
      acc.push(property.value.name);
    }
    return acc;
  }, []);
}

適宜 JSDoc を記載しているのと、 @typedef で型を定義しています。
これは TypeScript の型を活用して、なるべく開発体験を向上するためです。

checkNodeAndReport

この関数では、指定されたノードの型をチェックして、リアクティブな値で .value がついていない場合はエラーを報告するようにしています。
コメントアウトしている部分を解除すると、 eslint --fixcmd + s で自動修正が行われるようになります

checkIdentifier

この関数では、識別子ノードがリアクティブな変数からのものであり、チェックをスキップすべき条件に該当しない場合に checkNodeAndReport を呼び出すようにしています。

チェックをスキップすべき条件は、条件式を見ていただければと思いますが、
特筆すべき事項としては、 isComposablesArgumentisArgumentOfFunctionisWatchArguments や、 isSkipFunctionArgument のチェックがあります。

isComposablesArgument

composables の関数の引数として、リアクティブな値(.value なし)を期待している場合が基本的に多いです。
この条件は、composables から分割された関数(正確には変数)の関数の引数チェックをスキップするようにしています。

composables から分割代入されたものを配列に格納していって、それをスキップの対象にしているので、関数だけではない、ということです。

const { fn } = useComposable();

fn(リアクティブな値); // ここはチェックをスキップ

isArgumentOfFunction

この条件は、useから始まる関数名の引数にはチェックをスキップするようにしています。

useComposable(リアクティブな値); // ここはチェックをスキップ

composables の関数は関数名にuseをつけることが慣習となっているため、このような条件で判断を行っています。

isWatchArguments

この条件は、watch関数の引数にはチェックをスキップするようにしています。

// ここはチェックをスキップ
watch(リアクティブな値, () => {
  console.log(リアクティブな値.value); // ここはチェック
});

watch の監視対象の変数には.valueをつける必要がないため、このような条件で判断を行っています。

isSkipFunctionArgument

この条件は、 COMPOSABLE_FUNCTION_PATTERN の配列に含まれる関数名の引数にはチェックをスキップするようにしています。

もし上記条件に当てはまらないような関数名だったときに対応できるように、穴を開けるような感覚でこの条件を追加しています。

checkMemberExpression

この関数では、メンバー式ノードがリアクティブな変数からのものであり、
チェックをスキップすべき条件に該当しない場合かつ .value がついていない場合に checkNodeAndReport を呼び出すようにしています。

ルールの export

最後にreactiveValueSuffixと定義して設定をexportしています。
ESLint のメタデータを設定して、ルールの動作を定義しています。

VariableDeclaratorFunctionDeclarationArrowFunctionExpressionIdentifierMemberExpression の各ノードタイプに対して適切なチェック関数を呼び出し、リアクティブな値に .value が付いているかを確認します。

ルール自体は完成したので、これを ESLint の設定ファイルで有効にしていきます。

ルールの有効化

この記事の最初の方で

eslint.config.mjs
import withNuxt from './.nuxt/eslint.config.mjs';
import eslintCustomRulesPlugin from './settings/rules/index.js';

export default withNuxt({
  languageOptions: {
    parserOptions: {
      parser: '@typescript-eslint/parser',
      project: './tsconfig.json',
    },
  },
  plugins: { 'coding-rules': eslintCustomRulesPlugin },
  rules: {
    'coding-rules/reactive-value-suffix': 'error',
  },
});

のように設定していると思うので、settings/rules/index.jsの部分を記述していきます。

settings/rules/index.js
import { reactiveValueSuffix } from './reactive-value-suffix.js';

const plugin = {
  meta: {
    name: 'eslint-custom-rules-plugin',
    version: '1.0.0',
  },
  rules: {
    'reactive-value-suffix': reactiveValueSuffix,
  },
};
export default plugin;

とすれば大丈夫です。これで作成したカスタムルールを使用できます!

これでリアクティブな値に.valueをつけることを強制するルールが完成しました。
これでいくつかバグを減らすことができそうですね!

長くなりましたが、皆様の環境でも実装してみてください :)

GitHubで編集を提案

Discussion