Webpack Basic Usage

Posted by Fish on 2017-05-04

( 2017-05-03 寫作當下使用的是 webpack 2.3.3)

Webpack 是個神奇且好用的東西,但 webpack2 的 document 目前不盡完善,有許多狀況與細節並沒有解釋或解釋詳盡。在這裡簡單的介紹使用 Webpack 完成 Code splitting ,以及使用 Chunkhash 達成解決 File Caching 的過程。

打包 (Bundle)

打包是最基本的 Webpack 功能,假設我們有個檔案叫index.js,裡面長得像是

1
2
3
4
5
'use strict';
var axios = require('axios');
(function(){
...

那麼,基本的webpack.config.js大概定義如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';
var webpack = require('webpack');
var path = require('path');
var config = {
entry: {
app: './src/index.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
}
}
module.exports = config;

當我們執行webpack --config webpack.config.js之後,webpack 大概做了以下的事情:

  1. 根據entry的定義,知道要打包一個叫做app的檔案,從./src/index.js作為起始文件,開始讀取檔案
  2. index.js裡面require('axios'),因此在執行環境中尋找axios插件,一般會從entry的相對路徑以及node_modules裡面找。
  3. index.jsaxios打包成一份檔案輸出為app.jsoutput[name].js就是指檔案名稱用entry所指定的,然後檔案放置的位置以path指定。其中__dirname是指執行webpack指令的根目錄

也就是說,我們開發可以結構化地把檔案分拆,像一般寫程式那樣。但我們不需要在 template 中用許多個<script>將所有js檔案載入,並還要注意先後順序。執行完webpack之後,只需要載入app.js就可以了。

多個檔案打包成一個

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';
var webpack = require('webpack');
var path = require('path');
var config = {
entry: {
app: ['./src/index.js', './src/auth.js', './src/message.js']
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
}
}
module.exports = config;

webpack 會按照陣列的順序,一一打包檔案,然後整合為一包app.js

打包成多個檔案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use strict';
var webpack = require('webpack');
var path = require('path');
var config = {
entry: {
app: './src/index.js',
auth: ['./src/auth.js', './src/user.js'],
message: './src/message.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
}
}
module.exports = config;

這樣設定的話,webpack 最終就會打包出三個檔案,分別是app.js,auth.js,message.js

拆分 (Code Splitting)

每次執行webpack,webpack就會幫我們將檔案重新打包、更新。但隨著架構愈來愈大時,我們自己的檔案、使用的第三方套件增多,我們打包出來的檔案也會愈來愈肥。這對頁面載入將是個很大的負荷。有時候我們可能只是改個一兩行,但整個app.js就會需要更新,使用者端就需要重新載入新的一整大包。

但其實第三方套件並不像我們自己的 Code 那麼經常變動,通常是我們自己想升版才會更動。而且寫js我們會使用的第三方套件通常挺多。如果能將這些部分與我們自己的 Code 分開打包成不同的一包,在我們更新自己的 Code 的時候,只會更新我們 Code 整合的那包,不會更動到第三方套件的那一包,可以減少一定程度的 Loading 負荷。

首先我們將webpack.config.js中的 config 更新成

1
2
3
4
5
6
7
8
9
10
var config = {
entry: {
app: './src/index.js',
vendor: 'axios'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
}
}

執行webpackaxios這個第三方套件的會被打包進vendor.js,但我們會發現app.js裡面還是有axios,這是因為不同的entry,webpack 會分開打包,各自的 dependencies 各自處理。

為了讓共同的第三方套件都打包成同一包,我們將webpack.config.js更改為

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var config = {
entry: {
app: './src/index.js',
vendor: 'axios'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
})
]
}

CommonsChunkPlugin會將各個entry中共同的套件包裝到指定的name檔案。所以當我們執行webpack之後,我們會發現這次axios只出現在vendor.js了。

