一文搞定ESlint插件

创建一个自定义的 ESLint 插件是一个涉及多个步骤的过程,它能帮助你强制执行项目特有的编码规范。

ESLint 插件概述

ESLint 插件是一个 npm 包,它可以导出一个或多个以下内容:

  • 规则 (Rules) :自定义的校验逻辑。
  • 配置 (Configs) :预设的规则集,例如 plugin:your-plugin/recommended
  • 处理器 (Processors) :允许 ESLint 处理非 JavaScript 文件中的代码(例如 Markdown 文件中的代码块)。
  • 环境 (Environments) :定义全局变量。

项目结构

一个典型的 ESLint 插件项目结构如下:

javascript 复制代码
eslint-plugin-my-custom-rules/
├── lib/
│   ├── rules/
│   │   ├── no-specific-console.js  // 规则1:禁用特定的 console 方法
│   │   ├── enforce-variable-prefix.js // 规则2:强制变量名前缀
│   │   └── require-noopener-noreferrer.js // 规则3:检查 a 标签的 rel 属性
│   └── utils/
│       └── index.js                // 可选的工具函数
├── tests/
│   ├── lib/
│   │   └── rules/
│   │       ├── no-specific-console.test.js
│   │       ├── enforce-variable-prefix.test.js
│   │       └── require-noopener-noreferrer.test.js
├── .eslintrc.js                    // 插件开发时的 ESLint 配置
├── package.json
└── index.js                        // 插件主入口文件

1. 初始化项目和安装依赖

首先,创建一个新的 Node.js 项目:

bash 复制代码
mkdir eslint-plugin-my-custom-rules
cd eslint-plugin-my-custom-rules
npm init -y

安装 ESLint 作为开发依赖,因为我们需要它来测试我们的插件和规则:

bash 复制代码
npm install eslint --save-dev
# 或者使用 yarn
# yarn add eslint --dev

2. 插件主入口文件 (index.js)

这是插件的入口点,ESLint 会从这里加载你的规则、配置等。

js 复制代码
// eslint-plugin-my-custom-rules/index.js

/**
 * @fileoverview 主入口文件,导出插件的规则和配置。
 * @author Your Name
 */

"use strict";

// 引入规则模块
const noSpecificConsole = require("./lib/rules/no-specific-console");
const enforceVariablePrefix = require("./lib/rules/enforce-variable-prefix");
const requireNoopenerNoreferrer = require("./lib/rules/require-noopener-noreferrer");

module.exports = {
    rules: {
        // 规则名格式:<插件名>/<规则ID>,但在插件内部定义时,我们只用规则ID
        // ESLint 会自动添加插件名前缀,例如 'my-custom-rules/no-specific-console'
        "no-specific-console": noSpecificConsole,
        "enforce-variable-prefix": enforceVariablePrefix,
        "require-noopener-noreferrer": requireNoopenerNoreferrer,
    },
    configs: {
        recommended: {
            plugins: [
                // 在配置中引用插件时,需要使用插件名,
                // 通常是 npm 包名去掉 `eslint-plugin-` 前缀
                // 如果你的包名是 `eslint-plugin-my-custom-rules`,这里就是 `my-custom-rules`
                // 但由于我们还没发布,先用一个占位符或实际的插件名
                "my-custom-rules" // 假设插件名为 my-custom-rules
            ],
            rules: {
                "my-custom-rules/no-specific-console": "warn",
                "my-custom-rules/enforce-variable-prefix": ["error", { prefix: "cust_" }],
                "my-custom-rules/require-noopener-noreferrer": "error",
                // 你也可以在这里包含一些 ESLint 核心规则的推荐配置
                "semi": "error",
                "no-unused-vars": "warn"
            }
        },
        strict: {
            plugins: [
                "my-custom-rules"
            ],
            rules: {
                "my-custom-rules/no-specific-console": "error", // 更严格的配置
                "my-custom-rules/enforce-variable-prefix": ["error", { prefix: "app_", caseSensitive: true }],
                "my-custom-rules/require-noopener-noreferrer": "error",
            }
        }
    }
    // 如果有处理器,也可以在这里导出
    // processors: {
    //   ".md": myMarkdownProcessor
    // }
};

代码讲解:

  • rules: 一个对象,键是规则的 ID,值是规则的实现模块。

  • configs: 一个对象,可以定义多个配置集。例如,recommended 配置通常包含插件作者推荐使用的规则。

    • plugins: 数组,列出此配置依赖的插件。
    • rules: 对象,配置插件中规则的启用状态和选项。规则的键名格式为 plugin-name/rule-name

3. 理解 AST (Abstract Syntax Tree)

ESLint 通过将代码解析成 AST,然后遍历 AST 节点来分析代码。理解 AST 是编写 ESLint 规则的关键。

你可以使用 AST Explorer 来查看不同代码片段生成的 AST 结构。确保选择正确的解析器(如 espree 这是 ESLint 默认的,或 @babel/eslint-parser,或 @typescript-eslint/parser)。

常见的 AST 节点类型:

  • Identifier: 标识符,如变量名、函数名、属性名。
  • Literal: 字面量,如字符串、数字、布尔值。
  • VariableDeclaration: 变量声明 (var, let, const)。
  • FunctionDeclaration: 函数声明。
  • CallExpression: 函数调用。
  • MemberExpression: 成员表达式,如 object.propertyobject['property']
  • JSXOpeningElement, JSXAttribute, JSXIdentifier: JSX 相关节点 (如果你的项目使用 React/JSX)。

4. 创建自定义规则

每个规则都是一个 JavaScript 模块,导出一个包含 metacreate 方法的对象。

