前端代码质量进阶:自定义 eslint 规则校验业务逻辑

自定义 eslint 规则校验代码业务逻辑

eslint 是 JavaScript 社区中主流的 lint 工具,提供的大量规则有效的保障了许多项目的代码质量。本文将介绍如何经过自定义 eslint 检查规则,校验项目中特有的一些业务逻辑,如 i18n、特殊做用域、特殊 API 使用规范性等。

代码静态分析与 eslint

代码静态分意指是不须要实际执行代码就能获取到程序中的部分信息并加以使用,lint 就是其中一种常见的实践,一般为检查代码中错误的写法或是不符合标准的代码风格。许多编程语言都自带 lint 工具,甚至直接将其植入到编译器中。javascript

但这一重要的功能对于 JavaScript 来讲倒是一大痛点,做为动态且弱类型的语言 JavaScript 没有编译阶段也就无从进行静态分析,这致使程序错误只能在运行时被发现,部分错误很是低级例如variable is undefined。而当程序变得更为复杂时,这类错误甚至难以在开发、测试阶段暴露,只会在用户实际使用的过程当中遇到,形成严重的后果。java

为了弥补语言天生的弱点,社区开发出了一些 lint 工具,在所谓预编译阶段完成代码的静态分析检查,而 eslint 就是其中的佼佼者。如今社区已经广泛接受使用 eslint 做为代码规范工具,也延伸出了许多经常使用的规则与规则集。但实际上 eslint 拓展性极佳,咱们还能够基于 eslint 提功的静态分析能力对代码进行业务逻辑的检查,本文将讲解一些笔者所在项目中的静态分析实践,以说明这一方案的适用场景和优缺点。node

eslint 基本原理

首先快速说明 eslint 工做的基本流程,帮助理解它将给咱们提供哪些方面的能力以及如何编写咱们的自定义规则。git

配置规则与插件

eslint 主要依靠配置决定执行哪些规则的校验,例如咱们能够经过配置no-extra-semi决定是否须要写分号,这类规则中不包含具体的业务逻辑,而是对全部项目通用,所以会被集成在 eslint 的内置规则中。github

而还有一些规则也不包含业务逻辑,但只在部分项目场景中使用,如 React 相关的大量规则,那么显然不该该集成在内置规则中,但也应该自成一个集合。这种状况下 eslint 提供了另外一种规则单位——插件,能够做为多个同类规则的集合被引入到配置中。正则表达式

若是咱们准备自定义一些规则用于校验项目中的业务逻辑,那么也应该建立一套自用的插件,并将自用的规则都存放其中。推荐使用 eslint 的 yeoman generator 脚手架新建插件或规则,该脚手架可以生成插件项目的目录结构、规则文件、文档以及单元测试等模版,下文中咱们将经过示例理解这些文件的的做用。chrome

JavaScript 解析

如上文所说,要实现静态分析则须要自建一个预编译阶段对代码进行解析,eslint 也不例外。express

首先咱们看看大部分编译器工做时的三个阶段:npm

  1. 解析,将未经处理的代码解析成更为抽象的表达式,一般为抽象语法树,即 AST。
  2. 转换,经过修改解析后的代码表达式,将其转换为符合预期的新格式。
  3. 代码生成,将转换后的表达式生成为新的目标代码。

若是想快速的加深对编译器工做原理的理解,推荐阅读 the-super-tiny-compiler编程

对于 eslint 而言,主要是将 JavaScript 代码解析为 AST 以后,再在遍历 AST 的过程当中对代码进行各个规则的校验。所以 eslint 也有一个解析器用于将原始代码解析为特定的 AST,目前所使用的解析器是 eslint 基于 Acorn 开发的一个名为 Espree 的项目。而对于咱们编写自定义规则来讲更关心的是解析器生成的 AST 节点的结构,在阅读 eslint 文档以后会了解到包括 Espree 在内的许多编译器项目都须要一套 JavaScript 的 AST 规范,而为了保证规范的一致性以及实效性,社区共同维护了一套规范:estree

在接下来说解规则编写与执行的过程当中,咱们将直接引用 estree 的各类 AST 结构。

