JS Parser
指的是JavaScript
的代码解析器。用于扫描JavaScript
代码,解析成AST
结构。然后对AST
进行修改,最终再将AST
生成代码。
JS Parser
用途十分广泛,比如babel
的代码转换,prettier
中的代码格式化,rollup
代码打包构建等等。
但总的来说,无论什么样的JS Parser
都会分成三步:
- 将代码解析成
AST
- 对
AST
进行操作 - 将
AST
生成代码
因此本文将从四个方面去介绍下如何手写 JS Parser
:
- 将代码解析成
AST
语法树 - 对
AST
节点进行操作 - 将
AST
语法树生成代码 - 总结并给出完整的
JS Parser
代码
相信看完就能手动实现最简单的JS Parser
了
先从第一步开始说起。
一.将代码解析成AST
语法树
将代码解析成AST
语法树实际上会分为两步:
- 词法分析
- 语法分析
1. 词法分析
词法分析的原因很简单,需要先把字符串分割成token
数组,方便语法分析。比如代码:
const a = 1;
const
关键字和自定义的变量名a
,都是英文字母组成,但实际上是完全不同的作用,需要进行分组。
遍历代码字符串,根据不同的作用,上述代码,就可以分为:
- 关键字
const
。 - 自定义的变量名
a
。 - 变量的值
1
。 - 符号,
=
和;
。 - 处理空格换行字符
' ', \t , \n , \r
既然是遍历代码字符串,首先需要确定遍历的终止条件:
currentIndex
等于或大于当前代码字符串的长度时,停止遍历。
while (currentIndex < code.length) { {}
接下来处理变量和关键字
JavaScript
中变量可以由英文字母,数字,或者下划线定义。所以代码如下:
// 是否是字母
function isAlpha(char: string): boolean {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
// 是否是数字
function isDigit(char: string): boolean {
return char >= '0' && char <= '9'
}
// 是否是下划线
function isUnderline(char: string): boolean {
return char === '_'
}
如果属于,说明是一个变量,进行字符串累加,拼成一个单词
if (isAlpha(currentChar)) {
let identifier = ''
const startIndex = currentIndex
while (
isAlpha(currentChar) ||
isDigit(currentChar) ||
isUnderline(currentChar)
) {
identifier += currentChar
currentIndex++
currentChar = code[currentIndex]
}
}
其中identifier
就是一个单词了,还需要判断下identifier
是否是关键字:
对于关键字,我们可以使用最暴力的方法:枚举。
变量声明的关键字一共有三个,分别是let
,const
,var
。(其他关键字也可以这样处理)
// 关键字的token生成函数
const KEY_WORDS = {
let(start: number) {
return { type: 'Let', value: 'let', start, end: start + 3 }
},
const(start: number) {
return { type: 'Const', value: 'const', start, end: start + 5 }
},
var(start: number) {
return { type: 'Var', value: 'var', start, end: start + 3 }
},
}
// 其他自定义变量名的token生成函数
function identifierToken(start, value) {
return {
type: 'Identifier',
value,
start,
end: start + value.length,
}
}
if (identifier in KEY_WORDS) {
// 如果是关键字的话,直接返回关键字的token
tokens.push(KEY_WORDS[identifier as keyof typeof KEY_WORDS](startIndex))
} else {
// 如果不是关键字,就是变量名,生成变量名token返回
tokens.push(identifierToken(startIndex, identifier))
}
接下来是处理符号,对于=
的处理:
function assignSign(start) {
return { type: 'Assign', value: '=', start, end: start + 1 }
}
if (currentChar === '=') {
tokens.push(assignSign(currentIndex))
currentIndex++;
}
;
号同理可得:
function emicolonSign(start: number) {
return { type: TokenType.Semicolon, value: ';', start, end: start + 1 }
}
if (currentChar === ';') {
tokens.push(emicolonSign(currentIndex))
currentIndex++;
}
对于数字的处理:
需要注意的是,数字可以带符号.
且只能带一次
function number(start, value) {
return {
type: "Number",
value,
start,
end: start + value.length,
raw: value,
}
}
if (isDigit(currentChar)) {
let number = ''
let isFloat = false
while (isDigit(currentChar) || (currentChar === '.' && !isFloat)) {
if (currentChar === '.') {
isFloat = true
}
number += currentChar
currentIndex++
currentChar = code[currentIndex]
}
tokens.push(numberSign(currentIndex, number))
}
所以,代码const a = 1;
词法分析就可以转化为:
[
{ type: 'Const', value: 'const', start: 0, end: 5 },
{ type: 'Identifier', value: 'a', start: 6, end: 7 },
{ type: 'Assign', value: '=', start: 8, end: 9 },
{ type: 'Number', value: '1', start: 10, end: 11, raw: '1' },
{ type: 'Semicolon', value: ';', start: 11, end: 12 },
]
这一块的完整代码如下:
// 是否是字母
function isAlpha(char: string): boolean {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
// 是否是数字
function isDigit(char: string): boolean {
return char >= '0' && char <= '9'
}
// 是否是下划线
function isUnderline(char: string): boolean {
return char === '_'
}
const KEY_WORDS = {
let(start: number) {
return { type: 'Let', value: 'let', start, end: start + 3 }
},
const(start: number) {
return { type: 'Const', value: 'const', start, end: start + 5 }
},
var(start: number) {
return { type: 'Var', value: 'var', start, end: start + 3 }
},
}
function identifierToken(start: number, value: string) {
return {
type: 'Identifier',
value,
start,
end: start + value.length,
}
}
function assignSign(start: number) {
return { type: 'Assign', value: '=', start, end: start + 1 }
}
function emicolonSign(start: number) {
return { type: "Semicolon", value: ';', start, end: start + 1 }
}
function numberSign(start: number, value: string) {
return {
type: "Number",
value,
start,
end: start + value.length,
raw: value,
}
}
function isWhiteSpace(char: string): boolean {
return char === ' ' || char === '\t' || char === '\n' || char === '\r'
}
const code = 'const a = 1;'
const tokens = []
let currentIndex = 0
while (currentIndex < code.length) {
let currentChar = code[currentIndex]
if (isAlpha(currentChar)) {
let identifier = ''
const startIndex = currentIndex
while (
isAlpha(currentChar) ||
isDigit(currentChar) ||
isUnderline(currentChar)
) {
identifier += currentChar
currentIndex++
currentChar = code[currentIndex]
}
if (identifier in KEY_WORDS) {
// 如果是关键字的话,直接返回关键字的token
tokens.push(KEY_WORDS[identifier as keyof typeof KEY_WORDS](startIndex))
} else {
// 如果不是关键字,就是变量名,生成变量名token返回
tokens.push(identifierToken(startIndex, identifier))
}
}
if (isWhiteSpace(currentChar)) {
currentIndex++
}
if (currentChar === '=') {
tokens.push(assignSign(currentIndex))
currentIndex++;
}
if (currentChar === ';') {
tokens.push(emicolonSign(currentIndex))
currentIndex++;
}
if (isDigit(currentChar)) {
let number = ''
let isFloat = false
while (isDigit(currentChar) || (currentChar === '.' && !isFloat)) {
if (currentChar === '.') {
isFloat = true
}
number += currentChar
currentIndex++
currentChar = code[currentIndex]
}
tokens.push(numberSign(currentIndex, number))
}
}
console.log("tokens: ", tokens)
上面只是针对于一个例子做了词法分析,实际上JavaScrip
的词法规则比这复杂的多
如果需要查询JavaScrip
的语法规则,推荐官方地址:
当然我们也可以直接按照自己对JavaScript
的理解写词法分析,这同时也是复习JavaScript
语法的一个过程。
词法分析完成后接下来就是语法分析。
2. 语法分析
语法分析我们会遍历词法分析的Token
数组,构造出AST
语法树。
还是上面的例子,代码:
const a = 1;
词法分析结果:
[
{ type: 'Const', value: 'const', start: 0, end: 5 },
{ type: 'Identifier', value: 'a', start: 6, end: 7 },
{ type: 'Assign', value: '=', start: 8, end: 9 },
{ type: 'Number', value: '1', start: 10, end: 11, raw: '1' },
{ type: 'Semicolon', value: ';', start: 11, end: 12 },
]
语法分析的目的是遍历词法分析的Token
数组,根据语言的语法规则,将Token
组合成各类语法节点。
因为不同的语法节点需要不同的处理方式,所以需要进行分类,一般可以分为:
Literal
字面量,表示值Identifier
标识符,其中变量名,属性名,参数名等都属于标识符Statement
语句,表示可以独立执行的最小单元,比如reture
,break
,if(){}
,for(const item of list){}
等Declaration
声明语句,语句的一种特殊类型,在作用域内声明一个变量,比如class a{}
,let a = 1
,function a(){}
Expression
表达式,也是一种特殊的语句,特点是执行完后有返回值
比如我们可以通过分析声明语句(Declaration
)和表达式(Expression
),判断变量是否被使用过,从而实现tree shaking
。
所以语法分析的关键是:
- 根据语言的规则,定义合适的语法节点
- 将
Token
数组转化成语法节点
定义合适的语法节点我们可以直接站在巨人的肩膀上,参考下estree标准
通过它的官方文档,我们能看到:
- 它定义了丰富的
AST
节点 - 直观的展示每个节点的数据结构:
- 每年一更新,有人持续维护
而且eslint
,acorn
,babel
等JS parser
都采用了这个标准,我们当然也可以。
但需要注意的是,estree
标准有些节点是没有定义的,但babel
却可以解析,是为什么呢?
因为babel
在estree
标准之上做了一些自定义的节点扩展。
比如注释节点CommentBlock
,estree
标准就没有定义,但下列代码在@babel/parser
中可以解析成AST
语法树。
/**
* @description: comment
* @param {Statement} statement
* @return {*}
*/
有了语法分析规则,我们便可以开始写代码了:
先将 estree 拉到本地,方便搜索查找节点。
搜索const
在es2015.md
中,属于VariableDeclaration
节点。
extend interface VariableDeclaration {
kind: "var" | "let" | "const";
}
接着我们再去找VariableDeclaration
节点的数据结构:
interface VariableDeclaration <: Declaration {
type: "VariableDeclaration";
declarations: [ VariableDeclarator ];
kind: "var";
}
VariableDeclaration
节点又继承Declaration
节点
interface Declaration <: Statement { }
Statement
节点继承Node
interface Statement <: Node { }
Node
节点结构如下:
interface Node {
type: string;
loc: SourceLocation | null;
}
interface SourceLocation {
source: string | null;
start: Position;
end: Position;
}
到这里我们大概知道了一个const
节点的具体结构:
interface VariableDeclaration {
type: "VariableDeclaration";
start: Position;
end: Position;
declarations: [ VariableDeclarator ];
kind: "const";
}
interface VariableDeclarator <: Node {
type: "VariableDeclarator";
id: Pattern;
init: Expression | null;
}
- 类型属性表示是变量声明语句
VariableDeclaration
- 有位置属性
start
,end
。表示在代码的具体位置。将AST
语法树重新生成代码时会用到。 - 关键字属性
kind
- 具体声明的内容
declarations
是一个数组,因为变量声明可以是多个,比如const a = 1,b = 2;
- 变量声明中
VariableDeclarator
,id
指的变量名,init
是初始值的属性
解析Token
数组的过程就是遍历Token
,对于不同类型的Token
进行不同的操作。
完整代码如下:
const program = {
type: "Program",
body: [],
start: 0,
end: Infinity,
}
let tokenCurrentIndex = 0
function parseLiteral(): any {
const token = tokens[tokenCurrentIndex]
let value: string | number | boolean = token.value!
if (token.type === "Number") {
value = Number(value)
}
const literal = {
type: "Literal",
value: token.value!,
start: token.start,
end: token.end,
raw: token.raw!,
}
tokenCurrentIndex++
return literal
}
function parseIdentifier(): any {
const token = tokens[tokenCurrentIndex]
const identifier: any = {
type: "Identifier",
name: token.value!,
start: token.start,
end: token.end,
}
tokenCurrentIndex++
return identifier
}
const parse = () => {
const currentToken = tokens[tokenCurrentIndex]
if (currentToken.type === 'Const') {
const { start } = currentToken
const kind = currentToken.value
tokenCurrentIndex++
const declarations = []
const id = parseIdentifier()
if (currentToken.type === "Assign") {
tokenCurrentIndex++
}
const init = parseLiteral()
const declarator = {
type: "VariableDeclarator",
id,
init,
start: id.start,
end: id.end,
}
declarations.push(declarator)
tokenCurrentIndex++
const node = {
type: "VariableDeclaration",
kind,
declarations,
start,
end: tokens[tokenCurrentIndex].end,
}
return node
}
}
while (tokenCurrentIndex < tokens.length) {
// 分析当前的token
const node = parse()
// 将当前节点加入到body中
program.body.push(node)
tokenCurrentIndex++
if (tokenCurrentIndex === tokens.length) {
program.end = node.end
}
}
console.log("program:",program)
上面是最粗的方法,但这里我们还可以站在巨人的肩膀上。
acorn
和babel
已经实现了JS parser
。那我们可以先用他们,把代码解析成AST
结构,我们再照着AST
结构去实现就好了。这样就更方便直观了。
可以通过 astexplorer 网站直接看下acorn
解析的AST
结构:
最终解析结果如下:
{
"type": "Program",
"start": 0,
"end": 12,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 12,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 11,
"value": 1,
"raw": "1"
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
二. 对AST
节点进行操作
比如babel
的兼容性降级,把const
转换成var
根据AST
的节点类型和节点的结构,进行针对性的修改:
这是最直接的修改方式,通俗易懂。
ast.body.forEach(node=>{
if(node.type === 'VariableDeclaration' && node.kind !== 'var'){
node.kind = 'var'
}
})
不过更常用的做法是实现一个walk
函数,用到访问者模式
访问者模式是一种较为复杂的行为型设计模式,它包含访问者和被访问元素两个主要组成部分,这些被访问的元素通常具有不同的类型,且不同的访问者可以对它们进行不同的访问操作。访问者模式使得用户可以在不修改现有系统的情况下扩展系统的功能,为这些不同类型的元素增加新的操作。
在使用访问者模式时,被访问元素通常不是单独存在的,它们存储在一个集合中,这个集合被称为「对象结构」,访问者通过遍历对象结构实现对其中存储的元素的逐个操作。
walk
函数内部调用visit
,遍历AST
节点。同时参数传入enter
和leave
函数,在遍历节点进入和离开时,对当前AST
节点进行操作:
export function walk(
ast: Statement,
{ enter, leave }: { enter: WalkOperate; leave: WalkOperate },
): void {
visit(ast, undefined, enter, leave)
}
三. 将AST
语法树生成代码
生成代码的过程实际就是遍历AST
语法树,根据AST
节点不同类型,进行不同的操作,拼接出代码进行返回。
比如生成VariableDeclaration
类型的节点代码
generateVariableDeclaration(node: VariableDeclaration): void {
const { start, declarations, kind, end } = node
// 封装了一些对字符串的操作,可选择字符串的起始节点,替换字符
this.code.update(start, start + kind.length, kind)
this.code.update(end, end + 1, ';')
// 遍历声明的内容,进行生成代码
declarations.forEach((declaration) => {
const { type } = declaration
if (type === NodeType.VariableDeclarator) {
return this.generateLiteral(declaration)
}
})
}
四. 总结
实现功能有:
- parse: 将代码解析为
AST
- walk: 遍历
AST
的结构以执行自定义操作 - generate: 将
AST
解析为代码 - build: 支持
tree shaking
轻量级打包构建工具
npm 地址:www.npmjs.com/package/ran…
test
目前添加了大量的测试用例。但感觉可能还有遗漏之处🤔️
比如写的时候才发现,JavaScript
还有一种语法是label
标签语法。感觉比with
语法更少有人使用。但又确实存在。
如果发现有bug
的地方,欢迎大家的issue
,pr
,star
。
根据上文简单的实现了一个类型的JS parser
后,后续的语法解析就可以照猫画虎,重复实现了。
后续可能会考虑支持下jsx
和typescript
。
总的来说,手写一个JS parser
获益匪浅,平时工作也有很多用到的地方。比起大而全的babel
,手写的JS parser
功能可能比较单一,但方便自己扩展,同时也更加轻量。
对于个人来说,建议尝试手写JS parser
,自己折腾折腾技术。但对于公司业务来说,还是建议使用acorn
或者其他成熟的解析工具更为合适。