从零开发一个模块化打包工具
前言
构建打包是前端工程化领域的关键组成之一。作为一名前端开发者,对构建打包工具的认知,是绕不过去的一道坎。构建工具帮助前端流程化,自动化,更对前端各大框架有着深远的影响,大多数前端框架已经深度依赖编译时工具去实现。
本次咱们就面向编译打包的基础功能,从零开发一个模块化的打包工具。
大纲
主要分为以下主题:
- 编译
- 模块解析
- 目标代码生成
- 打包
- 实战示例
编译
既然是模块化打包工具,那我们就需要从源文件中解析出本模块依赖哪些模块,另外还需要做这些工作:
- 源文件可能无法直接在浏览器中运行,需要做转译,翻译成等价含义的目标代码,通过 js 引入,例如 img, css;
- 一些 js 超集或方言,例如 typescript, coffeescript 等,部分 js 标准中的特性部分浏览器暂时未实战的,jsx 等以上都需要做转译,翻译成浏览器能直接运行的 js 代码
- 还有一些其他工程化的需求,例如 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 的这些核心能力。
完。