最近学习了 webpack4 的使用, 并尝试了对项目 webpack 进行升级和优化, 记录一下此次升级的一些实践过程.
痛苦的开发体验
漫长的等待
项目在 2016 年引入了 webpack 作为打包工具, 并使用 vue-cli 搭建 build 相关的代码, 之后再无较大更新. 随着项目迭代至今, 代码量早已不是当年寥寥的几千行, 本地启动开发环境也从当年的十几秒暴增至现在 200s 以上, 每次 run dev 或者 rebuild 都伴随着长时间目光呆滞的等待
混乱之治
在两年多的时间跨度里, 项目的构建代码被无数人反复修改, 充斥着冗余, 杂乱以及只有上帝才能理解的逻辑. 同事在做主题功能时不得不另起炉灶, 单独开了一个用 webpack4 构建的小工程隐藏在 CSS 文件夹下的某个角落, 等待着未来某一天项目随 webpack4 的大一统而重见天日. webpack2 和 webpack4 并存迫使 team 里每个小伙伴都要开两个终端, 一个跑项目, 另一个跑样式
概括一下就是: build 太慢, 构建代码混乱, 小伙伴们的开发效率低下
怎么解决? 少说废话, 麻利儿的升级 webpack4
提升构建效率
提升打包效率, 可以简单的概括为两条路:
提升单位时间内的打包速度
清理不必要打包的文件
多管齐下: happyPack
如同这个插件的名字一样, 用完之后确实能让人 happy, 打包速度提升的不是一星半点, 原理就是开启多个 node 子进程并行的用各种 loader 去处理待打包的源文件, 换言之即提升单位时间内的打包速度
引用 happyPack 官方的说法:
HappyPack sits between webpack and your primary source files (like JS sources) where the bulk of loader transformations happen. Every time webpack resolves a module, HappyPack will take it and all its dependencies and distributes those files to multiple worker "threads".
Those threads are actually simple node processes that invoke your transformer. When the compiled version is retrieved, HappyPack serves it to its loader and eventually your chunk.
拿自己的本子做实验, 比公司的电脑性能要好一些, 公司的本按 webpack2 的配置跑一直都在 200s 以上, 重启电脑后初次 build 甚至直逼 5 分钟
项目使用 webpack2 本地启动耗时
使用 webpack4 本地启动耗时
webpack4 + happyPack(babel-loader) 本地启动耗时
webpack4 + happyPack(babel-loader + eslint-loader) 本地启动耗时
从实验结果可以看到, 使用 happyPack 之后编译速度提升非常明显, 时间上缩短了近 55%, 优化效果是显著的
happyPack 支持很多常用的 loader(happyPack 兼容性列表), 可以在 webpack 配置中使用多个 happyPack 实例, 用不同的 loader 分开处理, 例如对. JS 文件先后进行 eslint-loader 和 babel-loader, 并且可以通过 happyPack 创建 ThreadPool 使这些 happyPack 实例共享一个线程池, 提升资源的利用率.
关于 happyPack 的配置和使用, 官方文档上写的很清晰, 百度一下也有大量的教程性文章可以参考, 这里不再详细介绍
用 dll 剥离第三方库
项目中难免会使用一些第三方的库, 除非版本升级, 一般情况下, 这些库的代码不会发生较大变动, 这也就意味着这些库没有必要每次都参与到构建和 rebuild 的过程中. 如果能把这部分代码提取出来并提前构建好, 那么在构建项目的时候就可以直接跳过第三方库, 进一步提升效率, 换言之即清理不必要打包的文件.
dllPlugin+dllReferencePlugin
dll 是微软实现共享函数库概念的一种方式(百度百科说的), 本身不可被执行, 供其他程序调用. 这里借鉴了 dll 的思想, webpack 提供了内置插件 dllPlugin+dllReferencePlugin, 可以轻松搞定这件事, 只需要做好这几件事就可以了:
独立出一套 webpack 配置 webpack.dll.conf, 用 dllPlugin 定义要打包的 dll 文件
运行 webpack.dll.conf 生成 xxx.dll.JS 及相应的 manifest 文件 manifest-xxx.JSON, 并在项目模板 index.HTML 中引入各个 xxx.dll.JS
在项目的 webpack 配置中, 通过 dllReferencePlugin 及 manifest-xxx.JSON 告诉 webpack 哪些包已经提前构建好了, 不再需要重复构建
webpack4 + happyPack(xxx-loader) + dll 本地启动耗时
从 71s 到 45s, 这又是一个不小的进步, 时间进一步缩短了近 40%, 相比较最初的 webpack2 编译耗时, 效率增加了 71%, 即便是用公司的本子, 效率也至少能增加 50% 以上. 看到这个结果, 笔者的第一反应是: 卧槽!!! 好吧, 这种慨叹除了包含对结果的惊讶, 更多的是没想到以前的构建低效的令人发指.
稍稍优化一下性能
除了减少代码的打包时间, 使用 dll 还有助于网页性能的优化. 通常我们会把第三方库提取到文件名为 vendors 的代码块里, 这样做的好处是防止公共依赖被重复打包, 同时其变化频率较低, 在生产环境下具有相对稳定的哈希值, 可充分利用浏览器的缓存策略减少对 vendors 文件的请求. 但可能导致单个 JS 文件体积过大, 当重新请求资源时会产生比较明显的阻塞. 使用 dll 之后, 因为大量的第三方库被提前提取, vendors 的体积相应减小, 请求 vendors 文件的网络开销也相应降低
不使用 dll,vendors 的体积
使用 dll 后 vendors 的体积
有些同学可能会有疑惑, 虽然 vendors 的体积降低了, 但是减少的部分只是换了个地方, 被提取到 xxx.dll.JS 文件里而已, 该请求的还是要请求, 总的开销并没有减少. 其实 dll 本身可以通过配置多个入口继续拆分, 通过浏览器的并发请求进一步优化请求 dll 文件的性能.
- {
- entry: {
- vue: ['vue', 'vuex', 'vue-router'], // vue 全家桶 dll: vue.dll.JS
- ec: ['echarts', 'echarts-wordcloud'], // echarts 相关 dll: ec.dll.JS
- commons: [
- // 其他第三方库: commons.dll.JS
- ]
- }
- }
当然, 即使是在开发环境下, 3.88M 的 vendors 包仍然很大, 这里只是展现一下通过 dll 剥离第三方库的效果, 关于代码分割及其相关的优化不在这里详细讨论.
一些小坑
关于插件的配置及使用, 需要注意的是 webpack.dll.conf 中, output 暴露出的 library 名称需要与 DllPlugin 的 name 相同, 官方文档中也有强调
- {
- output: {
- filename: '[name].dll.js',
- path: path.resolve(__dirname, '..', 'lib/dll'),
- library: '[name]_[hash]'
- // vendor.dll.JS 中暴露出的全局变量名, DllPlugin 中会使用此名称作为 manifest 的 name,
- // 故这里需要和 webpack.DllPlugin 中的 name: '[name]_[hash]' 保持一致.
- },
- plugins: [
- new webpack.DllPlugin({
- path: utils.resolve('lib/dll/manifest-[name].json'),
- name: "[name]_[hash]" // 和 library 保持一致
- })
- ]
- }
此外, vue 默认使用 runtime 包, 在开发环境下, 如果需要 vue 编译模板, 比如这样使用:
- new Vue({
- template: '<div>{{ hi }}</div>'
- })
则必须引入完整版的 vue 包, 在 webpack 的 alias 配置中需要这样写(参考 vue 文档):
- module.exports = {
- // ...
- resolve: {
- alias: {
- 'vue$': 'vue/dist/vue.esm.js' // 用 webpack 1 时需用'vue/dist/vue.common.js'
- }
- }
- }
这也就意味着 webpack.dll.conf 中对 vue 的引用要与项目中保持一致, 否则在构建项目时不会跳过对 vue 的打包
关于 dllPlugin 和 dllReferencePlugin 这两个插件具体的配置和使用, 官方文档给出了使用示例, 百度一下也有大量的教程性文章可以参考, 这里不再详细介绍
dll 加速与 manifest 探究
如果只是想了解如何提升构建效率, 那么这部分可以直接跳过了
在笔者完成配置后, 并不是一下就达到了 45s 的水平, 第一次启动时效果并不是很好, 没有明显的效率提升, 那折腾半天弄啥咧? 加上 dll 之后打包时间并没有明显的缩短, 说明仍然有第三方库进入了打包流程. webpack 中有一个 manifest 的概念, 笔者只知道与模块的映射和加载有关, 并不清楚具体的内容, 所以当时也只是猜测与此相关, 沿着这条路继续往下排查. 果然, 在使用 dllReferencePlugin 时少引了几个 manifest.JSON 文件, 这纯粹是因为笔者疏忽大意, 没仔细看文档(所以好好看文档很重要啊), 却也借此机会简单的研究了一下 manifest 是什么鬼, 以及为啥使用 dll 能加速.
打包 dll 出来的是什么
查看一下运行完 webpack.dll.conf 之后生成了哪些文件
对于多入口的情况, 每个入口文件都会生成一个 dll 文件及一个 JSON 文件, 以 vue 为例, 看看 vue.dll.JS 和 manifest-vue.JSON 这两个文件里都是什么东东
- vue.dll.JS:
- var vue_01cf92ee1ec06f1bc497 =
- (function(modules) { // webpackBootstrap
- var installedModules = {};
- function __webpack_require__(moduleId) {
- // __webpack_require__ source code
- }
- return __webpack_require__(__webpack_require__.s = 0)
- })
- ({
- "./node_modules/vue/dist/vue.esm.js":
- (function (module, __webpack_exports__, __webpack_require__)) {
- "use strict";
- eval("xxx"); // webpack require vue
- }),
- // 其他模块...
- // ...
- 0: (function (module, exports, __webpack_require__) {
- eval("module.exports = __webpack_require__;\n\n//# sourceURL=webpack://[name]_[hash]/dll_vue?");
- })
- })
上面这段立即执行函数看起来稍微有点费劲, 我们换一种写法并保留部分细节
- var requireModules = function(modules) { // webpackBootstrap
- var installedModules = {};
- function __webpack_require__(moduleId) {
- if (installedModules[moduleId]) { // 检测模块是否已经加载
- return installedModules[moduleId].exports;
- }
- var module = installedModules[moduleId] = { // 创建模块
- i: moduleId,
- l: false,
- exports: {}
- };
- // 加载模块
- modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
- // 标记模块已经被加载
- module.l = true;
- // 返回模块导出的内容
- return module.exports;
- }
- // 定义__webpack_require__的属性和方法
- // __webpack_require__.xxx = xxx
- // ...
- return __webpack_require__(__webpack_require__.s = 0); // 执行 modules[0], 暴露出 vue.dll.JS 内部模块的加载器
- }
- var modules = {
- "./node_modules/vue/dist/vue.esm.js": // 模块 id
- function (module, __webpack_exports__, __webpack_require__)) { // 模块加载函数
- eval("xxx"); // webpack 加载 vue
- },
- // 其他模块
- // ...
- // 暴露加载器
- 0: function (module, exports, __webpack_require__) { // 整个 vue.dll.JS 模块
- // 暴露 vue.dll.JS 的内部模块加载器, 供外部调用并加载 vue 相关的模块
- eval("module.exports = __webpack_require__;\n\n//# sourceURL=webpack://[name]_[hash]/dll_vue?");
- }
- }
- var vue_01cf92ee1ec06f1bc497 = requireModules(modules);
dll 文件中做了如下几件事情:
定义了各个子模块加载函数的映射表, 即字面量对象 modules
定义了内部加载器
__webpack_require__
及模块缓存 installedModules
通过 requireModule 函数将内部加载器暴露给了全局变量
vue_01cf92ee1ec06f1bc497
, 供外部加载模块时调用
当 index.HTML 中引入了 vue.dll.JS 之后, dll 内部模块的加载器就被暴露在 global 下, webpack 加载模块时就可以直接调用 vue_01cf92ee1ec06f1bc497, 最终结果等效为:
var vue_01cf92ee1ec06f1bc497 = __webpack_require__; // 被闭包在内部的加载器
所以 vue.dll.JS 本身不能执行内部模块的代码, 只是提供给外部去调用, 这也正是 dll 文件的定义
光有 dll 还不行, 项目的 webpack 需要知道 dll 暴露出了一个叫 vue_01cf92ee1ec06f1bc497 的加载器, 以及这个加载器内部包含了哪些模块, 而 manifest 文件就包含了这些信息.
- manifest-vue.JSON:
- {
- "name": "vue_01cf92ee1ec06f1bc497",
- "content": {
- "./node_modules/vue/dist/vue.esm.js": {
- "id": "./node_modules/vue/dist/vue.esm.js",
- "buildMeta": {
- "exportsType": "namespace",
- "providedExports": ["default"]
- }
- }
- }
- }
manifest 中保留了模块来源的详细信息, 并将其作为模块检索的 id, 同时还指明了加载这些模块需要用哪个__webpack_require__加载器, 在程序运行时__webpack_require__能够通过模块 id 加载对应的模块, 参考 webpack 官方的解释:
As the compiler enters, resolves, and maps out your application, it keeps detailed notes on all your modules. This collection of data is called the "Manifest" and it's what the runtime will use to resolve and load modules once they've been bundled and shipped to the browser. No matter which module syntax you have chosen, those import or require statements have now become webpack_require methods that point to module identifiers. Using the data in the manifest, the runtime will be able to find out where to retrieve the modules behind the identifiers.
告诉项目 dll 在哪, 里面有什么
有了 manifest, 怎么告诉项目我有 dll, 不需要重复打包呢? DLLReferencePlugin 把 manifest 文件传递给了项目的 webpack, 告诉它哪些模块是可以直接引用的, 打包过程可以跳过. DllReferencePlugin.JS 中读取了 manifest 文件, 把 dll 暴露的加载器以外部依赖的形式挂载到 webpack 的模块工厂.
读取 manifest:
- compiler.hooks.beforeCompile.tapAsync( // webpack 创建 compilation 前的钩子, 读取 dll 中的模块信息(manifest)
- "DllReferencePlugin",
- (params, callback) => {
- if ("manifest" in this.options) {
- const manifest = this.options.manifest;
- if (typeof manifest === "string") {
- params.compilationDependencies.add(manifest);
- compiler.inputFileSystem.readFile(manifest, (err, result) => { // 读取 manifest 文件
- params["dll reference" + manifest] = parseJson(result.toString("utf-8"));
- return callback();
- });
- return;
- }
- }
- return callback();
- }
- );
创建外部依赖:
- // webpack 创建 compilation 后的钩子, 告诉 webpack 我有个 dll 以及 dll 里都有哪些模块
- compiler.hooks.compile.tap("DllReferencePlugin", params => {
- // 读取 manifest 中的配置
- let manifest = this.options.manifest;
- if (typeof manifest === 'string') {
- manifest = params["dll reference" + manifest];
- }
- let name = this.options.name || manifest.name;
- let sourceType = this.options.sourceType || manifest.sourceType;
- let content = this.options.content || manifest.content;
- // 创建外部依赖
- const externals = {};
- const source = "dll-reference" + name; // 告诉 webpack 暴露出的全局变量, 并以 dll-reference 作为前缀表示这是一个 dll 资源
- externals[source] = name; // 资源名称: vue_01cf92ee1ec06f1bc497
- const normalModuleFactory = params.normalModuleFactory;
- // 引入外部模块工厂插件, 以外部依赖的方式挂载 dll
- new ExternalModuleFactoryPlugin(sourceType || "var", externals).apply(
- normalModuleFactory
- );
- // 引入代理模块工厂插件, 为 dll 中的每个模块创建代理
- new DelegatedModuleFactoryPlugin({
- source: source,
- type: this.options.type,
- scope: this.options.scope,
- context: this.options.context || compiler.options.context,
- content,
- extensions: this.options.extensions
- }).apply(normalModuleFactory);
- });
可以看到 webpack 是通过 manifest.name 来匹配 dll 资源的, 这也是为什么在 webpack.dll.conf 中, DllPlugin 的 name 属性必须要与 output 的 library 属性一致的原因
webpack 建立模块的过程在 normalModuleFactory 中完成, 它包含了一些内置的钩子函数, 用于在模块解析, 创建时添加处理逻辑. 这里引入了两个关键的插件 ExternalModuleFactoryPlugin 和 DelegatedModuleFactoryPlugin, 它们在 normalModuleFactory 的钩子函数中做了什么呢?
创建 dll 模块, 加速打包
在项目 webpack 的 compilation 真正开始前, 已经得到了所有 dll 的信息, 剩下的就交给 webpack 的 normalModuleFactory 自己去处理了. ExternalModuleFactoryPlugin 和 DelegatedModuleFactoryPlugin 这两个插件分别在 factory 钩子 (建立模块工厂),module 钩子(创建模块) 中添加了自己的回调函数, 让 webpack 在解析模块时会先去从外部依赖中查找, 如果找到了就直接创建一个模块代理对象, 在 build 阶段不再使用 loader 处理模块, 否则创建一个普通模块对象, 在 build 阶段用 loader 加载资源.
结合 DllReferencePlugin, 整体流程如下:
进入 normalModuleFactory 的流程之后, 首先在 factory 钩子中获取创建外部模块的工厂函数, ExternalModuleFactoryPlugin 插件在 factory 钩子中定义了工厂函数:
- // ExternalModuleFactoryPlugin.JS
- normalModuleFactory.hooks.factory.tap( // factory 钩子
- "ExternalModuleFactoryPlugin",
- factory => (data, callback) => { // 返回一个创建外部模块的工厂函数
- const context = data.context;
- const dependency = data.dependencies[0];
- const handleExternal = (value, type, callback) => {
- // 输入参数的整理
- // ...
- callback(
- null,
- new ExternalModule(value, type || globalType, dependency.request) // 为 dll 创建一个外部模块
- );
- return true;
- };
- const handleExternals = (externals, callback) => {
- // 对 Array,Object 等不同类型 externals 的处理
- // ...
- if (
- typeof externals === "object" &&
- Object.prototype.hasOwnProperty.call(externals, dependency.request)
- ) {
- return handleExternal(externals[dependency.request], callback); // 如果请求的资源是外部资源, 则创建外部模块对象
- }
- callback();
- };
- handleExternals(this.externals, (err, module) => {
- if (err) return callback(err);
- if (!module) return handleExternal(false, callback);
- return callback(null, module); // 通过传入的 callback, 将刚刚创建的外部模块传回到 webpack 的模块构建流程中
- });
- }
- );
factory 钩子返回了这个工厂函数, 它会被 normalModuleFactory 立即调用, vue_01cf92ee1ec06f1bc497 就被作为一个外部模块挂载到 normalModuleFactory 中
工厂建立好之后, normalModuleFactory 就会进入模块解析的过程(resolver), 在解析结束之后为解析结果默认创建一个 NormalModule 对象, 并将其作为参数传入 module 钩子函数. 在 module 钩子中, DelegatedModuleFactoryPlugin 会判断传入的 NormalModule 是否存在于 dll, 如果存在则创建一个代理对象并返回, 否则直接返回 NormalModule
- normalModuleFactory.hooks.module.tap(
- "DelegatedModuleFactoryPlugin",
- module => {
- if (module.libIdent) {
- const request = module.libIdent(this.options);
- if (request && request in this.options.content) { // option.content 就是 manifest 中的 content
- const resolved = this.options.content[request];
- return new DelegatedModule( // 为 dll 中的模块创建代理
- this.options.source, // vue_01cf92ee1ec06f1bc497
- resolved,
- this.options.type,
- request,
- module
- );
- }
- }
- return module;
- }
- );
查看 DelegatedModule 类的定义, 可以看到 needRebuild 方法直接返回了 false,build 方法直接将模块标记为 built, 并加入相关依赖, 没有执行 loader, 因此在代码构建时 dll 中的模块被跳过, 不会参与打包过程
- class DelegatedModule extends Module {
- constructor(request, type, userRequest) {}
- needRebuild(fileTimestamps, contextTimestamps) {
- return false; // 跳过 rebuild 过程
- }
- build(options, compilation, resolver, fs, callback) {
- this.built = true; // 标记模块为 "已构建" 状态
- this.buildMeta = Object.assign({}, this.delegateData.buildMeta);
- this.buildInfo = {};
- this.delegatedSourceDependency = new DelegatedSourceDependency(
- this.sourceRequest
- );
- this.addDependency(this.delegatedSourceDependency); // 加入代理的相关依赖
- this.addDependency(
- new DelegatedExportsDependency(this, this.delegateData.exports || true)
- );
- callback();
- }
- // 其他方法
- // ...
- }
相比较而言, 普通模块则会参与打包和 rebuild 的过程
- class NormalModule extends Module {
- constructor(request, type, userRequest) {}
- needRebuild(fileTimestamps, contextTimestamps) {
- // rebuild 判定代码
- // ...
- }
- build(options, compilation, resolver, fs, callback) {
- return this.doBuild();
- }
- doBuild(options, compilation, resolver, fs, callback) {
- runLoaders(); // 运行 loaders, 构建模块
- }
- // 其他方法
- // ...
- }
至此, manifest 完成了自己的使命, dll 则静静的等待 runtime 时被调用
结语
通过这次 webpack 的升级, 完成了项目 webpack4 的大一统, 解决了小伙伴们各种头疼的问题, 并且得到了小伙伴们积极的反馈, 构建过程比以前清爽不少, 构建效率也大幅提升. 在升级过程中, 还顺带了解一下 dll 的工作过程, 收获了不少知识. 在此总结出来记录一下此次大一统的过程.
来源: https://juejin.im/post/5bfa696d51882579117f7d26