深入前端工程化-AST基础篇

前言

如今前端领域各种框架、工具层出不穷,学习这些框架/工具需要耗费大量的精力与时间,而且很多东西如果不用就忘记了,所以我们更要关注这些技术背后的本质。

我们都知道浏览器对资源的识别是通过 Content-Type 这个 HTTP 头做到的,它只认识 .html.css.js 这类资源,而如今为了提升前端开发效率出现了各种各样的资源拓展,比如:

  • .vue
  • .less
  • .jsx
  • .pcss
  • .ts

最终通过 RollupWebpackVite 这类构建工具又回到了浏览器认识的前端资源,那这个构建过程是如何做到的?

这就是今天要给大家分享的主题:AST 在前端领域的应用,希望给大家带来收获!

什么是 AST

人类语言的 “描述”

我们试想这样一个场景:假如让你描述一个人你该怎么描述?

你可能会从几个方面去描述:

  • 外貌:有着深邃的双眸、飘逸的头发
  • 性格:稳重、不骄不躁
  • 爱好:弹吉他

我们能通过上述维度去描述一个人,而在编程领域中呢,我们会接触很多语言和框架,比如在前端中有 JavaScript(.js)TypeScript(.ts)Vue(.vue)Less(.less),同样也有规则去描述它们,而这个就是 AST(Abstract Syntax Tree),一种用于 表示程序代码抽象语法结构 的树形数据结构。

语法结构?没错是你理解的那个语法结构,和人类语言一样。

我们回忆一下以前上语文课学一个句子怎么学的,以下面这句话为例子:如果明天不下雨,我就去爬山。

站在语文老师的角度语法分析如下:

这句话是一个条件句,由两个分句组成,主句为“我就去爬山”,从句为“如果明天不下雨”。从句中,“如果”是引导词,表示条件,后面跟着一个陈述句“明天不下雨”,表示条件的前提。主句中,“就”是副词,表示结果,后面跟着一个动词短语“去爬山”,表示条件成立时的结果。整个句子的语法结构为“从句+主句”的条件句结构。

画个图更直观:

深入前端工程化-AST基础篇

其实可以看到去分析一个句子的时候,老师会帮我们把句子拆得很碎:

  • 如果
  • 明天不下雨
  • 标点符号
  • 去爬山
  • 标点符号

接着就是进行语义化分析即语法分析:

  • 如果 -> 条件
  • 不下雨 -> 陈述句
  • 我 -> 主语
  • 就 -> 副词
  • 去爬山 -> 动词短语

这样就把一段句子分析完啦!

编程语言的 “描述”

那如果用 JavaScript 去描述呢这段逻辑呢?

const isRainyTomorrow = false;
const goHiking = () => console.log('go hiking');

if (!isRainyTomorrow) {
  goHiking();
}

我们分析一段句子的时候是通过大脑辅助进行的,而编程语言想去识别代码的语法结构也是需要这样的 “大脑”,它的名字叫编译器。下面是编译器的处理过程:

1.分词/词法分析(Tokenizing/Lexing)

分词/词法分析主要是将代码字符串分解为一个个的词法单元(token)。下面是对上述 JS 代码的词法分析:

  • const:关键字,用于声明常量。
  • isRainyTomorrow:常量名,表示明天是否下雨的布尔值。
  • =:赋值运算符,用于将 false 赋值给 isRainyTomorrow
  • false:布尔字面量,表示 isRainyTomorrow 的值为 false
  • ;:语句结束符,用于标记语句的结束。
  • const:关键字,用于声明常量。
  • goHiking:箭头函数名,表示去爬山的函数。
  • =:赋值运算符,用于将箭头函数赋值给 goHiking 常量。
  • () => console.log('go hiking'):箭头函数表达式,表示一个不接受参数的箭头函数,当调用时会输出一条消息。
  • {}:函数体,包含了要执行的语句。
  • if:关键字,用于开始一个条件语句块。
  • (!isRainyTomorrow):条件表达式,使用逻辑非运算符 ! 将 isRainyTomorrow 的值取反,表示如果明天不下雨。
  • {}:条件语句块,包含了要执行的语句。
  • goHiking():函数调用表达式,调用 goHiking 函数输出一条消息。
  • ;:语句结束符,用于标记语句的结束。

我们再回忆一下上面人类语言,是不是感觉差不多?都是把每个词语拿出来分析,包括标点符号,是不是有点像把一个物品的零件全拆开的感觉呢?

