背景
有很多场景需要用到 TypeScript 解析器,
比如对 .ts 文件使用 ESLint,又比如在 Babel 技术栈中支持 .ts 文件。
其中,ESLint 对 TypeScript 的解析,使用了 @typescript-eslint/parser,
Babel 对 TypeScript 的解析,使用了 @babel/parser。
我们打算逐个来分析一下,它们是怎么解析的。
本文先从 @typescript-eslint/parser 开始。
1. 调试 ESLint
为了看清楚 @typescript-eslint/parser 到底是怎么解析 TypeScript 的,
我新建了一个测试项目,并写好了 VSCode 调试配置。
源码在这里:debug-eslint
1.1 目录结构
debug-eslint
├── .eslintrc <- ESLint 配置
├── .gitignore
├── .vscode
│ └── launch.json <- VSCode 调试配置
├── index.ts <- 测试 ESLint 功能的 .ts 文件
└── package.json
1.2 外部依赖
package.json 中增加了这些依赖,
{
...,
"devDependencies": {
"@typescript-eslint/eslint-plugin": "3.0.1",
"@typescript-eslint/parser": "3.0.1",
"eslint": "7.1.0",
"typescript": "3.9.3"
},
}
安装依赖
$ npm i
1.3 测试文件 & ESLint 配置
为了测试 ESLint 的功能,我新建了一个 index.ts 文件
const a = 1;
然后配置 ESLint,配置文件默认为 .eslintrc
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error"
}
}
其中,parser
字段指定让 ESLint 使用自定义的解析器 @typescript-eslint/parser,
为了测试 ESLint 的 lint 功能,我配置了 @typescript-eslint/eslint-plugin,
并启用了 @typescript-eslint/no-unused-vars
这条规则。
测试文件 index.ts 中,我们定义了变量 a
但没有使用,ESLint 会报这样的错误。
$ npm run lint
> debug-eslint@1.0.0 lint /Users/.../debug-eslint
> eslint index.ts
/Users/.../debug-eslint/index.ts
1:7 error 'a' is assigned a value but never used @typescript-eslint/no-unused-vars
✖ 1 problem (1 error, 0 warnings)
其中 lint
scripts 已经配置在 package.json 中了,后文会提到。
1.4 VSCode 调试配置
调试配置文件是 VSCode 自动生成的,也可以手动新建 .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug ESLint",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"debug"
],
"port": 5858,
"stopOnEntry": true
}
]
}
其中,runtimeExecutable
,runtimeArgs
,port
,stopOnEntry
是我后来添加的,
用于和 package.json 中的 debug
npm scripts 配合使用。
因此,package.json 中需要新增一个 debug
scripts,
{
...,
"scripts": {
"lint": "eslint index.ts",
"debug": "node --inspect-brk=5858 node_modules/.bin/eslint index.ts"
},
}
这样当 VSCode 启动调试时,会自动执行 debug
这条 scripts,并进入断点了。
1.5 进行调试
使用 VSCode 打开 debug-eslint 项目,然后按 F5
启动调试。
2. ESLint 主流程
为了能够独立分析 @typescript-eslint/parser 的业务逻辑,
先从整体上了解一下 ESLint 的 lint 过程是有帮助的。
我根据上文的调试方法,在 VSCode 中跟进代码,找到了一个能够反映 ESLint 整体流程的断点位置。
eslint/lib/linter/linter.js#L908,
当前 ESLint 的版本是 v7.1.0。
在图中所示的位置打个断点,然后按 F5
执行到断点位置,
这个断点位置是 ESLint 用来反馈错误的,我们来看一下调用栈。
调用栈可以分为两个部分,
(1)从 main
到 _verifyWithoutProcessors
,ESLint 对 TypeScript 源码进行了解析。
(2)从 runRules
到 report
,执行 ESLint 插件体系中的 rules,得到 lint 结果。
后半部分的逻辑,我们不太关心,
概括而言,ESLint 拿到前半部分解析到的 AST 之后,会对 AST 进行遍历。
从而触发 rules 中给特定 AST 节点注册的事件监听器(Working with Rules)。
值得一提的是,向 AST 节点注册事件监听器的方式,跟 jQuery 有异曲同工之妙。
都是采用了 css selector 的语法形式,对节点进行匹配。
不同是只是 jQuery 监听的是 DOM Tree,ESLint 监听的是 AST 罢了。
触发了特定 AST 节点的监听器之后,ESLint 会执行检查逻辑,
本例中 @typescript-eslint/no-unused-vars
规则,
会调用 eslint/lib/rules/no-unused-vars.js#L621 中的 Program:exit
监听器。
这个回调会在遍历器离开 Program
节点时触发。
它检查了所有的未被使用的变量,如果有的话,就调用 context.report
报告错误。
3. 解析过程
3.1 parser
明白了 ESLint 检查错误的流程之后,就可以静下心来研究 @typescript-eslint/parser 的解析过程了。
通过查看上文的调用栈,我们找到了 ESLint 对 TypeScript 源码进行解析的位置。
位于 _verifyWithoutProcessors
函数中 eslint/lib/linter/linter.js#L1124 ,
_verifyWithoutProcessors(textOrSourceCode, providedConfig, providedOptions) {
...
let parserName = DEFAULT_PARSER_NAME;
let parser = espree;
if (typeof config.parser === "object" && config.parser !== null) {
parserName = config.parser.filePath;
parser = config.parser.definition;
} else if (typeof config.parser === "string") {
...
}
...
if (!slots.lastSourceCode) {
const parseResult = parse(
text,
parser,
parserOptions,
options.filename
);
...
} else {
...
}
...
try {
lintingProblems = runRules(
...
);
} catch (err) {
...
}
...
}
可以看到 _verifyWithoutProcessors
先是对 TypeScript 进行解析 parse
,
然后将 parse 结果传给 runRules
执行 lint。
其中,parser
默认会使用 espres。
但由于本例中,我们在 .eslintrc 中配置了自定义 parser
{
"parser": "@typescript-eslint/parser",
...,
}
所以,实际进行解析的 parser 就是变成 @typescript-eslint/parser 了。
/Users/.../debug-eslint/node_modules/_@typescript-eslint_parser@3.0.1@@typescript-eslint/parser/dist/index.js
正是 node_modules 中的 @typescript-eslint/parser v3.0.1 模块。
3.2 estree
(1)标准的 TypeScript AST
我们再跟进 @typescript-eslint/parser 的解析过程,
发现它又调用了 @typescript-eslint/typescript-estree。
在 typescript-estree/src/create-program/createIsolatedProgram.ts#L75 生成了 AST。
import * as ts from 'typescript';
...
function createIsolatedProgram(code: string, extra: Extra): ASTAndProgram {
...
const program = ts.createProgram(
[extra.filePath],
...
compilerHost,
);
const ast = program.getSourceFile(extra.filePath);
...
}
看到这里就有些眼熟了,原来是调用了 typescript 模块生成了 AST。
完整的调用栈如下,
(2)转换成 ESTree
ESLint 调用 typescript 模块将 TypeScript 源码转为 AST 之后,并没有直接交给下游进行遍历。
而是对 AST 进行了变换,转换成了兼容 ESLint 的 ESTree,
这是为了兼容更多现有的 ESLint plugin。
转换过程发生在 parseAndGenerateServices
typescript-estree/src/parser.ts#L426-L438 函数中,
function parseAndGenerateServices<T extends TSESTreeOptions = TSESTreeOptions>(
code: string,
options: T,
): ParseAndGenerateServicesResult<T> {
...
const { ast, program } = getProgramAndAST(
code,
shouldProvideParserServices,
extra.createDefaultProgram,
)!;
...
const { estree, astMaps } = astConverter(ast, extra, preserveNodeMaps);
...
}
我们来看下调用栈的分叉,
main
execute
lintFiles
executeOnFiles
verifyText
verifyAndFix
verify
_verifyWithConfigArray
_verifyWithoutProcessors
parse
parseForESLint
parseAndGenerateServices <- 解析 & 转换
getProgramAndAST <- 解析
createIsolatedProgram
...
astConverter <- 转换
convertProgram
converter
convertNode
runRules
...
parseAndGenerateServices
先调用了 getProgramAndAST
将源码解析为标准的 TypeScript AST,
然后再调用 astConverter
将 TypeScript AST 转换成 ESTree。
(3)astConverter
astConverter
位于 typescript-estree/src/ast-converter.ts#L34,
它实例化了一个 Converter
,然后调用 convertProgram
进行程序转换。
export function astConverter(
ast: SourceFile,
extra: Extra,
shouldPreserveNodeMaps: boolean,
): { estree: TSESTree.Program; astMaps: ASTMaps } {
...
const instance = new Converter(ast, {
...
});
const estree = instance.convertProgram();
...
}
从 astConverter
开始的调用栈是这样的,
astConverter
convertProgram
converter <- 递归
convertNode
...
converter <- 递归
convertProgram
是整个转换器的入口函数,它调用了 converter
实例方法,在 AST 上进行递归转换。
通过对 AST 进行遍历,将新的 ESTree 重新生成(
createNode
)出来。
converter
会调用一个巨型的 switch...case
函数,
convertNode
,typescript-estree/src/convert.ts#L592-L2697,有 2106
行。
它会根据不同的 TypeScript AST 节点,创造不同的 ESTree 节点。
4. 总结
本文介绍了 ESLint 解析 TypeScript 源码的过程。
我们先是配置 .eslintrc 自定义了 parser,@typescript-eslint/parser,
并跑通了一个示例,从整体上了解了 ESLint 的 lint 流程。
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error"
}
}
然后,仔细研究了 @typescript-eslint/parser 和 @typescript-eslint/typescript-estree。
发现在解析过程中,ESLint 会先调用 typescript 模块,生成标准的 TypeScript AST,
然后再将它转换成兼容 ESLint 的 ESTree。
看来熟悉官方的 typescript 模块还是很重要的。
参考
github: debug-eslint
eslint v7.1.0
@typescript-eslint/parser v3.0.1
@typescript-eslint/typescript-estree v3.0.1
typescript
ESTree