一文搞定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 导出规则和可选的配置。

相关推荐
小小小小宇11 分钟前
业务项目中使用自定义eslint插件
前端
babicu12315 分钟前
CSS Day07
java·前端·css
小小小小宇19 分钟前
业务项目使用自定义babel插件
前端
前端码虫40 分钟前
JS分支和循环
开发语言·前端·javascript
GISer_Jing42 分钟前
MonitorSDK_性能监控(从Web Vital性能指标、PerformanceObserver API和具体代码实现)
开发语言·前端·javascript
余厌厌厌43 分钟前
墨香阁小说阅读前端项目
前端
fanged44 分钟前
Angularjs-Hello
前端·javascript·angular.js
lichuangcsdn1 小时前
springboot集成websocket给前端推送消息
前端·websocket·网络协议
程序员阿龙1 小时前
基于Web的濒危野生动物保护信息管理系统设计(源码+定制+开发)濒危野生动物监测与保护平台开发 面向公众参与的野生动物保护与预警信息系统
前端·数据可视化·野生动物保护·濒危物种·态环境监测·web系统开发
岸边的风1 小时前
JavaScript篇:JS事件冒泡:别让点击事件‘传染’!
开发语言·前端·javascript