规则 1: no-specific-console.js

目标 :禁止使用 console.logconsole.warn,但允许 console.error。可以配置禁止的 console 方法列表。

js 复制代码
// eslint-plugin-my-custom-rules/lib/rules/no-specific-console.js

/**
 * @fileoverview 禁止使用特定的 console 方法。
 * @author Your Name
 */
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: "problem", // `problem`(可能导致错误),`suggestion`(改进建议),或 `layout`(代码风格)
    docs: {
      description: "禁止使用特定的 console 方法",
      category: "Best Practices", // 规则分类 (可选)
      recommended: true, // 是否在插件的 "recommended" 配置中推荐
      url: "https://example.com/docs/rules/no-specific-console", // 指向规则文档的URL (可选)
    },
    fixable: null, // "code" 或 "whitespace" (如果规则可以自动修复)
    schema: [ // 定义规则的选项
      {
        type: "object",
        properties: {
          disallowedMethods: {
            type: "array",
            items: {
              type: "string",
            },
            uniqueItems: true,
            default: ["log", "warn"], // 默认禁止 log 和 warn
          },
        },
        additionalProperties: false,
      },
    ],
    messages: { // 规则报告的错误信息模板
      unexpectedConsoleMethod: "禁止使用 console.{{methodName}}。",
    },
  },

  create(context) {
    // context 对象包含了与规则执行相关的信息和方法
    // context.getSourceCode():获取源代码和 AST
    // context.report():报告问题
    // context.options:获取规则的配置选项

    const options = context.options[0] || {}; // 获取用户配置的选项
    const disallowedMethods = new Set(options.disallowedMethods || ["log", "warn"]);

    return {
      // 访问者模式:当 ESLint 遍历 AST 时,会调用与节点类型同名的方法
      // 例如,遇到 CallExpression 节点时,会调用这里的 CallExpression 方法
      CallExpression(node) {
        // 检查是否是 console 调用
        // node.callee 是被调用的函数部分
        // 例如 console.log() 中,node.callee 是 MemberExpression (console.log)
        // 例如 alert() 中,node.callee 是 Identifier (alert)

        if (node.callee.type === "MemberExpression") {
          const callee = node.callee;
          // callee.object 是成员表达式的对象部分 (console)
          // callee.property 是成员表达式的属性部分 (log)
          if (
            callee.object.type === "Identifier" &&
            callee.object.name === "console"
          ) {
            if (
              callee.property.type === "Identifier" &&
              disallowedMethods.has(callee.property.name)
            ) {
              context.report({
                node: node, // 问题所在的 AST 节点
                messageId: "unexpectedConsoleMethod", // 对应 meta.messages 中的键
                data: { // 用于填充消息模板
                  methodName: callee.property.name,
                },
                // 可选的 fix 函数
                // fix(fixer) {
                //   // return fixer.remove(node); // 例如,移除整个 console 语句
                // }
              });
            }
          }
        }
      },
      // 你可以定义其他节点类型的访问者,例如:
      // Identifier(node) { /* ... */ },
      // FunctionDeclaration(node) { /* ... */ },
      // 'Program:exit'(node) { /* 在遍历完整个程序后执行 */ }
    };
  },
};

代码讲解 (no-specific-console.js):

  • meta 对象:

    • type: 规则类型。problem 表示规则发现的是代码错误或潜在问题。

    • docs: 规则的文档信息。

      • description: 简短描述。
      • category: 通常用于 ESLint 核心规则分类,插件也可以使用。
      • recommended: 布尔值,指示此规则是否应包含在插件的 recommended 配置中。
      • url: 指向详细规则文档的链接。
    • fixable: 如果规则能够自动修复代码,则设置为 "code""whitespace"。此规则暂不提供修复。

    • schema: 定义规则接受的选项。这里我们允许用户通过一个 disallowedMethods 数组来配置哪些 console 方法是禁止的。

    • messages: 定义规则报告问题时使用的消息模板。{{methodName}} 是一个占位符。

  • create(context) 函数:

    • 此函数在 ESLint 遍历 AST 时为每个文件执行一次。它返回一个对象,该对象的键是 AST 节点类型(或选择器),值是当 ESLint 访问这些节点时要调用的函数(访问者方法)。

    • context 对象:

      • context.options: 一个数组,包含用户在 ESLint 配置文件中为该规则提供的选项。context.options 对应 schema 中的第一个对象。

      • context.report({ node, messageId, data, fix }): 用于报告代码中的问题。

        • node: 触发问题的 AST 节点。
        • messageId: 引用 meta.messages 中的消息键。
        • data: 一个对象,用于填充消息模板中的占位符。
    • 访问者方法 CallExpression(node) :

      • 当 ESLint 遇到一个函数调用表达式 (如 console.log(), myFunc()) 时,会执行此方法。
      • 我们检查 node.callee (被调用的部分) 是否是一个 MemberExpression (如 console.log)。
      • 然后检查这个 MemberExpressionobject 是否是名为 consoleIdentifier
      • 再检查 property (方法名,如 log, warn) 是否在 disallowedMethods 集合中。
      • 如果都匹配,就调用 context.report() 报告问题。

规则 2: enforce-variable-prefix.js

目标 :强制使用 constlet 声明的变量必须以特定前缀开头(例如 cust_)。前缀和大小写敏感性可配置,并提供自动修复。

js 复制代码
// eslint-plugin-my-custom-rules/lib/rules/enforce-variable-prefix.js

