使用AST优雅的定制你的代码

2,057 阅读9分钟

使用AST优雅的定制你的代码

AST 简介

AST(Abstract Syntax Tree)抽象语法树,是源代码语法结构的一种抽象表示。它以树状形式表示编程语言的语法结构,树上的每个节点都代表源代码中的一种结构。简而言之就是源代码的一个树状结构表示形式

AST 原理

生成AST一般分为以下几步:

process

词法分析

将整个代码字符串分割成最小语法单元数组

// 源代码
const foo = 'hello world';

// Tokens
[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "foo"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "String",
        "value": "'hello world'"
    }
]

语法分析

在分词基础上建立分析语法单元之间的关系,将词汇进行一个立体的组合,确定词语之间的关系,确定词语最终的表达含义。简单来说语法分析是对语句和表达式识别,确定之前的关系,是个递归过程。

手撸一个最简AST

为了更好的理解AST的原理,这里我参照acorn这个库来做一个最简版的代码解析器,通过这个Demo我们可以从中了解到代码解析器的工作原理。

下面是这次要解析的代码,而且实现的功能也只支持这两种语法。

import moduleA from "./moduleA.js";
import moduleB from "./moduleB.js";

function add(v1, v2) { return v1 + v2 }

词法分析

通过上面的代码中可以得知,我们大概出现了以下几种Token

  • 关键字(keyword):importfromfunctionreturn
  • 普通名字(name):moduleAmoduleBaddv1v2
  • 部分符号:
    • 分号(semi):
    • 逗号(comma):,
    • 点(dot):.
    • 左括弧(parenL):(
    • 右括弧(parenR):)
    • 左花括号(braceL):{
    • 右花括号(braceR):}
    • 加号(plusMin):+
  • 结束符:eof

下面我们针对这串源码进行分词解析,具体代码如下:

// 关键词及符号枚举
const types = {
  keyword: ["import", "from", "function", "return"],
  punctuation: [";", ",", ".", "(", ")", "{", "}", "+", "-"]
};

// 获取词的类型
function getTokenType(token) {
  if (types.keyword.includes(token)) {
    return 'Keyword';
  } else if (types.punctuation.includes(token)) {
    return 'Punctuation';
  } else {
    return 'Identifier';
  }
}

// 分词器
export function tokenizer(input) {
  // 当前检索的下标,用于像光标一样跟踪我们在代码中的位置。
  let current = 0;
  // 用于存放我们分词结果的`tokens`数组
  let tokens = [];
  let token = '';
  let stringStart = false;
  while (current < input.length) {
    const char = input[current];
    // 如果是空格则跳过
    if (/\s/.test(char) && !token) {
      current++;
      continue;
    }
    // 如果是字符串
    if (char === '"') {
      if (!stringStart) {
        stringStart = true;
        token += char;
      } else {
        stringStart = false;
        token += char;
        tokens.push({
          type: "String",
          value: token
        });
        token = '';
      }
      current++;
      continue;
    }
    // 如果碰到换行符、空格,添加到返回数组中
    if (/[\r\t|\s]/.test(char) && !stringStart) {
      tokens.push({
        type: getTokenType(token),
        value: token
      });
      token = '';
      current++;
      continue;
    }
    // 如果碰到括号、逗号、点,添加到返回数组中
    if (/[;|\(|\)|\{|\}|\.|,]/.test(char) && !stringStart) {
      if (token) tokens.push({
        type: getTokenType(token),
        value: token
      });
      tokens.push({
        type: 'Punctuation',
        value: char
      });
      token = '';
      current++;
      continue;
    }
    token += char;
    current++;
  }
  // 返回分词结果
  return tokens;
}

分词后的结果如下:

[
  { type: 'Keyword', value: 'import' },
  { type: 'Identifier', value: 'moduleA' },
  { type: 'Keyword', value: 'from' },
  { type: 'String', value: '"./moduleA.js"' },
  { type: 'Punctuation', value: ';' },
  { type: 'Keyword', value: 'import' },
  { type: 'Identifier', value: 'moduleB' },
  { type: 'Keyword', value: 'from' },
  { type: 'String', value: '"./moduleB.js"' },
  { type: 'Punctuation', value: ';' },
  { type: 'Keyword', value: 'function' },
  { type: 'Identifier', value: 'add' },
  { type: 'Punctuation', value: '(' },
  { type: 'Identifier', value: 'v1' },
  { type: 'Punctuation', value: ',' },
  { type: 'Identifier', value: 'v2' },
  { type: 'Punctuation', value: ')' },
  { type: 'Punctuation', value: '{' },
  { type: 'Keyword', value: 'return' },
  { type: 'Identifier', value: 'v1' },
  { type: 'Punctuation', value: '+' },
  { type: 'Identifier', value: 'v2' },
  { type: 'Punctuation', value: '}' },
  { type: 'Identifier', value: 'add' },
  { type: 'Punctuation', value: '(' },
  { type: 'Identifier', value: 'moduleA' },
  { type: 'Punctuation', value: '.' },
  { type: 'Identifier', value: 'val' },
  { type: 'Punctuation', value: ',' },
  { type: 'Identifier', value: 'moduleB' },
  { type: 'Punctuation', value: '.' },
  { type: 'Identifier', value: 'val' },
  { type: 'Punctuation', value: ')' },
  { type: 'Punctuation', value: ';' }
]

语法分析

语法分析一般使用正则表达式(例如Vue中模板解析)或者有限状态机

有限状态机(Finite-state machine)是一个非常有用的模型,可以模拟世界上大部分事物。

它有三个特征:

  • 状态总数(state)是有限的。
  • 任一时刻,只处在一种状态之中。
  • 某种条件下,会从一种状态转变(transition)到另一种状态。

下面的针对上述代码实现的解析器,大体思路就是构思好解析过程中会遇到的各种状态,通过维护这个状态机的状态来进行不同的操作。

// 解析器
export function parse(tokens) {
  // AST
  const program = {
    type: 'Program',
    body: []
  }
  // 当前下标
  let current = 0;
  // 当前子项的程序语法
  let statement = {};
  /**
   * 当前状态
   * 这里我有以下几种状态
   * ImportDeclaration:导出态
   * FunctionDeclaration:函数态
   * ParamsDeclaration:函数参数态
   * BodysDeclaration:函数体态
   * ReturnDeclaration:函数返回态
   */
  let currentState = 'default'
  while (current < tokens.length) {
    // 当前的词
    const token = tokens[current];
    // 上个词
    const preToken = tokens[current - 1];
    switch (token.type) {
      // 匹配关键字
      case 'Keyword':
        switch (token.value) {
          // 如果是import
          case 'import':
            currentState = 'ImportDeclaration'
            // 设置类型为”导出语法“
            statement.type = 'ImportDeclaration';
            // 设置导出标识数组
            statement.specifiers = [];
            // 设置源导出模块
            statement.source = {};
            break;
          // 如果是function
          case 'function':
            currentState = 'FunctionDeclaration'
            // 设置类型为”函数语法“
            statement.type = 'FunctionDeclaration';
            // 设置函数名
            statement.id = {};
            // 设置参数
            statement.params = [];
            // 设置函数方法体
            statement.body = {
              // 因为这里我只支持块级函数,所以直接写死。正常来说还需要兼容类似于箭头函数等写法
              type: "BlockStatement",
              body: []
            };
            break;
          // 如果是return
          case 'return':
            currentState = 'ReturnDeclaration';
            break;
        }
        current++;
        continue;

      // 匹配标点符号
      case 'Punctuation':
        switch (token.value) {
          case ';':
            program.body.push(statement);
            statement = {};
            break;
          case '(':
            // 如果当前是函数态,则切换为参数态
            if (currentState === 'FunctionDeclaration') {
              currentState = 'ParamsDeclaration';
            }
            break;
          case ')':
            switch (currentState) {
              case 'ParamsDeclaration':
                // 如果当前是函数参数态且遇到了右括号,则关闭参数态置为函数态
                currentState = 'FunctionDeclaration'
                break;
            }
            break;
          case '{':
            // 如果当前是函数态,则切换为参数态
            if (currentState === 'FunctionDeclaration') {
              currentState = 'BodysDeclaration';
            }
            break;
          case '}':
            switch (currentState) {
              case 'BodysDeclaration':
                // 如果当前是函数体态且遇到了右括号,则关闭参数态置为函数态
                currentState = 'FunctionDeclaration'
                break;
            }
            break;
        }
        current++;
        continue;

      // 匹配标识符
      case 'Identifier':
        switch (currentState) {
          // 如果当前状态是导入态
          case 'ImportDeclaration':
            switch (preToken.value) {
              case 'import':
                // 这里因为import导出时有可能是 { } 多个导出,用了数组
                // 填入import导出标识
                statement.specifiers.push({
                  type: 'ImportDefaultSpecifier',
                  local: {
                    type: token.type,
                    name: token.value
                  }
                });
                break;
            }
            break;
          // 如果当前状态是函数态
          case 'FunctionDeclaration':
            // 填充函数名
            statement.id = {
              type: "Identifier",
              name: token.value
            }
            break;
          // 如果当前状态是函数参数态
          case 'ParamsDeclaration':
            statement.params.push({
              type: "Identifier",
              name: token.value
            })
            break;
          // 如果当前状态是函数体态
          case 'BodysDeclaration':
            // 因为并不是每个函数一上来就是return
            // 所以这个状态用于处理函数里面的方法
            break;
          // 如果当前状态是函数返回态
          case 'ReturnDeclaration':
            const nextCommaIndex = tokens.slice(current).findIndex(e => e.value === '}')
            const expression = tokens.slice(current, current + nextCommaIndex).map(e => e.value);
            // 通过正则匹配,如果是二元表达式
            if (/\S*[\+|\-|\*|\/|\%]\S*/.test(expression.join(''))) {
              statement.body.body.push({
                type: "ReturnStatement",
                argument: {
                  type: "BinaryExpression",
                  left: {
                    type: "Identifier",
                    name: expression[0]
                  },
                  operator: expression[1],
                  right: {
                    type: "Identifier",
                    name: expression[2]
                  }
                }
              })
            }
            program.body.push(statement);
            current += nextCommaIndex;
            break;
        }
        current++;
        continue;

      // 匹配字符串
      case 'String':
        // 如果当前状态是import
        switch (currentState) {
          case 'ImportDeclaration':
            switch (preToken.value) {
              case 'from':
                // 如果上个关键字是from,则添加导入的源路径信息
                statement.source = {
                  type: "Literal",
                  value: token.value.substr(1, token.value.length - 2),
                  raw: token.value
                }
                break;
            }
            break;
        }
        current++;
        continue;
    }

    // 常规来说,AST如果在这里都没匹配到的话,直接报错。意味着语法错误
    // 但是这里我只做了最简单的匹配,所以直接跳过不支持的语法
    current++;
    continue;
  }
  return program
}

