eslint 作为前端工程代码规范的一个校验库,在前端项目被广泛使用,应该是目前最流行,当然长话短说,这次分享主要讲一下eslint 插件的原理,这部分涉及ast的一些知识。
AST
eslint是一个代码检查工具,他需要将代码编译一种能够被理解的格式,通过配置文件和输入的规则来校验这个格式是否正确,这个格式是AST。
AST : (Abstract Syntax Tree)抽象语法树,是前端很多工具用的一种描述代码的格式,通过分支和节点组成。AST常用于代码转换、压缩等步骤。
为了方便理解,先贴一个转换的网站(astexplorer.net/),这里可以看到代码具体转换后的格式
主要功能有:
- 代码转换
- 代码压缩
- 代码静态分析、检查
- 生产代码
基于ast的工具 :
- babel 代码编译、转换,比如es5 → es6,或者用babel插件去做国际化或埋点,babel插件和eslint插件很像
- eslint、preitter 等代码格式校验检查工具
- sourcemap : 调试的代码定位、错误代码定位到源码,大致的结构长这样,最主要就是mappings,这个也是通过代码的行列数计算的
{
version : 3,
file: "out.js",
sourceRoot : "",
sources: ["foo.js", "bar.js"],
names: ["src", "maps", "are", "fun"],
mappings: "AAgBC,SAAQ,CAAEA"
}
- webpack 打包过程 (比如 three shaking ),将一些没有引用、没有被调用的无关代码从你的构建产物中去除掉
// a.ts
export const a = 100;
export function add(num1: number, num2: number): number {
return num1 + num2;
}
// b.ts
import { add } from './a.ts';
console.log(add(100, 200));
- UglifyJs 代码压缩
词法分析
这是编译的第一个阶段(扫描),词法分析是编译器通过读取输入的字符串,识别字符串代码的含义,生产一系列标记,这种标记被叫做tokens。比如下面的a变量和add函数,这些token一般包含运算符、关键字、常量等
const a: string = 'hello world';
const b = 10 * (5 + 5);
function add(a: number, b :number):number {
return a + b;
}
class Person {
eat() {
}
sleep() {
}
}
class Student extends Person {
doHomeWork() {
}
}
关于ast token 的具体可以在这里查:github.com/babel/babel…
不同的编译器编译后的ast都不太一样,摘选第一行代码分析一下,编译器通过识别关键字(const)、标识符(a: string),数值(“hello world”),最后也识别出是一段声明语句。
这里的Identifer、StringLiteral就是token,用这些节点串起来来描述代码构成。
const a: string = 'hello world';
// Tokens
[
{
"type": "Keyword",
"value": "var"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "StringLiteral",
"value": "'hello world'"
}
]
语法分析
在词法分析分词结束,需要通过文法规则(rules of grammer)将这些token串起来,生产语法树,整个过程是个递归的过程。
这里是只粗略带一下编译器的工作过程,js编译器过程很复杂,常用的js编译器:
- ESprima
- Acorn
- @babel/parser
- .......
// const a: string = 'hello world';
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a",
"typeAnnotation": {
"type": "TSTypeAnnotation",
"typeAnnotation": {
"type": "TSStringKeyword",
}
}
},
"init": {
"type": "Literal",
"value": "hello world",
},
}
],
"kind": "const"
}]
}
代码生成阶段
在AST转换后,接下来是代码的生成阶段,这个过程是generate阶段,generate阶段是将AST打印成字符串,从一个AST的根节点进行递归打印,针对不同的AST节点做处理。
比如像if语句的IfStatement会先打印一个”if”,然后打印空格,再接着打印”(”,然后打印node的节点信息,遇到语句块的时候会判断是否需要打印”{”,因为if的括号在语法上是可以省略。
来看另一个针对Jsx的,这里两个函数是打印jsx element的,比如像OpeningElement,会打印”<”,然后打印jsx的名字、属性等,最后判断是否的自我闭合的element。
在@babel/generator会定义一系列的打印方法,可以在这里查阅
使用
eslint常用的配置可以有如下,配置的权重也是有顺序的,但常用的也就第一个
- .eslintrc.js
- .eslintrc.cjs
- .eslintrc.json
- .......
eslint其实也算做跟babel有一些类似的功能,eslint在检查你的代码的时候,先得识别出你这部分的语法。
比如你用es6的语法,如果你配置的不对,可能在某些情况下eslint无法识别出来,这里面就涉及到代码的转换过程。
{
languageOptions: {
ecmaVersion: 5, // lint 检查 ECMAScript 5
sourceType: "module" // 文件导入导出格式
}
}
一些环境的配置可以参考:eslint.org/docs/latest…
配置文件
eslint在执行的过程中,会在文件目录逐级往上去检索你的配置文件,或者你通过设置root: true 的情况,在monorepo的结构中你也可以只定义一个lint配置,子项目读取这个配置来约束daim。
配置项也挺多,具体了解可以到这里看,下面主要说一下比较重要的配置:
-
parseOptions
(ESLint 允许指定支持的 JavaScript 语言选项或者是其他的选项,比如jsx, 这个配置在rule也有)
-
ecmaVersion: 指定 ECMAScript 版本,具体要看支持情况
-
sourceType:设置模块
-
ecmaFeatures
- jsx: 支持jsx,
- globalReturn: 允许全局return
-
-
extends: 引入插件包,比如[[@qunhe/arch-eslint-plugin]
-
rule: 规则,包括官方的和自己定义的
-
parser: 具体的解析器
// .eslintrc.js
module.exports = {
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"@xxx/eslint-plugin-custom"
],
"rule": {
"custom/no-return-type": "error"
}
}
``
运行原理
- 运行eslint 脚本
- 执行eslint.js 文件
- 获取执行的参数,比如校验的文件类型,或者禁用disable(--no-inline-config)
- 新建eslint类实例,linter 类实例
- fs.readFile 读取对应文件,读取配置文件,转换代码
- 读取plugin的所有rule,然后test file by rule,获取最终的执行结果,收起起来缓存
- 读取是否有fix配置,有的话就根据fix来改正代码
具体原理可以通过全局npm包看源码,这里不详细深入
规则(rule)开发
本文主要是介绍rule规则的开发,eslint的对rule的定义是返回一个对象,对象有meta属性和create函数
// 实际上日常对于插件的开发只需要搭建这么个框架
module.exports = {
meta: {
},
create(context) {
}
}
meta
meta就是对这个插件的一些描述,meta的内容包括但不限于
-
type: 表示是建议性还是问题性的规则,类比warn和 error
-
docs:描述自定义规则的一些工具属性
- description:规则描述
- recommended:对于核心规则,指定是否有@eslint/js 的 recommend配置调用
- url: 对于规则描述的url,一般这个可以省略
-
fixable:eslint规则有fix功能, 在终端调用eslint —fix可以选择修复问题
-
schema:可以定义一些运行时的属性,在create的context.options访问
create
create函数接受一个context参与,context包含一些rule的能力,比如上报、获取运行参数等等,这个create函数是在eslint访问AST的过程中反复调用的,比如下面这个三个函数,分别表示eslint编译器走到对应ast的节点做的操作
create(context) {
FunctionDeclaration() {} // 函数声明
ImportDeclaration() {} // 导入语句
VariableDeclaration() {} // 变量
}
一些context的主要用途
-
id: 规则id
-
fileName:目前运行的文件名
-
cwd: 当前工作的文件路径,跟node那个差不多
-
sourceCode:一个SourceCode对象
-
languageOptions:一些代码语言版本的配置
- sourceType:文件模式,script、module、commonjs
- parser:对应的解析器(比如@typescript-eslint/parser、@babel/eslint-parser)
- parserOptions:描述eslint解析代码的方式,比如严格模式、支持jsx等
- ecmaVersion: 用于解析当前文件的 ECMA 版本
-
report(options):发布警告或错误的的方法,一般是命中校验的规则后调用
options:
- message:问题的解释
- messageId; 消息Id,对于规则可以定义在特殊的Id,在全局实用
- loc:包含两个属性start和end,表示代码的行列数,这个还是比较常见,平时eslint报错了能看到,包括sourceMap也应用这个(loc → start → { line: number; column: number;})
- node:对应遍历的AST节点
- fix(fixer):fix函数可以修复错误
现在来实践一下,比如说我现在想写一个没console.log函数调用的插件,填补meta部分
meta: {
name: 'no-console-log',
meta: {
type: 'problem',
docs: {
description: 'could not use console.log function in code',
recommended: 'error'
},
fixable: true
}
}
然后根据ast工具,选取parser,拿到对应的结构,如下可以看到解析出了MemberExpression
接下来补充create部分,这里描述的是获取到console.log调用的这行代码,然后提供fix函数,可以执行fixer,将这行调用替换为空,移除掉
create(context) {
return {
MemberExpression(node) {
if (node.object && node.property) {
const { object, property, loc } = node;
if (object.name === 'console' && property.name === 'log') {
context.report({
message: 'could not use console.log',
loc,
node,
fixable: (fixer) => {
return fixer.replaceText(node, '');
}
})
}
}
}
}
}
// no-console-log.ts
{
name: "no-console-log",
meta: {
type: "problem",
docs: {
description: "Disallow console.log",
recommended: "error"
},
schema: [],
messages: {
'noConsoleLog': "console.log is not allowed"
}
},
defaultOptions: [],
create(context) {
return {
MemberExpression(node: TSESTree.MemberExpression) {
const p = node.property as any;
const obj = node.object as any;
if (p.name === 'log' && obj.name === 'console') {
context.report({
node,
loc: node.loc,
messageId: 'noConsoleLog',
})
}
},
}
}
}
到这里整体就写完啦,这个比较简单,可以去eslint的官方packages看他们实现的rule,还是挺复杂的,另外在判断节点的时候可以选取一些npm封装库,会更规范。
关于自定义规则的一些内容可以在这里看
挑了个官方的rule规则: