最近学习了webpack4的使用,并尝试了对项目webpack进行升级和优化,记录一下这次升级的一些实践过程。javascript
项目在2016年引入了webpack做为打包工具,并使用vue-cli搭建build相关的代码,以后再无较大更新。随着项目迭代至今,代码量早已不是当年寥寥的几千行,本地启动开发环境也从当年的十几秒暴增至如今200s以上,每次run dev或者rebuild都伴随着长时间目光呆滞的等待css
在两年多的时间跨度里,项目的构建代码被无数人反复修改,充斥着冗余、杂乱以及只有上帝才能理解的逻辑。同事在作主题功能时不得不另起炉灶,单独开了一个用webpack4构建的小工程隐藏在css文件夹下的某个角落,等待着将来某一天项目随webpack4的大一统而重见天日。webpack2和webpack4并存迫使team里每一个小伙伴都要开两个终端,一个跑项目,另外一个跑样式html
归纳一下就是:build太慢,构建代码混乱,小伙伴们的开发效率低下vue
怎么解决?少说废话,麻利儿的升级webpack4java
提高打包效率,能够简单的归纳为两条路:node
- 提高单位时间内的打包速度
- 清理没必要要打包的文件
如同这个插件的名字同样,用完以后确实能让人happy,打包速度提高的不是一星半点,原理就是开启多个node子进程并行的用各类loader去处理待打包的源文件,换言之即提高单位时间内的打包速度webpack
引用happyPack官方的说法:git
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".github
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.web
拿本身的本子作实验,比公司的电脑性能要好一些,公司的本按webpack2的配置跑一直都在200s以上,重启电脑后初次build甚至直逼5分钟
从实验结果能够看到,使用happyPack以后编译速度提高很是明显,时间上缩短了近55%,优化效果是显著的
happyPack支持不少经常使用的loader(happyPack兼容性列表),能够在webpack配置中使用多个happyPack实例,用不一样的loader分开处理,例如对.js文件前后进行eslint-loader和babel-loader,而且能够经过happyPack建立ThreadPool使这些happyPack实例共享一个线程池,提高资源的利用率。
关于happyPack的配置和使用,官方文档上写的很清晰,百度一下也有大量的教程性文章能够参考,这里再也不详细介绍
项目中不免会使用一些第三方的库,除非版本升级,通常状况下,这些库的代码不会发生较大变更,这也就意味着这些库没有必要每次都参与到构建和rebuild的过程当中。若是能把这部分代码提取出来并提早构建好,那么在构建项目的时候就能够直接跳过第三方库,进一步提高效率,换言之即清理没必要要打包的文件。
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的体积
有些同窗可能会有疑惑,虽然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
这两个插件具体的配置和使用,官方文档给出了使用示例,百度一下也有大量的教程性文章能够参考,这里再也不详细介绍
若是只是想了解如何提高构建效率,那么这部分能够直接跳过了
在笔者完成配置后,并非一下就达到了45s的水平,第一次启动时效果并非很好,没有明显的效率提高,那折腾半天弄啥咧?加上dll以后打包时间并无明显的缩短,说明仍然有第三方库进入了打包流程。webpack中有一个manifest的概念,笔者只知道与模块的映射和加载有关,并不清楚具体的内容,因此当时也只是猜想与此相关,沿着这条路继续往下排查。果真,在使用dllReferencePlugin时少引了几个manifest.json文件,这纯粹是由于笔者疏忽大意,没仔细看文档(因此好好看文档很重要啊),却也借此机会简单的研究了一下manifest是什么鬼,以及为啥使用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://%5Bname%5D_%5Bhash%5D/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://%5Bname%5D_%5Bhash%5D/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.
有了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的钩子函数中作了什么呢?
在项目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的工做过程,收获了很多知识。在此总结出来记录一下这次大一统的过程。