用 webpack 實現持久化緩存

什麼是持久化緩存?

原文連接https://sebastianblade.com/using-webpack-to-achieve-long-term-cache/

緩存(cache)一直是前端性能優化的重頭戲,利用好靜態資源的緩存機制,可使咱們的 web 應用更加快速和穩定。僅僅簡單的資源緩存是不夠的,咱們還要爲不斷更新的資源作持久化緩存(Long term cache)。之前咱們能利用服務端模板和構建打包,來給資源增長版本標記,如 app.js?v=1.0.0,但在大流量的網站中,這種更新部署方式會引發下面的問題:javascript

大公司裏怎樣開發和部署前端代碼? - 回答做者: 張雲龍css

上述回答中針對前端代碼部署的最終方案是:html

  • 細粒度文件名替換(文件內容摘要)
  • 細粒度資源依賴追蹤和摘要
  • 非覆蓋式更新

但想從頭實現一整套完整的前端部署方案,對於小公司來講仍是很是難的。不只如此,從目前 Web 發展趨勢來看,現在前端早已不是傳統 Web 應用架構可以 Hold 住的了,先後端分離,前端應用化、工程化的需求在迅速增長:模塊化開發、模塊依賴解析、代碼壓縮、圖片壓縮、請求數最小化、雪碧圖、字體壓縮、CSS 預處理、ES2015/6/7 編譯、模板引擎等,都是在構建過程當中要實現的功能。前端

自從 Node.js 和 npm 問世後,最理解前端優化需求的前端架構師/工程師也能夠用本身最熟悉的 JavaScript 來實現本身想要的工程化工具了,社區也前後創造出了 Grunt、gulp、fis、webpack、rollup 等工程化工具,它們做用及架構各不相同,如 gulp 專一流程化任務,rollup 專一模塊打包……vue

對於今天提出的問題:持久化緩存,它涉及了模塊化,模塊依賴,靜態資源優化,模塊打包,文件摘要處理等問題,現在(2016+)能把這些問題解決並作的最好的社區驅動工具備且只有 webpackjava

同類模塊打包工具橫向對比表 -> Comparison - Why webpack?node

目前 webpack 2.2.0 已正式發佈,是時候用最新的工具來建立更完善的前端構建了。react

經過配置,逐步實現持久化緩存

1、文件 Hash 摘要webpack

  1. webpack 配置
  2. 不穩定的 chunkhash
  3. webpack-md5-hash 的問題
  4. 如何生成穩定的模塊 ID?

2、如何避免頻繁的 chunk 內容變更git

  1. 合理劃分公共模塊
  2. 代碼分割
  3. import()
  4. 提取公共模塊
  5. 提取 CSS
  6. 對 chunks 作最後的優化

1、文件 Hash 摘要

Hash 文件名(vendor.f02bc2.js)是實現持久化緩存的第一步,目前 webpack 有兩種計算 hash 的方式:

  1. 計算全部 chunks 的 hash —— [hash]
  2. 爲每一個 chunk 計算 hash —— [chunkhash]

第一種是每次編譯生成一個惟一 hash,適合 chunk 拆分很少的小項目,但全部資源全打上同一個 hash,沒法完成持久化緩存的需求。

第二種是 webpack 爲每一個 chunk 資源都生成與其內容相關的 hash 摘要,爲不一樣的資源打上不一樣的 hash。

相關官方文檔:

webpack 配置

JS 資源的 [chunkhash] 由 webpack 計算,Images/Fonts 的 [hash] 由webpack/file-loader 計算,提取的 CSS 的 [contenthash] 由 webpack/extract-text-webpack-plugin 計算。避免冗雜,這裏只寫出了部分 webpack 2 配置:

JavaScript
// production output: { filename: '[name].[chunkhash:8].bundle.js', chunkFilename: '[name].[chunkhash:8].js' }, module: { rules: [{ test: /\.(jpe?g|png|gif|svg)$/i, loader: 'url-loader', options: { limit: 1000, name: 'assets/imgs/[name].[hash:8].[ext]' } }, { test: /\.(woff2?|eot|ttf|otf)$/i, loader: 'url-loader', options: { limit: 10000, name: 'assets/fonts/[name].[hash:8].[ext]' } }] }, plugins: [ new ExtractTextPlugin('[name].[contenthash:8].css') ] 

