如何编写自定义 ESLint 规则
背景
在项目中,我们有时会重写一些外部模块,并且希望后续在项目中的统一使用重写后的模块。以笔者负责的一个项目为例,该项目重写了 react-router 的 Link 组件:
// 之前
import { Link, useModel } from 'umi';// 重写后
import { useModel } from 'umi';
import { Link } from '@/utils/router';
如果可以使用 ESLint 检验并自动替换某个模块的导入路径,那么就可以统一这个模块的导入路径了。由于现在的 ESLint 目前并没有提供类似的规则,因此我们需要开发一个自定义规则来解决该问题。
ESLint 规则定义
ESLint 规则支持配置,根据我们的需要定义如下的 ESLint 规则。
{
"rules": {
"import-path-plus/no-internal-modules": [
"warn",
{
"target": "umi",
"replace": "@/utils/router",
"names": ["Link", "useHistory"]
}
]
}
}我们希望通过 target 导入的 names 可以自动替换为通过 replace 导入。
实现
ESLint 的工作原理
ESLint 会首先读取配置,使用配置中的 parser (默认为 espree)把代码解析为 AST 并进行遍历,然后在遍历到「不同的节点」或者「特定的时机」的时候,触发相应的处理函数。
借助 AST Explorer[1] 查看一段代码的 AST:
我们需要处理的节点类型为 ImportDeclaration,具体来说就是检查该节点的 source。如果 source 在我们规则参数配置的 target 中,则进一步检查该节点的下的 ImportSpecifier 节点,如果对应规则的 names 包含该节点,则上报错误。
创建项目
为了方便开发者实现自定义规则,ESLint 提供了 generator-eslint[2] 来快速创建项目。先安装该命令行工具,然后执行 yo eslint:rule 新建项目。在创建的项目中我们重点关注 /lib/rules 目录下对应的规则文件:
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
}, create(context) {
return {
ImportDeclaration: (node) => {},
};
},
};
规则代码主要分为 meta 对象和 create 方法两个部分。
meta 对象主要是规则的相关信息,包括文档描述、参数的 schema 等。
create 函数返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree[3] 定义的 AST) 时,用来访问节点的方法。
create函数返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree 定义的 AST) 时,用来访问节点的方法。
• 如果一个 key 是个节点类型或 selector[4],在 向下 遍历树时,ESLint 调用 visitor 函数
• 如果一个 key 是个节点类型或 selector,并带有
:exit,在向上遍历树时,ESLint 调用 visitor 函数• 如果一个 key 是个事件名字,ESLint 为代码路径分析[5]调用 handler 函数
一个规则可以使用当前节点和它周围的树,报告或修复问题。
create 函数有一个 context 参数,context 对象有一些属性和方法可以使用,我们这里用到的主要有以下几个:
•
context.options: ESLint 规则配置传入的参数•
context.report: 检测到错误之后报告的方法•
context.getSourceCode(): 返回一个SourceCode对象,可以使用该对象处理传递给 ESLint 的源代码
具体实现
在匹配到 ImportDeclaration 节点后,需要遍历我们配置的规则。如果节点的 source 可以匹配规则的 target,则遍历节点的 specifiers,当配置的 names 包含该 specifiers 时,即上报错误:
const visitor = {
ImportDeclaration: (node) => {
context.options.forEach((option) => {
const regexp = minimatch.makeRe(option.target); if (!regexp.test(node.source.value)) {
return;
}
node.specifiers.forEach((spec, index) => {
if (spec.type !== 'ImportSpecifier') {
return;
}
if (option.names.includes(spec.imported.name)) {
reportModule(spec, node, option);
}
});
});
},
};
context.report 上报错误时,通过 fix 参数来修复错误,我们这里需要先删除匹配到的 name,然后再新的一行插入替换路径后的导入代码。
插入代码直接调用 fixer.insertTextAfter 就可以。但在移除相关的导入时,如果直接使用 fixer.remove 会保留 name 后面的逗号,所以可以通过 sourceCode.getTokenAfter 拿到逗号的 range,再使用 fixer.removeRange 来删除。
const sourceCode = context.getSourceCode();const reportModule = (spec, node, option) => {
const moduleName =
spec.imported.name == spec.local.name
? spec.imported.name
: `${spec.imported.name} as ${spec.local.name}`;
const afterToken = sourceCode.getTokenAfter(spec);
context.report({
node: spec.imported,
messageId: 'replace-module',
fix: function (fixer) {
return [
fixer.removeRange([
spec.range[0],
afterToken.value === ',' ? afterToken.range[1] : spec.range[1],
]),
fixer.insertTextAfter(
node,
`\nimport { ${moduleName} } from '${option.replace}';`,
),
];
},
});
};
单元测试
代码写完之后我们需要进行测试,测试可以使用 ESLint 提供的单元测试工具。测试分为 valid 和 invalid 两部分,valid 是符合规则的代码,invalid 是不符合规则的代码,在 invalid 中写入所有我们需要处理的情况:
const options = [
{
target: 'umi',
replace: '@/utils/router',
names: ['Link', 'useHistory'],
},
];ruleTester.run('no-internal-modules', rule, {
valid: [
{ code: "import { useRequest } from 'umi'", options },
{ code: "import { useRequest, useModel } from 'umi'", options },
],
invalid: [
{
code: "import { Link, useModel } from 'umi';",
errors: [
{
messageId: 'replace-module',
data: { name: 'Link', replace: '@/utils/router' },
},
],
options,
output:
"import { useModel } from 'umi';\nimport { Link } from '@/utils/router';",
},
],
});
在测试通过之后,将该项目发布到 npm,就可以提供给其他开发者使用了。完整的代码[6]
总结
本文从实际需求出发,简单介绍了 ESLint 的基本原理,并编写 ESLint 规则来解决开发过程中的问题。大家可以结合自己的实践,发掘更多 ESLint 的用法,让它帮助我们写出更健壮的代码。
引用链接
[1] AST Explorer: https://astexplorer.net/[2] generator-eslint: https://www.npmjs.com/package/generator-eslint[3] ESTree: https://github.com/estree/estree[4] selector: https://cn.eslint.org/docs/developer-guide/selectors[5] 代码路径分析: https://cn.eslint.org/docs/developer-guide/code-path-analysis[6] 代码: https://github.com/HungryFeng/eslint-plugin-import-path-plus