货拉拉技术

如何编写自定义 ESLint 规则

背景

在项目中,我们有时会重写一些外部模块,并且希望后续在项目中的统一使用重写后的模块。以笔者负责的一个项目为例,该项目重写了 react-router 的 Link 组件:

// 之前
import { Link, useModel } from 'umi';

// 重写后
import { useModel } from 'umi';
import { Link } from '@/utils/router';

如果可以使用 ESLint 检验并自动替换某个模块的导入路径,那么就可以统一这个模块的导入路径了。由于现在的 ESLint 目前并没有提供类似的规则,因此我们需要开发一个自定义规则来解决该问题。

ESLint 规则定义

ESLint 规则支持配置,根据我们的需要定义如下的 ESLint 规则。

{
  "rules": {
    "import-path-plus/no-internal-modules": [
      "warn",
      {
        "target": "umi",
        "replace": "@/utils/router",
        "names": ["Link", "useHistory"]
      }
    ]
  }
}

我们希望通过 target 导入的 names 可以自动替换为通过 replace 导入。

实现

ESLint 的工作原理

ESLint 会首先读取配置,使用配置中的 parser (默认为 espree)把代码解析为 AST 并进行遍历,然后在遍历到「不同的节点」或者「特定的时机」的时候,触发相应的处理函数。

借助 AST Explorer[1] 查看一段代码的 AST:

Image
image.png

我们需要处理的节点类型为 ImportDeclaration,具体来说就是检查该节点的 source。如果 source 在我们规则参数配置的 target 中,则进一步检查该节点的下的 ImportSpecifier 节点,如果对应规则的 names 包含该节点,则上报错误。

创建项目

为了方便开发者实现自定义规则,ESLint 提供了 generator-eslint[2] 来快速创建项目。先安装该命令行工具,然后执行 yo eslint:rule 新建项目。在创建的项目中我们重点关注 /lib/rules 目录下对应的规则文件:

module.exports = {
  meta: {
    type: 'problem',
    fixable: 'code',
  },

  create(context) {
    return {
      ImportDeclaration: (node) => {},
    };
  },
};

规则代码主要分为 meta 对象和 create 方法两个部分。

meta 对象主要是规则的相关信息,包括文档描述、参数的 schema 等。

create 函数返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree[3] 定义的 AST) 时,用来访问节点的方法。

create 函数返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree 定义的 AST) 时,用来访问节点的方法。

  • • 如果一个 key 是个节点类型或  selector[4],在  向下 遍历树时,ESLint 调用 visitor 函数

  • • 如果一个 key 是个节点类型或 selector,并带有 :exit,在向上遍历树时,ESLint 调用 visitor 函数

  • • 如果一个 key 是个事件名字,ESLint 为代码路径分析[5]调用  handler  函数

一个规则可以使用当前节点和它周围的树,报告或修复问题。

create 函数有一个 context 参数,context 对象有一些属性和方法可以使用,我们这里用到的主要有以下几个:

  • • context.options: ESLint 规则配置传入的参数

  • • context.report: 检测到错误之后报告的方法

  • • context.getSourceCode(): 返回一个SourceCode对象,可以使用该对象处理传递给 ESLint 的源代码

具体实现

在匹配到 ImportDeclaration 节点后,需要遍历我们配置的规则。如果节点的 source 可以匹配规则的 target,则遍历节点的 specifiers,当配置的 names 包含该 specifiers 时,即上报错误:

const visitor = {
  ImportDeclaration: (node) => {
    context.options.forEach((option) => {
      const regexp = minimatch.makeRe(option.target);

      if (!regexp.test(node.source.value)) {
        return;
      }

      node.specifiers.forEach((spec, index) => {
        if (spec.type !== 'ImportSpecifier') {
          return;
        }

        if (option.names.includes(spec.imported.name)) {
          reportModule(spec, node, option);
        }
      });
    });
  },
};

context.report 上报错误时,通过 fix 参数来修复错误,我们这里需要先删除匹配到的 name,然后再新的一行插入替换路径后的导入代码。

插入代码直接调用 fixer.insertTextAfter 就可以。但在移除相关的导入时,如果直接使用 fixer.remove 会保留 name 后面的逗号,所以可以通过 sourceCode.getTokenAfter 拿到逗号的 range,再使用 fixer.removeRange 来删除。

const sourceCode = context.getSourceCode();

const reportModule = (spec, node, option) => {
  const moduleName =
    spec.imported.name == spec.local.name
      ? spec.imported.name
      : `${spec.imported.name} as ${spec.local.name}`;

  const afterToken = sourceCode.getTokenAfter(spec);

  context.report({
    node: spec.imported,
    messageId: 'replace-module',
    fix: function (fixer) {
      return [
        fixer.removeRange([
          spec.range[0],
          afterToken.value === ',' ? afterToken.range[1] : spec.range[1],
        ]),

        fixer.insertTextAfter(
          node,
          `\nimport { ${moduleName} } from '${option.replace}';`,
        ),
      ];
    },
  });
};

单元测试

代码写完之后我们需要进行测试,测试可以使用 ESLint 提供的单元测试工具。测试分为 valid 和 invalid 两部分,valid 是符合规则的代码,invalid 是不符合规则的代码,在 invalid 中写入所有我们需要处理的情况:

const options = [
  {
    target: 'umi',
    replace: '@/utils/router',
    names: ['Link', 'useHistory'],
  },
];

ruleTester.run('no-internal-modules', rule, {
  valid: [
    { code: "import { useRequest } from 'umi'", options },
    { code: "import { useRequest, useModel } from 'umi'", options },
  ],

  invalid: [
    {
      code: "import { Link, useModel } from 'umi';",
      errors: [
        {
          messageId: 'replace-module',
          data: { name: 'Link', replace: '@/utils/router' },
        },
      ],
      options,
      output:
        "import {  useModel } from 'umi';\nimport { Link } from '@/utils/router';",
    },
  ],
});

在测试通过之后,将该项目发布到 npm,就可以提供给其他开发者使用了。完整的代码[6]

总结

本文从实际需求出发,简单介绍了 ESLint 的基本原理,并编写 ESLint 规则来解决开发过程中的问题。大家可以结合自己的实践,发掘更多 ESLint 的用法,让它帮助我们写出更健壮的代码。

引用链接

[1] AST Explorer: https://astexplorer.net/
[2] generator-eslint: https://www.npmjs.com/package/generator-eslint
[3] ESTree: https://github.com/estree/estree
[4] selector: https://cn.eslint.org/docs/developer-guide/selectors
[5] 代码路径分析: https://cn.eslint.org/docs/developer-guide/code-path-analysis
[6] 代码: https://github.com/HungryFeng/eslint-plugin-import-path-plus