3

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

 2 years ago
source link: https://webfe.kujiale.com/mo-kuai-hua-da-bao-gong-ju/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

09 November 2021 on webpack, Javascript 浏览量:23

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

主要分为以下主题:

  1. 目标代码生成

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

  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)

image

对以下字符进行区别并切分即可:
● 控制字符,零宽连接、零宽非连接字符;
● 空白字符,制表符、空白符等;
● 换行符,LF、CR;
● 注释;
● 各种标点字符(Punctuators),+-=*/;
● Tokens,标识符、字符串、数字、Template、TemplateSubstitutionTail、正则表达式;

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

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

  1. 模板字符串
    模板字符串被拆分为 2 中,一个是属于 CommonToken 中的 Template,它只负责匹配:
    ● 非替换模板(NoSubstitutionTemplate),相比字符串能包含换行符;
    ● 模板头(TemplateHead),image--1-增加了 ${ 部分的特殊字符,注意这里的特殊字符也包含在 Punctuators 内;
    另一个是属于模板字符的中间部分与尾巴部分的内容:

image--3-

综合来说例如这个模板字符 111${a}22${b}33,会被拆分为
● `111${
● a
● }22${
● b
● }33`
这样拆分目的只为了之后的语法与语义分析,可以知道 a 与 b 这 2 个标识符。

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

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

image--5-

通过此规范 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 大家可以自行查阅。

  1. 这段代码能捕获错误么?
try { 2.a } catch(e) { console.log(e); } 

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

解析流程
配置下特殊字符:
image--1--3

开始进行词法分析:
image--2--2

每个条件分支都以最小的条件进行匹配,优先匹配特殊字符,最终都当做标识符来处理。
对空白字符、换行符做合并,对操作符做组合判断,因为连续的操作符可能会有特殊含义,例如 += 代表独立的含义,而不是拆分成 + 与 = ,这样就丢失了原有信息,会影响后续的语法分析。
拿字符串举例,必须完整匹配 ‘ 或 ” 之间的内容,且要字符串中间字符不能包含换行符,且引号还要对称。

image--3--3

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

image--4--3

这是大家关注比较多的地方,从词法分析解析出的一个个 token,翻译成结构化的抽象语法树对象。 通过语法分析,我们最终就能区分声明、表达式、语句、函数等。

import 语句的分析过程

image--1--2

图中我们可以看出 ImportDeclaration 的所有状态, import 标识符后面紧随其后的有可能是有命名空间导入,名称导入,默认导入,命名空间导入。若不符合这些状态,则需要直接报错。
其他语句的解析跟上述流程类似。基于此我们解析出了一个简化版本的抽象语法树。
由于我们暂时只需要解析 ImportDeclaration 与 RequireCallExpress ,所以其他语法先忽略。

image--2--1

parse 为遇到是 ImportDelaration 的语句时进入,startWalk 即为遍历的起点,即为子状态机的入口点,根据不同条件进入不同的后续状态节点,遍历后最终解析为一个 ImportDeclaration 对象,会包含其中的关键信息。
importsList 为导入的内容,nameSpaceImport 为整体导入的别名,fromClause 为依赖的模块地址,可能相对路径、绝对路径或 node_modules 中的模块。基于 fromClause 我们才可以继续下一步的模块解析步骤。

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

构造依赖树

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

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

image--3--2

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

image--4--2

循环依赖问题

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

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

image--5--1

图片等文件资源插件直接作为文件,无依赖。

css 样式资源插件:

image--6-

目标文件生成

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

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

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

image--7-

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

image--8-

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

image--9-

上图中的 ROOT_MOD_HOLDER 会替换为所有模块的目标代码 map。
ROOT_PATH_HOLDER 会替换为启动入口的 key。

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

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

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

打包后代码

image--10-

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK