手摸手,帶你用合理的姿勢使用webpack4(上)

花褲衩發表於2018-08-07

本文作者來自 華爾街見聞技術團隊 - 花褲衩

前幾天 webpack 作者 Tobias Koppers 釋出了一篇新的文章 webpack 4.0 to 4.16: Did you know?(需翻牆),總結了一下webpack 4釋出以來,做了哪些調整和優化。 並且說自己正在著手開發 webpack 5

Oh you are still on webpack 3. I’m sorry, what is blocking you? We already working on webpack 5, so your stack might be outdated soon…

翻譯成中文就是:

手摸手,帶你用合理的姿勢使用webpack4(上)

正好我也在使用一個文件生成工具 docz(安利一波) 也最低需要webpack 4+,新版webpack效能提高了不少,而且webpack 4 都已經發布五個多月了,想必應該已經沒什麼坑了,應該可以安心的按照別人寫的升級攻略升級了。之前一直遲遲不升級完全是被去年被 webpack 3 坑怕了。它在 code splitting 的情況下 CommonsChunkPlugin會完全失效。過了好一段時間才修復,欲哭無淚。

所以這次我等了快大半年才準備升級到webpack 4 但萬萬沒想到還是遇到了不少的問題! 有很多之前遺留的問題還是沒有很好地解決。但最主要的問題還是它的文件有所欠缺,已經廢除了的東西如commonsChunkPlugin還在官方文件中到處出現,很多重要的東西卻一筆帶過,甚至沒寫,需要使用者自己去看原始碼才能解決。

還比如在v4.16.0版本中廢除了optimization.occurrenceOrderoptimization.namedChunksoptimization.hashedModuleIdsoptimization.namedModules 這幾個配置項,替換成了optimization.moduleIdsoptimization.chunkIds,但文件完中全沒有任何體現,所以你在新版本中還按照文件那樣配置其實是沒有任何效果的。

最新最完整的文件還是看他專案的配置WebpackOptions.json,強烈建議遇到不清楚的配置項可以看這個,因為它一定保證是和最新程式碼同步的。

吐槽了這麼多,我們言歸正傳。由於本次手摸手篇幅有些長,所以拆解成了上下兩篇文章:

  • 上篇 -- 就是普通的在webpack 3的基礎上升級,要做哪些操作和遇到了哪些坑
  • 下篇 -- 是在webpack 4下怎麼合理的打包和拆包,並且如何最大化利用 long term caching

本文章不是手摸手從零教你 webpack 配置,所以並不會講太多很基礎的配置問題。比如如何處理 css 檔案,如何配置 webpack-dev-server,講述 file-loader 和 url-loader 之間的區別等等,有需求的推薦看 官方文件 或者 survivejs 出的一個系列教程。或者推薦看我司的另一篇 wbepack 入門文章,已同步到 webpack4 傳送門

升級篇

前言

我一直認為模仿和借鑑是學習一個新東西最高效的方法。所以我建議還是通過借鑑一些成熟的 webpack 配置比較好。比如你專案是基於 react 生態圈的話可以借鑑 create-react-app ,下載之後npm run eject 就可以看到它詳細的 webpack 配置了。vue 的話由於新版vue cli不支援 eject了,而且改用 webpack-chain來配置,所以借鑑起來可能會不太方便,主要配置 地址。覺得麻煩的話你可以直接借鑑 vue-element-admin配置。或者你想自己發揮,你可以借鑑 webpack 官方的各種 examples,來組合你的配置。

升級 webpack

首先將 webpack 升級到 4 之後,直接執行webpack --xxx是不行的,因為新版本將命令列相關的東西單獨拆了出去封裝成了webpack-cli。會報如下錯誤:

The CLI moved into a separate package: webpack-cli. Please install webpack-cli in addition to webpack itself to use the CLI.

所有你需要安裝npm install webpack-cli -D -S。你也可將它安裝在全域性。

同時新版 webpack 需要Node.js 的最低支援版本為 6.11.5不要忘了升級。如果還需要維護老專案可以使用 nvm 來做一下 node 版本管理。

升級所有依賴

因為webpack4改了 它的hook api ,所以所有的loadersplugins都需要升級才能夠適配。

可以使用命令列 npm outdated,列出所以可以更新的包。免得再一個個去npm找相對於的可用版本了。

手摸手,帶你用合理的姿勢使用webpack4(上)

反正把devDependencies的依賴都升級一下,總歸不會有錯。

帶來的變化

其實這次升級帶來了不少改變,但大部分其實對於普通使用者來說是不需要關注的,比如這次升級帶來的功能SideEffectsModule Type’s IntroducedWebAssembly Support,基本平時是用不到的。我們主要關注那些對我們影響比較大的改動如:optimization.splitChunks代替原有的CommonsChunkPlugin(下篇文章會著重介紹),和Better Defaults-mode更好的預設配置,這是大家稍微需要關注一下的。

手摸手,帶你用合理的姿勢使用webpack4(上)

如果想進一步瞭解 Tree ShakingSideEffects的可見文末擴充閱讀。 上圖參考 Webpack 4 進階

預設配置

webpack 4 引入了零配置的概念,被 parcel 刺激到了。 不管效果怎樣,這改變還是值得稱讚的。

最近又新出了 Fastpack 可以關注一下。

言歸正題,我們來看看 webpack 預設幫我們做了些什麼?

development 模式下,預設開啟了NamedChunksPluginNamedModulesPlugin方便除錯,提供了更完整的錯誤資訊,更快的重新編譯的速度。

module.exports = {
+ mode: 'development'
- devtool: 'eval',
- plugins: [
-   new webpack.NamedModulesPlugin(),
-   new webpack.NamedChunksPlugin(),
-   new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("development") }),
- ]
}
複製程式碼

production 模式下,由於提供了splitChunksminimize,所以基本零配置,程式碼就會自動分割、壓縮、優化,同時 webpack 也會自動幫你 Scope hoistingTree-shaking

module.exports = {
+  mode: 'production',
-  plugins: [
-    new UglifyJsPlugin(/* ... */),
-    new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production") }),
-    new webpack.optimize.ModuleConcatenationPlugin(),
-    new webpack.NoEmitOnErrorsPlugin()
-  ]
}
複製程式碼

webpack 一直以來最飽受詬病的就是其配置門檻極高,配置內容極其複雜和繁瑣,容易讓人從入門到放棄,而它的後起之秀如 rollup、parcel 等均在配置流程上做了極大的優化,做到開箱即用,所以webpack 4 也從中借鑑了不少經驗來提升自身的配置效率。願世間再也不需要 webpack 配置工程師

html-webpack-plugin

用最新版本的的 html-webpack-plugin你可能還會遇到如下的錯誤:

throw new Error('Cyclic dependency' + nodeRep)

產生這個 bug 的原因是迴圈引用依賴,如果你沒有這個問題可以忽略。

目前解決方案可以使用 Alpha 版本,npm i --save-dev html-webpack-plugin@next

或者加入chunksSortMode: 'none'就可以了。

但仔細檢視文件發現設定成chunksSortMode: 'none'這樣是會有問題的。

Allows to control how chunks should be sorted before they are included to the HTML.

這屬性會決定你 chunks 的載入順序,如果設定為none,你的 chunk 載入在頁面中載入的順序就不能夠保證了,可能會出現樣式被覆蓋的情況。比如我在app.css裡面修改了一個第三方庫element-ui的樣式,通過載入順序的先後來覆蓋它,但由於設定為了none,打包出來的結果變成了這樣:

<link href="/app.8945fbfc.css" rel="stylesheet">
<link href="/chunk-elementUI.2db88087.css" rel="stylesheet">
複製程式碼

app.css被先載入了,之前寫的樣式覆蓋就失效了,除非你使用important或者其它 css 權重的方式覆蓋它,但這明顯是不太合理的。 vue-cli正好也有這個相關 issue,尤雨溪也在不使用@next版本的基礎上 hack 了它,有興趣的可以自己研究一下,本人在專案中直接使用了@next版本,也沒遇到其它什麼問題(除了不相容 webpack 的 prefetch/preload 相關 issue)。兩種方案都可以,自行選擇。

其它 html-webpack-plugin 的配置和之前使用沒有什麼區別。

mini-css-extract-plugin

與 extract-text-webpack-plugin 區別

由於webpack4對 css 模組支援的完善以及在處理 css 檔案提取的方式上也做了些調整,所以之前我們一直使用的extract-text-webpack-plugin也完成了它的歷史使命,將讓位於mini-css-extract-plugin

使用方式也很簡單,大家看著 文件 抄就可以了。

它與extract-text-webpack-plugin最大的區別是:它在code spliting的時候會將原先內聯寫在每一個 js chunk bundle的 css,單獨拆成了一個個 css 檔案。

原先 css 是這樣內聯在 js 檔案裡的:

手摸手,帶你用合理的姿勢使用webpack4(上)

將 css 獨立拆包最大的好處就是 js 和 css 的改動,不會影響對方。比如我改了 js 檔案並不會導致 css 檔案的快取失效。而且現在它自動會配合optimization.splitChunks的配置,可以自定義拆分 css 檔案,比如我單獨配置了element-ui作為單獨一個bundle,它會自動也將它的樣式單獨打包成一個 css 檔案,不會像以前預設將第三方的 css 全部打包成一個幾十甚至上百 KB 的app.xxx.css檔案了。

手摸手,帶你用合理的姿勢使用webpack4(上)

壓縮與優化

打包 css 之後檢視原始碼,我們發現它並沒有幫我們做程式碼壓縮,這時候需要使用 optimize-css-assets-webpack-plugin 這個外掛,它不僅能幫你壓縮 css 還能優化你的程式碼。

//配置
optimization: {
  minimizer: [new OptimizeCSSAssetsPlugin()];
}
複製程式碼

手摸手,帶你用合理的姿勢使用webpack4(上)

如上圖測試用例所示,由於optimize-css-assets-webpack-plugin這個外掛預設使用了 cssnano 來作 css 優化, 所以它不僅壓縮了程式碼、刪掉了程式碼中無用的註釋、還去除了冗餘的 css、優化了 css 的書寫順序,優化了你的程式碼 margin: 10px 20px 10px 20px; =>margin:10px 20px;。同時大大減小了你 css 的檔案大小。更多優化的細節見文件

contenthash

但使用 MiniCssExtractPlugin 有一個需求特別注意的地方,在預設文件中它是這樣配置的:

new MiniCssExtractPlugin({
  // Options similar to the same options in webpackOptions.output
  // both options are optional
  filename: devMode ? "[name].css" : "[name].[hash].css",
  chunkFilename: devMode ? "[id].css" : "[id].[hash].css"
});
複製程式碼

簡單說明一下: filename 是指在你入口檔案entry中引入生成出來的檔名,而chunkname是指那些未被在入口檔案entry引入,但又通過按需載入(非同步)模組的時候引入的檔案。

copy 如上程式碼使用之後發現情況不對!每次改動一個xx.js檔案,它對應的 css 雖然沒做任何改動,但它的 檔案 hash 還是會發生變化。仔細對比發現原來是 hash 惹的禍。 6.f3bfa3af.css => 6.40bc56f6.css

手摸手,帶你用合理的姿勢使用webpack4(上)

但我這是根據官方文件來寫的!為什麼還有問題!後來在文件的最最最下面發下了這麼一段話!

For long term caching use filename: [contenthash].css. Optionally add [name].

非常的不理解,這麼關鍵的一句話會放在 Maintainers 還後面的地方,預設寫在配置裡面提示大家不是更好?有熱心群眾已經開了一個pr,將文件預設配置為 contenthashchunkhash => contenthash相關 issue

這個真的蠻過分的,稍不注意就會讓自己的 css 檔案快取無效。而且很多使用者平時修改程式碼的時候都不會在意自己最終打包出來的 dist資料夾中到底有哪些變化。所以這個問題可能就一直存在了。浪費了多少資源!人艱不拆!大家覺得 webpack 難用不是沒道理的。