我們可以進一步將webpack.config.js再改為

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var config = {
entry: {
app: './src/index.js',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
// this assumes your vendor imports exist in the node_modules
return module.context && module.context.indexOf('node_modules') !== -1;
}
})
]
}

其中minChunks是指定判定套件為共同套件的條件,將其打包進指定檔案中。若是將minChunks指定為 2,那麼就是說在各entry中,總共出現2次以上的套件就將其打包進vendor.js這的檔案。而我們在這邊寫的 callback,是判斷套件若是有出現在node_modules中的,就視為共同套件打包進vendor.js中。

值得特別注意的是在這次的webpack.config.js中,我們的entry沒有指定vendor,僅僅靠CommonsChunkPlugin完成第三方套件的打包。這在 webpack 稱為 Implicit Common Vendor Chunk。

版本 (Chunkhash)

瀏覽器多有 Cache 的機制,static file (image, css, js…etc)會留有一份快取在 client 端。在一定時間內,若是瀏覽器發現這網站要求app.js,而app.js在本地端有一份 copy ,那瀏覽器會使用本地的app.js copy。這個機制是為了優化網頁的讀取,但卻造成了一個麻煩的情況。當我們更新了app.js,但因為檔名沒變,瀏覽器不會知道檔案內容改變了,會繼續使用本地端較舊的 copy 版本。

為了解決這個問題,通常的辦法是在檔案名稱加上版本號:app.v1.js。當我們更新app.js時,就更新版本號:app.v2.js,讓瀏覽器認為是要求一份不同的 js ,達到即時更新的效果。

身為一位工程師,改 code 才是我們的本職。需要在每次改 code 之後去注意版本號、手動更新,是一件麻煩且浪費我們腦內記憶體的事情。透過 webpack ,我們可以輕鬆達成這件事。

webpack.config.js中的output改為

1
2
3
4
5
output: {
filename: '[name].[chunkhash].js',
path: path.resolve(__dirname, 'dist')
...
}

webpack 打包檔案之後,會在檔案名自動添上一個chunkhash,所以我們會得到一個像是app.3c75dbb16437c09415ac.js的檔案名稱。每次更改 code,重新打包,webpack 就會產生一個新的 chunkhash,也就達成了更改版本的效果。

每個打包出的檔案的chunkhash都是 unique 的,這在打包成不同檔案的時候,可以幫助我們達到一個目的:更新app.js時,若vendor.js不需要更新,他可以維持舊的chunkhash,進而達到讓瀏覽器「只更新需要更新的套件」這件事。

但實際執行webpack後,我們會發現當我們更改了一小段index.js的 code,產出的 app.[chunkhash].jsvendor.[chunkhash].js[chunkhash]部分都更新了,並沒有達到預期的只更新app.js的效果。

這是因為每次 webpack 在執行打包時,會將一些 runtime code 帶進打包出的 Common Chunk 之中,也就是我們的 vendor.js,來幫助打包後的檔案處理引入插件。為了解決這個問題,我們再引入一個叫做 manifest 的檔案來處理這個問題。

manifest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var config = {
entry: {
app: '/src/index.js'
},
output: {
filename: '[name].[chunkhash].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
return module.context && module.context.indexOf('node_modules') !== -1;
}
}),
// extract all common modules from vendor and app bundles
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
})
]
}

這樣的設定下,首先 webpack 會把屬於 node_modules 中的第三方套件通通打包進 vendor,然後再把 appvendor 共同的 modules 打包進 manifest。但因為 vendorapp 並沒有共同套件了,所以 manifest 最後只剩下 runtime code。

之後 html 只要按順序把 manifest.[chunkhash].js, vendor.[chunkhash].jsapp.[chunkhash].js載入,就可以讓我們的 scripts 動起來了。雖然這樣我們多載入了一個檔案,但少變動 vendor 所省下的 loading 總的來說還是有益的。

HTML template

