4.3 模块化规范的兼容与工具演进

如果没有深入了解过前端工程化技术,可能并不会对模块化规范带来的影响有太多感知,因为只从代码编写的层面来看,无论是使用import还是require来引用模块,最终的打包产物都可以在浏览器中运行,但这并不意味着浏览器环境能够兼容CommonJS规范和ES Module规范,而是因为在工程实践中,为了方便复用Node.js庞大的第三方生态,构建工具通常会将模块包装为符合CommonJS规范的模块。以webpack为例,它在构建产物中注入了一段用于模拟CommonJS模块加载器的运行时代码,然后将开发者编写的代码嵌入其中,最终打包后的代码才得以在浏览器环境中正常运行。当开发者需要开发通用的SDK时,通常会使用rollup作为打包工具,为了使构建产物能够支持不同的模块化标准,一般会生成多个符合不同模块化规范的SDK文件。换句话说就是,我们看到的“兼容”实际上是构建工具通过工程化的手段回避了模块化规范兼容性的问题。

除了IE浏览器之外,主流的浏览器大都实现了基于ES Module的模块系统,Node.js也兼容了CommonJS和ES Module规范(从Node.js v13开始,package.json中声明了“type”:“module”的包都会采用ES Module标准进行解析),那么在浏览器环境中加载依赖还存在哪些问题呢?

Node.js中引用第三方依赖时并不需要写相对路径,只需要指定依赖名即可,Node.js模块系统中对路径的处理有一套自己的方案,例如自动拼接node_modules中第三方依赖的完整路径,自动解析package.json中入口字段的声明,自动在末尾补全index文件名或文件扩展名等,而浏览器环境中并没有提供类似的解析策略,要想在浏览器环境中使用ES Module规范来加载CommonJS模块,比较容易想到的处理办法是将第三方依赖转换为ES Module模块,并将模块的引用地址全部替换为独立的线上地址(而不是与开发者编写的代码打包在一起),这样浏览器就可以直接加载这些模块了,但这种简单的方式会引发新的问题——“请求爆炸”。例如最常用的Antd组件库,里面使用的第三方依赖有上千个,如果去掉打包环节直接从浏览器加载这些依赖,那么加载过程就变成了网络请求,仅一个Antd组件库就需要发送近2000个网络请求,这种方案对于首屏渲染的影响是非常大的。

从数量上来看,针对第三方依赖进行打包还是很有必要的。在webpack原本的设计理念中,每次启动本地开发服务器时,都会重新做一次打包,随着项目体量的增大,此操作会变得越来越耗时。但在开发过程中,第三方依赖的代码基本上是维持不变的,只需要预处理一次即可。基于这样的现状,第三方依赖和源代码其实可以拆分为两个维度的概念,也就是将几乎不变的代码和开发者编写的应用层源码拆分开,开发工具不再将其打包在一起,而是为第三方依赖单独构建一个依赖包,以达到一次处理到处使用的目的。随着开发者对技术方案的不断探索,以Snowpack和Vite为代表的下一代WebApp开发工具也随之而生。

以Vite为例,其核心原理就是在启动本地开发服务器之前,先使用开发工具遍历源码目录,将每个文件中的代码转换为AST(抽象语法树),然后解析获取其中所有的Import-Declaration类型的节点(即引用声明节点),获得所有需要用到的第三方依赖的路径后,将解析出的第三方依赖路径作为多个入口传入传统的构建工具(例如在webpack中通过将entry属性配置为对象来实现多文件入口),从而获得一个虚拟的node_modules目录,出现在entry配置对象中的依赖项会继续作为目标文件存在,其他的公共依赖部分则会被打包成一个大的依赖项。接下来工具会跳过对源码的处理,直接启动本地服务器,新的构建工具使用的index.html模板会直接使用ES Module规范的语法来加载源代码的入口文件:

<script type="module" src="/index.js">

本地服务器会将静态资源请求代理到源码目录的对应文件中。这样在开发环境中,所有的模块都会基于ES Module规范来加载,整体构建流程的变化如图4-3所示。

061-1

图4-3 不同构建工具的原理对比图

在这种新的架构下,第三方依赖已经完成了预处理,在没有新的外部依赖的情况下,理论上每次启动本地开发服务器的时间都可以到达秒级,加上跨语言实现的高性能构建工具(如esbuild)的助力,相较于传统构建工具在面对大型项目时动辄几分钟的构建耗时而言,其优势已经非常明显了。

在这样的基本方案中,node_modules中的依赖项在每个开发者本地环境中都需要经过一次预处理,很明显这部分工作在多人协作的项目中是重复的,我们完全可以将这部分工作转移到云端完成,然后在本地开发环境中通过插件将所有指向本地node_modules目录中的依赖全部代理到某个包分发服务上(例如著名的esm.sh[1],它也支持私有化部署)。这样,相应的模块只要被引用过,就会在云端生成符合ES Module规范的构建产物,当其他开发者再次引用时就可以直接使用构建结果。当然也可以自行实现一个简易的依赖分发服务,至少它可以帮助你更好地治理团队内部依赖项管理混乱的问题。

新一代依赖管理方案和开发工具仍处于建设初期,相信随着前端工程师的持续努力,前端的工程化支持和开发体验都会越来越好。


[1]https://github.com/alephjs/esm.sh,使用esbuild将npm包构建为ES Module规范的包分发网络。