创建一个自定义的 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.property
或object['property']
。JSXOpeningElement
,JSXAttribute
,JSXIdentifier
: JSX 相关节点 (如果你的项目使用 React/JSX)。
4. 创建自定义规则
每个规则都是一个 JavaScript 模块,导出一个包含 meta
和 create
方法的对象。
规则 1: no-specific-console.js
目标 :禁止使用 console.log
和 console.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
)。 - 然后检查这个
MemberExpression
的object
是否是名为console
的Identifier
。 - 再检查
property
(方法名,如log
,warn
) 是否在disallowedMethods
集合中。 - 如果都匹配,就调用
context.report()
报告问题。
- 当 ESLint 遇到一个函数调用表达式 (如
-
规则 2: enforce-variable-prefix.js
目标 :强制使用 const
或 let
声明的变量必须以特定前缀开头(例如 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
: 布尔值,控制规则是否应用于let
和const
。
-
messages
: 定义了两种错误消息。
-
-
create(context)
函数:-
获取并校验选项。如果必需的
prefix
未提供,则规则不执行。 -
checkVariableDeclarator(node)
函数:-
此函数作为
VariableDeclarator
节点的访问者。当 ESLint 遇到如const myVar = ...
中的myVar = ...
部分时,会调用它。 -
node.id.name
: 获取变量名。 -
node.parent.kind
: 获取声明类型 (const
,let
,var
)。 -
根据选项过滤掉不适用的声明类型。
-
比较变量名的开头部分与
requiredPrefix
。 -
大小写处理:
- 如果
caseSensitive
为true
,则进行严格比较。 - 如果
caseSensitive
为false
,则先进行不区分大小写的比较以判断前缀是否存在,如果存在但大小写不匹配,则单独报告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
属性,解析其值,并检查是否包含noopener
和noreferrer
。 -
报告逻辑:
-
如果
targetBlank
为 true:- 如果
allowReferrer
为false
且既没有noopener
也没有noreferrer
,则报告missingRel
。 - 否则,如果缺少
noopener
,报告missingNoopener
。 - 否则,如果
allowReferrer
为false
且缺少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
: 一个包含valid
和invalid
数组的对象。-
valid
: 一个数组,包含不应报告错误的代码片段。每个元素可以是字符串或对象。 -
invalid
: 一个数组,包含应报告错误的代码片段。每个元素必须是对象,并包含code
和errors
属性。-
code
: 要测试的代码。 -
options
: (可选) 传递给规则的选项。 -
errors
: 一个数组,描述期望的错误。每个错误对象可以包含:message
或messageId
: 期望的错误消息或消息 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 插件:
no-specific-console
: 禁止特定的console
方法,可配置。enforce-variable-prefix
: 强制变量名前缀,可配置,可自动修复。require-noopener-noreferrer
: 要求target="_blank"
的<a>
标签包含rel="noopener noreferrer"
,可配置,可自动修复,针对 JSX。
每个规则都有其 meta
信息、create
函数(包含 AST 访问逻辑)和相应的测试用例 (RuleTester
)。插件本身也有一个主入口文件 index.js
导出规则和可选的配置。