自己动手写符合自己业务需求的eslint规则

Posted Jtag特工

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了自己动手写符合自己业务需求的eslint规则相关的知识,希望对你有一定的参考价值。

自己动手写符合自己业务需求的eslint规则

使用eslint和stylelint之类的工具扫描前端代码现在已经基本成为前端同学的标配。但是,业务这么复杂,指望eslint等提供的工具完全解决业务中遇到的代码问题还是不太现实的。我们一线业务同学也要有自己的写规则的能力。

eslint是构建在AST Parser基础上的规则扫描器,缺省情况下使用espree作为AST解析器。rules写好对于AST事件的回调,linter处理源代码之后会根据相应的事件来回调rules中的处理函数。

另外,在进入细节之前,请思考一下:eslint的边界在哪里?哪些功能是通过eslint写规则可以做到的,哪些是用eslint无法做到的?

先学会如何写规则测试

兵马未动,测试先行。规则写出来,如何用实际代码进行测试呢?

所幸非常简单,直接写个json串把代码写进来就好了。

我们来看个no-console的例子,就是不允许代码中出现console.*语句的规则。

首先把规则和测试运行对象ruleTester引进来:

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require("../../../lib/rules/no-console"),
     RuleTester  = require("../../../lib/rule-tester");

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester();

然后我们就直接调用ruleTester的run函数就好了。有效的样例放在valid下面,无效的样例放在invalid下面,是不是很简单。

我们先看下有效的:

ruleTester.run("no-console", rule, 
    valid: [
        "Console.info(foo)",

        // single array item
         code: "console.info(foo)", options: [ allow: ["info"] ] ,
         code: "console.warn(foo)", options: [ allow: ["warn"] ] ,
         code: "console.error(foo)", options: [ allow: ["error"] ] ,
         code: "console.log(foo)", options: [ allow: ["log"] ] ,

        // multiple array items
         code: "console.info(foo)", options: [ allow: ["warn", "info"] ] ,
         code: "console.warn(foo)", options: [ allow: ["error", "warn"] ] ,
         code: "console.error(foo)", options: [ allow: ["log", "error"] ] ,
         code: "console.log(foo)", options: [ allow: ["info", "log", "warn"] ] ,

        // https://github.com/eslint/eslint/issues/7010
        "var console = require('myconsole'); console.log(foo)"
    ],

能通过的情况比较容易,我们就直接给代码和选项就好。

然后是无效的:

    invalid: [

        // no options
         code: "console.log(foo)", errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.error(foo)", errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.info(foo)", errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.warn(foo)", errors: [ messageId: "unexpected", type: "MemberExpression" ] ,

        //  one option
         code: "console.log(foo)", options: [ allow: ["error"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.error(foo)", options: [ allow: ["warn"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.info(foo)", options: [ allow: ["log"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.warn(foo)", options: [ allow: ["error"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,

        // multiple options
         code: "console.log(foo)", options: [ allow: ["warn", "info"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.error(foo)", options: [ allow: ["warn", "info", "log"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.info(foo)", options: [ allow: ["warn", "error", "log"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,
         code: "console.warn(foo)", options: [ allow: ["info", "log"] ], errors: [ messageId: "unexpected", type: "MemberExpression" ] ,

        // In case that implicit global variable of 'console' exists
         code: "console.log(foo)", env:  node: true , errors: [ messageId: "unexpected", type: "MemberExpression" ] 
    ]
);

无效的要判断下出错信息是不是符合预期。

我们使用mocha运行下上面的测试脚本:

./node_modules/.bin/mocha tests/lib/rules/no-console.js

运行结果如下:

  no-console
    valid
      ✓ Console.info(foo)
      ✓ console.info(foo)
      ✓ console.warn(foo)
      ✓ console.error(foo)
      ✓ console.log(foo)
      ✓ console.info(foo)
      ✓ console.warn(foo)
      ✓ console.error(foo)
      ✓ console.log(foo)
      ✓ var console = require('myconsole'); console.log(foo)
    invalid
      ✓ console.log(foo)
      ✓ console.error(foo)
      ✓ console.info(foo)
      ✓ console.warn(foo)
      ✓ console.log(foo)
      ✓ console.error(foo)
      ✓ console.info(foo)
      ✓ console.warn(foo)
      ✓ console.log(foo)
      ✓ console.error(foo)
      ✓ console.info(foo)
      ✓ console.warn(foo)
      ✓ console.log(foo)


  23 passing (83ms)

如果在valid里面放一个不能通过的,则会报错,比如我们加一个:

ruleTester.run("no-console", rule, 
    valid: [
        "Console.info(foo)",

        // single array item
         code: "console.log('Hello,World')", options: [] ,

就会报下面的错:

  1 failing

  1) no-console
       valid
         console.log('Hello,World'):

      AssertionError [ERR_ASSERTION]: Should have no errors but had 1: [
  
    ruleId: 'no-console',
    severity: 1,
    message: 'Unexpected console statement.',
    line: 1,
    column: 1,
    nodeType: 'MemberExpression',
    messageId: 'unexpected',
    endLine: 1,
    endColumn: 12
  
]
      + expected - actual

      -1
      +0
      
      at testValidTemplate (lib/rule-tester/rule-tester.js:697:20)
      at Context.<anonymous> (lib/rule-tester/rule-tester.js:972:29)
      at processImmediate (node:internal/timers:464:21)

说明我们刚加的console是会报一个messageId为unexpected,而nodeType为MemberExpression的错误。

我们应将其放入到invalid里面:

    invalid: [

        // no options
         code: "console.log('Hello,World')", errors: [ messageId: "unexpected", type: "MemberExpression" ] ,

再运行,就可以成功了:

    invalid
      ✓ console.log('Hello,World')

规则入门

会跑测试之后,我们就可以写自己的规则啦。

我们先看下规则的模板,其实主要要提供meta对象和create方法:

module.exports = 
    meta: 
        type: "规则类型,如suggestion",

        docs: 
            description: "规则描述",
            category: "规则分类:如Possible Errors",
            recommended: true,
            url: "说明规则的文档地址,如https://eslint.org/docs/rules/no-extra-semi"
        ,
        fixable: "是否可以修复,如code",
        schema: [] // 选项
    ,
    create: function(context) 
        return 
            // 事件回调
        ;
    
;

总体来说,一个eslint规则所能做的事情,就是写事件回调函数,在回调函数中使用context中获取的AST等信息进行分析。

context提供的API是比较简洁的:

代码信息类主要我们使用getScope获取作用域的信息,getAncestors获取上一级AST节点,getDeclaredVariables获取变量表。最后的绝招是直接获取源代码getSourceCode自己分析去。

markVariableAsUsed用于跨文件分析,用于分析变量的使用情况。

report函数用于输出分析结果,比如报错信息、修改建议和自动修复的代码等。

这么说太抽象了,我们来看例子。

还以no-console为例,我们先看meta部分,这部分不涉及逻辑代码,都是一些配置:

    meta: 
        type: "suggestion",

        docs: 
            description: "disallow the use of `console`",
            recommended: false,
            url: "https://eslint.org/docs/rules/no-console"
        ,

        schema: [
            
                type: "object",
                properties: 
                    allow: 
                        type: "array",
                        items: 
                            type: "string"
                        ,
                        minItems: 1,
                        uniqueItems: true
                    
                ,
                additionalProperties: false
            
        ],

        messages: 
            unexpected: "Unexpected console statement."
        
    ,

我们再看no-console的回调函数,只处理一处Program:exit, 这是程序退出的事件:

        return 
            "Program:exit"() 
                const scope = context.getScope();
                const consoleVar = astUtils.getVariableByName(scope, "console");
                const shadowed = consoleVar && consoleVar.defs.length > 0;

                /*
                 * 'scope.through' includes all references to undefined
                 * variables. If the variable 'console' is not defined, it uses
                 * 'scope.through'.
                 */
                const references = consoleVar
                    ? consoleVar.references
                    : scope.through.filter(isConsole);

                if (!shadowed) 
                    references
                        .filter(isMemberAccessExceptAllowed)
                        .forEach(report);
                
            
        ;

获取作用域和AST信息

我们首先通过context.getScope()获取作用域信息。作用域与AST的对应关系如下图:

我们前面的console语句的例子,首先拿到的都是全局作用域,举例如下:

<ref *1> GlobalScope 
  type: 'global',
  set: Map(38) 
    'Array' => Variable 
      name: 'Array',
      identifiers: [],
      references: [],
      defs: [],
      tainted: false,
      stack: true,
      scope: [Circular *1],
      eslintImplicitGlobalSetting: 'readonly',
      eslintExplicitGlobal: false,
      eslintExplicitGlobalComments: undefined,
      writeable: false
    ,
    'Boolean' => Variable 
      name: 'Boolean',
      identifiers: [],
      references: [],
      defs: [],
      tainted: false,
      stack: true,
      scope: [Circular *1],
      eslintImplicitGlobalSetting: 'readonly',
      eslintExplicitGlobal: false,
      eslintExplicitGlobalComments: undefined,
      writeable: false
    ,
    'constructor' => Variable 
      name: 'constructor',
      identifiers: [],
      references: [],
      defs: [],
      tainted: false,
      stack: true,
      scope: [Circular *1],
      eslintImplicitGlobalSetting: 'readonly',
      eslintExplicitGlobal: false,
      eslintExplicitGlobalComments: undefined,
      writeable: false
    ,
...

具体看一下38个全局变量,复习下javascript基础吧:

    set: Map(38) 
      'Array' => [Variable],
      'Boolean' => [Variable],
      'constructor' => [Variable],
      'Date' => [Variable],
      'decodeURI' => [Variable],
      'decodeURIComponent' => [Variable],
      'encodeURI' => [Variable],
      'encodeURIComponent' => [Variable],
      'Error' => [Variable],
      'escape' => [Variable],
      'eval' => [Variable],
      'EvalError' => [Variable],
      'Function' => [Variable],
      'hasOwnProperty' => [Variable],
      'Infinity' => [Variable],
      'isFinite' => [Variable],
      'isNaN' => [Variable],
      'isPrototypeOf' => [Variable],
      'JSON' => [Variable],
      'Math' => [Variable],
      'NaN' => [Variable],
      'Number' => [Variable],
      'Object' => [Variable],
      'parseFloat' => [Variable],
      'parseInt' => [Variable],
      'propertyIsEnumerable' => [Variable],
      'RangeError' => [Variable],
      'ReferenceError' => [Variable],
      'RegExp' => [Variable],
      'String' => [Variable],
      'SyntaxError' => [Variable],
      'toLocaleString' => [Variable],
      'toString' => [Variable],
      'TypeError' => [Variable],
      'undefined' => [Variable],
      'unescape' => [Variable],
      'URIError' => [Variable],
      'valueOf' => [Variable]
    ,

我们看到,所有的变量,都以一个名为set的Map中,这样我们就可以以遍历获取所有的变量。

针对no-console的规则,我们主要是要查找是否有叫console的变量名。于是可以这么写:

    getVariableByName(initScope, name) 
        let scope = initScope;

        while (scope) 
            const variable = scope.set.get(name);

            if (variable) 
                return variable;
            

            scope = scope.upper;
        

        return null;
    ,

我们可以在刚才列出的38个变量中发现,console是并没有定义的变量,所以

const consoleVar = astUtils.getVariableByName(scope, "console");

的结果是null.

于是我们要去查找未定义的变量,这部分是在scope.through中,果然找到了name是console的节点:

[
  Reference 
    identifier: Node 
      type: 'Identifier',
      loc: [SourceLocation],
      range: [Array],
      name: 'console',
      parent: [Node]
    ,
    from: <ref *2> GlobalScope 
      type: 'global',
      set: [Map],
      taints: Map(0) ,
      dynamic: true,
      block: [Node],
      through: [Circular *1],
      variables: [Array],
      references: [Array],
      variableScope: [Circular *2],
      functionExpressionScope: false,
      directCallToEvalScope: false,
      thisFound: false,
      __left: null,
      upper: null,
      isStrict: false,
      childScopes: [],
      __declaredVariables: [WeakMap],
      implicit: [Object]
    ,
    tainted: false,
    resolved: null,
    flag: 1,
    __maybeImplicitGlobal: undefined
  
]

这样我们就可以写个检查reference的名字是不是console的函数就好:

        function isConsole(reference) 
            const id = reference.identifier;

            return id && id.name === "console";
        

然后用这个函数去filter scope.though中的所有未定义的变量:

scope.through.filter(isConsole);

最后一步是输出报告,针对过滤出的reference进行报告:

                    references
                        .filter(isMemberAccessExceptAllowed)
                        .forEach(report);

报告问题使用context的report函数:

        自己动手写符合自己业务需求的eslint规则

自己动手编写stylelint规则

自己动手编写stylelint规则

自己动手编写stylelint规则

动手试试Android Studio插件开发

自己动手编写vue之旅