分词(tokenizing) 和词法分析(Lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法 单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。—《你不知道的JavaScript》

2.解析/语法分析(Parsing)

得到了一堆 “零件” 后,我们需要对零件进行 “分类” 啦,这个分类的过程就是语法分析了,编译器会把上面产生的词法单元转换一个嵌套的树形结构 —> AST

Program
├── VariableDeclaration (const isRainyTomorrow = false)
│   ├── VariableDeclarator
│   │   ├── Identifier (isRainyTomorrow)
│   │   └── Literal (false)
│   └── kind: const
├── VariableDeclaration (const goHiking = () => console.log('go hiking'))
│   ├── VariableDeclarator
│   │   ├── Identifier (goHiking)
│   │   └── ArrowFunctionExpression
│   │       ├── params: []
│   │       └── body: BlockStatement
│   │           └── ExpressionStatement
│   │               └── CallExpression
│   │                   ├── MemberExpression
│   │                   │   ├── Identifier (console)
│   │                   │   └── Identifier (log)
│   │                   └── Literal ('go hiking')
│   └── kind: const
└── IfStatement (!isRainyTomorrow)
    ├── UnaryExpression (!)
    │   └── Identifier (isRainyTomorrow)
    └── BlockStatement
        └── ExpressionStatement
            └── CallExpression
                └── Identifier (goHiking)

大家也可以打开这个astexplorer网站查看结构:

深入前端工程化-AST基础篇

看到一堆陌生的属性?别慌,我们学一门语言的时候哪有刚开始全部掌握语法的,我们不用刻意去记住它们,我们只要想着生成 AST 的过程就是帮助我们分析代码的具体意义。既然是一颗树,那么每一个节点都有它的意义,我简单给大家介绍下上述几个节点的含义:

  • Program:表示整个程序。
  • VariableDeclaration:表示常量/变量声明语句。
    • 第一个 VariableDeclarator 子节点:表示常量 isRainyTomorrow 声明和赋值。
    • 第二个 VariableDeclarator 子节点:箭头函数 goHiking 的声明和赋值。
  • IfStatement:表示条件语句块。
    • UnaryExpression:条件表达式
    • BlockStatement: 条件语句块
      • ExpressionStatement:表达式,由语句和可选分号组成。
        • CallExpression: 表示函数调用(goHiking 调用)

通过这些节点的类型很容易就把一门编程语言描述清楚了,其他编程语言也是类似的,只是不同语言 AST 的解析规则是不同的,所以需要不同的编译器进行处理。这个就像人类语言一样,中文和英文的语法能一样吗?

AST 是有规范的,目前都会遵循ESTree这个社区维护的规范,对于不熟悉的节点类型可以直接查看规范。目前遵循 ESTree 规范主流的几款解析器:Acorn(解析器的老大,很多其他解析器基于它做的)、 EsprimaTypeScript(兼容了 estree )、UglifyJS(非标准的 ESTree 规范)、@babel/parser

3.代码生成

有了这棵 AST 之后呢?那能做的事情老多了,我们待会在下面解决的问题中细说,不过编译器一般都会利用 AST 生成代码,但 JavaScript 和传统编译语言(C、C++)不太一样,它是一门即时编译(JIT)语言,在运行代码前就编译,所以它是变编译边执行的,而且它是可以跑到不同宿主环境的,最终被编译成宿主环境能识别的 “低级” 代码。以 Node.js 为例,使用以下命令去生成字节码:

node --print-bytecode index.js

生成的结果如下:

[generated bytecode for function: goHiking]
Parameter count 0
Frame size 0
   12 E0 00 00       LdaSmi [0]
   0B 00             Ret

[generated bytecode for function: <Script>]
Parameter count 0
Frame size 0
   1E 00 00 00       LdaSmi [0]
   1F 01 00 00       Star r1
   12 E0 00 00       LdaSmi [0]
   1F 02 00 00       Star r2
   1D 03 00 00       LdaTrue
   3E 02 01 03 00    Test r2, r3
   1A 02 00          JmpFalse 16 (0x00000010)
   1F 01 00 00       Star r1
   1E 00 00 00       LdaSmi [0]
   1F 02 00 00       Star r2
   1D 03 00 00       LdaTrue
   3E 02 01 03 00    Test r2, r3
   1A 02 00          JmpFalse 16 (0x00000010)
   12 E0 00 00       LdaSmi [0]
   0B 00             Ret
   1E 00 00 00       LdaSmi [0]
   1F 01 00 00       Star r1
   12 E0 00 00       LdaSmi [0]
   1F 02 00 00       Star r2
   1D 03 00 00       LdaTrue
   3E 02 01 03 00    Test r2, r3
   1A 02 00          JmpFalse 16 (0x00000010)
   1F 01 00 00       Star r1
   1E 00 00 00       LdaSmi [0]
   1F 02 00 00       Star r2
   1D 03 00 00       LdaTrue
   3E 02 01 03 00    Test r2, r3
   1A 02 00          JmpFalse 16 (0x00000010)
   12 E0 00 00       LdaSmi [0]
   0B 00             Ret

大家看个样子就可以了,不用关心细节,它最后肯定转换成计算机指令都在通过 CPU 执行啦,这就不在本文范畴了。当然它的功能远远不止于此,下面我们看看它有哪些应用场景。

应用场景

在前端开发中,AST 属于是你看不见摸不着,但你天天在使用的东西。接下来我们来看看它有哪些应用场景吧。

代码转换

上面有介绍过 AST 可以实现从高级语言到低级语言的转换,除了这种转换,也可以实现 ES6 -> ES5TS -> JS这种转换。

Babel 就是使用 AST 来将 ES6+ 代码转换为向后兼容的 JavaScript 代码。Babel 将源代码解析为 AST,然后对 AST 进行修改和转换,最后再将 AST 转换回 JavaScript 代码。比如可选链语法转换为向后兼容的条件表达式语法的过程:

深入前端工程化-AST基础篇

除了 Babel,像 Vue Template 转换为 Render FunctionJSX 转换为 JavaSript 的过程都是类似的,基本上都是经历了这几步:

  • Parse:调用编译器的解析器去生成 AST。
  • Transform:对 AST 进行遍历转换,转换成一种新的语法。
  • Code Generation:代码生成,将新生成的语法树转换新的代码。

掌握了这个原理后,市面上所有前端框架、工具的拓展的基本原理你都可以搞懂是咋个回事,当然你也可以拓展自己的一套规则,然后写出一个新的语言,TypeScript 就是这么诞生的!

代码检查

AST 在代码检查上也有很广泛的运用,比如我们最常用的 ESLint,它可以对代码的风格和语法进行分析,纠出不符合规则的地方,并且在 IDE 展示报错,也可以帮我们去修复错误,它内部的原理也是用到了 AST

ESLint 的工作原理如下:

  • 解析代码ESLintJavaScript 代码解析为 AST,这个过程使用了 Esprima 这个 JavaScript 解析器(默认,且针对 .js 文件)。
  • 遍历 ASTESLint 遍历 AST 中的每个节点,这些节点代表了代码中的不同部分,如变量、函数、语句等。
  • 应用规则ESLint 使用一组规则来检查 AST 中的节点,以查找代码中的问题。规则是由用户配置的,可以启用或禁用不同的规则。
  • 发现问题:当 ESLint 发现一个问题时,它会生成一个警告或错误消息,指出问题所在的位置和原因。
  • 输出结果ESLint 将检查结果输出到控制台或文件中,以便用户查看和处理。

可以看到代码检查与代码转换相比,少了 AST 转换,并且也不需要生成新的代码,整体流程都差不多的,后续会有专门的文章会从源码角度分享 ESLint 的机制,大家可以期待下😘。

代码优化

有了 AST 之后,我们也可以去做很多代码优化的事情:

  • 分析代码:性能问题、兼容问题、代码行数统计、模块依赖分析,我们甚至可以基于这些做埋点,专门用于分析代码问题。
  • 代码混淆:比如 Webpack 打包后的代码替换了变量名称,基本上没人能看懂。
  • 代码压缩:删除无用空格、注释,比如我们很早之前用过的 UglifyJS

当然 AST 的能力不止于此,上面介绍的场景都是比较常见的,希望大家看到这里能对 AST 的场景和基础有所了解就行啦!

总结

本文主要是给大家介绍 AST 的基础概念以及一些常见的应用场景,在工程化领域 AST 是非常核心的底座,除了上文提到的一些场景还有比如:Webpack 中的 Loader 机制、Vue3 中的 @vue/compiler-sfc 对 SFC 的编译、代码静态扫描工具、按需加载插件这类场景都有应用到 AST,后续的文章会有相关的实战来讲述 AST 的应用,期待一下哟~

参考

原文链接:https://juejin.cn/post/7225623397144313916 作者:不烧油的小火柴

(0)
上一篇 2023年4月25日 上午10:36
下一篇 2023年4月25日 上午10:46

相关推荐

发表回复

登录后才能评论