下面是解析器将分词结果解析后的语法树,这里我参照着acorn的语法拼接

{
    "type": "Program",
    "body": [
        {
            "type": "ImportDeclaration",
            "specifiers": [
                {
                    "type": "ImportDefaultSpecifier",
                    "local": {
                        "type": "Identifier",
                        "name": "moduleA"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "value": "./moduleA.js",
                "raw": "\"./moduleA.js\""
            }
        },
        {
            "type": "ImportDeclaration",
            "specifiers": [
                {
                    "type": "ImportDefaultSpecifier",
                    "local": {
                        "type": "Identifier",
                        "name": "moduleB"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "value": "./moduleB.js",
                "raw": "\"./moduleB.js\""
            }
        },
        {
            "type": "FunctionDeclaration",
            "id": {
                "type": "Identifier",
                "name": "add"
            },
            "params": [
                {
                    "type": "Identifier",
                    "name": "v1"
                },
                {
                    "type": "Identifier",
                    "name": "v2"
                }
            ],
            "body": {
                "type": "BlockStatement",
                "body": [
                    {
                        "type": "ReturnStatement",
                        "argument": {
                            "type": "BinaryExpression",
                            "left": {
                                "type": "Identifier",
                                "name": "v1"
                            },
                            "operator": "+",
                            "right": {
                                "type": "Identifier",
                                "name": "v2"
                            }
                        }
                    }
                ]
            }
        }
    ]
}

这里我们就实现了一个最简单的js解析器,可以看出真正实现一个完全可用的js解析器是一个相当庞大的工程。所以站在巨人的肩膀上,后面我们直接使用现有的成熟js解析器。

常用的JS解析器

Esprima

第一个用JavaScript编写的符合EsTree规范的JavaScript的解析器,后续多个编译器都是受它的影响。

Acorn

acorn和Esprima很类似,输出的ast都是符合EsTree规范的,目前webpack的AST解析器用的就是acorn,和Esprima一样,也是不直接支持JSX

@babel/parser(babylon)

babel官方的解析器,最初fork于acorn,后来完全走向了自己的道路,从babylon改名之后,其构建的插件体系非常强大。

Espree

eslintprettier的默认解析器,最初fork于Esprima的一个分支(v1.2.2),后来因为ES6的快速发展,但Esprima短时间内又不支持,后面就基于acorn开发了。

利用AST优雅的修改你的代码

在了解了js解析器生成AST的原理后,我们开始利用AST来优雅的修改我们的代码,实现我们想要的功能。下面我会举两个例子,来实践下。

tree shaking

为了更好的理解AST能干什么,这里我们来实现一个最简单的tree shaking,具体思路如下:

  1. 首先我们获取到需要进行tree shaking的源代码
  2. 将源代码通过acorn转换为AST
  3. 找出其中所有的变量声明函数声明,并存储到decls
  4. 找出其中所有的表达式,并存储到calledDecls
  5. 遍历decls找出不需要的节点存储到code数组中
  6. 通过code移除ast中没有用到的节点
  7. 重新生成代码

首先,我们假设下面代码是我们要执行tree shaking的源代码,这里面subtract方法和 num3变量并未使用。

const add = (a, b) => a + b;
function subtract(a, b) { return a - b }
const num1 = 1;
const num2 = 10;
const num3 = 100;
add(num1, num2);

然后我们封装tree-shaking方法,这里的大致思路和webpacktree shaking标记处有用的节点相反,我是找出所有的变量声明及函数声明,然后再找出所有表达式中用到函数和变量。最后再从AST中移除不需要的用到的节点,通过escodegen将AST重新生成代码。具体代码如下:

import * as acorn from 'acorn';
import escodegen from 'escodegen';

// tree shaking方法
export function shaking(input) {
  // decls 存储所有的函数或变量声明类型节点
  const decls = new Map();
  // calledDecls 存储了代码中使用到的表达式节点
  const calledDecls = [];
  // code 存储了没有被节点类型匹配的节点
  const code = [];
  // 转换为AST
  const program = acorn.parse(input, {
    sourceType: 'module'
  });
  // 遍历AST,标记有效节点
  for (let node of program.body) {
    switch (node.type) {
      // 变量声明的话加入decls
      case 'VariableDeclaration':
        for (let decl of node.declarations) {
          decls.set(decl.id, node);
        }
        break;
      // 函数声明的话加入decls
      case 'FunctionDeclaration':
        decls.set(node.id, node);
        break;
      // 表达式的话加入calledDecls
      case 'ExpressionStatement':
        if (node.expression.type == "CallExpression") {
          calledDecls.push(node);
        }
        break;
    }
  };
  // 遍历找出没有用到的代码
  for (let [key, value] of decls) {
    if (!calledDecls.some(e => e.expression.callee.name === key.name) && !calledDecls.some(e => e.expression.arguments.some(c => c.name === key.name))) {
      code.push(value);
    }
  }
  // 遍历找出该删除的节点
  let deleteIndex = [];
  for (let i in code) {
    for (let j in program.body) {
      if (JSON.stringify(code[i]) === JSON.stringify(program.body[j])) {
        deleteIndex.push(j);
      }
    }
  }
  program.body = program.body.filter((e, i) => !deleteIndex.includes(i.toString()));
  // AST转换为Code
  return escodegen.generate(program);
}

这里输出的结果为:

import fs from 'fs';
import { shaking } from './treeshaking.js'

const sourceCode = fs.readFileSync('./tree-shaking/source.js').toString();
const treeshaking = shaking(sourceCode);
console.log(treeshaking);
/**
 * const add = (a, b) => a + b;
 * const num1 = 1;
 * const num2 = 10;
 * add(num1, num2);
 */

静态图片上传替换

我们知道在前端的性能优化中,使用CDN是一个比较常见的优化点。一般CDN会设置多个节点服务器,分布在不同区域中,便于用户进行数据传递和访问。当某一个节点出现问题时,通过其他节点仍然可以完成数据传输工作,可以提高用户访问网站的响应速度。且现在的云提供商基本上都采用“分布式存储”,将中心平台的内容分发到各地的边缘服务器,使用户能够就近获取所需内容,降低网络用时,提高用户访问响应速度和命中率。利用了索引、缓存等技术。

举个比较典型例子,当我们在开发小程序时,因为打包后的包体积不能超过2M,那么这时候我们将图片上传到CDN是一个有效减少包体积的方案,但是同时为了本地开发方便也提高开发效率,我们希望在本地开发时可以直接使用本地图片。因此我们可以通过ast来实现一个自定义webpack loader完成静态图片上传CDN并修改链接的功能。

这里我以Vue的项目作为例子,具体步骤如下

初始化vue项目

修改App.vue代码如下,这里只保留了logo.png这张图片的引入

<template>
  <div>
    <img alt="Vue logo" src="./assets/logo.png" />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  name: "App",
});
</script>
实现OSS上传

新建plugins/oss-upload.js文件,这里我们封装下ali-oss的上传方法

const OSS = require('ali-oss');
// 统一前缀
const prePath = 'web-image'
let client = new OSS({
  accessKeyId: '你的accessKeyId',
  accessKeySecret: '你的accessKeySecret',
  bucket: 'wynne-typora',
  region: 'oss-cn-beijing'
})

// 上传方法
module.exports = function ossUpload(fileName, fileUrl) {
  return client.put(`${prePath}/${fileName}`, fileUrl);
}
实现自定义loader

新建plugins/oss-image-upload.js文件,这个文件作为我们的自定义loader的功能实现,具体思路如下:

  1. 首先取出template中的html
  2. 使用htmlparser2进行HTML解析,生成JSON
  3. 遍历JSON获取其中的img标签,并上传src指向的图片
  4. 上传完成后替换src路径,改为阿里云OSS的路径
  5. 通过htmlparser-to-html重新生成HTML
  6. 修改资源文件
const HtmlParser = require("htmlparser2");
const HtmlparserToHtml = require('htmlparser-to-html');
const OssUpload = require('./oss-upload');
const path = require('path')

module.exports = function (source) {
  // 开启异步loader
  const callback = this.async();
  // 获取template模板内容
  const template = source.match(/<template>[\s\S]*<\/template>/)[0];
  // 获取html
  const html = template.replace(/<\/?template>/g, '');
  // 获取资源路径
  let htmlJson = HtmlParser.parseDocument(html);
  // 绑定
  traverseElement = traverseElement.bind(this);
  // 遍历DOM,上传图片
  traverseElement(htmlJson.childNodes).then(() => {
    // 将JSON转回HTML
    const cdnHtml = HtmlparserToHtml(htmlJson.childNodes)
    // 替换template
    source = source.replace(/<template>[\s\S]*<\/template>/, `<template>${cdnHtml}<\/template>`)
    // 返回loader结果
    callback(null, source)
  })
};

// 遍历dom节点并上传图片
async function traverseElement(elements) {
  const resourcesPath = path.parse((this.resourcePath)).dir;
  for (let node of elements) {
    // 如果tap是图片且有src字段
    if (node.type === 'tag' && node.name === 'img' && node.attribs && node.attribs.src) {
      // 获取图片路径
      const filePath = path.resolve(resourcesPath, node.attribs.src)
      // 获取文件名
      const fileName = path.parse(filePath).base;
      // 启用上传
      node.attribs.src = await imageUpload(fileName, filePath);
    }
    // 如果还有子集,继续递归
    if (node.children) {
      await traverseElement(node.children);
    }
  }
}

// 上传到OSS并输出日志
async function imageUpload(fileName, filePath) {
  return new Promise((resolve, reject) => {
    // 上传OSS
    OssUpload(fileName, filePath).then(res => {
      if (res.res.status === 200) {
        console.log(`\r\n资源 ${fileName} 已成功上传`)
      } else {
        console.log(`\r\n资源 ${filePath} 上传失败`, res.res.statusMessage)
      }
      resolve(res.url);
    })
  })
}
本地loader调试

我们在刚刚初始化的vue项目中初始化新建vue.config.js,新增loader这里的loader指向我们写好的本地loader文件

const path = require('path')
module.exports = {
  chainWebpack: (config) => {
    config.module
      .rule('oss-image-upload')
      .test(/\.vue$/)
      .use('oss-image-upload')
        .loader(path.resolve(__dirname,'./plugins/oss-image-upload'))
        .end()
  }
}

之后执行npm run build,可以看到这时候我们这个自定义loader已经生效了

image-20210412160650190

然后我们再部署一下这个项目打开看下效果

image-20210412160746006