相关背景:
这篇文章与上篇《从0到1落地前端代码检测工具》文章本来为完整的一整篇文章,但是由于篇幅过长且本身耦合关系不大,故拆分成两篇:上篇主要是 ESLint 相关配置探索集成插件并落地项目的过程,偏向于配置化;下篇主要是 ESLint 自定义规则中针对 _.get() 第三参数做的处理,偏向源码原理性。
问题现状:
项目中使用 lodash.js 中的 _.get() 方法的时候,经常会出现一些比较奇怪的问题(bug),比如获取某个属性,在一个未定义的变量上利用 _.get() 如下获取值会导致值为 null
:
var obj = { name: null }
var name = _.get(obj, 'name', 'zly')
a 的值: null
复制代码
这样的写法并没有起到数据保护的作用,在后台返回位置数据的时候很容易抛错,这是因为 _.get() 方法的第三个参数失效
了,所以只能后续通过改变写法来规避这种问题:
var obj = { name: null }
var name = _.get(obj, 'name') || 'zly'
a 的值: 'zly'
复制代码
一. AST
1.1 AST 的概念:
ast 即 abstract syntax code,是源代码的抽象语法结构的树状表示
,树上的每个节点都表示源代码中的一种结构,所谓抽象就是表示把 js 代码进行了结构化的转化 —— 转化为一种数据结构。这种数据结构其实就是一个大的 json 对象,json 我们都比较熟悉,它就像一颗枝繁叶茂的树:有树根,有树干,有树枝,有树叶。无论多小多大,都是一棵完整的树。
前端 js 从编译到运行的过程:
词法分析(词法单元) -> 语法分析 -> AST
1.2 AST 的结构
在线astexplorer: blogz.gitee.io/ast/ (选择espree)
var name = _.get(obj, 'name', 'zly')
生成的AST:
{
"type": "Program",
"start": 0,
"end": 36,
"range": [
0,
36
],
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 36,
"range": [
0,
36
],
"declarations": [
{
"type": "VariableDeclarator", //声明
"start": 4,
"end": 36,
"range": [
4,
36
],
"id": {
"type": "Identifier",
"start": 4,
"end": 8,
"range": [
4,
8
],
"name": "name"
},
"init": {
"type": "CallExpression", //函数调用
"start": 11,
"end": 36,
"range": [
11,
36
],
"callee": {
"type": "MemberExpression", //函数调用的成员表达式(解析) == _.get
"start": 11,
"end": 16,
"range": [
11,
16
],
"object": {
"type": "Identifier", //标识符
"start": 11,
"end": 12,
"range": [
11,
12
],
"name": "_"
},
"property": {
"type": "Identifier", //标识符
"start": 13,
"end": 16,
"range": [
13,
16
],
"name": "get"
},
"computed": false
},
"arguments": [ //参数
{
"type": "Identifier", //标识符
"start": 17,
"end": 20,
"range": [
17,
20
],
"name": "obj"
},
{
"type": "Literal", //文本
"start": 22,
"end": 28,
"range": [
22,
28
],
"value": "name",
"raw": "'name'"
},
{
"type": "Literal",
"start": 30,
"end": 35,
"range": [
30,
35
],
"value": "zly",
"raw": "'zly'"
}
]
}
}
],
"kind": "var" //关键字
}
],
"sourceType": "module"
}
复制代码
简化的树形结构如下:
1.3 AST编译过程
二. ESLint 运行规则的流程图
eslint 每条规则针对的其实都是一个 node 模块,当用户配置好对应的规则后,eslint 就加载对应的规则、执行对应的模块,再根据用户提供的参数进行检查(比如看是否需要自动fix)
根据思路,梳理eslint大体的流程图如下:
三. 自定义规则
自定义规则文档:eslint.bootcss.com/docs/develo…
3.1 调试本地的规则文件
使用 --rulesdir
配置参数可以配置本地需要调试的规则文件:
"script": {
"lint": "eslint --rulesdir ./scripts/lodash/rules"
}
复制代码
3.2 规则文件的结构
module.exports = {
meta: {
type: "xxx",
docs: {
// 提示相关的文档信息
},
fixable: "code", // 是否可修复
messages: { // 在单元测试可以使用
unexpectedThirParameter:'Lodash.get() 第三个参数不建议使用',
unexpectedParameterLength: 'Lodash.get() 参数数量错误',
}
},
create: function(context) { // 分析时,会调用这个函数
return {
CallExpression(node) { // 函数调用的节点,也就是这个类型的节点
}
};
}
};
复制代码
一条 rule 就是一个 node 模块,其主要由 meta 和 create 两部分组成:
3.2.1 meta
meta 中包含规则的元数据,其中参数有:
- fixable: 该插件是否支持自动修复
- messages: 里面可以设置对应的messagId,供插件使用(对外报错的提示信息)
3.2.2 create
如果说 meta 表达的是我们想做什么,那么 create 则表达的是这条 rule 具体会怎么分析代码
:
- 参数context:对象上包含了:ESLint 在遍历 JavaScript 代码的抽象语法树时,用来访问节点的方法。
- 返回值:需要返回一个对象,里面可以提供对应的
ast 节点类型的函数
。 - 函数CallExpression:
抽象语法树对应的节点类型,每种节点类型在遍历的时候,解析器都会检测外部是否提供了对应的函数,如果有就调用、并且传递出当前节点
。
3.3 用户规则运行大概的逻辑
3.4 规则对不合法代码的检查
当插件运行这个规则,检查到代码中有不符合规则的代码,如果要提示代码不合法,规则内部可以调用context.report()
传入一个对象,可以传入的参数有:对应的节点
和 错误的提示消息
:
create: function(context) {
return {
CallExpression(node) {
if (node.arguments.length === 3) { // var a = _.get(obj, 'a', 'zly')
context.report({
node,
message: 'unexpectedThirParameter'
})
}
}
};
}
复制代码
3.5 ESLint 中的 fix 函数
当检测出代码不符合规范时,插件就去判断自定义规则的一些原数据,比如fixable
(是否可以自定fix)。如果有提供这个元数据且 report 有提供fix 函数
,插件就会调用规则里的 fix 函数,并且传递出一个对象,该对象包含若干可以操作 ast 的方法,我们需要的是:replaceText
(替换给定的节点或记号内的文本):
create: function(context) {
return {
CallExpression(node) {
context.report({
node,
message: 'unexpectedParameterLength',
fix(fixer) {
// fix 的逻辑
// 最终输出 newCode
return fixer.replaceText(node, newCode)
}
})
}
};
}
复制代码
其中主要是 fix 函数以及其提供的 fixer 参数,插件会调用 fix 函数,fix 函数需要返回的一个fixing 对象
。
3.6 获取源代码
在得到了必要的信息比如 fix(),fixer 对象后,接下来要考虑的就是如何获取对应源代码,因为这里能获取到的都是节点(ast),所以需要引入 ast 解析库,将 ast 转成源代码:
const escodegen = require('escodegen')
create(context) {
return {
CallExpression(node) {
const code = escodegen.generate(node) // 获取到源代码
}
}
}
复制代码
利用 context 上提供了一个 getSourceCode 方法,可以获取当前节点的源代码:
create: function(context) {
const source = context.getSourceCode() //获取源代码: _.get(xx,xx,xx)
return {
CallExpression(node) {
if (node.arguments.length === 3) {
context.report({
node,
message: '_.get()不建议使用第三个参数当默认值'
})
}
}
}
}
复制代码
在获取源代码的过程中,也有踩过坑,下面是一个方案的对比。
目的 | 我的解决方案 | 文档提供 |
---|---|---|
获取源代码 | 引入espree,手动反解析 | context.getSourceCode() |
fixable | 设置为true | 官方:“code” |
3.7 解析参数
3.7.1 根据特殊标识切代码
根据特殊标识去切代码这种方案虽然可以用,但是代码看起来很奇怪且阅读成本变高:
const codeArr = escodegen.generate(node).split(',')
// 错误示范,反编译,切代码: _.get(a, "b", 0) 会变成: ['_.get(a', '"b"', '0']
const defaultValue = codeArr[codeArr.length - 1].trim().split(')')[0]
// 利用括号去切代码,得到默认值
const code = `${codeArr[0]}, ${codeArr[1]}) || ${defaultValue}`
复制代码
3.7.2 正则匹配
利用正则匹配这种方案虽然看起来简洁,但是阅读成本更高且不方便维护:
const paramsReg = /(_\.get\([^,]+,[^,]+),([^,]+)([^)]+)?(\))/
复制代码
3.7.3 AST 区间
利用 ast 提供的区间来计算对应的参数区间,根据区间精确的进行代码的切分,获取我们需要的代码:
create(context) {
CallExpression(node) {
const [ object, path, defaultValue ] = getArgumentsByNode(context, node)
}
}
function getArgumentsByNode(context, node) {
if (!context || !node) {
throw new Error('参数错误')
}
const nodeArguments = node.arguments
if (!nodeArguments || nodeArguments.length === 0) {
throw new Error('传入的节点,没有参数; 或者不是一个函数调用')
}
const sourceCode = context.getSourceCode() // 资源的一个实例,上面提供很多方法,比如获取给定节点的源码
const originCode = sourceCode.getText(node) // 给定节点的源码
return nodeArguments.map((item) => {
const argumentLength = item.end - item.start
const sliceStartPosition = item.start - node.start
const sliceEndPosition = sliceStartPosition + argumentLength
return originCode.slice(sliceStartPosition, sliceEndPosition)
})
}
复制代码
以上三总方案各有利弊,但是最可靠 && 最易扩展
的还是基于区间去切分代码。
3.9 规则 auto fix
在前面我们已经获取了需要的(比如源代码,新代码等)重要信息,接下来就可以组装规则的核心逻辑
fix 了:
create: function(context) {
return {
CallExpression(node) {
context.report({
node,
message: 'unexpectedParameterLength',
fix(fixer) {
const [ object, path, defaultValue ] = getArgumentsByNode(context, node)
const newCode = `_.get(${object}, ${path}) || ${defaultValue}`
return fixer.replaceText(node, newCode)
}
})
}
}
}
复制代码
对于getArgumentsByNode
这个方法:只要传入上下文
和对应的节点
,就能获取该节点对应的参数
。后续针对一些自定义规则的尝试,都可以基于这个函数去进行扩展,用以封装出更多适用于业务本身的一些解析方法。
3.10 兼容一些特殊情况
到目前为止,规则的基本功能已经完成了,但是因为这个规则不单单只是 fix 功能,也有基本的一个校验逻辑
,比如参数不提供
,参数超过3个以上
的情况做校验:
var a = _.get() //不合法
var a = _.get(object) //不合法
var a = _.get(object, 'a', 0, 0) //不合法
var a = _.get(object, 'a', 0) //不合法,但是会自动fix
复制代码
针对 fix,也兼容了一种特殊的情况,比如当存在二元表达式的情况 或 有运算符优先级的问题等:
var a = _.get(object, 'a', 0) + 1
// 如果 auto fix 后,代码如下
// 由于 + 优先级比 || 高, fix 后就有问题
var a = _.get(object, 'a') || 0 + 1
复制代码
兼容代码的全貌如下:
create(context) {
CallExpression(node) {
// ——————————————————————————参数兼容——————————————————————————————
// 没有参数或者一个参数: _.get() / _.get(object)
if (node.arguments.length === 0 || node.arguments.length === 1) {
context.report({
node,
messageId: 'unexpectedParameterLength', // 错误Id
})
return
}
// 参数大于超过3个: _.get(object, 'key', 0, 0) 不推荐的写法
if (node.arguments.length > 3) {
context.report({
node,
messageId: 'unexpectedParameterLength', // 错误Id
})
return
}
const [ object, path, defaultValue ] = getArgumentsByNode(context, node)
const parentNode = node.parent
let newCode = `_.get(${object}, ${path}) || ${defaultValue}`
// ————————————————————————特殊运算符的各种兼容———————————————————————
//二元表达式 var key = _.get(object, "key", 0) + 1
if (parentNode.type === 'BinaryExpression') {
newCode = `(${newCode})`
}
}
}
复制代码
四. 单元测试
4.1 Jest 单元测试
jest 官网:www.jestjs.cn/docs/gettin…
4.1.1 Jest 测试规则
单纯的用 jest 做测试的话会比较简单,直接引入这个测试框架,写对应的测试用例就行了:
const Linter = require('eslint').Linter
const rules = require('../../lib/rules/lodash-get')
describe("关于lodash第三个参数的单元测试",()=>{
const linter = new Linter()
const config = {
rules: {
"lodash-get": "error"
}
}
linter.defineRule(key, rules['lodash-get'])
it("var key = _.get(object, 'key', '')", () => {
const code = `var key = _.get(object, 'key', '')`
const output = `var key = _.get(object, 'key') || ''`
expect(linter.verifyAndFix(code, config).output).toBe(output);
})
.....
}
复制代码
4.1.2 Jest 测试中出现问题
用 jest 测试会出现一些比较奇怪的测试用例,比如测试参数为空的时候,规则里针对参数为空只做了抛错
,并没有自动 fix:
it("Lodash.get() 参数为空", () => {
const code = `var get = _.get()`
const message = linter.verifyAndFix(code, config).messages[0].message
expect(message).toBe('Lodash.get() 参数为空');
})
复制代码
针对过程中出现得问题进行汇总如下:
- 参数为空时,不应该用
linter.verifyAndFix
,因为规则并没有 fix - 人工地去对比抛出的错误时就会很依赖错误信息,这样会导致
测试用例不健壮
(因为存在魔法字符串) - 即使利用
linter.verfify()
去针对特殊情况的用例进行测试,也需要自动去获取错误信息来做比对(会导致测试用例不健壮
) - 不好区分是
参数个数
不合法,还是参数
不合法 - 可读性不好,代码冗余,不好区分是
参数个数
不合法,还是参数
不合法
4.2 社区参考
不仅仅因为 jest 进行测试时发现了问题,还因为最终的结果并不符合我们的预期,所以需要去参考社区相关的项目使用的测试用例。这里主要参考的是 eslint-plugin-vue
这个插件里面的测试用例。
下面是 eslint-plugin-vue 里面自定义的 block-spacing(块里面前后要有空格)
规则,即使是这么大型的项目也有不专业的地方,比如用了魔法字符 brace-style
:
4.3 ESLint 提供的测试
这里的 messageId 就是在定义插件时里面的 meta 信息,在 ruleTester 调用 run 方法时会用插件检查 code,插件会抛出对应的错误信息、然后对比测试用例和插件内部的抛出的 messageId 是否一致,如果一致则测试通过:
const RuleTester = require('eslint').RuleTester
const ruleTester = new RuleTester();
const rules = require('../../lib/rules/lodash-get')
ruleTester.run('lodash-get', rules, {
valid: [
{
code: 'var key = _.get(object, "key") || {}',
}
],
invalid: [
{
code: 'var get = _.get()',
errors: [{
messageId: 'unexpectedParameterLength'
}]
},
{
code: 'var key = _.get(object)',
errors: [{
messageId: 'unexpectedParameterLength'
}]
},
{
code: 'var key = _.get(object, "key", {}, {})',
errors: [{
messageId: 'unexpectedParameterLength'
}]
},
{
code: 'var key = _.get(object, "key", 0) - 1',
output: 'var key = (_.get(object, "key") || 0) - 1',
errors: [{
messageId: 'unexpectedThirParameter',
}]
},
]
)}
复制代码
4.4 后续规则的维护
插件目前兼容的一些边界情况很少,测试用例覆盖的也不完全,因为在没有业务洗礼前是很难验证这个规则的完整性的,在使用了一段时间后、陆陆续续发现了一些边界的情况:
逻辑运算符
var key = _.get(object, "key", {}) && {}
for in循环
for (var key in _.get(object, "key", {})) {}
typeOf 操作符
typeof _.get(object, "key", {})
链式调用
// fix后变成有问题的代码 _.get(object, 'key') || [].map()
_.get(object, 'key', []).map()
复制代码
经分析后针对上述一些边界情况完善了规则的产出代码,针对有风险的代码就将 fix 后的结果加上括号:
if (
parentNode.type === 'BinaryExpression' || //二元表达式
parentNode.type === 'LogicalExpression' || // 逻辑运算符
parentNode.type === 'ForInStatement' || // for in
parentNode.operator === 'typeof' || // typeOf 操作符
parentNode.property //_.get(object, 'key', []).map()
) {
newCode = `(${newCode})`
}
复制代码
针对这些边界的代码兼容后,fix 的效果即为:
逻辑运算符
var key = _.get(object, "key", {}) && {}
fix后的代码
var key = (_.get(object, "key") || []) && {}
复制代码
五. 总结
到目前为止就是上篇 + 下篇
的完整篇幅了,几乎可以说是把 ESLint 很透彻地了解应用了。感谢曾经的团队和组长,文中涉及到前公司的一些内部项目名称已经打码。