ESLint自定义规则及源码解析

5,399 阅读10分钟

相关背景:

这篇文章与上篇《从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 从编译到运行的过程:

image.png 词法分析(词法单元) -> 语法分析 -> 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"
}

简化的树形结构如下:

image.png

1.3 AST编译过程

二. ESLint 运行规则的流程图

eslint 每条规则针对的其实都是一个 node 模块,当用户配置好对应的规则后,eslint 就加载对应的规则、执行对应的模块,再根据用户提供的参数进行检查(比如看是否需要自动fix)

根据思路,梳理eslint大体的流程图如下:

image.png

三. 自定义规则

自定义规则文档: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)
  })
}

以上三总方案各有利弊,但是最可靠 && 最易扩展的还是基于区间去切分代码。

image.png

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:

image.png

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 很透彻地了解应用了。感谢曾经的团队和组长,文中涉及到前公司的一些内部项目名称已经打码。