作者:祝鑫奔 - 字节跳动IES前端工程师 程序员间无休止的争论
我相信,基本所有程序员都遇到过类似的问题:
- 缩进用制表符好还是空格好?
- 如果缩进用空格的话用两个空格好还是四个空格好?
- 语句末尾要不要加分号?
- 数组最后要不要加逗号?
以及其他无数个争论不休的问题。这些问题困扰了广大程序员不知道多久,让多少个程序员吵得不可开交、头破血流。
制定规范
为了避免程序员内耗导致不可控制的惨烈局面,程序员们决定心平气和地谈谈该怎么解决这些问题。于是聪明的程序员通过各种方式(投票、一致同意等)达成了代码看起来应该是什么样的共识,确定了各种细节,以便结束争端,让大多数人满意。这个共识就是代码规范。
而这时候,JavaScript 引擎说了一句:关我什么事?
引擎不关心
其实不论程序员怎么折腾,JavaScript 引擎都不在乎,程序员写得再烂的代码,只要合法,JavaScript 引擎不会说半个不字;而再漂亮的代码,只要多了一个分号、空格或者逗号,JavaScript 引擎也会直接吐出来并说一句“这是什么垃圾?”
不论代码规范怎么改,看起来怎么不一样,只要是合法的,随你怎么折腾,反正结果都一样,即代码的内在含义是不变的。同样的代码,不论采取什么代码规范,都不会也不应该修改代码的内在含义。这是执行代码规范的底线。
规范实例
因此,程序员们先讨论并制定了各种各样的规范,比如 Google 的规范、AirBnb 的规范等。
这些规范规定了 JavaScript、TypeScript 以及 React 代码看起来应该是什么样的。只要某一种写法或者情况在规范里有相应的约束,就应该按这个约束来,没有约束的情况,随你怎么折腾。
但是规定了是什么样和能确保规范被执行了是两回事,需要有人检查代码是否执行了规范,并在合适的时候告诉程序员哪里有问题,需要改。
因此程序员们开发了一系列工具来监督广大的程序员。
规范实践
早期实践
JSLint
在远古时期,出现了 JSLint,这是由 Douglas Crockford 开发的一个 JavaScript 代码静态分析工具,不过 JSLint 不支持规则自定义,必须遵守作者提供的规则,这肯定不是广大程序员想要的。于是有了 JSHint。
JSHint
JSHint 基于 JSLint 开发,也是一个 JavaScript 代码静态分析工具,和 JSLint 不同的是,它可以自定义规则,非常灵活。然而不足也很明显,历史原因,JSHint 对 ES6 的支持度不够高,且不支持 JSX。
目前实践
ESLint
ESLint 是 Nicholas C. Zakas 主导开发的一个 JavaScript 代码静态分析工具,它既具有 JSHint 的高度可配置性,同时也通过插件机制完整支持了 ES6 语法以及 JSX,甚至通过插件机制可以支持任意阶段的 ECMAScript 特性,同时还可以自定义规则。近些年 ESLint 及其生态也是非常活跃。连 Microsoft 都通过 typescript-eslint 项目让 ESLint 可以支持 TypeScript 的分析。
StyleLint
StyleLint 与 ESLint 类似,也采用插件机制,同时支持 CSS、SCSS、LESS、stylus 等语言的支持,以及下一代 CSS 的语法。同样支持配置高度自定义以及自定义规则。
TSLint
TSLint 是早期的 TypeScript 的分析工具,后由兼容 ESLint 的 @typescript-eslint 项目所替代,TSLint 不再维护。
最后的选择
综合对比已有的实现,我们选择了最热门且最可靠的两个 Lint 工具,ESLint 和 StyleLint。
Lint 工具的抽象实现
不论是 ESLint 还是 StyleLint,他们的实现都是类似甚至一样的。
基本流程
抽象层面来说,Lint 工具就做了一件事,给定代码文本和规则,输出诊断信息。而一般来说有这么几个步骤。
转换为抽象语法树 AST
Lint 工具会先将文件解析为抽象语法树,否则无法分析代码是否存在问题,甚至不知道这段文本是不是合法的代码。抽象语法树抽象地定义了一段代码,语法树可以分析出这段代码的每个节点(变量、关键字、字符串、缩进等等)。
通过 AST 分析可能存在的问题
通过 AST 可以寻找可能存在问题的节点,而这些可能存在问题的节点和对应的问题,就成了这段代码针对该规则的诊断信息。
报告问题
Lint 工具输出的诊断信息需要以合适的形式展现给程序员,以便让他们修改有问题的代码。而根据 Lint 工具运行环境的不同,展现的形式也不一样。比如命令行工具一般会告诉程序员在哪个文件的哪行的哪一列违反了哪一条规则,这时候程序员需要找到这个文件的这一行的这一列针对这个规则进行修改;而在 IDE(比如 VSCode)来说,就会比较直观,有问题的代码会在其下划一条有颜色的波浪线,来提醒程序员这段代码可能存在问题。
修复问题
之前我们讨论过执行代码规范的底线,那就是不改变代码的内在含义。而为了让代码看起来一样,出错的代码必须被修改,有些问题对于 JavaScript 引擎来说是无关痛痒的,比如缩进多了一个或者少了一个,这种代码 JavaScript 引擎在执行的时候不会出现不一致;而有些问题对于 JavaScript 引擎来说可能就是致命的了,比如修改 Object.prototype 下的方法。对 JavaScript 无关痛痒的问题,Lint 工具可以自动修复,比如帮程序员对齐代码缩进,添加或者删除分号等等。这种自动修复可以让程序员专注于修复更加重要的问题上,而不是这些细枝末节的问题。
规则
规则是什么
规则实现了各种各样的规范,每一条规则都对应了某一条规范,规则实现规范靠的就是对 AST 的分析。举一个例子:var foo = "bar"
这段代码经过解析器得到的 AST 就是这样的:
{
"type": "VariableDeclaration",
"start": 0,
"end": 15,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 15
}
},
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 15,
"loc": {
"start": {
"line": 1,
"column": 4
},
"end": {
"line": 1,
"column": 15
}
},
"id": {
"type": "Identifier",
"start": 4,
"end": 7,
"loc": {
"start": {
"line": 1,
"column": 4
},
"end": {
"line": 1,
"column": 7
},
"identifierName": "foo"
},
"name": "foo"
},
"init": {
"type": "StringLiteral",
"start": 10,
"end": 15,
"loc": {
"start": {
"line": 1,
"column": 10
},
"end": {
"line": 1,
"column": 15
}
},
"extra": {
"rawValue": "bar",
"raw": ""bar""
},
"value": "bar"
}
}
],
"kind": "var"
}
程序员看了这一段代码可以很快分析出是什么意思,然而计算机不行,通过 AST,就可以让机器也理解这段代码的含义,进而作出合理的诊断或修复。通过这段代码,计算机知道了这是一个变量声明的语句,根节点的属性 kind
(69 行)告诉计算机这是一个 var 声明,而不是 const
或者 let
。而如果规则定义了不允许使用 var
声明的话,计算机就可以给出不应该使用 var 声明的诊断。为了让程序员知道是哪一个地方违反了规则,需要同时指明问题所在的位置,在这个例子中就是第 5 行的 loc 属性,loc.start 记录了问题开始的位置,而 loc.end 记录了问题结束的位置。
规则就是一个函数,输入 AST,输出诊断。
用图的方式表达更加清晰
AST
配置
配置是什么
配置是对规则的约束。同一条规则的实现,可能出现两种相反或者多种不一致的情况。比如末尾分号,有些规范禁止末尾分号,有些规范要求末尾分号,有些规范无所谓末尾分号,这时候就需要配置告诉规则,该如何针对输入的代码进行诊断。有些规则可能没有配置。
配置和规则的关系
配置和规则只有相互配合才可以工作,单独的配置或者单独的规则都是无法给出符合预期的诊断的。而两者又是相互独立的,因此配置可以和规则在一个包里一起发布,也可以分开单独发布。
配置管理
不同的语言之间甚至同一种语言不同方言之间都有不同的规则,因此也有不同的配置。而有些规则在同一种语言的不同方言之间是由冲突的,因此需要通过某种策略管理这些配置。
Config
这是 MyLinter 中不同语言、方言之间的关系,因此在 MyLinter 中,配置也被分为:
- @my/eslint-config-core
- JavaScript 核心规则配置
- React 规则配置
- @my/eslint-config-vue
- JavaScript 核心配置(来自于 @my/eslint-config-core)
- Vue 规则配置
- @my/eslint-config-ts
- TypeScript 核心配置
- React 规则配置(来自于 @my/eslint-config-core)
- @my/stylelint-config-core
- CSS 规则配置
- SCSS 规则配置
- LESS 规则配置
引擎
光有规则和配置也不能完整的完成任务,规则不知道要检查的代码是什么,也不知道配置是什么,也不知道最后的诊断要给谁看,需要有“指挥官”来协调他们相互合作,这个指挥官就是引擎。
引擎的工作
除解析器、规则、配置之外的工作都由引擎来负责。包括:
- 收集要检查的代码(文件或者字符串)
- 收集配置
- 根据配置收集和准备规则
- 准备解析器
- 将代码解析为 AST
- 将 AST 传递给规则
- 规则返回诊断
- 显示诊断
- 必要时进行自动修复
引擎会不断重复这些过程,直到检查结束。
诊断
根据使用环境的不同,对代码诊断的显示方式也会有所不同,
命令行
命令行中的提示样子都大同小异,错误信息会包括文件名、行、列以及问题,有些情况下还可以展示源代码的前后几行,便于寻找问题。
IDE
将诊断交给 IDE 之后,IDE 会在代码编辑界面里根据诊断提供的代码位置(开始的行列,结束的行列),在代码下方渲染出有颜色的波浪线,提示程序员这里存在问题。
MyLinter 的实现
多样的技术栈
前端技术遍地开花,因此需要支持的语言、语法及其组合相当多:
- JavaScript
- React
- Vue
- TypeScript
- React
- Vue
- CSS
- SCSS
- LESS
- Stylus
好在社区已经有了足够坚实的基础工具,对于 JavaScript 和 TypeScript 的所有语法组合,ESLint 就可以搞定了,而 CSS、SCSS、LESS 以及 Stylus 靠 StyleLint 就可以。
规则
对于 JavaScript 以及 TypeScript 的各种语法,社区已经有了相当完备的 ESLint 插件提供支持了,插件中包含了大量的规则可供使用。
如果还不能满足需求的话,可以实现自定义的规则并发布。
配置
静态配置
配置就是规范的实现。然而这么多语言和语法的组合,会导致一部分规则冲突,甚至无法正常工作。因此针对不同语言和语法的组合需要有不同的配置支持。不同的配置之间总会有一些相同的规则,这些规则如果分散在每个配置中将会导致巨大的维护困难,为此,MyLinter 准备了以下的配置继承结构:
- @my/eslint-config-core
- @my/eslint-config-vue
- @my/eslint-config-ts
- @my/stylelint-config-core
动态配置
大部分情况下,配置都不需要动态修改,但是在少数情况下,某些配置需要在运行时才能确定,在实际引擎运行时会根据状况,动态修改某些配置,比如在 IDE 中切换严格模式/宽松模式以及用户自定义配置。
Linter
假设我们的 Linter 名字是 MyLinter。
MyLinter 是一个自顶向下的架构。
对任何语言来说,该语言的 Linter 就是一个输入代码文本,输出诊断的函数。MyLinter 在设计时也是参照这个架构进行的。
Architecture
MyLinter
MyLinter 是一个抽象类,定义了三种操作:
- 给定文件夹及其他必要信息,对指定文件夹内某一种语言的所有文件进行检查并返回结果
- 给定代码文本、文件名及其他必要信息,返回该文本的诊断结果
- 给定代码文本、文件名及其他必要信息,返回该文本自动修复后的结果
实际上,Linter 一般会有更多的方法,比如:
- 搜索指定文件夹内该文件类型的文件
- 中断检查
在 MyLinter 中,任何语言的 Linter 都是对 MyLinter 的继承和实现,所有代码都是围绕着这三种操作进行的。
ESLinter
ESLinter 是 JavaScript 以及 TypeScript 类型文件的 Linter 抽象实现。在 ESLinter 中,ESLinter 提供了控制 ESLint 的 CLIEngine 实例、ESLint 的配置的接口以及使用 CLIEngine 应用该配置之后检查文件的接口,CLIEngine 实例和配置则是由派生的 Linter 实现,比如 ECMAScriptLinter 就会基于 ESLinter 控制并准备 CLIEngine 实例和配置,再交由 ESLinter 提供的文件检查接口返回检查结果。
StyleLinter
StyleLinter 和 ESLinter 很相似,只不过适用于 CSS、SCSS、LESS 类型文件。它也提供了控制 stylelint 实例和配置的接口以及使用 stylelint 应用配置检查文件的接口。CSSLinter、SCSSLinter、LESSLinter 都是 StyleLinter 的派生 Linter。
ECMAScriptLinter
ECMAScriptLinter 是 ESLinter 的派生类,同时也是 JavaScriptLinter 和 VueLinter 的基类,在这一层中提供了无差别的针对 JavaScript 语言本身的检查接口。
JavaScriptLinter
JavaScriptLinter 中提供了针对普通 JavaScript 代码以及 React 代码的检查接口。
VueLinter
VueLinter 提供了针对 Vue 文件定制的配置及检查接口。由于 Vue 和 React 会存在某些特定规则冲突,因此和普通 JavaScriptLinter 分开提供。
TypeScriptLinter
TypeScriptLinter 提供了针对普通 TypeScript(.ts 结尾)以及 TypeScript React(.tsx 结尾)定制的解析引擎和配置。
SCSSLinter
SCSSLinter 基于 StyleLinter,提供了支持 SCSS 语法的 Linter。
LESSLinter
LESSLinter 基于 StyleLinter,提供了支持 LESS 语法的 Linter。
CSSLinter
CSSLinter 基于 StyleLinter,提供了支持 CSS 语法的 Linter。
引擎
各种各样的 Linter 需要良好的调度才能正常工作,这就是引擎的工作。引擎需要准备 Linter 的实例,并负责:
- 按照顺序调度 Linter 检查指定文件夹
- 将单个文件的诊断请求分配到合适的 Linter 执行并返回诊断结果
- 将单个文件的自动修复请求分配到合适的 Linter 执行并返回应用自动修复后的结果
- 在需要的时候中断检查请求
- 在命令行中输出诊断结果并如期退出(没错误以 0 退出,有错误以 -1 退出)
Engine
诊断指定目录
命令行工具最大的一个用处就是诊断当前目录下的所有文件。除了命令行,还提供 Node.js API 供第三方库使用。
诊断单个文件
命令行工具和 Node.js API 均可以诊断单个文件,命令行通过指定诊断文件,而 Node.js API 一般用于 VSCode 等 IDE 用于实时检测代码质量。
自动修复文件
命令行工具和 Node.js API 均可以自动修复单个或多个文件,命令行通过指定自动修复文件,而 Node.js API 一般用于 VSCode 等 IDE 用于格式化代码。
API 设计
MyLinter 被设计为一个多用途的代码质量检测包,因此需要提供恰当的、易于理解的 API 供用户或者开发者使用。
Application
命令行
MyLint 提供的 my-lint 命令可以在命令行中检查当前目录下的所有文件或者指定模式匹配的文件或者单个文件,可选输出错误的级别或者格式。命令行会根据检查结果选择合适的退出码,集成至 Git Hooks 或者 CI/CD 中。
集成开发环境
有了 Node.js API,可以支持任何 IDE 下的实时代码检测功能,配合编辑器保存时自动格式化、命令行工具以及 Git Hooks,可以让开发者在编写代码时就可以写出符合规范的代码。Node.js API 理论上可支持 VSCode、Sublime Text、Atom、WebStorm 等 IDE。
展望
至此,我们已经完成了 MyLinter 中的所有核心功能,支持包括 JavaScript、TypeScript、CSS、SCSS、LESS 等语言。并可根据需要扩展到 JSON 甚至 Markdown。