从零开发一个模块化打包工具

前言

构建打包是前端工程化领域的关键组成之一。作为一名前端开发者,对构建打包工具的认知,是绕不过去的一道坎。构建工具帮助前端流程化,自动化,更对前端各大框架有着深远的影响,大多数前端框架已经深度依赖编译时工具去实现。

本次咱们就面向编译打包的基础功能,从零开发一个模块化的打包工具。

大纲

主要分为以下主题:

  1. 编译
  2. 模块解析
  3. 目标代码生成
  4. 打包
  5. 实战示例

编译

既然是模块化打包工具,那我们就需要从源文件中解析出本模块依赖哪些模块,另外还需要做这些工作:

  1. 源文件可能无法直接在浏览器中运行,需要做转译,翻译成等价含义的目标代码,通过 js 引入,例如 img, css;
  2. 一些 js 超集或方言,例如 typescript, coffeescript 等,部分 js 标准中的特性部分浏览器暂时未实战的,jsx 等以上都需要做转译,翻译成浏览器能直接运行的 js 代码
  3. 还有一些其他工程化的需求,例如 svg 转 react component,小图 base64等

传统的编译主要分为五个阶段:

词法分析,语法分析,语义分析,代码优化,目标代码生成。

本次重点挂关注1、2、5这3个阶段。

第3阶段语义分析主要对语言范畴进行静态语义检查,例如 tsc,eslint stylelint 等工具的检查阶段。

第4阶段代码优化,主要遵循代码的等价替换,例如公共子表达式提取,删除无用代码,循环优化。对于前端来说这里更像 babel transform 的阶段。

前置知识,关于有限状态机:

有限状态自动机拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。有限状态自动机可以表示为一个有向图。

编译阶段的解析会用到这个概念。

词法分析

对输入的源程序进行字符串扫描和分解,识别出一个个单词符号,例如标识符,操作符,字符串,数字等。

主要对应 ecma262 标准中的这部分:ECMAScript® 2022 Language Specification (tc39.es)

对一下字符进行区别并切分即可:

  • 控制字符,零宽连接、零宽非连接字符;
  • 空白字符,制表符、空白符等;
  • 换行符,LF、CR;
  • 注释;
  • 各种标点字符(Punctuators),+-=*/;
  • Tokens,标识符、字符串、数字、Template、TemplateSubstitutionTail、正则表达式;

只需要根据标准定义的内容,进行枚举匹配即可,这里讲下会比较有意思的点:

数字:

需要注意 . 与 e 字符,并包含在整个数字格式内。所以不是简单的特殊字符切分就能达到目的的。

模板字符串

模板字符串被拆分为 2 中,一个是属于 CommonToken 中的 Template,它只负责匹配:

  • 非替换模板(NoSubstitutionTemplate),相比字符串能包含换行符;
  • 模板头(TemplateHead),

