废话不多说,开搞。
创建一个eslint插件库
需要创建一个npm项目,建议命名为eslint-plugin-*
或者@yourScope/eslint-plugin[-*]
。
在库的导出文件中,导出一个标准的插件对象:
// lib/index.js
module.exports = {
rules: {
// key为规则id, 值为规则对象,一般放在单独的文件中导出,这里下文的两个规则为例
'no-use-foo-in-var-name': require('./rules/no-use-foo-in-var-name'),
},
configs: {
// 提供给使用者使用该插件时,对插件内的规则的默认使用
recommended: {
rules: {
'no-use-foo-in-var-name': 'error',
'no-use-string-substr': 'error',
},
},
},
};
接下来就是实现no-use-foo-in-var-name
规则。
创建规则
这里以创建一个简单的规则(no-use-foo-in-var-name
)为例,假定一个需求,禁止代码中命名为foo
这个单词。
首先对需求进行分析,禁止命名为foo
,那么应该需要禁止下面的情况中使用foo
标识符(也就是规则文档中不推荐部分):
- 参数定义时;
- 导入模块时通过
as
重新定义; - 解构时重新赋值定义;
- 参数名定义;
- 对象字面量中有
foo
成员变量。
对于下面这些情况,由于存在合理场景,且将不合理场景分析出来成本很高,所以需要忽略(也就是规则文档中维护的下面的代码不会警告部分):
- 导入第三方库直接有的
foo
成员; - 已有对象中的
foo
成员操作,如obj.foo = 'xxxx'
; - 对已有的对象中解构中包含
foo
,如const { foo } = object
;
由于本章节只是用于示例,所以只对上文中禁止的情况中,挑出两个进行实现: 参数定义时禁止使用foo
变量名和对象字面量中有foo
成员变量。
首先创建一个规则对象:
// lib/rules/no-use-foo-in-var-name.js
module.exports = {
// 规则元数据,具体阅读官网
meta: {
type: 'problem',
// 规则配置参数的验证器
schema: [],
// 文档信息
docs: {
description: '规则的描述',
url: '规则文档地址',
},
},
create(context) {
// 规则实际逻辑
},
};
然后在create
函数中实现访问器, 首先实现对参数定义的ast节点的访问器:
// lib/rules/no-use-foo-in-var-name.js
module.exports = {
// ...
create(context) {
return {
// ...
VariableDeclarator(node) {
const { id } = node;
if (!id || id.type !== 'Identifier') {
return;
}
const { name } = id;
if (name === 'foo') {
context.report({
node: id,
message: '请勿使用foo来命名参数',
});
}
},
};
},
};
接着对对象字面量中使用foo
进行解析:
// lib/rules/no-use-foo-in-var-name.js
module.exports = {
// ...
create(context) {
return {
// ...
ObjectExpression(node) {
const { properties } = node;
if (!properties && properties.length === 0) {
return;
}
properties.forEach((property) => {
if (property.type !== 'Property') {
return;
}
const { key } = property;
if (!key) {
return;
}
if ((key.type === 'Identifier' && key.name === 'foo')
|| (key.type === 'Literal' && key.value === 'foo')
) {
context.report({
node: key,
message: '请勿使用foo来命名对象属性',
});
}
});
},
};
},
};
注意,由于js是一门动态语言,而lint是基于代码的静态分析,所以对于一些比较动态化的操作,虽然实际效果是不符合规范,但校验时判断的成本比较大,这种情况一般都是放弃检验。如上面的代码中,通过
const obj = { ['fo' + '0']: 123 }
就可以绕过检查。
整个规则就已经实现。整体代码为:
// lib/rules/no-use-foo-in-var-name.js
module.exports = {
// 规则元数据,具体阅读官网
meta: {
type: 'problem',
// 规则配置参数的验证器
schema: [],
// 文档信息
docs: {
description: '',
url: '',
},
},
create(context) {
// 规则实际逻辑
return {
ObjectExpression(node) {
const { properties } = node;
if (!properties && properties.length === 0) {
return;
}
properties.forEach((property) => {
if (property.type !== 'Property') {
return;
}
const { key } = property;
if (!key) {
return;
}
if ((key.type === 'Identifier' && key.name === 'foo')
|| (key.type === 'Literal' && key.value === 'foo')
) {
context.report({
node: key,
message: '请勿使用foo来命名对象属性',
});
}
});
},
VariableDeclarator(node) {
const { id } = node;
if (!id || id.type !== 'Identifier') {
return;
}
const { name } = id;
if (name === 'foo') {
context.report({
node: id,
message: '请勿使用foo来命名参数',
});
}
},
};
},
};
为了保证规则的稳定性,还需要添加测试案例:
// lib/__tests__/no-use-foo-in-var-name.spec.js
// 规则对象
const rule = require("../rules/no-use-foo-in-var-name"),
const { RuleTester } = require("eslint");
const ruleTester = new RuleTester();
ruleTester.run("no-use-foo-in-var-name", rule, {
valid: [
{
code: "var bar = true",
},
{
code: "const obj = { bar: 123 }",
},
],
invalid: [
{
code: "var doo = true",
errors: [{ message: "请勿使用foo来命名参数" }]
},
{
code: "const doo = '123'",
errors: [{ message: "请勿使用foo来命名参数" }]
},
{
code: "const obj = { foo: 123 }",
errors: [{ message: "请勿使用foo来命名对象属性" }]
},
{
code: `func({ foo: 123 })`,
errors: [{ message: "请勿使用foo来命名对象属性" }]
},
]
});
关于测试对象更多的用法参考:RuleTester
开发插件和规则的最佳实践
本章节主要是在开发一些规则和阅读一些已有规则后,自己总结的一些经验和理念。
规则所包含的内容尽可能小
一个规则的检查范围和内容尽可能小,面向的需求场景也尽可能小。这样更加容易灵活搭配。
防君子不防小人
由于js是一门脚本语言,动态语法,具备元编程能力。而lint是基于静态语法分析,无法触及到运行时。所以对于规范场景,应遵循防君子不妨小人。
边界之外不要报告而是忽略
在实现检验逻辑时,应该是在某个边界之内进行规范检验,处于边界之外时,应该忽略。如检查声明时不允许定义声明某个变量名是,在这个声明没有变量,或者声明语句并不是常规模式定义一个变量时,也就是不符合的场景时,应该是忽略,而不是报错。如示例中的代码:
const { id } = node;
if (!id || id.type !== 'Identifier') {
return;
}
如果声明语句的id
不存在,或者id
的类型不是标识符时,那么应该立即终止流程,而不是去提示不符的规范。那么实际代码:
const { a } = object;
就不会去检验(没有错误提示)。
不要理所当然的认为一些ast节点必须有某些属性,因为可能随着es6的不断发展,或者在其他新的js方言中,不可能的场景就会真实存在。
一个规则包含的内容
建议每个规则,都应该有具体实现逻辑 + 文档 + 测试案例。
多个规则共享配置
多个规则都需要的一些配置,不建议要求使用每个规则都去配置,而是通过通过setting
机制。
单元测试
单元测试可以保证规则的稳定性。
-
RuleTester
在对象在创建时,可以配置一些选项,如指定语法解析器等. -
ruleTester
实例在执行ruleTester.run(ruleId, ruleObject, options)
的options
中可以在配置一些内容,比如配置setting
内容。 -
ruleTester.run
函数访问的结果就是直接能在mocha中能执行的内容,不需要在包裹在describe
和it
函数中。 - 由于jest是兼容mocha的写法,所以生成的测试内容也可以直接运行在jest中。
- 通过
ruleTester.run(ruleId, ruleObject, options)
中options
中的only
属性,可以只启动当前测试组中某一个代码验证。没有找到多个测试组如何只运行一个测试组的方式。
访问器
规则对象的create
函数需要返回一个节点的访问器,然后在eslint解析源码得到ast节点后,遍历这些节点,依次调用访问器执行,完成整个验证逻辑。跟babel类似的架构。
- 利用astexplorer辅助开发,事半功倍。
- eslint的规则对象是返回一个访问器工厂。并且这个工厂会在处理不同的文件时,重新被调用(不同于babel插件的机制)。所以可以在工作函数的变量作用域中声明共享变量,在多个节点访问器中通信;
- 不同于babel, 访问器除了能指定进入还是进出这样的时间节点外,还可以使用选择器和代码路径;
- 没有多节点的节点访问器声明方式,建议使用代码能力实现。
- 每个访问器内容,都应该做好边界处理,并且尽量异常边界提前结束,使用
if...return...
方式,提高代码可读性。 - 报告时,尽量精确到有问题的ast节点,提高使用者体验。报告信息尽量准确,根据实际内容变化,而不是统一都是一个提示信息。
- eslint的ast节点跟babel的ast节点的类型存在不同,如文本字符串字面量,在eslint中是
Literal
,babel中是StringLiteral
。 - eslint中单个节点访问器只提供
node
对象。这是由于相对于babel,eslint不需要进行对节点的修改操作(fix时的fixer对象有提供)。eslint也没有提供节点的子元素的遍历工具,需要手动自己遍历。 - eslint的ast节点也有跟babel节点一样有
parent
属性获取父节点。 - 尽量不要在一个大节点中手动遍历子节点判断,应直接编写子节点的访问器,然后通过
parent
对父节点进行判断,提高性能。 - 通过
context.getDeclaredVariables
可以快速获取一些语句的声明变量相关,如示例中获取声明语句的变量时。但这样写,往往无法在报告时定位到准确的点,只能提示定位在整个语句。
源码操作
通过context.getSourceCode
函数可以获取到当前的源码对象。可以用于获取源码,单个代码token,注释等。
- 建议在
create
函数中调用获取SourceCode
对象,然后每个节点访问器中使用。 - 常用的函数:
getText(node)
获取指定节点的源码,getAllComments
获取所有的注释,getCommentsBefore
,getCommentsAfter
,getCommentsInside
获取指定节点的相关注释信息。
作用域链的使用
通过context.getScope
可以获取到eslint遍历器遍历当前节点时所在的作用域。注意它不能找指定节点的作用域链,只能拿到当前遍历节点的作用域。即如果使用了手动遍历子节点,那无法获取某个子节点的作用域。
- 对于针对某些模块的使用规范时,建议尽量走作用域链的方式去实现。不建议通过先分析导入语句,获取到变量名,然后对所有的标识符或者罗列可能使用标识符的节点类型中去判断变量名是否一致的方式。不通过作用域的方式需要考虑的场景过多,且可能需要通过枚举的的方式去处理,无法保证百分百处理正确。
- 通过一个标识符找到其定义的流程: 首先使用
context.getScope()
访问当前访问节点的作用域对象,该对象中的set
属性是当前作用域中包含的所有变量。通过scopeSet.get(id)
,就可以获取到当前标识符对应的变量对象。该对象的defs
可以获取到变量的定义列表。 - 变量对象中的
references
可以获取到这个变量的引用列表。 -
references
引用列表是包含所有的变量,所以在做一些操作时,如果起始节点肯定没有问题时,还需要剔除起始节点,可以采用节点的坐标信息进行判断是否是同一个节点。 - 获取到的变量定义时,可能是重新赋值了另外一个变量,可以通过递归,直至找到最开始的那个定义值。
类型推断
在typescipt语法中,利用typescipt对变量的类型进行类型推断,可以更加精确的进行代码检验。
- typescipt针对elsint转解析器,会提供额外的类型服务,通过
context.parserServices
暴露,主要有program
,esTreeNodeToTSNodeMap
属性。其中program
提供一些ts能力,esTreeNodeToTSNodeMap
提供把esTreeNode
转换成tsNode
的能力。 -
program
为typescipt中创建的对象,具体内容可以查看typescipt中的源码。 -
program
常用的函数有getTypeChecker
,可以获取一个类型检查器。 -
TypeChecker
的具体内容可以查看typescipt中的源码。 -
TypeChecker
的常用函数getTypeAtLocation
可以获取当前节点的类型信息。 - 默认情况下,
@typescript-eslint/parser
中parserServices
提供类型数据,但都是一些基本数据类型和当前文件声明的类型。像当前环境中的类型(es6内置对象,bom, dom, node环境等),第三方库的*.d.ts
声明以及项目的*.d.ts
的类型都不会被包含,就连string[]
等所有数组类型都推断不出来(只能判断出来是一个对象类型)。如果要像typescipt代码开发那样,支持使用丰富的环境类型推断,需要在parserOptions
中配置project
指向一个tsconfig.json
文件,通过这个文件,得知当前所有的类型数据。 - 在配置
project
时,注意所有相关路径正确,不正确时,会导致生成program
失败。 - 在配置
project
后,进行单元测试时,会导致生成program
失败。debug源码后发现,对于单元测试设置的filename
地址,需要在实际物理路径上也存在这个文件(可以为空)。如果是使用@typescript-eslint/utils
中的RuleTester
, 默认的filename
为file.ts
。