不要在開發環境使用 [chunkhash]/[hash]/[contenthash],由於不須要在開發環境作持久緩存,並且這樣會增長編譯時間,開發環境用 [name] 就能夠了。

不穩定的 chunkhash

不過,只是計算 chunk MD5 摘要並修改 chunk 資源文件名是不夠的。Chunk 的生成還涉及到依賴解析和模塊 ID 分配,這是沒法穩定實質上沒有變化的 chunk 文件的 chunkhash 變更問題的本源,附一個未關閉的相關 issue:

Vendor chunkhash changes when app code changes #1315

正如問題 [#1315] 描述的那樣:雖然只修改了 app.js 的代碼,但在最終的構建結果中,vendor.js 的 chunkhash 也被修改了,儘管 vendor.js 的內容沒有實質變化。

其實這個場景比較簡單,只生成了 entry 和 vendor 兩個 chunk,形成上述問題的緣由有兩個:

  1. webpack runtime 中包含 chunks ID 及其對應 chunkhash 的對象,但 runtime 被集成到 vendor.js 中;
  2. entry 內容修改後,因爲 webpack 的依賴收集規則致使構建產生的 entry chunk 對應的 ID 發生變化,webpack runtime 也所以被改變。

webpack runtime(webpackBootstrap代碼很少,主要包含幾個功能:

  • 全局 webpackJsonp 方法:模塊讀取函數,用來區分模塊是否加載,並調用 __webpack_require__ 函數;
  • 私有 __webpack_require__ 方法:模塊初始化執行函數,並給執行過的模塊作標記;
  • 異步 chunk 加載函數(用 script 標籤異步加載),加載的 chunk 內容均被 webpackJsonp 包裹的,script 加載成功會直接執行。這個函數還包含了全部生成的 chunks 的路徑。在 webpack 2 中這個函數用到了 Promise,所以可能須要提供 Promise Polyfill;
  • 對 ES6 Modules 的默認導出(export default)作處理。

對於複雜項目的構建,因爲模塊間互相依賴,這種問題影響更爲巨大:可能只改動了一個小模塊,但在構建後,會發現全部與之直接或間接相關的 chunk 及其 chunkhash 都被更新了……這與咱們指望的持久化緩存的需求不符。

解決這個問題的核心在於生成穩定的模塊 ID,避免頻繁的 chunk 內容變更。

若是你看過 #1315 的回覆,可能會了解到 webpack-md5-hash 插件能夠解決這個問題,甚至 webpack 2 的文檔中也提示用這個插件解決。但我能夠負責任的告訴你,這個插件有缺陷……不要使用它,除非你想背黑鍋。

webpack-md5-hash 的問題

erm0l0v/webpack-md5-hash相關源碼) 經過模塊路徑來排序 chunk 的全部依賴模塊(僅這個 chunk 中的模塊,不含被 CommonsChunkPlugin 剔除的模塊),並將這些排序後的模塊源代碼拼接,最後用 MD5 拼接後內容的 chunkhash。插件這麼作的好處是,使 chunkhash 與該 chunk 內代碼作直接關聯,讓 chunk 與其依賴的模塊 ID 無關化,不管模塊 ID 如何變化,都不會影響父 chunk 的實質內容及 chunkhash。

這個方法比較有效,但在一些情景下,會使 webpack-md5-hash 失效,使構建變得不可信:

Hash does not change when only imported modules IDs change #7

好比一個簡單場景:有兩個入口 vendor 和 app。 
當 app.js 被修改後,其 chunk ID 隨之改變,vendor.js 中 app 對應的 chunk ID 也會改變,即 vendor 內容有變更,其 chunkhash 也理應改變。但 webpack-md5-hash 是根據 chunk 內實際包含模塊而生成的 chunkhash,和僅有 ID 引用的 chunk 內容無關,vendor 只包含 app chunk ID 的引用,並不包含其代碼,因此這次構建中 vendor 的 chunkhash 並不會改變。這樣形成的結果即是:瀏覽器依然會下載舊的 vendor,直接致使發版失誤!

所以 webpack-md5-hash 並無解決以前的問題:

  1. 如何生成穩定的模塊ID?
  2. 如何避免頻繁的 chunk 內容變更?

咱們先來解決第一個問題,第二個下一節解決。

如何生成穩定的模塊 ID?

默認,模塊的 ID 是 webpack 根據依賴的收集順序遞增的正整數,這種 ID 分配方式不太穩定,由於修改一個被依賴較多的模塊,依賴這個模塊的 chunks 內容均會跟着模塊的新 ID 一塊兒改變,但實際上咱們只想讓用戶下載有真正改動的 chunk,而不是全部依賴這個新模塊的 chunk 都從新更新。

所以 webpack (1) 默認的模塊 ID 分配不是很合適,咱們須要其餘工具來幫咱們穩定 ID:

  1. OccurrenceOrderPlugin 
    這個插件能夠改變默認的 ID 決定方式,讓 webpack 以依賴模塊出現的次數決定 ID 的值,次數越多 ID 越小。在依賴項變更不大狀況下,仍是一個比較好的方法,但當依賴出現次數有變化時,輸出的模塊 ID 則可能會有大幅變更(級聯)。(目前 webpack 2 已經將此插件默認啓用 ��)

  2. recordsPath 配置 
    它會輸出每次構建的「模塊路徑(loaders + module path)」與 ID 鍵值對 JSON,在下次構建時直接使用 JSON 中的 ID。但當修改模塊路徑或 loader 時,ID 會更新。 
    同時,須要注意的是 webpack.optimize.DedupePlugin() 插件不可與 recordsPath 共存,它會改變存下來的模塊 ID。

  3. NamedModulesPlugin 
    這個模塊能夠將依賴模塊的正整數 ID 替換爲相對路徑(如:將 4 替換爲 ./node_modules/es6-promise/dist/es6-promise.js)。

    • 開發模式,它可讓 webpack-dev-server 和 HMR 進行熱更新時在控制檯輸出模塊名字而不是純數字;
    • 生產構建環境,它能夠避免因修改內容致使的 ID 變化,從而實現持久化緩存。

    可是有兩個缺點:

    • 遞增 ID 替換爲模塊相對路徑,構建出來的 chunk 會充滿各類路徑,使文件增大;
    • 模塊(npm 和本身的模塊)路徑會泄露,可能致使安全問題。
  4. HashedModuleIdsPlugin 
    這是 NamedModulesPlugin 的進階模塊,它在其基礎上對模塊路徑進行 MD5 摘要,不只能夠實現持久化緩存,同時還避免了它引發的兩個問題(文件增大,路徑泄露)。用 HashedModuleIdsPlugin 能夠輕鬆地實現 chunkhash 的穩定化!

    不過這個插件只被添加到了 webpack 2 中,多是由於 webpack 2 正式版尚未發佈,HashedModuleIdsPlugin 一直沒有文檔,因此這裏有必要指明如何使用:

    new webpack.HashedModuleIdsPlugin()

    若是使用了 HashedModuleIdsPlugin,NamedModulesPlugin 就不要再添加了。

    幸運的是,咱們能夠經過直接添加 HashedModuleIdsPlugin.js 爲模塊到 webpack 1 的配置中,也能達到一樣穩定 chunkhash 的功能。

    const HashedModuleIdsPlugin = require('./HashedModuleIdsPlugin')
    // ...
    new HashedModuleIdsPlugin()

至此 chunkhash 已經穩定,是時候解決另外一個問題了……

2、如何避免頻繁的 chunk 內容變更?

通常場景下,咱們可能不須要作太多的優化,也不用追求持久化緩存,常規配置便可:

爲了節省篇幅,全部配置代碼我會盡可能縮減,文章最後會提供 DEMO,包含完整配置。

JavaScript
{ entry: { entry }, plugins: [ new HtmlWebpackPlugin({ chunks: ['vendor', 'entry'] }), new webpack.optimize.CommonsChunkPlugin({ names: 'vendor', minChunks: Infinity }) ] } 

但隨着業務需求變化,最初的單頁模式可能沒法知足需求,並且把公共模塊所有提取到 vendor 中,也沒法作到較好的持久化緩存,咱們須要更合理地劃分並提取公共模塊。

合理劃分公共模塊

稍大型的應用一般會包含這幾個部分:

類型 公用率 使用頻率 更新頻率
庫和工具 vue/react/redux/whatwg-fetch 等
定製 UI 庫和工具 UI 組件/私有工具/語法 Polyfill/頁面初始化腳本等
低頻庫/工具/代碼 富文本編輯器/圖表庫/微信 JSSDK/省市 JSON 等
業務模塊 包含業務邏輯的模塊/View

根據公用/使用/更新率來作公共模塊的劃分是比較科學:

  • 庫和工具 - libs
  • 定製 UI 庫和工具 - vendor
  • 業務模塊 - entries
  • 低頻庫/工具/代碼 - 分割爲 chunk

咱們可經過指定模塊的入口 chunk,來直接分離模塊。以 Vue 搭建的多入口單頁應用爲例:

JavaScript
{ entry: { libs: [ 'es6-promise/auto', 'whatwg-fetch', 'vue', 'vue-router' ], vendor: [ /* * vendor 中均是非 npm 模塊, * 用 resolve.alias 修改路徑, * 避免冗長的相對路徑。 */ 'assets/libs/fastclick', 'components/request', 'components/ui', 'components/bootstrap' // 初始化腳本 ], page1: 'src/pages/page1', page2: 'src/pages/page2' }, plugins: [ new HtmlWebpackPlugin({ // 省略部分配置 template: 'src/pages/page1/index.html', chunks: ['libs', 'vendor', 'page1'] }), new HtmlWebpackPlugin({ template: 'src/pages/page2/index.html', chunks: ['libs', 'vendor', 'page2'] }) ] } 

多頁入口最好用腳原本掃描目錄並生成,手動添加維護性較差,可參考 multi-vue

代碼分割

除了入口代碼的分離,咱們還缺乏對「低頻庫/工具/代碼」的處理,對於這類代碼最好的辦法是作代碼分割(Code Splitting),作到按需加載,進一步加速應用。

webpack 提供了幾種添加分割點的方法:

  • CommonJs: require.ensure
  • AMD: require
  • ES6 Modules (webpack 1 不支持)

添加分割點能夠主動將指定的模塊分離成另外一個 chunk,而不是隨當前 chunk 一塊兒打包。對於這幾種狀況處理很是好:

  • 比較大,且不經常使用的庫/工具,如 D3.js、Draft.js、微信 JSSDK、querystring 等;
  • 單頁應用中不經常使用的 router view,即某些不常訪問的介面。

CommonJs 和 AMD 添加分割點的方法就再也不贅述了,詳情請查看文檔:

注意

若是你使用了 babili (babel-minify) 來壓縮你的 ES6+ 代碼,請不要使用 require.ensure/require,由於 babili 會把 require 關鍵字壓縮,致使 webpack 沒法識別,形成構建問題。

import()

webpack 2 在 1.x 的基礎上增長了對 ES6 模塊(ES6 Modules)的支持,這意味着在webpack 2 環境下,import 導入模塊語法再也不須要編譯爲 require 了。還優化了 ES6 模塊依賴(Tree-shaking,後面會談到),並實現了 JS Loader Standard 規範定義中的 import(path) 方法。

注意

在 webpack v2.1.0-beta.28 中,System.import 方法已被廢棄,由於 System.import 不在提案中了,被 import() 代替。

因爲 import() 僅僅是個語法,不涉及轉換,所以咱們須要使用 babel 插件 syntax-dynamic-import 來讓 babel 能夠識別這個語法。另外 import() 也依賴編譯環境,要想讓運行環境經過 import() 進行按需加載,須要額外的插件:

JavaScript
const { search } = window.location import('./components/querystring.js') .then(querystring => { const searchquery = querystring.parse(search) // ... }) .catch(err => { Toast.error(err) console.error(err) }) 

配合 react-router:

React JSX
import { Router, Route, hashHistory } from 'react-router' import App from './App' const lazyLoad = moduleName => _ => import(`./components/${moduleName}`) .then(module => module.default) .catch(err => console.error(err)) export default function Root () { return ( <Router history={hashHistory}> <Route path='/' component={App}> <Route path='/home' getComponent={lazyLoad('Home')} /> <Route path='/posts' getComponent={lazyLoad('Posts')}> <Route path=':id' getComponent={lazyLoad('Article')} /> </Route> <Route path='/about' getComponent={lazyLoad('About')} /> </Route> </Router> ) } 

用模板字符串來動態加載模塊時,webpack 在編譯階段會把可能加載的模塊打包,並用正則匹配加載,懶加載示例代碼可見 blade254353074/react-router-lazy-import

提取公共模塊

在上述例子中,咱們劃分了公共模塊,並進行了代碼分割,下面咱們要作的是:提取頻繁共用的模塊,將 webpack runtime 構建爲內聯 script。

提取頻繁共用的模塊

提取公共模塊要使用 Commons-chunk-plugin,對於持久化緩存來講,咱們只須要將共用的模塊打包到 libs/vendor 中便可。

模塊有兩種共用狀況:

  • libs/vendor 與其餘 chunk 共用的模塊,如:vue/react/moment/whatwg-fetch
  • 多個 chunks 間共用的模塊,如 page1 和 page2 共用 Header 組件

對於想把全部共用的模塊所有提取的需求,咱們能夠作以下配置:

JavaScript
new webpack.optimize.CommonsChunkPlugin({ names: ['libs', 'vendor'].reverse() }) 

用上述配置構建時,webpack 會將 webpack runtime 打包到 libs 中(names 數組末尾的 chunk),而 chunks 間共用的模塊會打包到 vendor中。

若是你不想讓僅有兩個 chunks 共用的模塊被提取到 vendor 中,而想讓 n 個 chunks 共用的模塊被提取出來時,能夠藉助 minChunks 實現。 
minChunks 是指限定模塊被 chunks 依賴的最少次數,低於設定值(2 ≤ n ≤ chunks 總數)將不會被提取到公共 chunk 中。若是 chunks 太多,又不想讓全部公共模塊被分離到 vendor 中,能夠將 minChunks 設爲 Infinity,則公共 chunk 僅僅包含在 entry 中指定的模塊,而不會把其餘共用的模塊提取進去。

JavaScript
new webpack.optimize.CommonsChunkPlugin({ names: ['libs', 'vendor'].reverse(), // minChunks: 3 minChunks: Infinity }) 

CommonsChunkPlugin 彷佛仍是有些 Bug,當我用 vue-style-loader 時,其中的 addStyle.js 會被添加到依賴中,但在如下配置中,addStyle.js 在打包後會被 CommonsChunkPlugin 漏掉,致使沒法正常運行:

new webpack.optimize.CommonsChunkPlugin({
  names: ['libs', 'vendor'].reverse()
})
manifest (清單)

儘管咱們已經劃分好了 chunks,也提取了公共的模塊,但僅改動一個模塊的代碼仍是會形成 Initial chunk (libs) 的變化。緣由是這個初始塊包含着 webpack runtime,而 runtime 還包含 chunks ID 及其對應 chunkhash 的對象。所以當任何 chunks 內容發生變化,webpack runtime 均會隨之改變。

manifestwebpack runtime 中的 chunks 清單

正如文檔 # Manifest File - Code Splitting - Libraries中描述的那樣,咱們能夠經過增長一個指定的公共 chunk 來提取 runtime,從而進一步實現持久化緩存:

JavaScript
new webpack.optimize.CommonsChunkPlugin({ // 將 `manifest` 優先於 libs 進行提取, // 則能夠將 webpack runtime 分離到這個塊中。 names: ['manifest', 'libs', 'vendor'].reverse() // manifest 只是個有意義的名字,也能夠改爲其餘名字。 }) 

manifest 只是個特定的名字(多是包含了 chunks 清單,因此起名 manifest),若是僅僅是爲了分離 webpack runtime,能夠將 manifest 替換成任意你想要的名字。

這樣在咱們構建以後,就會多打包一個特別小(不足 2kb)的 manifest.js,解決了 libs 常常「被」更新的問題。不過,你可能發現了一個問題 —— manifest.js 實在是過小了,以致於不值得再爲一個小 js 增長資源請求數量。

這時候咱們能夠引入另外一個插件:inline-manifest-webpack-plugin。 
它能夠將 manifest 轉爲內聯在 html 內的 inline script,由於 manifest 常常隨着構建而變化,寫入到 html 中便不須要每次構建再下載新的 manifest 了,從而減小了一個小文件請求。此插件依賴 html-webpack-plugin 和 manifest 公共塊,所以咱們要配置 HtmlWebpackPlugin 且保持 manifest 的命名:

JavaScript
{ module: { rules: [{ test: /\.ejs$/, loader: 'ejs-loader' }] }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ names: ['manifest', 'libs', 'vendor'].reverse() }), new HtmlWebpackPlugin({ template: 'src/pages/page1/index.ejs', chunks: ['manifest', 'libs', 'vendor', 'page1'] }), new InlineManifestWebpackPlugin() ] } 

EJS Template:

HTML
<!-- ejs template --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Template</title> <%= htmlWebpackPlugin.files.webpackManifest %> </head> <body> <div id="app"></div> </body> </html> 

在 inline-manifest-webpack-plugin 的幫助下進行構建,最終咱們的 html 便內聯了 webpack runtime 腳本,提升了頁面的加載速度:inline manifest內聯 manifest 的 html

提取 CSS

這篇文章主要針對 JS 資源的持久化緩存優化,關於 CSS 提取請看 webpack/extract-text-webpack-plugin

對 chunks 作最後的優化

webpack 中對 chunks 作優化的還有這幾個插件:

  • DedupePlugin (webpack 2 已廢棄)
  • OccurrenceOrderPlugin (webpack 2 默認啓用)
  • LimitChunkCountPlugin
  • MinChunkSizePlugin
  • UglifyJsPlugin
  • AggressiveMergingPlugin
  • DllPlugin
  • DllReferencePlugin

關於 Tree Shaking

儘管 webpack 2 還未大量使用,但如今咱們有一個不得不用 webpack 2 的理由 —— Tree Shaking

注意

爲了不 import x from 'foo' 被 babel 轉換爲 require,咱們須要在 .babelrc 的 presets 配置中標明 "modules": false

JSON
{ "presets": [ ["latest", { "es2015": { "modules": false } }] ], "plugins": ["transform-runtime", "syntax-dynamic-import"], "comments": false } 

webpack 在構建過程當中只會標記出未使用的 exports,並不會直接將 dead code 去掉,由於爲了使工具儘可能通用,webpack 被設計爲:只標註未使用的 imports/exports。真正的清除死代碼工做,交給了 UglifyJS/babili 等工具。

webpack tree shakingDoes webpack include unused imports in the bundle or not?

UglifyJsPlugin 不只能夠將未使用的 exports 清除,還能去掉不少沒必要要的代碼,如無用的條件代碼、未使用的變量、不可達代碼等。

JavaScript
new webpack.optimize.UglifyJsPlugin({ compress: { warnings: true } }) 

warning若是打開了 UglifyJsPlugin 的 warning 功能,就能夠在構建結果中看到清除的代碼警告。

所以必須在生產環境中配置 UglifyJsPlugin,並啓用 -p (production) 環境,才能真正發揮 Tree Shaking 的做用。

其餘資源

引用

我的分類: Webpack打包工具
相關文章
相關標籤/搜索