likes
comments
collection
share

AST能帮我们做点啥?

作者站长头像
站长
· 阅读数 13

在前端工程化日益成熟的今天,每当我们去深入研究构建工具的构建过程和原理时,总会触及到一个叫抽象语法树,又名AST的知识领域,那么AST到底是个什么东西?他又能帮助我们做些什么样的工作呢?

希望能够通过这篇文章,让大家对抽象语法书AST有一个更加深入的了解。

AST是什么?

抽象语法树,或简称语法树,是源代码语法架构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。换句话来说,就是通过编译工具将我们的代码按照一定的规则,转化成为一个树状的结构.

这个树状的结构我们就称之为抽象语法树,通过阅读和操作这棵树,我们便可以对源码进行分析和优化。

举个例子,我们可以在PlayGround(astexplorer.net/) 上去观察AST的具体结构,当我们定义了一个简单的变量时,可以看到我们的AST是这样的

const a = 1

AST能帮我们做点啥?

显然,通过遍历这棵树的所有信息,即使我们没有没有看到源代码,也可以清晰的知道源代码做了什么样的事情。紧接着,如果我们修改了这棵树的某些信息,例如将value改成了456,那么我们也就是间接的通过这棵树完成了源码的修改。

AST实际上在当今前端领域中做的就是这么一件事情:

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;
}

AST能帮我们做点啥?

然后,通过@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,然后再替代原来的箭头函数。

最终结果打印:

AST能帮我们做点啥?

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的实现原理。

AST能帮我们做点啥?

代码压缩

当我们将代码写好之后,为了能够让页面达到更好的性能,将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;
};

AST能帮我们做点啥?

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之后,我们就可以很轻松的去做各种我们想做的事情了。

AST的真正意义

前段时间,我在知乎上看到了尤雨溪回答了虚拟Dom的相关问题,其中有一句话让我印象非常深刻,

AST能帮我们做点啥?

虚拟Dom的真正价值之一其实是为前端的视图层提供了一层抽象,而利用这个抽象,可以将视图层映射到各个端之间,比如小程序,Native等等。

AST能帮我们做点啥?

为什么我会提到这个呢?是因为在我看来,AST也同样提供了这样的价值。我们在开发的过程中,会遇到各种各样的语言,例如JavaScript, Typescript, CSS, Less等等,我们总是需要在编译层面上针对这些语言做一些事情,但是每个语言都有其各自的语法规则和书写形式,难道编译器要针对不同的语言都去出一套不同的编译规则吗?

实际上,AST作为抽象层就很大程度上缓解了这件事情,无论是哪种语言需要进行编译,都先通过各自的工具转化为AST,编译工具最终读到的都是AST的结构代码,这样的话,无论是扩展性和维护性都会有非常大的提高。

AST能帮我们做点啥?