來自 http://zhenyong.site/2016/10/...javascript
使用 webpack 構建輸出文件時,一般會給文件名加上 hash,該 hash 值根據文件內容計算獲得,只要文件內容不變,hash 就不變,因而就能夠利用瀏覽器緩存來節省下載流量。但是 webpack 提供的 hash 彷佛不那麼靠譜...css
本文只圍繞如何保證 webpack 1.x 在 生產發佈階段 輸出穩定的 hash 值展開討論,若是對 webpack 還沒了解的,能夠戳 webpack。html
本文 基於 webpack 1.x 的背景展開討論,畢竟有些問題在 webpack 2 已經獲得解決。爲了方便描述問題,文中展現的代碼、配置可能很挫,也許不是工程最佳實踐,請輕拍。前端
懶得看文章的能夠考慮直接讀插件源碼 zhenyong/webpack-stable-module-id-and-hashjava
除了 html 文件之外,其餘靜態資源文件名都帶上哈希值,根據文件自己的內容計算獲得,保證文件沒變化,則構建後的文件名跟上次同樣。node
假設文件目錄長這樣:webpack
/src |- pageA.js (入口1) |- pageB.js (入口2)
使用 webpack 配置:git
entry: { pageA: './src/pageA.js', pageB: './src/pageB.js', }, output: { path: __dirname + '/build', // [hash:4] 表示截取 [hash] 前四位 filename: '[name].[hash:4].js' },
首次構建輸出:github
pageA.c56c.js 1.47 kB 0 [emitted] pageA pageB.c56c.js 1.47 kB 1 [emitted] pageB
再次構建輸出:web
pageA.c56c.js 1.47 kB 0 [emitted] pageA pageB.c56c.js 1.47 kB 1 [emitted] pageB
hash 值是穩定的呀,是否是就能夠了呢?且慢!
根據 Configuration · webpack/docs Wiki :
[hash] is replaced by the hash of the compilation.
意譯:
[hash] 是根據一個 compilation 對象計算得出的哈希值,若是 compilation 對象的信息不變,則 [hash] 不變
結合 how to write a plugin 提到:
A
compilation
object represents a single build of versioned assets. While running Webpack development middleware, a new compilation will be created each time a file change is detected, thus generating a new set of compiled assets. A compilation surfaces information about the present state of module resources, compiled assets, changed files, and watched dependencies.
意譯:
compilation
對象表明對某個版本進行一次編譯構建的過程,若是在開發模式下(例如用 --watch 檢測變化,實時編譯),則每次內容變化時會新建一個 complidation,包含了構建所需的上下文信息(構建器配置、文件、文件依賴)。
咱們來動一下 pageA.js
,再次構建:
pageA.e6a9.js 1.48 kB 0 [emitted] pageA pageB.e6a9.js 1.47 kB 1 [emitted] pageB
發現 hash 變了,而且全部文件的 hash 值老是同樣,這彷佛就跟文檔描述的一致,只要構建過程依賴的任何資源(代碼)發生變化,compilation
的信息就會跟上一次不同了。
那是否是確定說,源碼不變的話,hash 值就必定穩定呢?也不是的,咱們改一下 webpack 配置:
entry: { pageA: './src/pageA.js', // 再也不構建入口 pageB // pageB: './src/pageB.js', },
再次構建:
pageA.1f01.js 1.48 kB 0 [emitted] pageA
compilation
的信息還包括構建上下文,因此,移除入口或者換個loader 都會引發 hash 改變。
[hash]
的缺點很明顯,不是根據內容來計算哈希,可是 hash 值是"穩定的",用這種方案能保證『每次上線,瀏覽器訪問到的靜態資源都是新的(url 變了)』
你接受用 [hash]
嗎,我是接受不了?因而咱們看 webpack 提供的另外一種根據內容計算 hash 的配置。
[chunkhash] is replaced by the hash of the chunk.
意譯:
[chunkhash] 根據 chunk 的內容計算獲得。(chunk 能夠理解成一個輸出文件,其中可能包含多個 js 模塊)
咱們改下配置:
entry: { pageA: './src/pageA.js', pageB: './src/pageB.js', }, output: { path: __dirname + '/build', filename: '[name].[chunkhash:4].js', },
構建試試:
pageA.f308.js 1.48 kB 0 [emitted] pageA pageB.53a9.js 1.47 kB 1 [emitted] pageB
動下 pageA.js
再構建:
pageA.16d6.js 1.48 kB 0 [emitted] pageA pageB.53a9.js 1.47 kB 1 [emitted] pageB
發現只有 pageA 的 hash 變了,彷佛 [chunkhash] 就能解決問題了?且慢!
咱們目前的代碼沒涉及到 css,先加點 css 文件依賴:
/src |- pageA.js |- pageA.css //pageA.js require('./a.css');
給 webpack 配置 css 文件的 loader,而且抽取全部樣式輸出到一個文件
module: { loaders: [{ test: /\.css$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader') }], }, plugins: [ // 這裏的 contenthash 是 ExtractTextPlugin 根據抽取輸出的文件內容計算獲得 new ExtractTextPlugin('[name].[contenthash:4].css') ],
構建:
pageA.ab4b.js 1.6 kB 0 [emitted] pageA pageA.b9bc.css 36 bytes 0 [emitted] pageA
改一下樣式,那麼樣式的 hash 確定會變的,那 pageA.js 的 hash 變不變呢?
答案是『變了』:
pageA.0482.js 1.6 kB 0 [emitted] pageA pageA.c61a.css 31 bytes 0 [emitted] pageA
記得以前說 webpack 的 [chunkhash]
是根據 chunk 的內容計算的,而 pageA.js 這個 chunk 的輸出在 webpack 看來是包括 css 文件的,只不過被你抽取出來罷了,因此你改 css 也就改了這個 chunk 的內容,這體驗很很差吧,怎麼讓 css 不影響 js 的 hash 呢?
... this.applyPlugins("chunk-hash", chunk, chunkHash); chunk.hash = chunkHash.digest(hashDigest); ...
經過這段代碼能夠發現,經過在 'chunk-hash' "鉤子" 中替換掉 chunk 的 digest 方法,就能夠自定義 chunk.hash
了。
查看文檔 how to write a plugin 瞭解怎麼寫插件來註冊一個鉤子方法:
plugins: [ ... new ContentHashPlugin() // 添加插件(生產發佈階段使用) ], }; // 插件函數 function ContentHashPlugin() {} // webpack 會執行插件函數的 apply 方法 ContentHashPlugin.prototype.apply = function(compiler) { compiler.plugin('compilation', function(compilation) { compilation.plugin('chunk-hash', function(chunk, chunkHash) { // 這裏註冊了以前說到的 'chunk-hash' 鉤子 chunk.digest = function () { return '這就是自定義的 hash 值'; } }); }); };
那麼這個 hash 值如何計算好呢?
能夠將 chunk 所依賴的各個模塊 (單個源碼文件) 的內容拼接後計算一個 md5 做爲 hash 值,固然須要對全部文件排序後再拼接:
var crypto = require('crypto'); var md5Cache = {} function md5(content) { if (!md5Cache[content]) { md5Cache[content] = crypto.createHash('md5') // .update(content, 'utf-8').digest('hex') } return md5Cache[content]; } function ContentHashPlugin() {} ContentHashPlugin.prototype.apply = function(compiler) { var context = compiler.options.context; function getModFilePath(mod) { // 獲取形如 './src/pageA.css' 這樣的路徑 // libIdent 方法會處理好不一樣平臺的路徑分隔符問題 return mod.libIdent({ context: context }); } // 根據模塊對應的文件路徑排序 //(能夠根據模塊ID,可是暫時不靠譜,後面會講) function compareMod(modA, modB) { var modAPath = getModFilePath(modA); var modBPath = getModFilePath(modB); return modAPath > modBPath ? 1 : modAPath < modBPath ? -1 : 0; } // 獲取模塊源碼,開發階段別用 function getModSrc(mod) { return mod._source && mod._source._value || ''; } compiler.plugin("compilation", function(compilation) { compilation.plugin("chunk-hash", function(chunk, chunkHash) { var source = chunk.modules.sort(compareMod).map(getModSrc).join(''); chunkHash.digest = function() { return md5(source); }; }); }); }; module.exports = ContentHashPlugin;
此時,pageA.css 修改以後,不再會影響 pageA.js 的 hash 值。
另外要注意,ExtractTextPlugin 會把 pageA.css 的內容抽取以後,替換該模塊的內容 mod._source._value
爲:
// removed by extract-text-webpack-plugin
因爲每個 css 模塊都對應這段內容,因此不會影響效果。
erm0l0v/webpack-md5-hash 插件也是爲了解決相似問題,可是它其中的『排序』算法是基於模塊的 id,而模塊的 id 理論上是不穩定的,接下來咱們就討論不穩定的模塊 ID 帶來的坑。
咱們簡單的把每一個文件理解爲一個模塊(module),在 webpack 處理模塊依賴關係時,會給每一個模塊定義一個 ID,查看 webpack/Compilation.js 發現,webpack 根據收集 module 的順序給每一個模塊分配遞增數字做爲 ID,至於『收集的 module 順序』,在你開發生涯裏,這玩意絕對是不穩定!不穩定的!
咱們的文件結構如今長這樣:
/src |- pageA.js |- pageB.js |- a.js |- b.js |- c.js
pageA.js
require('./a.js') // a.js require('./b.js') // b.js var a = 'this is pageA';
pageB.js
require('./b.js') // b.js' require('./c.js') // c.js var b = 'this is pageB';
更新配置,把引用達到 2 次的模塊抽取出來:
output: { chunkFilename: "[id].[chunkhash:4].bundle.js", ... plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: "commons", minChunks: 2, chunks: ["pageA", "pageB"], }), ...
build build build:
pageA.1cda.js 262 bytes 0 [emitted] pageA pageB.0752.js 280 bytes 1 [emitted] pageB commons.14bf.js 3.64 kB 2 [emitted] commons
觀察 pageB.0752.js,有一段:
__webpack_require__(2) // b.js' __webpack_require__(3) // c.js var b = 'this is pageB';
從上面看出,webpack 構建時給 b.js
的模塊 ID 爲 2
這時,咱們改一下 pageA.js:
// 移除對 a.js 的依賴 // require('./a.js') // a.js require('./b.js') // b.js var a = 'this is pageA';
build build build :
pageA.a945.js 200 bytes 0 [emitted] pageA pageB.0752.js 271 bytes 1 [emitted] pageB commons.14bf.js 3.65 kB 2 [emitted] commons
嗯! 只有 pageA.js 的 hash 變了,挺合理合理,咱們進去 pageB.0752.js 看看
__webpack_require__(1) // b.js' __webpack_require__(2) // c.js var b = 'this is pageB';
看出來了沒!此次構建,webpack 給 b.js
的 ID 是 1。
咱們 pageB.js 的 hash 沒變,由於背後依賴的模塊內容 (b.js、c.js) 沒有變呀,可是此時 pageB.0752.js 的內容確實變了,若是你用 CDN 上傳這個文件,也許會傳不上去,由於文件大小和名稱如出一轍,就是這個不穩定的模塊 ID 給坑的!
怎麼解決呢?
第一念頭:把原來計算 hash 的方式改一下,就那構建輸出後的文件內容來計算?
細想: 不要,明明 pageB 這一次就不用從新上傳的,浪費。
比較優雅的思路就是:讓模塊 ID 給我穩定下來!!!
webpack 文檔提供了幾種方案
這個插件根據 module 被引用的次數(被 entry 引用、被 chunk 引用)來排序分配 ID,若是你的整個應用的文件依賴是沒太多變化,那麼模塊 ID 就穩定,可是誰能保證呢?
>Store/Load compiler state from/to a json file. This will result in persistent ids of modules and chunks. 會記錄每一次打包的模塊的"文件處理路徑"使用的 ID,下次打包一樣的模塊直接使用記錄中的 ID:
"node_modules/style-loader/index.js!node_modules/css-loader/index.js!src/b.css": 9,
這就要求每一個人都得提交這份文件了,港真,我以爲體驗不好咯。 另一旦你修改文件名,或者是增減 loader,原來的路徑就無效了,從而再次入坑!
DllPlugin 和 DllReferencePlugin
原理就是在你打包源碼前,你得新建一個構建配置用 [DllPlugin](https://github.com/webpack/webpack/tree/master/examples/dll) 單獨打包生成一份模塊文件路徑對應的 ID 記錄,而後在你的原來配置使用 [DllReferencePlugin](https://github.com/webpack/webpack/tree/master/examples/dll-user) 引用這份記錄,跟 recordsPath 大同小異,可是更高效和穩定,可是這個額外的構建,我以爲不夠優雅,至於能快多少呢,我目前還不在乎這個速度,另外仍是得提交多一份記錄文件。
以上兩個插件的思路都是用模塊對應的文件路徑直接做爲模塊 ID,而不是 webpack 1 中的默認使用數字,另外 webpack 1 不接受非數字做爲 模塊 ID。
把模塊對應的文件路徑經過一個哈希計算映射爲數字,用這個全局惟一的數字做爲 ID 就解決了,妥妥的!
參考:
給出 webpack 1.x 中的解決方案:
... xx.prototype.apply = function(compiler) { function hexToNum(str) { str = str.toUpperCase(); var code = '' for (var i = 0; i < str.length; i++) { var c = str.charCodeAt(i) + ''; if ((c + '').length < 2) { c = '0' + c } code += c } return parseInt(code, 10); } var usedIds = {}; function genModuleId(module) { var modulePath = module.libIdent({ context: compiler.options.context }); var id = md5(modulePath); var len = 4; while (usedIds[id.substr(0, len)]) { len++; } id = id.substr(0, len); return hexToNum(id) } compiler.plugin("compilation", function(compilation) { compilation.plugin("before-module-ids", function(modules) { modules.forEach(function(module) { if (module.libIdent && module.id === null) { module.id = genModuleId(module); usedIds[module.id] = true; } }); }); }); }; ...
註冊鉤子的思路跟以前的 content hash 插件差很少,獲取到模塊文件路徑後,經過 md5 計算輸出 16 進制的字符串([0-9A-E]),再把字符串的字符逐個轉爲 ascii 形式的整數,因爲 16 進制字符串只會包含 [0-9A-E]
,因此保證單個字符轉化的整數是兩位就能保證這個算法是有效的。
舉例:
path = '/node_module/xxx' md5Hash = md5(path) // => A3E... nul = hexToNum(md5Hash) // => 650369
這個方案還有些小缺點,就是用模塊文件路徑做爲哈希輸入還不是百分百完美,若是文件名改了,那麼模塊 ID 就 "不穩定了"。其實,能夠用模塊文件內容做爲哈希輸入,考慮到效率問題,權衡之下仍是用路徑好了。
爲了保證 webpack 1.x 生產階段的文件 hash 值可以完美跟文件內容一一映射,查閱了大量信息,根據目前 github 上討論的解決方案算是大致解決了問題,可是還不夠優雅和完美,因而借鑑 webpack 2 的思路加上一點小技巧,比較優雅地解決了這個問題。
插件放在 Github: zhenyong/webpack-stable-module-id-and-hash『有用的話給個 star 嘛 O(∩_∩)O』