Writing custom TypeScript ESLint rules: How I learned to love the AST Why writing your own eslint plugins and rules ? The (not so) imaginary problem we’re solving Creating a TS project to lint If there’s a pattern, there’s automation Initializing our eslint plugin project Going further

2025-06-08

编写自定义 TypeScript ESLint 规则:我如何爱上 AST

为什么要编写自己的 eslint 插件和规则?

我们正在解决的(并非如此)虚构的问题

创建 TS 项目进行 lint

如果有模式,就有自动化

初始化我们的 eslint 插件项目

更进一步

在这篇博文中,我们将学习如何编写自定义 ESLint 插件来帮助您完成原本需要花费数天时间的手动任务。


任务是什么?一个 eslint 规则,将泛型添加到 Enzyme 浅层调用中,这样我们就可以避免在测试期间出现组件的类型错误。

让我们深入了解 AST 的世界:它们并不像看起来那么可怕!

为什么要编写自己的 eslint 插件和规则?

  • 写起来很有趣,并能帮助你更多地了解 JS/TS

  • 它可以帮助强制执行公司特定的风格和模式

  • 它可以为你节省数天的工作时间😃

目前已经有很多规则,包括如何设置花括号的样式不从异步函数返回 await 表达式甚至不使用 undefined 初始化变量

问题是,lint 规则几乎是无限的。事实上,我们经常会看到针对某些库、框架或用例的新插件层出不穷。所以,为什么不自己动手写一个呢?我保证,这并不可怕!

我们正在解决的(并非如此)虚构的问题

教程经常使用 foo、bar 和 baz 或类似的抽象概念来教你一些东西。为什么不去解决一个实际问题呢?我们团队在转换到 TypeScript 后尝试解决一些 TypeScript 类型错误时遇到了一个问题。

如果您使用过酶来测试 TypeScript React 代码库,您可能知道浅层调用接受泛型,即您的组件。例如shallow<User>(<User {...props})

来自 DefinitelyTyped 的酶的浅层类型定义来自 DefinitelyTyped 的酶的浅层类型定义

如果不传递呢?可能“没问题”,但当你尝试访问组件的 props 或方法时,就会出现类型错误,因为 TypeScript 认为你的组件是一个通用的 React 组件,没有 props、state 或方法。

当然,如果你从头开始编写新的测试,你可以使用 IDE 或 TypeScript 的 tsc 命令立即捕获它并添加泛型。但你可能需要将它添加到 1 个、100 个甚至 1000 个测试中,例如,因为:

  • 你将整个项目从 JS 迁移到 TS,完全没有输入

  • 你将整个项目从 Flow 迁移到 TS,但缺少一些库的类型

  • 您是 TS 项目的新贡献者,使用酶来测试反应组件,并且不熟悉泛型

事实上,这是我在团队中遇到的一个问题,我们今天编写的相同的 eslint 规则通过在整个项目中修复这个问题为我们节省了大量时间。

ESLint 是如何工作的?AST 的魔力

在开始深入研究创建 ESLint 规则之前,我们需要了解什么是 AST 以及为什么它们对开发人员如此有用。

AST,即抽象语法树,是以树的形式表示您的代码,可供计算机读取和操作。

我们用高级的、人类可理解的语言(例如 C、Java、JavaScript、Elixir、Python、Rust 等)为计算机编写代码,但计算机并非人类:换句话说,它无法理解我们所写内容的含义。我们需要一种方法让计算机从语法的角度解析你的代码,理解它const是变量声明,{}有时是对象表达式的开头,有时是函数的开头……等等。这通过抽象语法树 (AST) 来实现,这是必要的一步。

一旦它理解了它,我们就可以用它做很多事情:通过将它传递给引擎来执行它,对它进行 lint ……或者甚至通过反过来执行相同的过程来生成新代码。

AST 是我们日常使用的工具的基础,例如 Babel、Webpack 和 eslint/prettier。

引用Jason Williams 的话,生成 AST 的基本架构可以是:

我们将代码分解为具有语义含义的各种标记(又名词法分析器/标记器),对它们进行分组,并将它们作为字符组发送给将生成表达式的解析器,该解析器可以容纳其他表达式。—— Jason Williams -让我们在 Rust 中构建一个 JavaScript 引擎@JSConf EU
2019

这样的树听起来很熟悉?这和 HTML 代码被解析成DOM节点树的方式非常相似。事实上,只要有对应的解析器,我们就可以生成任何语言的抽象表示。

我们来看一个简单的JS例子:

const user = {
  id: "unique-id-1",
  name: "Alex",
};
Enter fullscreen mode Exit fullscreen mode

它可以用 AST 来表示:


AST Explorer 中我们的 JS 代码的抽象表示。

为了实现可视化,我们使用了一个非常棒的工具:https://astexplorer.net。它允许我们可视化多种语言的语法树。我建议将不同的 JS 和 TS 代码粘贴到那里,稍微探索一下这个工具,因为我们稍后会用到它!

确保选择要粘贴的代码的语言以获取正确的 AST!

创建 TS 项目进行 lint

如果您已经有 TS + React + Jest 项目,请随意跳到下一部分,或者从这一部分中选择您需要的内容!

让我们创建一个虚拟的 React + TypeScript + Jest + Enzyme 项目,它将受到我们之前看到的类型问题的影响。

从概念上讲,解析 TypeScript 代码与解析 JS 代码并无二致,我们需要一种方法将 TypeScript 代码解析成树状结构。值得庆幸的是,typescript-eslint 插件已经自带了TypeScript 解析器。那就开始吧!

创建一个ast-learning文件夹并添加一个包含 react、jest、enzyme、eslint 和所有类型定义的 package.json 文件。

{
  "name": "ast-learning",
  "version": "1.0.0",
  "description": "Learn ASTs by writing your first ESLint plugin",
  "main": "src/index.js",
  "dependencies": {
    "react": "17.0.0",
    "react-dom": "17.0.0",
    "react-scripts": "3.4.3"
  },
  "devDependencies": {
    "@babel/preset-env": "^7.12.1",
    "@babel/preset-react": "^7.12.5",
    "@types/enzyme": "^3.10.8",
    "@types/enzyme-adapter-react-16": "^1.0.6",
    "@types/jest": "^26.0.15",
    "@types/react": "^16.9.56",
    "@types/react-dom": "^16.9.9",
    "@typescript-eslint/eslint-plugin": "^4.8.1",
    "@typescript-eslint/parser": "^4.8.1",
    "babel-jest": "^26.6.3",
    "enzyme": "3.11.0",
    "enzyme-adapter-react-16": "1.15.5",
    "eslint": "^7.13.0",
    "jest": "^26.6.3",
    "react-test-renderer": "^17.0.1",
    "ts-jest": "^26.4.4",
    "typescript": "3.8.3"
  },
  "scripts": {
    "lint": "eslint ./*.tsx",
    "test": "jest index.test.tsx",
    "tsc": "tsc index.tsx index.test.tsx --noEmit true --jsx react"
  }
}
Enter fullscreen mode Exit fullscreen mode

让我们创建一个最小tsconfig.json文件来让 TypeScript 编译器满意 :)。

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "module": "esnext",
    "lib": ["es6", "dom"],
    "jsx": "react",
    "moduleResolution": "node"
  },
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

作为我们项目的最后一个配置步骤,让我们.eslintrc.js暂时添加空规则:

module.exports = {
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 12,
    sourceType: "module",
  },
  plugins: [
    "@typescript-eslint",
    "ast-learning", // eslint-plugin-ast-learning
  ],
  rules: {
    "ast-learning/require-enzyme-generic": "error",
  },
};
Enter fullscreen mode Exit fullscreen mode

现在我们的项目已经准备好所有配置,让我们创建index.tsx包含User组件的项目:

import * as React from "react";

type Props = {};
type State = { active: boolean };

class User extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { active: false };
  }
  toggleIsActive() {
    const { active } = this.state;
    this.setState({ active: !active });
  }

  render() {
    const { active } = this.state;
    return (
      <div className="user" onClick={() => this.toggleIsActive()}>
        User is {active ? "active" : "inactive"}
      </div>
    );
  }
}

export { User };
Enter fullscreen mode Exit fullscreen mode

以及一个名为的测试文件index.test.tsx

import * as React from "react";
import * as Adapter from "enzyme-adapter-react-16";
import * as enzyme from "enzyme";
import { User } from "./index";

const { configure, shallow } = enzyme;

configure({ adapter: new Adapter() });

describe("User component", () => {
  it("should change state field on toggleIsActive call", () => {
    const wrapper = shallow(<User />);
    // @ts-ignore
    wrapper.instance().toggleIsActive();
    // @ts-ignore
    expect(wrapper.instance().state.active).toEqual(true);
  });

  it("should change state field on div click", () => {
    const wrapper = shallow(<User />);
    wrapper.find(".user").simulate("click");
    // @ts-ignore
    expect(wrapper.instance().state.active).toEqual(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

现在运行npm i && npx ts-jest config:init && npm run test
我们可以看到,由于// @ts-ignore指令注释,TSX 编译正常。

@ts-ignore指令注释指示 TypeScript 编译器忽略下一行的类型错误。所以,它编译通过,测试也运行正常,一切正常吗?不对!让我们删除@ts-ignore指令注释,看看会发生什么。

❌❌现在测试甚至无法运行,并且我们的测试中有 3 个 TypeScript 错误。

哦不😞!正如介绍中所说,我们可以手动将泛型添加到所有浅层调用中来解决这个问题。
可以,但可能不应该。

const wrapper = shallow<User>(<User />); // here, added User generic type
Enter fullscreen mode Exit fullscreen mode

shallow这里的模式很简单,我们需要获取调用的参数,然后将其作为类型参数(也就是泛型)传递。
我们当然可以让计算机帮我们生成这个吧?既然有模式,就一定有自动化。

耶,这就是 lint 规则的用例!让我们编写代码来修复我们的代码吧🤯

如果有模式,就有自动化

如果你能从代码中找到一些模式,让计算机能够分析、警告、阻止你执行某些操作,甚至帮你编写代码,那么 AST 就能带来神奇的效果。在这种情况下,你可以:

  • 编写 ESLint 规则,可以:

    • 使用自动修复功能,防止错误并帮助遵守约定,使用自动生成的代码
    • 没有自动修复,提示开发人员应该做什么
  • 编写一个codemod。这是一个不同的概念,同样得益于 AST,但是为了在大批量文件中运行而生,并且对遍历和操作 AST 拥有更强的控制力。在代码库中运行 codemod 是一项更繁重的操作,不像 eslint 那样每次击键都运行。

正如你所猜测的,我们将编写一个 eslint 规则/插件。开始吧!

初始化我们的 eslint 插件项目

现在我们有了一个要编写规则的项目,让我们通过创建另一个名为eslint-plugin-ast-learningnext 的项目文件夹来初始化我们的 eslint 插件ast-learning

⚠️️eslint 插件遵循惯例eslint-plugin-your-plugin-name

让我们首先创建一个package.json文件:

{
  "name": "eslint-plugin-ast-learning",
  "description": "Our first ESLint plugin",
  "version": "1.0.0",
  "main": "index.js"
}
Enter fullscreen mode Exit fullscreen mode

并且index.js包含我们所有插件的规则,在我们的例子中只有一个,require-enzyme-generic:

const rules = {
  "require-enzyme-generic": {
    meta: {
      fixable: "code",
      type: "problem",
    },
    create: function (context) {
      return {};
    },
  },
};

module.exports = {
  rules,
};
Enter fullscreen mode Exit fullscreen mode

每个规则包含两个属性:meta和。您可以在此处create阅读文档,但 tl;dr 是

  • meta对象将包含有关 eslint 将使用的规则的所有信息,例如:

  • 简而言之,它有什么作用?

  • 它可以自动修复吗?

  • 它是否会导致错误并需要优先解决,或者它只是风格问题

  • 完整文档的链接是什么?

  • create函数将包含规则的逻辑。它使用上下文对象调用,该对象包含许多有用的属性,详情请参阅此处

它返回一个对象,其中的键可以是tokens你当前正在解析的 AST 中存在的任意值。对于每个 token,eslint 都允许你编写一个方法声明,其中包含针对该 token 的具体逻辑。token 的示例包括:

  • CallExpression:函数调用表达式,例如:
shallow()
Enter fullscreen mode Exit fullscreen mode
  • VariableDeclaration:变量声明(不带前面的 var/let/const 关键字)例如:
SomeComponent = () => (<div>Hey there</div>)
Enter fullscreen mode Exit fullscreen mode
  • StringLiteral:字符串文字,例如
'test'
Enter fullscreen mode Exit fullscreen mode

了解什么是什么的最好方法是将您的代码粘贴到 ASTExplorer 中(同时确保为您的语言选择正确的解析器)并探索不同的标记。

定义 lint 错误发生的条件


ASTExplorer 输出我们的代码

转到 AST explorer 的左侧窗格并选择我们的 shallow() 调用(或将鼠标悬停在右侧窗格上的相应属性上):您将看到它是CallExpression类型

那么,让我们在规则中添加逻辑来匹配这一点!
我们将CallExpression属性添加到方法返回的对象中create

const rules = {
  "require-enzyme-generic": {
    meta: {
      fixable: "code",
      type: "problem",
    },
    create: function (context) {
      return {
        CallExpression(node) {
          // TODO: Magic 🎉
        },
      };
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

你声明的每个方法都会被 ESLint 回调,并node在遇到时返回相应的结果。
如果我们查看 babel(TS 解析器使用的 AST 格式)文档,我们可以看到 节点CallExpression包含一个callee属性,即ExpressionExpression因为 有一个属性,所以我们在方法name内部创建一个检查。CallExpression

CallExpression(node) {
  // run lint logic on shallow calls
  if (node.callee.name === "shallow" && !node.typeParameters) {
    // Do something, but what?
  }
},
Enter fullscreen mode Exit fullscreen mode

我们还要确保只针对那些不存在泛型的浅层调用。回到 AST Explorer,我们可以看到有一个名为 typeArguments 的条目,它是 Babel AST 调用的typeParameters,它是一个包含函数调用类型参数的数组。因此,让我们确保它undefined(没有泛型 egshallow()或空泛型 eg shallow<>)或是一个空数组(意味着我们有一个内部没有任何内容的泛型)。


Enter fullscreen mode Exit fullscreen mode

好了!我们找到了应该报告错误的条件。

下一步是使用context.report方法。查看 ESLint 文档,我们可以看到此方法用于报告警告/错误,并提供自动修复方法:

您将使用的主要方法是context.report(),它会发布警告或错误(取决于所使用的配置)。此方法接受一个参数,该参数是一个包含以下属性的对象:(更多内容请参阅 ESLint 文档

我们将输出 3 个属性:

  • node(当前节点)。它有两个用途:告诉 eslint错误发生的位置,以便用户在运行 eslint 时看到行信息,或者在 IDE 中使用 eslint 插件高亮显示。此外,它还告诉用户当前节点是什么,以便我们可以对其进行操作,或者在节点前后插入文本。

  • message:eslint 将针对此错误报告的消息

  • fix:自动修复此节点的方法

  CallExpression(node) {
    if (
      node.callee.name === "shallow" &&
      !(node.typeParameters && node.typeParameters.length)
    ) {
      context.report({
        node: node.callee, // shallow
        message:
          `enzyme.${node.callee.name} calls should be preceded by their component as generic. ` +
          "If this doesn't remove type errors, you can replace it with <any>, or any custom type.",
        fix: function (fixer) {
          // TODO
        },
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

我们成功输出了错误。但我们希望更进一步,自动修复代码,可以使用eslint --fixflag,也可以使用 IDE 的 eslint 插件。
让我们来编写 fix 方法吧!

编写fix方法

首先,让我们编写一个早期返回,它将插入<any>到 shallow 关键字之后,以防我们没有使用某些 JSX 元素调用 shallow()。

为了在节点或标记后插入,我们使用该insertTextAfter方法。

insertTextAfter(nodeOrToken, text) - 在给定节点或标记后插入文本

fix: function (fixer) {
  const hasJsxArgument =
    node.arguments &&
    node.arguments.find(
      (argument, i) => i === 0 && argument.type === "JSXElement"
    );
  if (!hasJsxArgument) {
    fixer.insertTextAfter(node.callee, "<any>");
  }
};

Enter fullscreen mode Exit fullscreen mode

在提前返回之后,我们知道第一个参数是一个 JSX 元素。如果这是第一个参数(它应该shallow()只接受一个作为第一个参数,正如我们在它的typesJSXElement中看到的),我们就获取它并将其作为泛型插入。

fix: function (fixer) {
  const hasJsxArgument =
    node.arguments &&
    node.arguments.find(
      (argument, i) => i === 0 && argument.type === "JSXElement"
    );
  if (!hasJsxArgument) {
    fixer.insertTextAfter(node.callee, "<any>");
  }

  const expressionName = node.arguments[0].openingElement.name.name;
  return fixer.insertTextAfter(node.callee, `<${expressionName}>`);
}
Enter fullscreen mode Exit fullscreen mode

就这样!我们捕获了调用 shallow() 的 JSX 表达式的名称,并将其作为泛型插入到 shallow 关键字之后。

现在让我们在之前创建的项目中使用我们的规则!

使用我们的自定义插件

回到我们的 ast-learning 项目,让我们安装我们的 eslint 插件 npm 包:

npm install ./some/path/in/your/computer从您指定的路径安装 Node 模块。这对于本地 Node 模块开发非常有用!

npm install ../eslint-plugin-ast-learning
Enter fullscreen mode Exit fullscreen mode

到目前为止,如果我们通过运行来对不应该通过 ling 的文件进行 lint npm run lint,或者使用我们的编辑器打开index.test.tsx它(如果它安装了 eslint 插件),我们将看不到任何错误,因为我们还没有添加插件和规则。

让我们将它们添加到我们的.eslintrc.js文件中:

    module.exports = {
     "parser": "@typescript-eslint/parser",
     "parserOptions": {
      "ecmaVersion": 12,
      "sourceType": "module"
     },
     "plugins": [
      "@typescript-eslint",
      "ast-learning", // eslint-plugin-ast-learning
     ],
     "rules": {
      "ast-learning/require-enzyme-generic": 'error'
     }
    }
Enter fullscreen mode Exit fullscreen mode

如果您npm run lint再次运行或使用带有 eslint 插件的 IDE 转到该文件,您现在应该会看到错误:

    /Users/alexandre.gomes/Sites/ast-learning/index.test.tsx
      12:21  error  enzyme.shallow calls should be preceeded by their component as generic. If this doesn't remove type errors, you can replace it
     with <any>, or any custom type  ast-learning/require-enzyme-generic
      20:21  error  enzyme.shallow calls should be preceeded by their component as generic. If this doesn't remove type errors, you can replace it
     with <any>, or any custom type  ast-learning/require-enzyme-generic

    ✖ 2 problems (2 errors, 0 warnings)
      2 errors and 0 warnings potentially fixable with the `--fix` option.
Enter fullscreen mode Exit fullscreen mode

它们可以自动修复,真有意思!不如试试看?

❯ npm run lint -- --fix
Enter fullscreen mode Exit fullscreen mode

哇哦!我们的文件现在包含了泛型。想象一下它在数千个文件中运行。代码生成的威力!

更进一步

如果您想了解有关 ESLint 自定义插件的更多信息,您需要阅读非常完整的 ESLint 文档。

你还需要为你的规则添加大量的测试,因为根据经验,eslint 自动修复(以及 jscodeshift codemods,这是另一篇文章的主题)有很多边缘情况可能会破坏你的代码库。测试不仅是确保规则可靠性的必要条件,也是贡献官方规则的必要条件😉

鏂囩珷鏉yu簮锛�https://dev.to/alexgomesdev/writing-custom-typescript-eslint-rules-how-i-learned-to-love-the-ast-15pn
PREV
我的编程之旅:学习数学!
NEXT
十大 Git GUI 客户端