增加了 ${ 部分的特殊字符,注意这里的特殊字符也包含在 Punctuators 内;

另一个是属于模板字符的中间部分与尾巴部分的内容:

综合来说例如这个模板字符 111${a}22${b}33,会被拆分为

  • `111${
  • a
  • }22${
  • b
  • }33`

这样拆分目的只为了之后的语法与语义分析,可以知道 a 与 b 这 2 个标识符。

emoji 表情作为标识符为什么不行?

**
**

除了个别特殊字符外,一个标识符由以下内容组成。

**
**

通过此规范 www.unicode.org/Public/UCD/latest/ucd/DerivedCoreProperties.txt,搜索 ID_Start,我们可以看到所有包含在内的有限字符集。

...省略很多 
4E00..9FFC    ; ID_Start # Lo [20989] CJK UNIFIED IDEOGRAPH-4E00..CJK UNIFIED IDEOGRAPH-9FFC 
...省略很多 
AC00..D7A3    ; ID_Start # Lo [11172] HANGUL SYLLABLE GA..HANGUL SYLLABLE HIH 
D7B0..D7C6    ; ID_Start # Lo  [23] HANGUL JUNGSEONG O-YEO..HANGUL JUNGSEONG ARAEA-E 
D7CB..D7FB    ; ID_Start # Lo  [49] HANGUL JONGSEONG NIEUN-RIEUL..HANGUL JONGSEONG PHIEUPH-THIEUTH 
F900..FA6D    ; ID_Start # Lo [366] CJK COMPATIBILITY IDEOGRAPH-F900..CJK COMPATIBILITY IDEOGRAPH-FA6D 
...省略很多 

😊 unicode 编码后为 “\ud83d\ude0a”,d83d 不属于 ID_Start 的范围内。

洋葱 unicode 编码后为 “\u6d0b\u8471”,6d0b 属于 ID_Start 范围内,至于后者 ID_Continue 大家可以自行查阅。

这段代码能捕获错误么?

try { 2.a } catch(e) { console.log(e); } 

答案是不能。由于数字 token 的不合法, js 解释器词法解析阶段就会报错,不会等到执行阶段。

解析流程

配置下特殊字符:

开始进行词法分析:

每个条件分支都以最小的条件进行匹配,优先匹配特殊字符,最终都当做标识符来处理。

对空白字符、换行符做合并,对操作符做组合判断,因为连续的操作符可能会有特殊含义,例如 += 代表独立的含义,而不是拆分成 + 与 = ,这样就丢失了原有信息,会影响后续的语法分析。

拿字符串举例,必须完整匹配 ‘ 或 ” 之间的内容,且要字符串中间字符不能包含换行符,且引号还要对称。

词法分析后,我们可以得到类似如下内容:

语法分析

这是大家关注比较多的地方,从词法分析解析出的一个个 token,翻译成结构化的抽象语法树对象。

通过语法分析,我们最终就能区分声明、表达式、语句、函数等。

来一段 import 语句的分析过程。

图中我们可以看出 ImportDeclaration 的所有状态, import 标识符后面紧随其后的有可能是有命名空间导入,名称导入,默认导入,命名空间导入。若不符合这些状态,则需要直接报错。

其他语句的解析跟上述流程类似。基于此我们解析出了一个简化版本的抽象语法树。

由于我们暂时只需要解析 ImportDeclaration 与 RequireCallExpress ,所以其他语法先忽略。

源码简介

parse 为遇到是 ImportDelaration 的语句时进入,startWalk 即为遍历的起点,即为子状态机的入口点,根据不同条件进入不同的后续状态节点,遍历后最终解析为一个 ImportDeclaration 对象,会包含其中的关键信息。

importsList 为导入的内容,nameSpaceImport 为整体导入的别名,fromClause 为依赖的模块地址,可能相对路径、绝对路径或 node_modules 中的模块。基于 fromClause 我们才可以继续下一步的模块解析步骤。

模块解析

上面讲述的是单文件的解析。模块化的打包方式是基于一个入口,把这个入口作为根节点,查找出整个依赖树。这个章节主要讲述分析依赖树的过程。

编译入口文件,我们拿到抽象语法树后,可以解析出子依赖,我们只需做一下树的遍历,具体哪种遍历方式其实不影响最终效果。

关键代码示例:parseDependencyGraph 方法能根据入参 node 解析出整个依赖树,入参 node 默认为入口文件。根据模块节点,编译此节点内容,解析出节点依赖的模块。再根据节点的 fromClause 解析出依赖文件的绝对路径,我们这里使用 node 自带的 require.resolve 来获取 node_modules 中的文件位置。文件路径的查找方式与 node 加载某 js 文件的查找方式相同。这里对节点做了下深度遍历解析节点内容与依赖。

一个 Module 的结构如下,包含模块路径、文本内容、依赖模块。

循环依赖

可以把已经解析过的模块暂存在一个 Map 集合里,key 即为模块 resolve 后的绝对路径,遇到已经加载过的,跳过即可。

现在只实现了 js文件的相互依赖解析,继续考虑下其他类型文件。其他类型文件的解析考虑使用插件的方式支持外部自定义。

图片等文件资源插件

直接作为文件,无依赖。

css 样式资源插件

目标文件生成

模块解析后,我们得到了以打包入口为根节点的依赖树。整个依赖树只有源文件的信息,我们需要把整个依赖树进行遍历,并处理每个节点,转换成目标文件。每个节点的转译也是属于上述编译章节的一部分。

对于 js 我们需要把 ast 转换成 可运行的 js string。这一步骤相对解析,会比较简单,无需做状态回溯,都是已知状态。

贴代码,只关注了inport export部分内容。

对于图片文件,我们直接转成 base64 string。

对于css文件,我们目标代码需要做的就是 style 标签创建,内联其内容,并插入到 head 中。

打包

整个树的目标代码也都生成好了。我们再基于这棵树进行遍历,用函数包装每个模块,函数入参使用 CMD 规范的关键字,将所有模块存放在一个大map下,key 为文件相对项目路径,value 为模块执行函数。

将生成的 map string 插入到以下运行时代码中,输出到目标文件,即打包完毕!


上图中的 ROOT_MOD_HOLDER 会替换为所有模块的目标代码 map。

ROOT_PATH_HOLDER 会替换为启动入口的 key。

模块运行时,每个模块只执行一次,执行结果保存在 exports 下,便于之后使用。

实战示例

我们这里就跑一个 demo,会包含样式,还有各种js 模块,进行一个整体的功能演示。

功能演示,代码参考文末地址。有兴趣可以下载下来跑一跑。

打包后代码

总结

通过这篇文章,我们对前端构建打包工具有了一个更深入的认知。了解了打包的各个流程以及每个流程需要做哪些事情。也包含了现代打包工具需要的内容,例如非js文件的打包。

相比 webapck 打包工具,我们还欠缺一些非常重要的功能。例如更强大的插件化机制,chunk split,扩展性更高的架构。插件与loader机制是 webpack 强大的根本,基于它 webpack 建立了一个生态。后续我们可以分析一下 webpack 的这些核心能力。

完。

源码参见:https://github.com/Zenser/tinypack