用 Webpack 打包已经很熟了,但有天被问到”Webpack 是怎么把 CommonJS 模块打成一个 bundle 的”,居然答不上来。打开生产构建的 bundle 看了一眼,发现并不复杂,整理一下。
从一个最简单的例子开始
假设有两个文件:
1 | // math.js |
Webpack 打包后(省略模板代码,保留核心逻辑),bundle 大概长这样:
1 | (function(modules) { |
关键点:
- 所有模块被放进一个
{ moduleId: function }的对象 - Webpack 实现了自己的
__webpack_require__,模拟 Node.js 的require - 每个模块被包在一个函数里,模块内部的变量是私有的(函数作用域隔离)
- 模块只被执行一次,结果缓存在
installedModules里
ES Module 的处理方式
ES6 的 import/export 是静态的(编译时确定依赖关系),Webpack 在编译时把它们转成上面那套 module.exports 写法。
但 ES Module 有个特殊处理:导出的值是”实时绑定”的,不是值的拷贝。
1 | // counter.js |
Webpack 处理 export 时,会把导出值变成 getter:
1 | // 编译后的 counter 模块(简化) |
这样 import { count } 之后,每次访问 count 都通过 getter 拿当前值,实现了”实时绑定”。
Tree Shaking 的前提
Tree Shaking(摇树,去掉未使用的 export)只对 ES Module 有效,对 CommonJS 无效,为什么?
CommonJS 是动态的:
1 | // 这种写法完全合法,但静态分析无法判断 require 的是什么 |
require 在运行时才解析,exports 可以动态赋值,静态分析根本无从下手。
ES Module 是静态的:
1 | // import/export 必须在顶层,不能在条件里 |
Webpack 在编译阶段分析模块的 import/export 图,标记哪些 export 被用到了,没用到的在打包时直接删掉(由 Terser 负责实际删除)。
这就是为什么写工具库要用 ES Module 格式(.mjs 或者 package.json 里的 module 字段),这样使用者 Webpack 才能 tree-shake 掉没用到的部分。
Code Splitting:按需加载
Webpack 支持动态 import() 实现代码分割:
1 | // 点击按钮才加载这个模块 |
Webpack 看到动态 import() 后,会把 chart.js 及其依赖单独打成一个 chunk 文件。主 bundle 里只放一段异步加载逻辑:
1 | // Webpack 生成的异步加载代码(简化) |
chunk 文件加载完成后,通过 webpackJsonp 把模块注册到主 bundle 的模块表里,然后就可以正常 require 了。
这套机制是路由级别懒加载(() => import('./views/About.vue'))的底层实现。
SplitChunksPlugin:公共模块提取
如果多个 chunk 都依赖了 lodash,如果每个 chunk 都打一份 lodash,用户加载两个 chunk 就下载了两次 lodash,浪费。
SplitChunksPlugin 默认配置会分析所有 chunk,把被多个 chunk 引用的模块提取成公共 chunk:
1 | // webpack.config.js |
这样 lodash 只打包一次,用户浏览器缓存了之后,切换页面不用重新下载。
Webpack 本质上就是:实现一套运行时的模块系统,然后把所有模块的代码塞进去,从入口启动。理解了这个,看它生成的 bundle 就不会觉得神秘了。
评论加载中…