/**
 * @fileoverview 强制变量名使用特定前缀。
 * @author Your Name
 */
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "强制变量名使用特定前缀",
      category: "Stylistic Issues",
      recommended: false, // 假设默认不推荐,用户可以自行启用
      url: "https://example.com/docs/rules/enforce-variable-prefix",
    },
    fixable: "code", // 此规则可以自动修复代码
    schema: [
      {
        type: "object",
        properties: {
          prefix: {
            type: "string",
            description: "要求的变量名前缀",
          },
          caseSensitive: {
            type: "boolean",
            description: "前缀是否大小写敏感",
            default: false,
          },
          applyToLet: {
            type: "boolean",
            description: "是否应用于 let 声明的变量",
            default: true,
          },
          applyToConst: {
            type: "boolean",
            description: "是否应用于 const 声明的变量",
            default: true,
          }
        },
        additionalProperties: false,
        required: ["prefix"], // prefix 选项是必需的
      },
    ],
    messages: {
      missingPrefix: "变量 '{{variableName}}' 必须以 '{{prefix}}' 开头。",
      caseMismatch: "变量 '{{variableName}}' 的前缀 '{{actualPrefix}}' 大小写应为 '{{expectedPrefix}}'。",
    },
  },

  create(context) {
    const options = context.options[0];
    if (!options || !options.prefix) {
      // 如果没有提供 prefix,规则不执行任何操作或抛出错误
      // 更好的做法是在 schema 中将 prefix 设为 required
      // console.error("Rule 'enforce-variable-prefix' requires a 'prefix' option.");
      return {}; // 不注册任何访问者
    }

    const requiredPrefix = options.prefix;
    const caseSensitive = options.caseSensitive === true;
    const applyToLet = options.applyToLet !== false; // 默认 true
    const applyToConst = options.applyToConst !== false; // 默认 true

    /**
     * 检查变量声明节点。
     * @param {ASTNode} node - VariableDeclarator 节点。
     */
    function checkVariableDeclarator(node) {
      // node.id 是变量的标识符节点 (Identifier)
      // node.parent 是 VariableDeclaration 节点,可以获取 kind (var, let, const)
      const variableName = node.id.name;
      const declarationKind = node.parent.kind; // "var", "let", "const"

      if (declarationKind === "var") {
        return; // 此规则不检查 var
      }
      if (declarationKind === "let" && !applyToLet) {
        return;
      }
      if (declarationKind === "const" && !applyToConst) {
        return;
      }

      const actualPrefix = variableName.substring(0, requiredPrefix.length);

      if (caseSensitive) {
        if (actualPrefix !== requiredPrefix) {
          context.report({
            node: node.id,
            messageId: "missingPrefix",
            data: {
              variableName,
              prefix: requiredPrefix,
            },
            fix(fixer) {
              // 如果变量名已经有部分前缀,但大小写不对,或者完全没有前缀
              // 简单的修复是直接在前面添加,更复杂的可能需要替换
              // 这里我们假设如果前缀不匹配(包括大小写),就替换或添加
              if (variableName.toLowerCase().startsWith(requiredPrefix.toLowerCase()) && variableName.length >= requiredPrefix.length) {
                // 可能只是大小写问题,替换现有前缀
                return fixer.replaceTextRange([node.id.range[0], node.id.range[0] + requiredPrefix.length], requiredPrefix);
              } else {
                // 完全没有前缀或不相关的前缀,在前面插入
                return fixer.insertTextBefore(node.id, requiredPrefix);
              }
            }
          });
        }
      } else {
        // 不区分大小写比较
        if (actualPrefix.toLowerCase() !== requiredPrefix.toLowerCase()) {
          context.report({
            node: node.id,
            messageId: "missingPrefix",
            data: {
              variableName,
              prefix: requiredPrefix, // 报告时仍使用用户配置的原始前缀
            },
            fix(fixer) {
               if (variableName.toLowerCase().startsWith(requiredPrefix.toLowerCase()) && variableName.length >= requiredPrefix.length) {
                return fixer.replaceTextRange([node.id.range[0], node.id.range[0] + requiredPrefix.length], requiredPrefix);
              } else {
                return fixer.insertTextBefore(node.id, requiredPrefix);
              }
            }
          });
        } else if (actualPrefix !== requiredPrefix) {
          // 前缀存在但大小写不匹配 (例如,要求 cust_, 实际是 Cust_)
          // 这种情况也需要修复,确保大小写正确
          context.report({
            node: node.id,
            messageId: "caseMismatch",
            data: {
              variableName,
              actualPrefix: actualPrefix,
              expectedPrefix: requiredPrefix,
            },
            fix(fixer) {
              return fixer.replaceTextRange([node.id.range[0], node.id.range[0] + requiredPrefix.length], requiredPrefix);
            }
          });
        }
      }
    }

    return {
      // VariableDeclarator 是变量声明中的单个声明部分
      // 例如:const a = 1, b = 2; 中有两个 VariableDeclarator 节点 (a=1 和 b=2)
      VariableDeclarator: checkVariableDeclarator,
    };
  },
};

代码讲解 (enforce-variable-prefix.js):

  • meta 对象:

    • type: "suggestion",因为这更多是风格问题。

    • fixable: "code",表明此规则可以自动修复代码。

    • schema: 定义了更复杂的选项:

      • prefix: 字符串,必需的前缀。
      • caseSensitive: 布尔值,前缀是否大小写敏感。
      • applyToLet, applyToConst: 布尔值,控制规则是否应用于 letconst
    • messages: 定义了两种错误消息。

  • create(context) 函数:

    • 获取并校验选项。如果必需的 prefix 未提供,则规则不执行。

    • checkVariableDeclarator(node) 函数:

      • 此函数作为 VariableDeclarator 节点的访问者。当 ESLint 遇到如 const myVar = ... 中的 myVar = ... 部分时,会调用它。

      • node.id.name: 获取变量名。

      • node.parent.kind: 获取声明类型 (const, let, var)。

      • 根据选项过滤掉不适用的声明类型。

      • 比较变量名的开头部分与 requiredPrefix

      • 大小写处理:

        • 如果 caseSensitivetrue,则进行严格比较。
        • 如果 caseSensitivefalse,则先进行不区分大小写的比较以判断前缀是否存在,如果存在但大小写不匹配,则单独报告 caseMismatch
      • context.report({ fix }) :

        • fix(fixer): 这是一个函数,接收一个 fixer 对象。

        • fixer.insertTextBefore(node, text): 在指定节点前插入文本。

        • fixer.replaceTextRange(range, text): 替换指定范围的文本。

        • 修复逻辑:

          • 如果变量名已经以某种形式的前缀开头(例如,大小写不同),则替换这部分。
          • 否则,在变量名前面插入所需的正确前缀。

规则 3: require-noopener-noreferrer.js

目标 : 对于使用 target="_blank"<a> 标签,强制要求同时设置 rel="noopener noreferrer" 或至少包含 noopener。此规则主要针对 JSX 环境。

js 复制代码
// eslint-plugin-my-custom-rules/lib/rules/require-noopener-noreferrer.js

/**
 * @fileoverview 要求 target="_blank" 的 a 标签必须有 rel="noopener noreferrer"。
 * @author Your Name
 */
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: "problem",
    docs: {
      description:
        '要求 target="_blank" 的 <a> 标签必须包含 rel="noopener" 或 rel="noopener noreferrer"',
      category: "Security",
      recommended: true,
      url: "https://example.com/docs/rules/require-noopener-noreferrer",
    },
    fixable: "code",
    schema: [
      {
        type: "object",
        properties: {
          allowReferrer: {
            type: "boolean",
            default: false,
            description: "是否允许单独使用 rel="noopener" 而不强制 noreferrer"
          }
        },
        additionalProperties: false
      }
    ],
    messages: {
      missingRel:
        '使用 target="_blank" 的 <a> 标签必须包含 rel="noopener noreferrer" 属性。',
      missingNoopener:
        '使用 target="_blank" 的 <a> 标签的 rel 属性必须包含 "noopener"。',
      missingNoreferrer:
        '使用 target="_blank" 的 <a> 标签的 rel 属性必须包含 "noreferrer" (除非 allowReferrer 为 true)。',
      disallowedReferrer:
        '当 allowReferrer 为 false 时,使用 target="_blank" 的 <a> 标签的 rel 属性必须包含 "noreferrer"。'
    },
  },

  create(context) {
    const options = context.options[0] || {};
    const allowReferrer = options.allowReferrer === true;

    /**
     * 获取 JSX 属性的值。
     * @param {ASTNode} attribute - JSXAttribute 节点。
     * @returns {string|null} 属性值,如果不是简单字符串则返回 null。
     */
    function getAttributeValue(attribute) {
      if (attribute && attribute.value) {
        if (attribute.value.type === "Literal") {
          return String(attribute.value.value);
        }
        if (attribute.value.type === "JSXExpressionContainer" && attribute.value.expression.type === "Literal") {
          return String(attribute.value.expression.value);
        }
      }
      return null;
    }

    return {
      JSXOpeningElement(node) {
        // 只检查 <a> 标签
        if (node.name.type !== "JSXIdentifier" || node.name.name !== "a") {
          return;
        }

        let targetBlank = false;
        let relAttribute = null;
        let hasNoopener = false;
        let hasNoreferrer = false;

        // 遍历所有属性
        for (const attribute of node.attributes) {
          if (attribute.type === "JSXAttribute") {
            const attrName = attribute.name.name;
            if (attrName === "target") {
              const value = getAttributeValue(attribute);
              if (value && value.toLowerCase() === "_blank") {
                targetBlank = true;
              }
            } else if (attrName === "rel") {
              relAttribute = attribute; // 保存 rel 属性节点本身,方便后续修复
              const value = getAttributeValue(attribute);
              if (value) {
                const relValues = value.toLowerCase().split(/\s+/);
                hasNoopener = relValues.includes("noopener");
                hasNoreferrer = relValues.includes("noreferrer");
              }
            }
          }
        }

        if (targetBlank) {
          if (!hasNoopener && !allowReferrer && !hasNoreferrer) {
            context.report({
              node: node,
              messageId: "missingRel",
              fix(fixer) {
                const relValueToAdd = 'noopener noreferrer';
                if (relAttribute) {
                  // rel 属性已存在,需要修改
                  if (relAttribute.value && relAttribute.value.type === "Literal") {
                    const existingValue = relAttribute.value.value;
                    return fixer.replaceText(relAttribute.value, `"${existingValue ? existingValue + ' ' : ''}${relValueToAdd}"`);
                  } else if (relAttribute.value && relAttribute.value.type === "JSXExpressionContainer" && relAttribute.value.expression.type === "Literal") {
                     const existingValue = relAttribute.value.expression.value;
                     return fixer.replaceText(relAttribute.value.expression, `"${existingValue ? existingValue + ' ' : ''}${relValueToAdd}"`);
                  }
                  // 无法简单修复复杂的表达式,或者可以尝试更复杂的逻辑
                  return null;
                } else {
                  // rel 属性不存在,需要添加
                  return fixer.insertTextAfter(node.attributes[node.attributes.length -1] || node.name, ` rel="${relValueToAdd}"`);
                }
              },
            });
          } else if (!hasNoopener) {
             context.report({
              node: relAttribute || node,
              messageId: "missingNoopener",
              fix(fixer) {
                if (relAttribute && relAttribute.value && relAttribute.value.type === "Literal") {
                  const existingValue = relAttribute.value.value;
                  const newValue = `${existingValue ? existingValue + ' ' : ''}noopener`.trim();
                  return fixer.replaceText(relAttribute.value, `"${newValue}"`);
                } else if (relAttribute && relAttribute.value && relAttribute.value.type === "JSXExpressionContainer" && relAttribute.value.expression.type === "Literal") {
                  const existingValue = relAttribute.value.expression.value;
                  const newValue = `${existingValue ? existingValue + ' ' : ''}noopener`.trim();
                  return fixer.replaceText(relAttribute.value.expression, `"${newValue}"`);
                } else if (!relAttribute) {
                  return fixer.insertTextAfter(node.attributes[node.attributes.length -1] || node.name, ` rel="noopener"`);
                }
                return null;
              }
            });
          } else if (!allowReferrer && !hasNoreferrer) {
            context.report({
              node: relAttribute || node,
              messageId: "missingNoreferrer",
              fix(fixer) {
                if (relAttribute && relAttribute.value && relAttribute.value.type === "Literal") {
                  const existingValue = relAttribute.value.value;
                  const newValue = `${existingValue ? existingValue + ' ' : ''}noreferrer`.trim();
                  return fixer.replaceText(relAttribute.value, `"${newValue}"`);
                } else if (relAttribute && relAttribute.value && relAttribute.value.type === "JSXExpressionContainer" && relAttribute.value.expression.type === "Literal") {
                  const existingValue = relAttribute.value.expression.value;
                  const newValue = `${existingValue ? existingValue + ' ' : ''}noreferrer`.trim();
                  return fixer.replaceText(relAttribute.value.expression, `"${newValue}"`);
                } else if (!relAttribute) {
                  // 如果 rel 不存在但 noopener 已通过其他方式满足(理论上不太可能到这里,因为上面会先报 missingNoopener)
                  return fixer.insertTextAfter(node.attributes[node.attributes.length -1] || node.name, ` rel="noreferrer"`);
                }
                return null;
              }
            });
          }
        }
      },
    };
  },
};

代码讲解 (require-noopener-noreferrer.js):

  • meta 对象:

    • type: "problem",因为这涉及到安全问题 (Tabnabbing)。
    • fixable: "code"
    • schema: 允许一个 allowReferrer 选项,如果为 true,则仅要求 noopener,不强制 noreferrer
    • messages: 针对不同情况的错误消息。
  • create(context) 函数:

    • getAttributeValue(attribute): 一个辅助函数,用于从 JSX 属性节点中提取字符串值。它处理了属性值为字符串字面量 (value="text") 或包含字符串字面量的 JSX 表达式 (value={"text"}) 的情况。

    • JSXOpeningElement(node) 访问者:

      • 首先检查当前 JSX 元素是否是 <a> 标签 (node.name.name === "a").

      • 遍历该元素的所有属性 (node.attributes)。

      • 查找 target 属性,如果其值为 _blank,则设置 targetBlank = true

      • 查找 rel 属性,解析其值,并检查是否包含 noopenernoreferrer

      • 报告逻辑:

        • 如果 targetBlank 为 true:

          • 如果 allowReferrerfalse 且既没有 noopener 也没有 noreferrer,则报告 missingRel
          • 否则,如果缺少 noopener,报告 missingNoopener
          • 否则,如果 allowReferrerfalse 且缺少 noreferrer,报告 missingNoreferrer
      • 修复逻辑 (fix(fixer)) :

        • 如果 rel 属性存在,则修改其值以包含缺失的 noopener 和/或 noreferrer
        • 如果 rel 属性不存在,则在 <a> 标签的最后一个属性后(或标签名后)插入新的 rel 属性。
        • 修复时需要注意保持现有 rel 值的其他部分(如果有)。
        • 修复逻辑会尝试处理 rel 属性值为简单字符串字面量的情况。对于更复杂的表达式(如 rel={getRelValue()}),简单的修复可能不适用,此时可以返回 null 不进行修复,或实现更复杂的修复逻辑。

5. 测试规则

测试 ESLint 规则通常使用 ESLint 内置的 RuleTester 工具。

首先,确保安装了 eslint

css 复制代码
npm install eslint --save-dev

然后为每个规则创建测试文件。

测试 no-specific-console.js

js 复制代码
// eslint-plugin-my-custom-rules/tests/lib/rules/no-specific-console.test.js
"use strict";

const rule = require("../../../lib/rules/no-specific-console");
const { RuleTester } = require("eslint");

const ruleTester = new RuleTester({
  // 必须设置 parserOptions,特别是 ecmaVersion,以便支持现代JS语法
  parserOptions: { ecmaVersion: 2021 }
});

ruleTester.run("no-specific-console", rule, {
  valid: [
    // 默认情况下,console.error 是允许的
    { code: "console.error('This is an error');" },
    { code: "console.info('This is an info');" }, // 假设 info 不在默认禁止列表
    // 使用选项允许 console.log
    {
      code: "console.log('Allowed log');",
      options: [{ disallowedMethods: ["warn", "debug"] }],
    },
    // 空选项对象,使用默认值
    {
      code: "console.error('Error allowed by default');",
      options: [{}],
    },
    // 其他对象的 .log 方法
    { code: "myObj.log('This is fine');" },
    { code: "something.console.log('This is also fine');" },
  ],

  invalid: [
    // 默认禁止 console.log
    {
      code: "console.log('This is a log');",
      errors: [
        {
          messageId: "unexpectedConsoleMethod",
          data: { methodName: "log" },
          type: "CallExpression", // 错误的 AST 节点类型
        },
      ],
    },
    // 默认禁止 console.warn
    {
      code: "console.warn('This is a warning');",
      errors: [
        {
          messageId: "unexpectedConsoleMethod",
          data: { methodName: "warn" },
          type: "CallExpression",
        },
      ],
    },
    // 使用选项禁止 console.error
    {
      code: "console.error('Forbidden error');",
      options: [{ disallowedMethods: ["error", "info"] }],
      errors: [
        {
          messageId: "unexpectedConsoleMethod",
          data: { methodName: "error" },
        },
      ],
    },
    // 使用选项禁止 console.debug,并测试多个调用
    {
      code: "console.debug('Debug message'); console.log('Log message');",
      options: [{ disallowedMethods: ["debug", "log"] }],
      errors: [
        {
          messageId: "unexpectedConsoleMethod",
          data: { methodName: "debug" },
          line: 1,
          column: 1 // 错误开始的列号
        },
        {
          messageId: "unexpectedConsoleMethod",
          data: { methodName: "log" },
          line: 1,
          column: 30 // 大约位置
        },
      ],
    },
  ],
});

代码讲解 (no-specific-console.test.js):

  • RuleTester: ESLint 提供的测试工具。

  • parserOptions: 传递给解析器的选项,ecmaVersion 很重要。

  • ruleTester.run(ruleName, ruleModule, testCases):

    • ruleName: 规则的名称。

    • ruleModule: 规则的实现。

    • testCases: 一个包含 validinvalid 数组的对象。

      • valid: 一个数组,包含不应报告错误的代码片段。每个元素可以是字符串或对象。

      • invalid: 一个数组,包含应报告错误的代码片段。每个元素必须是对象,并包含 codeerrors 属性。

        • code: 要测试的代码。

        • options: (可选) 传递给规则的选项。

        • errors: 一个数组,描述期望的错误。每个错误对象可以包含:

          • messagemessageId: 期望的错误消息或消息 ID。
          • data: (如果使用 messageId) 填充消息模板的数据。
          • type: 期望报告错误的 AST 节点类型。
          • line, column: 期望错误的位置。

测试 enforce-variable-prefix.js

js 复制代码
// eslint-plugin-my-custom-rules/tests/lib/rules/enforce-variable-prefix.test.js
"use strict";

const rule = require("../../../lib/rules/enforce-variable-prefix");
const { RuleTester } = require("eslint");

const ruleTester = new RuleTester({
  parserOptions: { ecmaVersion: 2021 }
});

ruleTester.run("enforce-variable-prefix", rule, {
  valid: [
    // 基本情况
    { code: "const cust_myVar = 10;", options: [{ prefix: "cust_" }] },
    { code: "let cust_anotherVar = 20;", options: [{ prefix: "cust_" }] },
    // 大小写敏感
    { code: "const PRE_value = 1;", options: [{ prefix: "PRE_", caseSensitive: true }] },
    // 不应用于 var
    { code: "var myVar = 30;", options: [{ prefix: "cust_" }] },
    // 配置不应用于 let 或 const
    { code: "let noPrefixLet = 1;", options: [{ prefix: "cust_", applyToLet: false }] },
    { code: "const noPrefixConst = 1;", options: [{ prefix: "cust_", applyToConst: false }] },
    // 前缀是完整单词的一部分
    { code: "const customValue = 1;", options: [{ prefix: "cust" }] }, // 'custom' starts with 'cust'
    { code: "const CustomValue = 1;", options: [{ prefix: "Cust", caseSensitive: true }] },
  ],
  invalid: [
    // 缺少前缀
    {
      code: "const myVar = 10;",
      options: [{ prefix: "cust_" }],
      errors: [{ messageId: "missingPrefix", data: { variableName: "myVar", prefix: "cust_" } }],
      output: "const cust_myVar = 10;", // 期望的修复后代码
    },
    {
      code: "let anotherVar = 20;",
      options: [{ prefix: "app_" }],
      errors: [{ messageId: "missingPrefix", data: { variableName: "anotherVar", prefix: "app_" } }],
      output: "let app_anotherVar = 20;",
    },
    // 大小写不匹配 (caseSensitive: true)
    {
      code: "const pre_value = 1;",
      options: [{ prefix: "PRE_", caseSensitive: true }],
      errors: [{ messageId: "missingPrefix", data: { variableName: "pre_value", prefix: "PRE_" } }],
      output: "const PRE_value = 1;",
    },
    // 大小写不匹配 (caseSensitive: false, 但实际大小写与配置不同)
    {
      code: "const CUST_value = 1;",
      options: [{ prefix: "cust_", caseSensitive: false }],
      errors: [{ messageId: "caseMismatch", data: { variableName: "CUST_value", actualPrefix: "CUST_", expectedPrefix: "cust_" } }],
      output: "const cust_value = 1;",
    },
    {
      code: "const CustValue = 1;", // 前缀是 Cust,要求是 cust_
      options: [{ prefix: "cust_", caseSensitive: false }],
      errors: [{ messageId: "missingPrefix", data: { variableName: "CustValue", prefix: "cust_" } }],
      output: "const cust_CustValue = 1;", // 修复:在前面添加
    },
     // 测试修复逻辑:当变量名已部分匹配(大小写不敏感)但大小写错误时,应替换而不是简单插入
    {
      code: "const PreValue = 1;",
      options: [{ prefix: "pre_", caseSensitive: false }], // 要求 pre_
      errors: [{ messageId: "caseMismatch", data: { variableName: "PreValue", actualPrefix: "Pre", expectedPrefix: "pre_" } }],
      output: "const pre_Value = 1;", // 替换 Pre 为 pre_
    },
    // 同时应用于 let 和 const
    {
      code: "let first = 1; const second = 2;",
      options: [{ prefix: "v_", applyToLet: true, applyToConst: true }],
      errors: [
        { messageId: "missingPrefix", data: { variableName: "first", prefix: "v_" }, line: 1, column: 5 },
        { messageId: "missingPrefix", data: { variableName: "second", prefix: "v_" }, line: 1, column: 22 },
      ],
      output: "let v_first = 1; const v_second = 2;",
    },
    // 仅应用于 const
    {
      code: "let first = 1; const second = 2;",
      options: [{ prefix: "c_", applyToConst: true, applyToLet: false }],
      errors: [
        { messageId: "missingPrefix", data: { variableName: "second", prefix: "c_" } },
      ],
      output: "let first = 1; const c_second = 2;",
    }
  ],
});

代码讲解 (enforce-variable-prefix.test.js):

  • output: 对于 invalid 测试用例,如果规则是 fixable,你可以提供 output 属性。RuleTester 会将 code 应用修复,并与 output 进行比较,以验证修复的正确性。

测试 require-noopener-noreferrer.js

js 复制代码
// eslint-plugin-my-custom-rules/tests/lib/rules/require-noopener-noreferrer.test.js
"use strict";

const rule = require("../../../lib/rules/require-noopener-noreferrer");
const { RuleTester } = require("eslint");

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: "module", // 支持 import/export
    ecmaFeatures: {
      jsx: true, // 必须启用 JSX 解析
    },
  },
});

ruleTester.run("require-noopener-noreferrer", rule, {
  valid: [
    // 没有 target="_blank"
    { code: 'const el = <a href="https://example.com">Link</a>;' },
    // 有 target="_blank" 和完整的 rel
    { code: 'const el = <a href="https://example.com" target="_blank" rel="noopener noreferrer">Link</a>;' },
    { code: 'const el = <a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a>;' }, // 顺序无关
    { code: 'const el = <a href="https://example.com" target={"_blank"} rel={"noopener noreferrer"}>Link</a>;' }, // JSX 表达式
    // allowReferrer: true, 只需要 noopener
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="noopener">Link</a>;',
      options: [{ allowReferrer: true }],
    },
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="noopener foo">Link</a>;', // 包含其他值
      options: [{ allowReferrer: true }],
    },
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="noopener noreferrer foo">Link</a>;',
      options: [{ allowReferrer: true }], // 有 noreferrer 也没问题
    },
    // 非 a 标签
    { code: '<div target="_blank"></div>' }
  ],
  invalid: [
    // 缺少 rel 属性
    {
      code: 'const el = <a href="https://example.com" target="_blank">Link</a>;',
      errors: [{ messageId: "missingRel" }],
      output: 'const el = <a href="https://example.com" target="_blank" rel="noopener noreferrer">Link</a>;',
    },
    // rel 属性存在但为空
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="">Link</a>;',
      errors: [{ messageId: "missingRel" }], // 或者可以细化为 missingNoopener 和 missingNoreferrer
      output: 'const el = <a href="https://example.com" target="_blank" rel="noopener noreferrer">Link</a>;',
    },
    // 缺少 noopener
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="noreferrer">Link</a>;',
      errors: [{ messageId: "missingNoopener" }],
      output: 'const el = <a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a>;',
    },
    // 缺少 noreferrer (默认情况下)
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="noopener">Link</a>;',
      errors: [{ messageId: "missingNoreferrer" }],
      output: 'const el = <a href="https://example.com" target="_blank" rel="noopener noreferrer">Link</a>;',
    },
    // allowReferrer: true, 但缺少 noopener
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="noreferrer">Link</a>;',
      options: [{ allowReferrer: true }],
      errors: [{ messageId: "missingNoopener" }],
      output: 'const el = <a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a>;',
    },
    // 属性值为 JSX 表达式
    {
      code: 'const el = <a href="https://example.com" target={"_blank"} rel={"noopener"}>Link</a>;',
      errors: [{ messageId: "missingNoreferrer" }],
      output: 'const el = <a href="https://example.com" target={"_blank"} rel={"noopener noreferrer"}>Link</a>;',
    },
    {
      code: 'const el = <a href="https://example.com" target={"_blank"}>Link</a>;',
      errors: [{ messageId: "missingRel" }],
      output: 'const el = <a href="https://example.com" target={"_blank"} rel="noopener noreferrer">Link</a>;',
    },
    // 修复:在现有 rel 中添加
    {
      code: 'const el = <a href="https://example.com" target="_blank" rel="nofollow">Link</a>;',
      errors: [{ messageId: "missingRel" }], // 假设 nofollow 不满足 noopener 或 noreferrer
      output: 'const el = <a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">Link</a>;',
    }
  ],
});
```**代码讲解 (`require-noopener-noreferrer.test.js`):**
*   `parserOptions.ecmaFeatures.jsx: true`: **非常重要**,因为此规则处理 JSX,测试器需要启用 JSX 解析。
*   测试用例覆盖了各种情况:缺少 `rel`,`rel` 中缺少 `noopener` 或 `noreferrer`,以及 `allowReferrer` 选项的影响。
*   `output` 属性展示了自动修复如何添加或修改 `rel` 属性。

## 6. 使用插件

要在项目中使用你本地开发的插件:

1.  **链接插件 (推荐开发时使用)**:
    在你的插件目录 (`eslint-plugin-my-custom-rules/`) 下运行:
    ```bash
    npm link
    ```
    然后在你的目标项目(想要使用此插件的项目)中运行:
    ```bash
    npm link eslint-plugin-my-custom-rules
    ```
    这会在目标项目的 `node_modules` 中创建一个指向你的插件目录的符号链接。

2.  **本地安装**:
    如果插件和目标项目在同一个文件系统下,你可以在目标项目中通过相对路径安装:
    ```bash
    npm install ../path/to/eslint-plugin-my-custom-rules
    # 或者
    # yarn add ../path/to/eslint-plugin-my-custom-rules
    ```

3.  **发布到 npm (生产使用)**:
    当你准备好后,可以将插件发布到 npm,然后像其他 ESLint 插件一样安装它。

**配置 ESLint (`.eslintrc.js` 或 `.eslintrc.json` 等) 来使用插件:**

假设你的插件名为 `eslint-plugin-my-custom-rules` (npm 包名)。ESLint 会自动去掉 `eslint-plugin-` 前缀来得到插件的短名称 `my-custom-rules`。

```javascript
// .eslintrc.js in your target project
module.exports = {
  // ... 其他配置 ...
  plugins: [
    "my-custom-rules", // 插件名 (去掉 eslint-plugin- 前缀)
  ],
  rules: {
    // 启用和配置插件中的规则
    "my-custom-rules/no-specific-console": ["warn", { "disallowedMethods": ["log", "info"] }],
    "my-custom-rules/enforce-variable-prefix": ["error", { "prefix": "proj_", "caseSensitive": true }],
    "my-custom-rules/require-noopener-noreferrer": "error",

    // 你也可以使用插件中定义的配置
    // "extends": [
    //   "plugin:my-custom-rules/recommended"
    // ],
    // 如果使用了 extends,就不需要再在 plugins 数组中列出,也不需要单独配置 recommended 中的规则
    // 除非你想覆盖 recommended 中的某些设置
  },
  // 如果你的规则需要特定的解析器选项(例如 JSX),确保在这里也配置了
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: "module",
    ecmaFeatures: {
      jsx: true,
    },
  },
  // ... 其他配置 ...
};