補充一點:目前MiniCssExtractPlugin也不是非常完美的,它現在預設會將每個 bundle 的 css 獨立於 js檔案, 單獨拆成一個 css 檔案。但這樣會產生一個新的問題。比如我有一個頁面它只有一行css,但也會被拆成了一個獨立的css檔案,還需要額外的一次 http 請求,非常的不合理。所以我就給官方提了一個 issue,呼籲增加一個minSize,當 css 的內容小於這個size的時候還是內聯到 js 檔案中,期待官方增加這個功能。

這裡再簡單說明一下幾種 hash 的區別:

  • hash

hash 和每次 build有關,沒有任何改變的情況下,每次編譯出來的 hash都是一樣的,但當你改變了任何一點東西,它的hash就會發生改變。

簡單理解,你改了任何東西,hash 就會和上次不一樣了。

  • chunkhash

chunkhash是根據具體每一個模組檔案自己的的內容包括它的依賴計算所得的hash,所以某個檔案的改動只會影響它本身的hash,不會影響其它檔案。

  • contenthash

它的出現主要是為了解決,讓css檔案不受js檔案的影響。比如foo.cssfoo.js引用了,所以它們共用相同的chunkhash值。但這樣子是有問題的,如果foo.js修改了程式碼,css檔案就算內容沒有任何改變,由於是該模組的 hash 發生了改變,其css檔案的hash也會隨之改變。

這個時候我們就可以使用contenthash了,保證即使css檔案所處的模組裡有任何內容的改變,只要 css 檔案內容不變,那麼它的hash就不會發生變化。

contenthash 你可以簡單理解為是 moduleId + content 所生成的 hash

熱更新速度

其實相對 webpack 線上打包速度,我更關心的本地開發熱更新速度,畢竟這才是和我們每一個程式設計師每天真正打交道的東西,打包一般都會扔給CI自動執行,而且一般專案每天也不會打包很多次。

webpack 4一直說自己更好的利用了cache提高了編譯速度,但實測發現是有一定的提升,但當你一個專案,路由懶載入的頁面多了之後,50+之後,熱更新慢的問題會很明顯,之前的文章中也提到過這個問題,原以為新版本會解決這個問題,但並沒有。

不過你首先要排斥你的熱更新慢不是,如:

  • 沒有使用合理的 Devtool souce map 導致
  • 沒有正確使用 exclude/include 處理了不需要處理的如node_modules
  • 在開發環境不要壓縮程式碼UglifyJs、提取 css、babel polyfill、計算檔案 hash 等不需要的操作

舊方案

最早的方案是開發環境中不是用路由懶載入了,只線上上環境中使用。封裝一個_import函式,通過環境變區分是否需要懶載入。

開發環境:

module.exports = file => require("@/views/" + file + ".vue").default;
複製程式碼

生成環境:

module.exports = file => () => import("@/views/" + file + ".vue");
複製程式碼

但由於 webpack import實現機制問題,會產生一定的副作用。如上面的寫法就會導致@/views/下的 所有.vue 檔案都會被打包。不管你是否被依賴引用了,會多打包一些可能永遠都用不到 js 程式碼。 相關 issue

目前新的解決方案思路還是一樣的,只在生成模式中使用路由懶載入,本地開發不使用懶載入。但換了一種沒副作用的實現方式。

新方案

使用babelplugins babel-plugin-dynamic-import-node。它只做一件事就是:將所有的import()轉化為require(),這樣就可以用這個外掛將所有非同步元件都用同步的方式引入了,並結合 BABEL_ENV 這個bebel環境變數,讓它只作用於開發環境下。將開發環境中所有import()轉化為require(),這種方案解決了之前重複打包的問題,同時對程式碼的侵入性也很小,你平時寫路由的時候只需要按照官方文件路由懶載入的方式就可以了,其它的都交給babel來處理,當你不想用這個方案的時候,也只需要將它從babelplugins中移除就可以了。

具體程式碼:

首先在package.json中增加BABEL_ENV

"dev": "BABEL_ENV=development webpack-dev-server XXXX"
複製程式碼

