虽然 Webpack 看上去无所不能,但从其本质上来说,Webpack 实质就是一个“前端模块打包器”。前端模块打包器做的事情很简单:它帮助开发者将 JavaScript 模块(各种类型的模块化规范)打包为一个或多个 JavaScript 脚本文件。
回到最初起源:前端为什么需要一个模块打包器呢?其实理由很简单:
- 不是所有浏览器都直接支持javascript
- 前端需要管理依赖脚本,把控不同脚本加载的顺序
- 前端需要按需加载不同类型的静态资源 想想一下,我们的 Web应用有这样一段内容:
1 | <html> |
每个javascript文件都需要额外的HTTP请求获取,并且因为依赖关系,1.js到6.js需要按顺序加载。因此,打包需求应运而生:
1 | <html> |
这里需要注意几点:
- 随着HTTP/2技术的推广,未来长远上看,浏览器像上述代码一样发送多个请求不再是性能瓶颈,但目前来看还过于乐观
- 并不是将所有脚本都打包在一起就是性能最优,
/dist/bundle.js
的size一般较大,但这属于另外的性能优化
话题了
总之,打包器的需求就是前端“刚需”,实现上述打包需要也并不简单,需要考虑:
- 如何维护不同脚本的打包顺序,保证
bundle.js
的可用性 - 如何避免不同脚本、不同模块的命名冲突
- 在打包过程中,如何确定真正需要的脚本,而不将没有用到的脚本排除在
bundle.js
之外
事实上,虽然当前 webpack
依靠loader机制实现了对于不同类型资源的解析和打包,依靠插件机制实现了第三方介入编译构建的过程,但究其本质,webpack只是一个“无所不能”的打包器,实现了:
1 | a.js + b.js + c.js. => bundle.js |
为了简化,以ESM模块化规范举例。假设我们有:
- circle.js模块求圆形面积
- square.js模块求正方形面积
- app.js模块作为主模块
对应内容分别如下代码:
1 | const PI = 3.141; |
经过 Webpack 打包之后,我们用 bundle.js
来表示 Webpack 处理结果
1 | // filename: bundle.js |
如上代码,我们维护了 module
变量,存储了不同模块信息,这个map中,key为模块路径名,value为一个被 wrapped 过的模块函数,我们先称之为 module factory function
, 该函数形如:
1 | function(exports, require) { |
这样做是为每个模块提供exports和require能力,同时保证了每个模块都处于一个隔离的函数作用域范围。
有了modules变量还不够,我们依赖webpackBundle方法,将所有内容整合在一起。webpackBundle方法接收modules模块信息以及一个入口脚本。代码如下:
1 | function webpackBundle({ modules, entry }) { |
手动实现打包器
核心思路如下:
- 读取入口文件 (比如entry.js)
- 基于AST分析入口文件,并产出依赖列表
- 使用Babel将相关模块编译到ES5
- 对每个依赖模块产出一个唯一的ID,方便后续读取模块相关内容
- 将每个依赖以及经过的Babel编译过后的内容,存储在一个对象中维护
- 遍历上一步中的对象,构建出一个依赖图
- 将各模块内容bundle产出
首先创建项目:
1 | mkdir bundler-playground && cd $_ |
并启动npm
1 | npm init -y |
安装以下依赖:
- @babel/parser用于分析源代码,产出 AST;
- @babel/traverse用于遍历 AST,找到 import 声明;
- @babel/core用于编译,将源代码编译为 ES5;
- @babel/preset-env搭配@babel/core使用;
- resolve用于获取依赖的绝对路径。