[Node] 随遇而安 TypeScript(五):typescript-eslint parser

背景

有很多场景需要用到 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
    }
  ]
}

其中,runtimeExecutableruntimeArgsportstopOnEntry 是我后来添加的,
用于和 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)从 runRulesreport,执行 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 函数,
convertNodetypescript-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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345