如果你在插件的 index.js 中定义了 configs (如 recommended),你可以这样使用:

js 复制代码
// .eslintrc.js
module.exports = {
  extends: [
    "eslint:recommended", // ESLint 官方推荐
    "plugin:my-custom-rules/recommended", // 使用你的插件的推荐配置
  ],
  // 如果需要,可以在这里覆盖或添加更多规则
  rules: {
    "my-custom-rules/enforce-variable-prefix": ["error", { "prefix": "global_", "caseSensitive": false }], // 覆盖推荐配置中的选项
  },
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: "module",
    ecmaFeatures: {
      jsx: true,
    },
  },
};

7. 运行测试

在插件的 package.json 中添加一个测试脚本:

json 复制代码
// eslint-plugin-my-custom-rules/package.json
{
  // ...
  "scripts": {
    "test": "node node_modules/eslint/bin/eslint.js . && mocha tests/lib/rules/**/*.test.js", // 先 lint 插件本身,然后运行测试
    "lint": "node node_modules/eslint/bin/eslint.js ."
  },
  // ...
}

你需要安装 mocha (或你选择的测试运行器) 作为开发依赖:

bash 复制代码
npm install mocha --save-dev

然后运行测试:

bash 复制代码
npm test

为了让 eslint . 能正确 lint 插件代码本身,你可能需要在插件的根目录也创建一个 .eslintrc.js 文件,例如:

js 复制代码
// eslint-plugin-my-custom-rules/.eslintrc.js
module.exports = {
  env: {
    es6: true,
    node: true,
    mocha: true, // 如果测试文件也想被 lint
  },
  extends: "eslint:recommended",
  parserOptions: {
    ecmaVersion: 2020, // 根据你的代码调整
  },
  rules: {
    // 根据需要调整
    "strict": ["error", "global"], // 因为我们的规则文件用了 "use strict";
  },
  // 如果你的插件代码本身也用了你自己的插件规则(元编程!),可以这样配置
  // plugins: ["my-custom-rules"],
  // rules: {
  //   "my-custom-rules/some-rule-for-plugin-dev": "error"
  // }
};

总结

已经创建了一个包含三个自定义规则的 ESLint 插件:

  1. no-specific-console: 禁止特定的 console 方法,可配置。
  2. enforce-variable-prefix: 强制变量名前缀,可配置,可自动修复。
  3. require-noopener-noreferrer: 要求 target="_blank"<a> 标签包含 rel="noopener noreferrer",可配置,可自动修复,针对 JSX。

每个规则都有其 meta 信息、create 函数(包含 AST 访问逻辑)和相应的测试用例 (RuleTester)。插件本身也有一个主入口文件 index.js 导出规则和可选的配置。

相关推荐
Mr_Mao2 小时前
Naive Ultra:中后台 Naive UI 增强组件库
前端
前端小趴菜054 小时前
React-React.memo-props比较机制
前端·javascript·react.js
摸鱼仙人~5 小时前
styled-components:现代React样式解决方案
前端·react.js·前端框架
sasaraku.5 小时前
serviceWorker缓存资源
前端
RadiumAg6 小时前
记一道有趣的面试题
前端·javascript
yangzhi_emo6 小时前
ES6笔记2
开发语言·前端·javascript
yanlele7 小时前
我用爬虫抓取了 25 年 5 月掘金热门面试文章
前端·javascript·面试
中微子8 小时前
React状态管理最佳实践
前端
烛阴8 小时前
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
前端·javascript
中微子8 小时前
JavaScript 事件与 React 合成事件完全指南:从入门到精通
前端