规则的执行

eslint 中通常一个规则存放在一个文件中,以 module 的形式导出并挂载,其结构以下:

module.exports = {
  meta: {
    docs: {
      description: 'disallow unnecessary semicolons',
      category: 'Possible Errors',
      recommended: true,
      url: 'https://eslint.org/docs/rules/no-extra-semi',
    },
    fixable: 'code',
    schema: [], // no options
  },
  create: function(context) {
    return {
      // callback functions
    };
  },
};

其中meta部分主要包括规则的描述、类别、文档地址、修复方式以及配置下 schema 等信息,对于项目中自用的规则来讲能够只填写基本的描述和类别,其他选项在有须要时再根据文档补充,并不会影响规则的检验逻辑。

create则须要定义一个函数用于返回一个包含了遍历规则的对象,而且该函数会接收context对象做为参数,context对象中除了包含report等报告错误的方法以外,还提供了许多帮助方法,能够简化规则的编写。下文中咱们会经过几个示例理解create函数的使用方式,但首先能够经过一段代码创建初步的印象:

module.exports = {
  create: function(context) {
    // declare the state of the rule
    return {
      ReturnStatement: function(node) {},
      'FunctionExpression:exit': function(node) {},
      'ArrowFunctionExpression:exit': function(node) {},
    };
  },
};

在这段代码中咱们能够看到create返回的所谓“包含了遍历规则的对象”的基本结构。对象的 value 均为一个接收当前 AST 节点的函数,而 key 则是 eslint 的节点 selector。selector 分为两部分,第一部分为必须声明的 AST 节点类型,如ReturnStatementFunctionExpression。第二部分则是可选的:exit标示,由于在遍历 AST 的过程当中会以“从上至下”再“从下至上”的顺序通过节点两次,selector 默认会在下行的过程当中执行对应的访问函数,若是须要再上行的过程当中执行,则须要添加:exit

那么 eslint 解析出的 AST 有哪些节点类型,每种节点的数据结构又是什么,则须要经过查看上文提到的 estree 定义文档进行了解。

适用场景与示例

接下来咱们会看到 eslint 自定义规则校验的一些具体示例,但首先咱们先要明确它的适用场景以及与一些常见代码 QA 手段的异同。

适用场景

咱们能够经过如下方法判断一个工具的质量:

工具质量 = 工具节省的时间 / 开发工具消耗的时间

对于静态分析来讲,要想提升“工具节省的时间”,应该要让检查的规则尽可能覆盖全局性的且常常发生的问题,如使用最为普遍的检查:是否使用了未定义的变量。同时还须要考虑当问题发生后 debug 所消耗的时间,例若有的项目有 i18n 需求,而在代码的个别地方又直接使用了中文的字符串,虽然问题很小,可是人工测试覆盖却很麻烦,若是可以经过工具进行覆盖,那么原来用于 debug 的时间也应该纳入“工具节省的时间”当中。

另外一方面则是对比“开发工具消耗的时间”,首先要强调经过静态分析去对逻辑进行判断,不管是学习成本仍是实际编写成本都较高,若是一类问题能够经过编写简单的单元测试进行覆盖,那么应该优先考虑使用单元测试。但有的时候代码逻辑对外部依赖较多,单元测试的开销很大,例如咱们有一段 e2e 测试的代码,须要在目标浏览器环境中执行一段代码,可是常规的 eslint 并不能判断某个函数中的代码实际执行在另外一个做用域下,部分检查就会失效,例如浏览器运行时引用的变量实际定义在本地运行时中,eslint 没法察觉。而若是经过单元测试覆盖,则须要实际运行对应的 e2e 代码,或者 mock 其执行环境的各类依赖,都是很是重的工做,取舍之下经过静态分析覆盖会事半功倍。

最后还须要考虑到使用体验,许多编辑器都有 eslint 的集成插件,能够在编程的过程当中实时检测各个规则,在实时性方面远强于单元测试等 QA 手段的使用体验。

示例 1:i18n

许多项目都有国际化的需求,所以项目中的文案须要避免直接使用中文,常见的方案包括用变量代替字符串或者使用全局的翻译函数处理字符串,例如:

// 错误:直接只用中文字符串
console.log('中文');
// 使用变量
const currentLocale = 'cn';
const T = {
  str_1: {
    cn: '中文',
  },
};
console.log(T.str_1[currentLocale]);
// 使用翻译函数处理
console.log(t('中文'));

若是出现了直接使用中文字符串的错误,其实在代码运行过程当中也不会有任何错误提示,只能靠 code review 和人工观察测试来发现。咱们尝试自定义一条 eslint 规则解决它,此处假设项目中使用的是将全部中文内容存放在一个变量中,其他地方直接引用变量的方法。

const SYMBOL_REGEX = /[\u3002\uff1b\uff0c\uff1a\u201c\u201d\uff08\uff09\u3001\uff1f\u300a\u300b]/;
const WORD_REGEX = /[\u3400-\u9FBF]/;

function hasChinese(value) {
  return WORD_REGEX.test(value) || SYMBOL_REGEX.test(value);
}

module.exports = {
  create: function(context) {
    return {
      Literal: function(node) {
        const { value } = node;
        if (hasChinese(value)) {
          context.report({
            node,
            message: '{{ str }} contains Chinese, move it to T constant.',
            data: {
              str: node.value,
            },
          });
        }
      },
    };
  },
};

在这段代码中,咱们在create里遍历全部Literal类型节点,由于咱们须要检查的对象是全部字符串。根据 estree 的定义,咱们会知道Literal类型阶段结构以下:

interface Literal <: Expression {
    type: "Literal";
    value: string | boolean | null | number | RegExp;
}

那么须要作的就是判断该节点的 value 是否包含中文,在这里咱们用的是正则表达式进行判断,当含有中文字符或标点时,就调用context.report方法报告一个错误。在应用这条规则以后,全局全部直接使用中文字符串的代码都会报错,只须要对统一存放中文的变量T所在的代码部分禁用这条规则,就能够避免误判。

在笔者所在项目中咱们使用的是“经过翻译函数处理”的方式,因此规则会更为复杂一些,须要判断当前字符串的父节点是否为咱们的翻译函数,Espree 会在每一个节点上都记录对应的父节点信息,所以咱们能够经过相似node.parent.callee.name === 't'这样的方式进行判断。不过实际状况中还须要作更安全、全面的判断,例如正确识别这样的使用方式t('你好' + '世界'),后一个字符串的父节点是加法运算符。

在这个示例中咱们主要理解了遍历函数的工做方式以及如何使用合理的节点类型实现需求,所以再也不过分展开实际场景中的细节实现。不过相信读者已经能够感觉到写一条自定义规则须要很是全面的考虑代码中的各种场景,这也是为何 eslint 要求自定义规则要遵循 TDD 的开发方式,用足够多的单元测试保证规则使用时符合预期,在最后咱们会介绍 eslint 提供的单测框架。

示例 2:特殊做用域

首先构建一个场景用于展现这类规则:

不管是以及很是成熟的 Node.JS + selenium 体系仍是较新的 headless chrome 生态,这类端到端工具通常都会提供在目标浏览器上执行一段 JavaScript 的能力,例如这样:

client.execute(
  function(foo, bar) {
    document.title = foo + bar;
  },
  ['foo', 'bar']
);

client.execute方法接收两个参数,第一个为在浏览器端执行的函数,第二个则是从当前代码传递给执行函数的参数,而浏览器端也只能使用传递的参数而不能直接使用当前代码中的变量。在这种场景下,很容易出现相似这样的问题:

const foo = 'foo';
const bar = 'bar';
client.execute(function() {
  document.title = foo + bar;
});

对于 eslint 来讲并不知道document.title = foo + bar;将在浏览器端的做用域中执行,而又发现有同名变量foobar被定义在当前代码中,则不会认为这段代码有错误,这种状况下咱们就能够尝试自定义规则来对这个特殊场景作检查:

module.exports = {
  create: function(context) {
    return {
      'Program:exit': function() {
        const globalScope = context.getScope();
        const stack = globalScope.childScopes.slice();

        while (stack.length) {
          const scope = stack.pop();
          stack.push.apply(stack, scope.childScopes);

          if (scope.block.parent.callee.property.name === 'execute') {
            const undefs = scope.through.forEach((ref) =>
              context.report({
                node: ref.identifier,
                message: "'{{name}}' is not defined.",
                data: ref.identifier,
              })
            );
          }
        }
      },
    };
  },
};

以上代码中继续省略一些过于细节的实现,例如判断子做用域是否为client.execute的第一个参数以及将浏览器中的全局变量加入未定义变量的白名单等等,重点关注 eslint 为咱们提供的一些帮助方法。

此次咱们的节点选择器为Program:exit,也就是下行完毕、开始上行完整的 AST 时执行咱们的自定义检查,Program类型的节点对应的是完整的源码树,在 eslint 中便是当前文件。

在检查时,首先咱们使用context.getScope获取了当前正在遍历的做用域,又因为咱们处在Program节点中,这个做用域即为这个代码文件中的最高做用域。以后咱们构建一个栈,经过不断地把 childScopes 压入栈中在读取出来的方式,实现递归的访问到全部的子做用域。

以后在处理每一个子做用域时,都作了一个简单的判断(一样是简化事后的版本),来肯定该做用域是否为咱们须要独立判断的client.execute方法中第一个函数内的做用域。

当找到该函数内的做用域以后,咱们就可使用scope对象上的各类方法进行判断了。事实上做用域是静态分析中较为复杂的部分,若是彻底独立的去判断做用域中的引用等问题相对复杂,好在 eslint 对外暴露了 scope manager interface,让咱们能够最大程度的复用封装好的各种做用域接口。

在 scope manager interface 中能够看到scope.through方法的描述:

The array of references which could not be resolved in this scope.

正是咱们须要的!因此最后只须要简单的遍历scope.through返回的未定义引用数组,就能够找到该做用域下全部的未定义变量。

经过这个示例,能够看出 eslint 自己已经对许多经常使用需求作了高阶的封装,直接复用能够大大缩减“开发工具消耗的时间”。

示例 3:保证 API 使用规范

继续构建一个场景:假如咱们在业务中咱们有一个内部 API "Checker",用于校验某些操做(action)是否可执行,而校验的方式是判断 action 对应的规则(rule)是否所有经过,代码以下:

const checker = new Checker({
  rules: {
    ruleA(value) {},
    ruleB(value) {},
  },
  actions: {
    action1: ['ruleA', 'ruleB'],
    action2: ['ruleB'],
  },
});

在 Checker 这个 API 使用的过程当中,咱们须要:

  1. 全部 action 依赖的 rule 都在rules属性中被定义。
  2. 全部定义的 rule 都被 action 使用。

因为 action 和 rule 的关联性只靠 action value 数组中的字符串名称与 rule key 值保持一致来维护,因此第一条要求若是出了问题只能在运行时发现错误,而第二条要求甚至不会形成任何错误,但在长期的迭代下可能会遗留大量无用代码。

固然这个场景咱们很容易经过单元测试进行覆盖,但若是 Checker 是一个在项目各类都会分散使用的 API,那么单元测试即便有一个通用的用例,也须要开发者手动导出 checker 再引入到测试代码中去,这自己就存在必定遗漏的风险。

从开发体验出发,咱们也尝试用 eslint 的自定义规则完成这个需求,实现一个实时的 Checker API 使用方式校验。

首先咱们须要在静态分析阶段分辨代码中的一个 Class 是否为 Checker Class,从而进一步作校验,单纯从变量名称判断过于粗暴,容易发生误判;而从 Class 来源分析极可能出现跨文件引用的状况,又过于复杂。因此咱们借鉴一些编程语言中处理相似场景的作法,在须要编译器特殊处理的地方加一些特殊的标记帮助编译器定位,例如这样:

// [action-checker]
const checker = new Checker({});

在构造 checker 实例的前一行写一个注释// [action-checker],代表下一行开始的代码是使用了 Checker API,在这基础上,咱们就能够开始编写 eslint 规则:

const COMMENT_MARKER = '[action-checker]';

function getStartLine(node) {
  return node.loc.start.line;
}

module.exports = {
  create: function(context) {
    const sourceCode = context.getSourceCode();
    const markerLines = {};

    return {
      Program: function() {
        const comments = sourceCode.getAllComments();
        comments.forEach((comment) => {
          if (comment.value.trim() === COMMENT_MARKER) {
            markerLines[getStartLine(comment)] = comment;
          }
        });
      },
      ObjectExpression: function(expressionNode) {
        const startLine = getStartLine(expressionNode);
        if (markLines[startLine - 1]) {
          // check actions and rules
        }
      },
    };
  },
};

在这个示例中,咱们使用了context.getSourceCode获取 sourceCode 对象,和上个例子中的 scope 相似,也是 eslint 封装事后的接口,例如能够继续经过sourceCode.getAllComments获取代码中的全部注释。

为了实现经过注释定位 checker 实例的目的,咱们在markLines对象中存储了带有特殊标记的注释的行数,获取行数的方式则是node.loc.start.line。这里的loc也是 eslint 给各个 AST 节点增长的一个重要属性,包含了节点对应代码在源代码中的坐标信息。

以后遍历全部ObjectExpression类型节点,经过markLines中存储的位置信息,肯定某个ObjectExpression节点是否为咱们须要校验的 checker 对象,再根据 estree 中定义的ObjectExpression结构,找到咱们须要的 actions values 和 rules keys 进行比较,此处不对细节处理作进一步展开。

这个示例说明注释做为静态分析中很是重要的元素有很好的利用价值,许多项目也提供从必定格式(例如 JSDoc)的注释中直接生成文档的功能,也是代码静态分析常见的应用,除了示例中用到的sourceCode.getAllComments能够获取全部注释,还提供sourceCode.getJSDocComment这样只获取 JSDoc 类型注释的方法。

总而言之,基于 eslint 提供的强大框架,咱们能够拓展出不少极大提升开发体验和代码质量的用法。

杂项

借鉴社区

eslint 自己提供的功能很强但也不少,光从文档中不必定能找到最适用的方法,而 eslint 自己已经有大量的 通用规则,不少时候直接从相近的规则中学习会更加有效。例如示例 2 中对做用域的判断就是从社区的通用规则no-undef中借鉴了不少大部分思路。

TDD

上文提到,静态分析须要很是全面的考虑编译器会遇到的各种代码,但若是每次编写规则都须要在一个很大的 code base 中进行测试效率也很低。所以 eslint 提倡用测试驱动开发的方式,先写出对规则的预期结果,再实现规则。

若是经过上文提到的 eslint yeoman 脚手架新建一个规则模版,会自动生成一个对应的测试文件。以示例 1 为例,内容以下:

const rule = require('../../../lib/rules/use-t-function');
const RuleTester = require('eslint').RuleTester;

const parserOptions = {
  ecmaVersion: 8,
  sourceType: 'module',
  ecmaFeatures: {
    experimentalObjectRestSpread: true,
    jsx: true,
  },
};

const ruleTester = new RuleTester({ parserOptions });
ruleTester.run('use-t-function', rule, {
  valid: [
    { code: 'fn()' },
    { code: '"This is not a chinese string."' },
    { code: "t('名称:')" },
    { code: "t('一' + '二' + '三')" },
  ],

  invalid: [
    {
      code: '<Col xs={6}>名称:</Col>',
      errors: [
        {
          message: '名称: contains Chinese, use t function to wrap it.',
          type: 'Literal',
        },
      ],
    },
  ],
});

核心的部分是require('eslint').RuleTester提供的单测框架 Class,传入一些参数例如解析器配置以后就能够实例化一个 ruleTester。实际执行时须要提供足够的 valid 和 invalid 代码场景,而且对 invalid 类型代码报告的错误信息作断言,当全部测试用例经过后,就能够认为规则的编写符合预期了。

完整示例代码

自定义 eslint 规则在咱们的实际项目中已经有所应用,示例中的实际完整规则代码都存放在公网 Github 仓库中,若是对文中跳过的细节实现感兴趣能够自行翻看。

相关文章
相关标签/搜索