本文內容只適用於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以及前端工程化的心得,希望能夠幫助大家少踩點坑。