使用AST优雅的定制你的代码
AST 简介
AST(Abstract Syntax Tree)抽象语法树,是源代码语法结构的一种抽象表示。它以树状形式表示编程语言的语法结构,树上的每个节点都代表源代码中的一种结构。简而言之就是源代码的一个树状结构表示形式。
AST 原理
生成AST一般分为以下几步:
词法分析
将整个代码字符串分割成最小语法单元数组
// 源代码
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):
import、from、function、return - 普通名字(name):
moduleA、moduleB、add、v1、v2 - 部分符号:
- 分号(semi):
; - 逗号(comma):
, - 点(dot):
. - 左括弧(parenL):
( - 右括弧(parenR):
) - 左花括号(braceL):
{ - 右花括号(braceR):
} - 加号(plusMin):
+
- 分号(semi):
- 结束符:
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
eslint、prettier的默认解析器,最初fork于Esprima的一个分支(v1.2.2),后来因为ES6的快速发展,但Esprima短时间内又不支持,后面就基于acorn开发了。
利用AST优雅的修改你的代码
在了解了js解析器生成AST的原理后,我们开始利用AST来优雅的修改我们的代码,实现我们想要的功能。下面我会举两个例子,来实践下。
tree shaking
为了更好的理解AST能干什么,这里我们来实现一个最简单的tree shaking,具体思路如下:
- 首先我们获取到需要进行
tree shaking的源代码 - 将源代码通过
acorn转换为AST - 找出其中所有的变量声明和函数声明,并存储到
decls - 找出其中所有的表达式,并存储到
calledDecls - 遍历
decls找出不需要的节点存储到code数组中 - 通过
code移除ast中没有用到的节点 - 重新生成代码
首先,我们假设下面代码是我们要执行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方法,这里的大致思路和webpack的tree 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的功能实现,具体思路如下:
- 首先取出
template中的html - 使用
htmlparser2进行HTML解析,生成JSON - 遍历
JSON获取其中的img标签,并上传src指向的图片 - 上传完成后替换
src路径,改为阿里云OSS的路径 - 通过
htmlparser-to-html重新生成HTML - 修改资源文件
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已经生效了
然后我们再部署一下这个项目打开看下效果