从0开始手写 JS Parser (AST)

2,254 阅读4分钟

JS Parser指的是JavaScript的代码解析器。用于扫描JavaScript代码,解析成AST结构。然后对AST进行修改,最终再将AST生成代码。

JS Parser用途十分广泛,比如babel的代码转换,prettier中的代码格式化,rollup代码打包构建等等。

但总的来说,无论什么样的JS Parser都会分成三步:

  1. 将代码解析成 AST
  2. AST进行操作
  3. AST生成代码

因此本文将从四个方面去介绍下如何手写 JS Parser:

  1. 将代码解析成AST语法树
  2. AST节点进行操作
  3. AST语法树生成代码
  4. 总结并给出完整的JS Parser代码

相信看完就能手动实现最简单的JS Parser

先从第一步开始说起。

一.将代码解析成AST语法树

将代码解析成AST语法树实际上会分为两步:

  1. 词法分析
  2. 语法分析

1. 词法分析

词法分析的原因很简单,需要先把字符串分割成token数组,方便语法分析。比如代码:

const a = 1;

const关键字和自定义的变量名a,都是英文字母组成,但实际上是完全不同的作用,需要进行分组。

遍历代码字符串,根据不同的作用,上述代码,就可以分为:

  1. 关键字const
  2. 自定义的变量名a
  3. 变量的值1
  4. 符号,=;
  5. 处理空格换行字符' ', \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的语法规则,推荐官方地址:

  1. ESMAScript的官方文档
  2. MDN JavaScript Grammar and types

当然我们也可以直接按照自己对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组合成各类语法节点。

因为不同的语法节点需要不同的处理方式,所以需要进行分类,一般可以分为:

  1. Literal字面量,表示值
  2. Identifier标识符,其中变量名,属性名,参数名等都属于标识符
  3. Statement语句,表示可以独立执行的最小单元,比如reture,break,if(){},for(const item of list){}
  4. Declaration声明语句,语句的一种特殊类型,在作用域内声明一个变量,比如class a{},let a = 1,function a(){}
  5. Expression表达式,也是一种特殊的语句,特点是执行完后有返回值

比如我们可以通过分析声明语句(Declaration)和表达式(Expression),判断变量是否被使用过,从而实现tree shaking

所以语法分析的关键是:

  1. 根据语言的规则,定义合适的语法节点
  2. Token数组转化成语法节点

定义合适的语法节点我们可以直接站在巨人的肩膀上,参考下estree标准

通过它的官方文档,我们能看到:

  1. 它定义了丰富的AST节点 image.png
  2. 直观的展示每个节点的数据结构: image.png
  3. 每年一更新,有人持续维护 image.png

而且eslint,acorn,babelJS parser都采用了这个标准,我们当然也可以。

但需要注意的是,estree标准有些节点是没有定义的,但babel却可以解析,是为什么呢?

因为babelestree标准之上做了一些自定义的节点扩展。

比如注释节点CommentBlockestree标准就没有定义,但下列代码在@babel/parser中可以解析成AST语法树。

/**
 * @description: comment
 * @param {Statement} statement
 * @return {*}
 */

有了语法分析规则,我们便可以开始写代码了:

先将 estree 拉到本地,方便搜索查找节点。

搜索constes2015.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;
}
  1. 类型属性表示是变量声明语句VariableDeclaration
  2. 有位置属性start,end。表示在代码的具体位置。将AST语法树重新生成代码时会用到。
  3. 关键字属性kind
  4. 具体声明的内容declarations是一个数组,因为变量声明可以是多个,比如const a = 1,b = 2;
  5. 变量声明中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)

上面是最粗的方法,但这里我们还可以站在巨人的肩膀上。

acornbabel已经实现了JS parser。那我们可以先用他们,把代码解析成AST结构,我们再照着AST结构去实现就好了。这样就更方便直观了。

可以通过 astexplorer 网站直接看下acorn解析的AST结构:

image.png

最终解析结果如下:

{
  "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节点。同时参数传入enterleave函数,在遍历节点进入和离开时,对当前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)
      }
    })
  }

四. 总结

代码如下:github.com/chaxus/ranl…

实现功能有:

  • parse: 将代码解析为AST
  • walk: 遍历AST的结构以执行自定义操作
  • generate: 将AST解析为代码
  • build: 支持 tree shaking 轻量级打包构建工具

npm 地址:www.npmjs.com/package/ran…

image.png

test目前添加了大量的测试用例。但感觉可能还有遗漏之处🤔️

比如写的时候才发现,JavaScript还有一种语法是label标签语法。感觉比with语法更少有人使用。但又确实存在。

如果发现有bug的地方,欢迎大家的issue,pr,star

根据上文简单的实现了一个类型的JS parser后,后续的语法解析就可以照猫画虎,重复实现了。

后续可能会考虑支持下jsxtypescript

总的来说,手写一个JS parser获益匪浅,平时工作也有很多用到的地方。比起大而全的babel,手写的JS parser功能可能比较单一,但方便自己扩展,同时也更加轻量。

对于个人来说,建议尝试手写JS parser,自己折腾折腾技术。但对于公司业务来说,还是建议使用acorn或者其他成熟的解析工具更为合适。

其他推荐