webpack多页面开发与懒加载hash解决方案
本文内容只适用于webpack v1版本,webpack v2已经修复了hash计算规则。
之前讨论了webpack的hash与chunkhash的区别以及各自的应用场景,如果是常规单页面应用的话,上篇文章提供的方案是没有问题的。但是前端项目复杂多变,应对复杂多页面项目时,我们不得不继续踩webpack的hash坑。
在进入正文之前先解释一下所谓的常规单页面和复杂多页面是什么意思。
这两个并非专业术语,而是笔者实在想不出更恰当的说法了,见谅。
1. 项目类型
1.1 常规单页面项目
常规单页面符合以下条件:
- 可以存在多个主js文件和css文件;
- 每个js文件都是同步打包的,也就是说不存在与主文件相关联的懒加载文件。
与主文件不关联的懒加载文件指的是逻辑与主文件完全无关的js文件,这类文件不参与主文件打包。比如主文件main.js
中有以下代码:
window.onload = function(){
var script = document.createElement('script');
script.src = '//static.daojia.com/bi.js';
document.head.appendChild(script);
}
其中bi.js
的内部逻辑与main.js
没有任何关联,它对于main.js
来说就是一个字符串而已。
与之相对应的是与主文件有逻辑关系的模块文件,比如以下代码:
window.onload = function(){
require.ensure([],function(require){
require('./part.a.js');
},'a');
}
其中part.a.js
是懒加载模块,以上源码经编译会生成独立的part文件,由main.js
按需加载。
1.2 复杂多页面项目
复杂多页面项目符合以下条件:
- 存在与主文件相关联的懒加载模块文件;
- 存在多个主js文件。
那么这种类型的项目复杂度在哪呢?如何应用webpack去解决hash问题?
2. 懒加载的hash解决方案
上篇文章webpack的hash与chunkhash的区别以及各自的应用场景提到应该使用chunkhash
结合webpack-md5-plugin作为js文件hash解决方案。这种方案在应对所有模块均同步编译的场景是没有问题的,但是请大家首先考虑下文的场景。
2.1 应用场景
入口文件main.app.js
的代码如下:
import '../style/main.app.scss';
import fn_d from './part.d.js';
console.log('main');
window.onload = function(){
require.ensure([],(require)=>{
require('./part.a.js');
});
}
异步模块part.a.js
代码如下:
import fn_d from './part.d.js';
console.log('part a');
setTimeout(()=>{
require.ensure([],(require)=>{
require('./part.b.js');
});
},10000);
异步模块part.b.js
代码如下:
import fn_c from './part.c.js';
import fn_d from './part.d.js';
console.log('part b');
使用webpack将以上源代码进行编译,输出以下文件:
main.app.[chunkhash].js
:主文件;part.a.[chunkhash].js
:异步模块a;part.b.[chunkhash].js
:异步模块b;main.app.[chunkhash].css
:样式文件。
截止到目前是没有问题的,现在,请大家想象一下:如果我们修改了part.a.js
源码,编译的结果文件哪些文件的hash改变了?
首先可以肯定的是part.a.[chunkhash].js
的hash值会改变,那么其他文件呢?
答案是:只有part.a.[chunkhash].js
的hash改变了,其余文件的hash都与修改前一致。
那么这种结果是否合理呢?
在回答这个问题之前,我们首先了解一下webpack runtime是如何加载异步模块的。请看以下代码:
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;
script.src = __webpack_require__.p + "js/part/part." + ({
"1": "a",
"2": "b"
}[chunkId] || chunkId) + "." + {
"1": "f5ea7d95",
"2": "b93662b0"
}[chunkId] + ".js";
head.appendChild(script);
上述代码是编译生成的main.app.[chunkhash].js
中实现懒加载的逻辑,原理就是大家熟知的动态生成<script>
标签。但是在对script.src
赋值时,webpack有以下三个概念需要知晓:
chunkId
,对应上述代码中的"1"
和"2"
;chunkName
,对应上述代码中的"a"
和"b"
;chunkHash
,对应上述代码中的"f5ea7d95"
和"b93662b0"
。
chunkId
和chunkName
暂时不用关心,我们只需要关注chunkHash
的变动。
也就是说,part.a.[chunkhash].js
和part.b.[chunkhash].js
的hash值是写死在main.app.[chunkhash].js
中的。按照之前的编译结果,part.a.[chunkhash].js
的hash变了,但是main.app.[chunkhash].js
的hash没变,那么用户的浏览器仍然缓存着旧版本的main.app.[chunkhash].js
,此时异步加载的part.a.[chunkhash].js
仍然是旧版本的文件。这显然是不符合需求的。
总结以上所述,懒加载模块的改动经编译,主文件的hash值没有变化,影响了版本发布。
2.2 引起问题的原因
笔者在初次遇到上述问题时,第一个出现在脑海里的念头是:主文件计算hash值时没有把异步模块的内容计算在内。
结合上篇文章webpack的hash与chunkhash的区别以及各自的应用场景,webpack-md5-plugin是在chunk-hash
钩子函数中替换了chunkhash
,那么webpack在执行chunk-hash
钩子函数之前对源代码的编译进行到了哪一步?
我们在chunk-hash
钩子函数内将各模块的信息打印出来:
compilation.plugin("chunk-hash", function(chunk, chunkHash) {
console.log(chunk);
});
由于打印信息太多,就不贴出来了。此时一共有5个chunk:
- css;
- html;
main.app
;part.a
;part.b
。
其中html和style都是由插件导出,所以这两个chunk是不会被分配chunkId
和chunkName
的,不会影响js的编译。
然后打印一下各模块对应此时的代码。main.app.js
此时的代码如下:
require('../styles/main.app.scss');
var _partD = require('./part.d.js');
var _partD2 = _interopRequireDefault(_partD);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
console.log('main');
window.onload = function () {
require.ensure(['./part.a.js'], function (require) {
require('./part.a.js');
}, 'a');
};"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = function (msg) {
console.log(msg);
};
可以看出,main.app.js
相关的同步模块part.d.js
的内容已经被编译进了主文件(最后三行),只是url仍然未改变。而异步模块part.a.js
不仅url仍然是原始的本地相对地址,而且内容也并没有编译进主文件。
但是请注意,上文提到的5个chunk中包含了part.a
,也就是说part.a.js
此时已经被编译了,并且已经计算了hash值。
详细的log信息大家可以自行打印出来研究。
此时main.app.js
的chunkhash仍然是使用webpack自身计算所得,webpack默认的chunkhash计算方法是将与当前模块所有相关的全部内容作为算法参数,包括style文件。而webpack-md5-hash插件对chunk-hash
钩子进行捕获并重新计算chunkhash,它的计算方法是只计算模块本身的当前内容(包括同步模块),也就是上文的代码。这种计算方式把异步模块的内容忽略掉了,造成了本文面对的问题:异步模块的修改并未影响主文件的hash值。
2.3 解决方案
既然找到了引起问题的原因,那么相应的解决方案相信大家心里多少有点数了。
可能会有人说:我不使用webpack-md5-hash插件不就行了吗?
大家还记得上篇文章webpack的hash与chunkhash的区别以及各自的应用场景提到的webpack计算chunkhash的方法,style文件也会被计算在内,所以使用webpack自身的chunkhash计算方案肯定是不可行的。
如果有研究webpack稍微深入的同学可能会发现:主文件使用[hash]
而不是[chunkhash]
,异步模块使用chunkhash
,同时搭配webpack-md5-hash插件使用。这种方案下,style的修改并不会影响主文件的[hash]
值。这种方案是否可行呢?
首先我们分析一下这种方案的原理。[hash]
是compilation实例的hash值,webpack是在所有的chunkhash基础上进行计算此hash值。默认情况下,main.app.js
的chunkhash会包括style文件的内容,而webpack-md5-hash插件将style文件内容剔除,只计算js部分。所以,style文件的修改不影响最后的[hash]
值。
乍看起来,以上方案是可以解决我们的问题的。但是大家请考虑这种场景:如果项目中存在不止一个主js文件呢?修改任意js代码会不会影响最终主文件的[hash]
值?
答案是肯定的!webpack将所有js文件的内容作为计算[hash]
的参数,任何js文件的修改都会影响最终的结果。也就是说,假如我修改了主文件main.app_a.js
或者main.app_a.js
的任意(同步/异步)模块,那么main.app.js
的hash值也会改变。这显然是不符合需求的。
既然上面的两种方案都不行,到底什么才是可行的方案呢?
其实,解决问题的关键在前文中都提到了,只要打印出chunk-hash
钩子函数的chunk信息,解决方案就浮出水面了。关键点有两个:
chunk-hash
时异步模块已经被编译了,并且生成了hash值;- 主文件有个数组类型的
chunks
属性,value是异步模块chunk的集合数组。
我们主文件中获取到各异步模块的hash值,然后将这些hash值与主文件的代码内容一同作为计算hash的参数,这样就能保证主文件的hash值会跟随异步模块的修改而修改。
基于以上方案,笔者站在巨人肩上,在webpack-md5-hash插件的基础上进行了简单地修改。代码如下:
compilation.plugin("chunk-hash", function(chunk, chunkHash) {
var source = chunk.modules.sort(compareModules).map(getModuleSource).reduce(concatenateSource, '');
// get children chunks hashes so that child chunk impact main file's hash
var child_hashes = '';
if (chunk.entry && chunk.name && chunk.chunks && chunk.chunks.length > 0) {
child_hashes = getHashes(chunk.chunks);
}
var chunk_hash = child_hashes === '' ? md5(source) : md5(source + child_hashes);
chunkHash.digest = function() {
return chunk_hash;
};
});
以上插件已发布webpack-split-hash
3. 总结
webpack的很多理念和解决方案是针对SPA项目的,多页面应用的一些问题需要一些复杂的方案去解决。hash是前端静态资源增量发布的通用手段,而webpack针对hash的解决方案是无法应对多页面项目的。本篇文章以笔者真实遇到的场景为例,记录了懒加载场景下各模块的hash解决方案。
最后打个广告,58到家前端工程集成解决方案boi已经开源。boi是对webpack的深度使用,它不是最好的前端工程解决方案,我们在不断踩坑的路上尽量分享webpack以及前端工程化的心得,希望能够帮助大家少踩点坑。