AST能帮我们做点啥?
在前端工程化日益成熟的今天,每当我们去深入研究构建工具的构建过程和原理时,总会触及到一个叫抽象语法树
,又名AST
的知识领域,那么AST到底是个什么东西?他又能帮助我们做些什么样的工作呢?
希望能够通过这篇文章,让大家对抽象语法书AST
有一个更加深入的了解。
AST是什么?
抽象语法树,或简称语法树,是源代码语法架构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。换句话来说,就是通过编译工具将我们的代码按照一定的规则,转化成为一个树状的结构.
这个树状的结构
我们就称之为抽象语法树,通过阅读和操作这棵树,我们便可以对源码进行分析和优化。
举个例子,我们可以在PlayGround(astexplorer.net/) 上去观察AST的具体结构,当我们定义了一个简单的变量时,可以看到我们的AST是这样的
const a = 1
显然,通过遍历这棵树的所有信息,即使我们没有没有看到源代码,也可以清晰的知道源代码做了什么样的事情。紧接着,如果我们修改了这棵树的某些信息,例如将value
改成了456
,那么我们也就是间接的通过这棵树完成了源码的修改。
AST实际上在当今前端领域中做的就是这么一件事情:
那么,在现在的前端领域,我们通过AST能做什么或者正在利用他做些什么呢?
语法兼容
无论你是写Vue还是React,是使用Webpack还是Rollup,我想你都或多或少的配置过Babel,以及绝大多数场景下,都是利用Babel来我们处理一些低版本浏览器的兼容性问题。
那么,Babel是怎么实现的呢?
首先,我们需要知道Babel本身除了提供兼容低版本浏览器的解决方案以外,还为我们提供了JS转化AST、遍历AST、识别AST、AST生成JS代码等四个npm包,分别对应着:
- @babel/parser
- @babel/traverse
- @babel/types
- @babel/generator
我们可以结合这四个包,利用Webpack loader的形式,去一步一步的实现代码的低版本浏览器兼容性。
假设我们有一段这样的代码:
const onClick = () => {
console.log('click')
}
显然,低版本浏览器是无法兼容const
以及箭头函数
的,我们应该怎么利用AST进行代码的转换呢?
回到一开始的那张图,我们首先需要将我们的源代码转为AST
:
const { parse } = require('@babel/parser')
module.exports = function (sourceCode) {
// 输入源代码,转化为AST
const AST = parse(sourceCode, {
sourceType: "module",
});
console.log(AST)
return sourceCode;
}
然后,通过@babel/traverse来分析我们的代码结构,当我们发现存在const
和箭头函数
时,我们来进行转换。
const generator = require("@babel/generator");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse");
const types = require("@babel/types");
module.exports = function (sourceCode) {
console.log('初始代码', sourceCode)
// 输入源代码,转化为AST
const AST = parse(sourceCode, {
sourceType: "module",
});
const visitor = {
VariableDeclaration(path) {
if (path.node.kind === "const") {
path.node.kind = "var";
}
},
ArrowFunctionExpression(path) {
const funcExpress = types.functionExpression(
null,
path.node.params,
path.node.body
)
path.replaceWith(funcExpress)
}
};
traverse.default(AST, visitor);
const output = generator.default(AST);
console.log('转化后代码', output.code)
return output.code;
}
通过代码,我们不难看出,在利用traverse对AST进行解析时,当我们发现VariableDeclaration
时,我们判断节点的kind
的类型是否为const
,如果是我们就替代成var
。另外,当碰到箭头函数时,我们通过@babel/types
提供的类型函数,生成一个普通的函数类型A'S'T,然后再替代原来的箭头函数。
最终结果打印:
TreeShaking
所谓TreeShaking,就是将我们的代码里没有用的部分给动态去除掉,当然在大多数情况下,前端的TreeShaking都必须依赖于ESM
的模块导入导出规范才能够达成。这是为什么呢?这是因为ESM
在编译阶段就可以通过顶部的导入语句,来确认模块的依赖和引入关系,进而进一步的分析出哪一些模块是我们永远都不会用到的,随即便可以进行删除。但是commonjs
的引入关系是可以动态执行的,所以也就无法达成TreeShaking。
当然,ESM
中的动态引入(dynamic import),也是无法在编译阶段判断依赖关系的,所以也就不支持
TreeShaking。
那么,当我们引入一个诸如lodash
的commonjs规范的包时,我们又怎么利用AST
来做TreeShaking呢?其实很简单,我们只需要将引入代码转化一下便可:
// 不支持Shaking
import { debounce } from 'lodash'
// 只引入单个文件,也就是间接完成了Shaking
import debounce from 'lodash/debounce'
怎么在AST层面上进行转换呢?
毫无疑问,我们首先需要将代码转化为AST
,然后从AST中匹配到我们的导入语句,也就是ImportDeclaration
,从导入语句中,获取到我们的导入内容,最后为每一个导入内容都生成一个单独的默认导入语句。
module.exports = function (sourceCode) {
console.log("初始代码", sourceCode);
const AST = parse(sourceCode, {
sourceType: "module",
});
const visitor = {
ImportDeclaration(nodePath) {
const node = nodePath.node;
// 判断导入语句是否发生在lodash
if (node.source.value !== 'lodash') {
return;
}
//所有的声明
const { specifiers } = node;
// 遍历声明
const importDeclarations = specifiers.map((specifier, index) => {
const moduleName = specifier.imported.name;
const localIdentifier = specifier.local;
// 每一个声明都生成一个单独的默认导入语句
return types.importDeclaration(
[types.ImportDefaultSpecifier(localIdentifier)],
types.StringLiteral(`lodash/${moduleName}`)
)
});
if (importDeclarations.length === 1) {
// 单个声明替换
nodePath.replaceWith(importDeclarations[0]);
} else {
// 多个声明替换
nodePath.replaceWithMultiple(importDeclarations);
}
},
};
traverse.default(AST, visitor);
const output = generator.default(AST);
console.log("转化后代码", output.code);
return sourceCode;
};
这样,一个简单的导入语句转换loader就达成了,其实这也就是babel-plugin-import
的实现原理。
代码压缩
当我们将代码写好之后,为了能够让页面达到更好的性能,将JS代码的体积进行压缩是我们常做的一件事情。而这件事情,实际上也离不开AST的解析。
例如压缩插件常做的几件事情:
1、去除冗余字符
/**
* 两数相加
*/
function add (a, b) {
return a + b;
}
通过阅读这段代码,我们可以知道在生产环境中,注释,空格,换行都是不需要的,但是这些东西会实打实的占用我们的文件体积,通过AST的分析,我们可以很快速的识别到这些信息,然后将其去除。
例如,我们想通过AST去删除函数前面无用的注释时:
module.exports = function (sourceCode) {
console.log("初始代码", sourceCode);
const AST = parse(sourceCode, {
sourceType: "module",
});
const visitor = {
FunctionDeclaration(path) {
types.removeComments(path.node);
},
};
traverse.default(AST, visitor);
const output = generator.default(AST);
console.log("转化后代码", output.code);
return sourceCode;
};
2、压缩变量名
显然,过长的变量名在文件中也会占用一定的体积。加上他们都在函数的作用域中,在作用域外不会引用它,此时可以让它们的变量名称更短。
/**
* 两数相加
*/
function add (firstsssssss, seconddddddd) {
return firstsssssss + seconddddddd;
}
function add (x, y) { return x + y; }
3、合并声明语句
// 压缩前
const a = 3; const b = 4;
// 压缩后
const a = 3, b = 4;
压缩可以做的事情有非常多,但是主要的逻辑都是通过AST的分析来针对性的删除或者转换代码。
CSS代码转换
当然,AST并不只是某个语言的特权。利用不同的转换工具,我们可以将各种各样的语言转化为相似的AST语法树结构,然后去做我们想做的各种事情。
除了JS之外,我们最常接触到语言还有CSS。在我们利用postCss插件去做各种CSS代码的转换时,例如px转rem,浏览器兼容性处理等等,就是利用了AST对CSS代码的解析和转换。
怎么利用postcss获取css代码的AST呢?我们可以来做:
.container {
display: flex;
}
const postcss = require('postcss')
module.exports = function (sourceCode) {
const cssAst = postcss.parse(sourceCode);
console.log('AST', cssAst)
return sourceCode;
};
我们可以看到CSS代码已经成功被转化为了AST:
当我们拿到源代码的AST之后,我们就可以很轻松的去做各种我们想做的事情了。
AST的真正意义
前段时间,我在知乎上看到了尤雨溪回答了虚拟Dom的相关问题,其中有一句话让我印象非常深刻,
虚拟Dom的真正价值之一其实是为前端的视图层提供了一层抽象,而利用这个抽象,可以将视图层映射到各个端之间,比如小程序,Native等等。
为什么我会提到这个呢?是因为在我看来,AST也同样提供了这样的价值。我们在开发的过程中,会遇到各种各样的语言,例如JavaScript, Typescript, CSS, Less等等,我们总是需要在编译层面上针对这些语言做一些事情,但是每个语言都有其各自的语法规则和书写形式,难道编译器要针对不同的语言都去出一套不同的编译规则吗?
实际上,AST作为抽象层就很大程度上缓解了这件事情,无论是哪种语言需要进行编译,都先通过各自的工具转化为AST,编译工具最终读到的都是AST的结构代码,这样的话,无论是扩展性和维护性都会有非常大的提高。
转载自:https://juejin.cn/post/7282228465931534394