一切都已逼近完美,只差當每次執行完webpack,我們就要更新一次我們的 template,把新的<script>部分的檔案名中的chunkhash更新。官方的教學是透過ChunkManifestPlugin產出一個manifest.json,會帶有檔案對應的資訊,然後把它直接在 template inline 引入。這種人工的作法實在令人覺得功虧一簣。另外還有一種方法是透過HtmlWebpackPlugin幫我們產生一個帶有chunkhash版本號的<script>的 html template。但使用方式略複雜,而且對於 template 有需多限制,若要客製化還要再學習許多插件。

基於我們只是想要簡單的讓 webpack 幫我們自動換版本號而已,小魚自己實在不想為了這件事再多加入這些東西。經過一段時間的 google + stackoverflow 之後,先寫出了以下的暴力做法。

webpack.config.jsplugin處多加一個自己寫的 Plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
plugins:[
...,
function () {
var templatePath = path.resolve(__dirname, 'templates');
var rawFilePath = path.join(templatePath, 'src/scripts.html');
var outFilePath = path.join(templatePath, 'base/partials/scripts.html');
var str = fs.readFileSync(rawFilePath, 'utf8');
if (process.env.NODE_ENV === 'production') {
this.plugin("done", function (stats) {
var counter = 0;
var replaceInFile = function (toReplace, replacement) {
var replacer = function (match) {
console.log('Replacing %s => %s', match, replacement);
return replacement
};
str = str.replace(new RegExp(toReplace, 'g'), replacer);
counter++;
if (counter === stats.compilation.chunks.length)
fs.writeFileSync(outFilePath, str);
};
stats.compilation.chunks.forEach(function (chunk) {
replaceInFile(
chunk.name + '.js',
chunk.name + '.' + chunk.renderedHash + '.js'
);
});
});
}
else {
fs.createReadStream(rawFilePath).pipe(fs.createWriteStream(outFilePath));
}
}
]

我們把<scripts>部分的 html 獨立成一個 template,然後直接使用nodefs來進行檔案的 I/O,讀入<script>的 template ,然後將檔案中的檔案名稱置換成帶有chunkhash的字串,再輸出成production用的 template。

我們設定this.plugin只在NODE_ENV === 'production'的時候執行,並且是在 webpack 已經打包完的done的階段,把結果的資訊讀出來,然後進行操作。而在開發模式下,不用多帶chunkhash可以省下一些麻煩,所以就略過這步驟。也可以把這段 code 寫成一個插件,然後在webpack.config.js引入,會讓程式碼看起來乾淨些。這邊只是一個簡單的快速介紹。

要注意的是,雖然官方文件說:

Running webpack -p (or equivalently webpack --optimize-minimize --define process.env.NODE_ENV="'production'").

但這裡的process.env.NODE_ENV並不是指定你執行時的NODE_ENV,而是對於 webpack 而言的變數。因此若我們就這麼執行webpack -p --config webpack.config.js,我們寫的那段 plugin 所認得的process.env.NODE_ENV是執行webpack指令的 node 環境,而非 webpack 自己設定的變數,webpack 還是只會跑開發部分的程式碼。(小魚還沒深入去了解其中的原因)

要解決這個問題,你可以使用官方的EnvironmentPlugin來處理,或者簡單地把你的指令改成NODE_ENV=production webpack -p --config webpack.config.js就可以了。

如此一來,在 production 環境下執行 NODE_ENV=production webpack -p --config webpack.config.js,webpack 就會幫我們把檔案打包好,加上chunkhash,然後再幫我們更改<script>的 template 了。

結語

這只是最、最、最基本的 webpack 功能,完全還無法體現 webpack 的強大之處。webpack 的各種插件、各類型檔案的花式打包、各種玄妙的 bundle 設定⋯⋯才是 webpack 強大且非同凡響的地方。不然其實以上這些功能,使用其他的打包工具也是能達到相同效果的。

這次只是簡單介紹了一下 webpack 最基本的使用與設定,若要深入感受 webpack 的強大,還是要多去瞭解與研究。