接著在.babelrc只能加入babel-plugin-dynamic-import-node這個plugins,並讓它只有在development模式中才生效。

{
  "env": {
    "development": {
      "plugins": ["dynamic-import-node"]
    }
  }
}
複製程式碼

之後就大功告成了,路由只要像平時一樣寫就可以了。文件

 { path: '/login', component: () => import('@/views/login/index')}
複製程式碼

這樣能大大提升你熱更新的速度。基本兩百加頁面也能在2000ms的熱跟新完成,基本做到無感重新整理。當然你的專案本身就不大頁面也不多,完全沒必要搞這些。當你的頁面變化跟不是你寫程式碼速度的時候再考慮也不遲。

打包速度

webpack 4 在專案中實際測了下,普遍能提高 20%~30%的打包速度。

本文不準備太深入的講解這部分內容,詳細的打包優化速度可以參考 slack 團隊的這篇文章,掘金還有譯文.

這裡有幾個建議來幫你加速 webpack 的打包速度。

首先你需要知道你目前打包慢,是慢在哪裡。

我們可以用 speed-measure-webpack-plugin 這個外掛,它能監控 webpack 每一步操作的耗時。如下圖:

手摸手,帶你用合理的姿勢使用webpack4(上)

可以看出其實大部分打包花費的時間是在Uglifyjs壓縮程式碼。和前面的提升熱更新的切入點差不多,檢視source map的正確與否,exclude/include的正確使用等等。

使用新版的UglifyJsPlugin的時候記住可以加上cache: trueparall: true,可以提搞程式碼打包壓縮速度。更多配置可以參考 文件 或者 vue-cli 的 配置

編譯的時候還有還有一個很慢的原因是那些第三方庫。比如echartselement-ui其實都非常的大,比如echarts打包完也還有 775kb。所以你想大大提高編譯速度,可以將這些第三方庫 externals 出去,使用script的方式引入,或者使用 dll的方式打包。經測試一般如echarts這樣大的包可以節省十幾秒到幾十秒不等。

還有可以使用一些並行執行 webpack 的庫:如parallel-webpackhappypack

順便說一下,升級一下node可能有驚喜。前不久將CI裡面的 node 版本依賴從 6.9.2 => 8.11.3,打包速度直接提升了一分多鐘。

總之我覺得打包時間控制在差不多的範圍內就可以了,沒必要過分的優化。可能你研究了半天,改了一堆引數發現其實也就提升了幾秒,但維護成本上去了,得不償失。還不如升級 node、升級 webpack、升級你的編譯環境的硬體水平來的實在和簡單。

比如我司CI使用的是騰訊雲普通的的 8 核 16g 的機器,這個專案也是一個很大的後臺管理專案 200+頁面,引用了很多第三方的庫,但沒有使用什麼happypackdll,只是用了最新版的webpack4node@8.11.3。 編譯速度穩定在兩分多鐘,完全不覺得有什麼要優化的必要。

手摸手,帶你用合理的姿勢使用webpack4(上)

Tree-Shaking

這其實並不是 webpack 4 才提出來的概念,最早是 rollup 提出來並實現的,後來在 webpack 2 中就實現了,本次在 webpack 4 只是增加了 JSON Tree ShakingsideEffects能讓你能更好的

不過這裡還是要提一下,預設 webpack 是支援Tree-Shaking的,但在你的專案中可能會因為babel的原因導致它失效。

因為Tree Shaking這個功能是基於ES6 modules 的靜態特性檢測,來找出未使用的程式碼,所以如果你使用了 babel 外掛的時候,如:babel-preset-env,它預設會將模組打包成commonjs,這樣就會讓Tree Shaking失效了。

其實在 webpack 2 之後它自己就支援模組化處理。所以只要讓 babel 不transform modules就可以了。配置如下:

// .babelrc
{
  "presets": [
    ["env", {
      modules: false,
      ...
    }]
  ]
}
複製程式碼

順便說一下都 8102 年了,請不要在使用babel-preset-esxxxx系列了,請用babel-preset-env,相關文章 再見,babel-preset-2015

下部分內容

擴充閱讀

相關文章