AST 简单实现

107 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第10天,点击查看活动详情

要实现一个 AST 我们首先要了解以下 AST 是什么? 作为什么级别的研发才有应该去了解这方面的知识呢?

  • AST 不能算作是一门技术,而可以说 是一种 JavaScript 中的 语法,词法分析, 转换的一种工具,其也不是用一个函数就能够实现的,作为一个前端开发工程师,对 AST 并不是必要了解的,而当你作为一个高级研发 及以上级别, 肯定是要对自己所使用的 语言,或者是框架要有很深的理解的, 而我们使用的 很多框架, 或者是 TS 很多语法其实都不是 JavaScript 的语法, 那么他们又是如何工作的呢? 其实他们就是利用了诸如 babel 这类语法转换工具来实现。
  • 语法的转换,词法的解析,都是有响应的工具函数的,其也可以应用在不同的场景中,我们可以举一反三,这里我就使用 vue 模板的解析,作为一个 demo, 从下面的导图中我们可以了解以下大致的流程:

Snipaste_2022-11-29_10-04-55.png 从上面我们可以看到, 我们从 模板 到 我们渲染函数,需要经过几次编译的过程,这几次过程就是 对 AST 树进行不同的转换过程

  • 我们下面就来看一下, 这几个方法 ,在编译当中起了什么样的作用

Snipaste_2022-11-29_10-06-24.png 最关键的三个方法 其实就是 parser,transform,generate, 用过词法的解析和标记,以及有限状态自动机的运转,对节点进行深度遍历后进行 上下文对象的注入,然后转换成 js 的抽象语法树

  • 下面是一个 简单的 demo 实现, 里面包含了状态机, token解析, parser 函数 及 transform 的简单实现, 大家可以配合注释一起食用
// 有限状态机的简易实现 tokenize
// 定义状态机的状态
const State = {
  initial:1, // 初始状态
  tagOpen:2, // 标签开始状态
  tagName:3,  // 标签名称状态
  text:4,  // 文本状态
  tagEnd:5, // 结束标签状态
  tagEndName:6 // 结束标签名称状态
}
// 一个辅助函数,用于判断是否是字符串
function isAlpha(char){
  return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str){
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while(str){
    // 查看第一个字符,注意,这里只是查看,没有消费字符
    const char = str[0]
    // Switch 匹配当前状态
    switch(currentState){
      // 状态机当前初始状态
      case State.initial:
        // 遇到字符 < 这里主要判断是 标签 还是 文本
        if(char === '<'){
          //1. 状态机切换到标签开始状态  
          currentState = State.tagOpen
          //2. 消费字符 <
          str = str.slice(1)
        }else if(isAlpha(char)){
          //1. 遇到字母,切换到文本状态
          currentState = State.text
          //2. 将当前字母缓存到 chars 数组中
          chars.push(char)
          //3. 消费当前字符
          str = str.slice(1)
        }
        break
      // 状态机当前状态处于标签开始状态
      case State.tagOpen:
        if(isAlpha(char)){
          //1. 遇到字母,切换到标签名称状态
          currentState = State.tagName
          //2. 将当前字符缓存到 chars 数组中
          chars.push(char)
          //3. 消费当前字符
          str = str.slice(1)
        }else if(char === '/'){  
          //1. 遇到字符 / , 切换到结束标签状态 </div> /
          currentState = State.tagEnd
          //2. 消费字符
          str = str.slice(1)
        }
        break
      case State.tagName:
        if(isAlpha(char)){
          // 遇到字母, 由于当前处于标签名称状态,所以不需要切换状态,需要将字符缓存到 chars 数组中 <div>  div
          chars.push(char)
          str = str.slice(1)
        }else if(char === '>'){
          // 切换到标签初始状态
          currentState = State.initial
          // 同时创建一个标签 Token, 并添加到 tokens 数组中, 此时 chars 数组中缓存的字符 就是标签名称
          tokens.push({
            type:'tag',
            name:chars.join('')
          })
          // chars 数组内容已经被消费,清空
          chars.length = 0
          // 同时消费当前字符 >
          str = str.slice(1)
        }
        break
      case State.text:
        if(isAlpha(char)){
          // 遇到字母,保持状态不变,当应该将当前字符缓存到 chars 数组
          chars.push(char)
          str = str.slice(1)
        }else if(char === '<'){
          currentState = State.tagOpen
          // 从文本状态 ===> 标签开始状态,此时应该创建文本 Token, 并添加到 tokens 数组中, 注意此时 chars 数组中的字符就是 文本内容
          tokens.push({
            type:'text',
            content:chars.join('')
          })
          chars.length = 0
          str= str.slice(1)
        }
        break
      case State.tagEnd:
        if(isAlpha(char)){
          // 遇到字母 切换到结束名称状态  如 </div> 开始走 div
          currentState = State.tagEndName
          chars.push(char)
          str = str.slice(1)
        }
        break
      case State.tagEndName:
        if(isAlpha(char)){
          // 遇到字母 不切换状态
          chars.push(char)
          str = str.slice(1)
        }else if(char === '>'){
          // 切换会初始状态
          currentState = State.initial
          // 从结束标签名称状态 ===> 初始状态, 应该保存结束标签名称 Token, 注意 此时 chars 数组中 缓存的内容就是 标签名称
          tokens.push({
            type:'tagEnd',
            name:chars.join('')
          })
          chars.length = 0
          str = str.slice(1)
        }
        break
    }
  }
  // 最后 返回 tokens
  return tokens
}

