编写自定义 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})
。
如果不传递呢?可能“没问题”,但当你尝试访问组件的 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",
};
它可以用 AST 来表示:
为了实现可视化,我们使用了一个非常棒的工具: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"
}
}
让我们创建一个最小tsconfig.json
文件来让 TypeScript 编译器满意 :)。
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"module": "esnext",
"lib": ["es6", "dom"],
"jsx": "react",
"moduleResolution": "node"
},
"exclude": ["node_modules"]
}
作为我们项目的最后一个配置步骤,让我们.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",
},
};
现在我们的项目已经准备好所有配置,让我们创建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 };
以及一个名为的测试文件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);
});
});
现在运行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
shallow
这里的模式很简单,我们需要获取调用的参数,然后将其作为类型参数(也就是泛型)传递。
我们当然可以让计算机帮我们生成这个吧?既然有模式,就一定有自动化。
耶,这就是 lint 规则的用例!让我们编写代码来修复我们的代码吧🤯
如果有模式,就有自动化
如果你能从代码中找到一些模式,让计算机能够分析、警告、阻止你执行某些操作,甚至帮你编写代码,那么 AST 就能带来神奇的效果。在这种情况下,你可以:
-
编写 ESLint 规则,可以:
- 使用自动修复功能,防止错误并帮助遵守约定,使用自动生成的代码
- 没有自动修复,提示开发人员应该做什么
-
编写一个codemod。这是一个不同的概念,同样得益于 AST,但是为了在大批量文件中运行而生,并且对遍历和操作 AST 拥有更强的控制力。在代码库中运行 codemod 是一项更繁重的操作,不像 eslint 那样每次击键都运行。
正如你所猜测的,我们将编写一个 eslint 规则/插件。开始吧!
初始化我们的 eslint 插件项目
现在我们有了一个要编写规则的项目,让我们通过创建另一个名为eslint-plugin-ast-learning
next 的项目文件夹来初始化我们的 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"
}
并且index.js
包含我们所有插件的规则,在我们的例子中只有一个,require-enzyme-generic:
const rules = {
"require-enzyme-generic": {
meta: {
fixable: "code",
type: "problem",
},
create: function (context) {
return {};
},
},
};
module.exports = {
rules,
};
每个规则包含两个属性:meta
和。您可以在此处create
阅读文档,但 tl;dr 是
-
该
meta
对象将包含有关 eslint 将使用的规则的所有信息,例如: -
简而言之,它有什么作用?
-
它可以自动修复吗?
-
它是否会导致错误并需要优先解决,或者它只是风格问题
-
完整文档的链接是什么?
-
该
create
函数将包含规则的逻辑。它使用上下文对象调用,该对象包含许多有用的属性,详情请参阅此处。
它返回一个对象,其中的键可以是tokens
你当前正在解析的 AST 中存在的任意值。对于每个 token,eslint 都允许你编写一个方法声明,其中包含针对该 token 的具体逻辑。token 的示例包括:
- CallExpression:函数调用表达式,例如:
shallow()
- VariableDeclaration:变量声明(不带前面的 var/let/const 关键字)例如:
SomeComponent = () => (<div>Hey there</div>)
- StringLiteral:字符串文字,例如
'test'
了解什么是什么的最好方法是将您的代码粘贴到 ASTExplorer 中(同时确保为您的语言选择正确的解析器)并探索不同的标记。
定义 lint 错误发生的条件
转到 AST explorer 的左侧窗格并选择我们的 shallow() 调用(或将鼠标悬停在右侧窗格上的相应属性上):您将看到它是CallExpression类型
那么,让我们在规则中添加逻辑来匹配这一点!
我们将CallExpression
属性添加到方法返回的对象中create
:
const rules = {
"require-enzyme-generic": {
meta: {
fixable: "code",
type: "problem",
},
create: function (context) {
return {
CallExpression(node) {
// TODO: Magic 🎉
},
};
},
},
};
你声明的每个方法都会被 ESLint 回调,并node
在遇到时返回相应的结果。
如果我们查看 babel(TS 解析器使用的 AST 格式)文档,我们可以看到 节点CallExpression
包含一个callee
属性,即Expression
。Expression
因为 有一个属性,所以我们在方法name
内部创建一个检查。CallExpression
CallExpression(node) {
// run lint logic on shallow calls
if (node.callee.name === "shallow" && !node.typeParameters) {
// Do something, but what?
}
},
我们还要确保只针对那些不存在泛型的浅层调用。回到 AST Explorer,我们可以看到有一个名为 typeArguments 的条目,它是 Babel AST 调用的typeParameters
,它是一个包含函数调用类型参数的数组。因此,让我们确保它undefined
(没有泛型 egshallow()
或空泛型 eg shallow<>
)或是一个空数组(意味着我们有一个内部没有任何内容的泛型)。
好了!我们找到了应该报告错误的条件。
下一步是使用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
},
});
}
}
我们成功输出了错误。但我们希望更进一步,自动修复代码,可以使用eslint --fix
flag,也可以使用 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>");
}
};
在提前返回之后,我们知道第一个参数是一个 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}>`);
}
就这样!我们捕获了调用 shallow() 的 JSX 表达式的名称,并将其作为泛型插入到 shallow 关键字之后。
现在让我们在之前创建的项目中使用我们的规则!
使用我们的自定义插件
回到我们的 ast-learning 项目,让我们安装我们的 eslint 插件 npm 包:
npm install ./some/path/in/your/computer
从您指定的路径安装 Node 模块。这对于本地 Node 模块开发非常有用!
npm install ../eslint-plugin-ast-learning
到目前为止,如果我们通过运行来对不应该通过 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'
}
}
如果您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.
它们可以自动修复,真有意思!不如试试看?
❯ npm run lint -- --fix
哇哦!我们的文件现在包含了泛型。想象一下它在数千个文件中运行。代码生成的威力!
更进一步
如果您想了解有关 ESLint 自定义插件的更多信息,您需要阅读非常完整的 ESLint 文档。
你还需要为你的规则添加大量的测试,因为根据经验,eslint 自动修复(以及 jscodeshift codemods,这是另一篇文章的主题)有很多边缘情况可能会破坏你的代码库。测试不仅是确保规则可靠性的必要条件,也是贡献官方规则的必要条件😉
鏂囩珷鏉yu簮锛�https://dev.to/alexgomesdev/writing-custom-typescript-eslint-rules-how-i-learned-to-love-the-ast-15pn