你用 webpack 1.x 輸出的 hash 靠譜不?

來自 http://zhenyong.site/2016/10/...javascript

使用 webpack 構建輸出文件時,一般會給文件名加上 hash,該 hash 值根據文件內容計算獲得,只要文件內容不變,hash 就不變,因而就能夠利用瀏覽器緩存來節省下載流量。但是 webpack 提供的 hash 彷佛不那麼靠譜...css

本文只圍繞如何保證 webpack 1.x 在 生產發佈階段 輸出穩定的 hash 值展開討論,若是對 webpack 還沒了解的,能夠戳 webpackhtml

本文 基於 webpack 1.x 的背景展開討論,畢竟有些問題在 webpack 2 已經獲得解決。爲了方便描述問題,文中展現的代碼、配置可能很挫,也許不是工程最佳實踐,請輕拍。前端

懶得看文章的能夠考慮直接讀插件源碼 zhenyong/webpack-stable-module-id-and-hashjava

目標

除了 html 文件之外,其餘靜態資源文件名都帶上哈希值,根據文件自己的內容計算獲得,保證文件沒變化,則構建後的文件名跟上次同樣。node

webpack 提供的 hash

[hash]

假設文件目錄長這樣: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]

[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 呢?

自定義 chunkhash

源碼 webpack/Compilation.js

...
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 帶來的坑。

模塊 ID 的坑

咱們簡單的把每一個文件理解爲一個模塊(module),在 webpack 處理模塊依賴關係時,會給每一個模塊定義一個 ID,查看 webpack/Compilation.js 發現,webpack 根據收集 module 的順序給每一個模塊分配遞增數字做爲 ID,至於『收集的 module 順序』,在你開發生涯裏,這玩意絕對是不穩定!不穩定的!

Module ID 不穩定怎麼了

咱們的文件結構如今長這樣:

/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 給我穩定下來!!!

給我穩定的 Module ID

webpack 1 的官方方案

webpack 文檔提供了幾種方案

  • OccurrenceOrderPlugin

    這個插件根據 module 被引用的次數(被 entry 引用、被 chunk 引用)來排序分配 ID,若是你的整個應用的文件依賴是沒太多變化,那麼模塊 ID 就穩定,可是誰能保證呢?
  • recordsPath 配置

    >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 大同小異,可是更高效和穩定,可是這個額外的構建,我以爲不夠優雅,至於能快多少呢,我目前還不在乎這個速度,另外仍是得提交多一份記錄文件。

webpack 2 的思路

以上兩個插件的思路都是用模塊對應的文件路徑直接做爲模塊 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』

參考資料

相關文章
相關標籤/搜索