// 上面的函数 没有处理 数字的情况
console.log(tokenize(`<p>vue</p>`)); // 正常来说这里得到的结果应该是 [{type:'tag',name:'p'},{type:'text',name:'vue'},{type:tagEnd,name:'p'}]

// 优化后的 parser 增加了 栈队列, 当前 无法处理 自闭 和 标签 以及 数值
function parser(res){
  // 首先对模板进行表计划,得到 tokens
  const tokens = tokenize(res)
  // 创建 Root 根节点
  const root = {
    type:'Root',
    children:[]
  }
  // 创建 elementStack 栈, 期初只有 Root 根节点
  const elementStack = [root]
  // 开启一个 while 循环扫描 tokens, 知道所有 token 都被 扫描完为止
  while(tokens.length){
    // 获取当前栈顶节点 作为父节点 parent
    const parent = elementStack[elementStack.length -1]
    // 当前扫描的 token
    const t = tokens[0]
    switch(t.type){
      case 'tag':
        // 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
        const elementNode = {
          type:'Element',
          tag:t.name,
          children:[]
        }
        // 将其添加到父级节点的 children 中
        parent.children.push(elementNode)
        // 将当前节点压入栈
        elementStack.push(elementNode)
        break;
      case 'text':
        // 如果当前 token 是文本, 则创建 text 类型的 AST 节点
        const textNode = {
          type:'Text',
          content:t.content
        }
        // 将其添加到父节点的 children 中
        parent.children.push(textNode)
        break;
      case 'tagEnd':
        // 遇到结束标签, 将栈顶节点弹出
        elementStack.pop()
        break
    }
    // 消费已经通过扫描的 token
    tokens.shift()
  }
  // 最后返回 AST 
  return root
}

const ASTSTR = `<div><p>vue</p><div>react</div></div>`
console.log(dump(parser(ASTSTR)));

// dump 工具函数, 用来进行 AST 节点的深度遍历访问 及打印
function dump(node,indent = 0){
  // 节点类型
  const type = node.type
  // 如果是根节点,则没有描述,如果是 Element 类型的节点,则使用 node.tag 作为节点的描述,如果是 Text 类型的节点,则使用 node.context 作为节点的描述
  const desc = node.type === 'Root' ? "" : node.type === 'Element' ? node.tag : node.content
  // 打印节点的类型和 描述信息
  console.log(`${'-'.repeat(indent)}${type}: ${desc}`);
  // 递归的打印子节点
  if(node.children){
    node.children.forEach(n => dump(n,indent + 2))
  }
}


// 转换函数的 简单原理
function transform(ast){
  // 存储上下文信息的对象
  const context = { 
    // 用来存储当前正在转换的节点
    currentNode:null,
    // 用来存储当前节点在父节点的 chilren 中的位置索引
    childIndex:0,
    // 用来存储当前转换节点的父节点
    parent:null,
    // 节点替换 用来替换当前正在转换的节点 context 这个对象会 通过递归透传, 所有子节点中 都可以通过 context对象访问这个方法
    replaceNode(node){
      context.currentNode = node
      context.parent.children[context.childIndex] = node
    },
    // 删除节点
    removeNode(){
      if(context.parent){
        // 调用数组的 splice 方法, 根据当前节点的索引 删除当前节点
        context.parent.children.splice(context.childIndex,1)
        // 将 context.currentNode 置空
        context.currentNode = null
      }
    },
    nodeTransforms:[
      transformElement,// 转换节点类型函数
      transformText,// 转换文本类型的函数
    ]
  }
  traverseNode(ast,context)
}

// 工作流的实现 只是包含了 进入阶段的工作流, 实际上还要包括退出阶段的工作流, 转换函数返回另一个函数,该函数即作为退出阶段的回调函数
function traverseNode(ast,context){
  // 设置当前转换的节点信息
  context.currentNode = ast
  const transforms = context.nodeTransforms
  for(let i =0;i<transforms.length;i++){
    transforms[i](context.currentNode,context)
  }
  const children = context.currentNode.children
  if(children){
    for(let i = 0; i < children.length ; i++){
      // 递归调用 traverseNode 转换子节点之前, 将当前节点设置为父节点
      context.parent = context.currentNode
      // 设置位置索引
      context.childIndex = i
      // 递归调用时, 将 context 透传
      traverseNode(children[i],context)
    }
  }
}