緩存(cache)一直是前端性能優化的重頭戲,利用好靜態資源的緩存機制,可使咱們的 web 應用更加快速和穩定。僅僅簡單的資源緩存是不夠的,咱們還要爲不斷更新的資源作持久化緩存(Long term cache)。之前咱們能利用服務端模板和構建打包,來給資源增長版本標記,如 app.js?v=1.0.0
,但在大流量的網站中,這種更新部署方式會引發下面的問題:javascript
上述回答中針對前端代碼部署的最終方案是:html
但想從頭實現一整套完整的前端部署方案,對於小公司來講仍是很是難的。不只如此,從目前 Web 發展趨勢來看,現在前端早已不是傳統 Web 應用架構可以 Hold 住的了,先後端分離,前端應用化、工程化的需求在迅速增長:模塊化開發、模塊依賴解析、代碼壓縮、圖片壓縮、請求數最小化、雪碧圖、字體壓縮、CSS 預處理、ES2015/6/7 編譯、模板引擎等,都是在構建過程當中要實現的功能。前端
自從 Node.js 和 npm 問世後,最理解前端優化需求的前端架構師/工程師也能夠用本身最熟悉的 JavaScript 來實現本身想要的工程化工具了,社區也前後創造出了 Grunt、gulp、fis、webpack、rollup 等工程化工具,它們做用及架構各不相同,如 gulp 專一流程化任務,rollup 專一模塊打包……vue
對於今天提出的問題:持久化緩存,它涉及了模塊化,模塊依賴,靜態資源優化,模塊打包,文件摘要處理等問題,現在(2016+)能把這些問題解決並作的最好的社區驅動工具備且只有 webpack。java
同類模塊打包工具橫向對比表 -> Comparison - Why webpack?node
目前 webpack 2.2.0 已正式發佈,是時候用最新的工具來建立更完善的前端構建了。react
1、文件 Hash 摘要webpack
2、如何避免頻繁的 chunk 內容變更git
Hash 文件名(vendor.f02bc2.js)是實現持久化緩存的第一步,目前 webpack 有兩種計算 hash 的方式:
第一種是每次編譯生成一個惟一 hash,適合 chunk 拆分很少的小項目,但全部資源全打上同一個 hash,沒法完成持久化緩存的需求。
第二種是 webpack 爲每一個 chunk 資源都生成與其內容相關的 hash 摘要,爲不一樣的資源打上不一樣的 hash。
相關官方文檔:
JS 資源的 [chunkhash] 由 webpack 計算,Images/Fonts 的 [hash] 由webpack/file-loader 計算,提取的 CSS 的 [contenthash] 由 webpack/extract-text-webpack-plugin 計算。避免冗雜,這裏只寫出了部分 webpack 2 配置:
// 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] 就能夠了。
不過,只是計算 chunk MD5 摘要並修改 chunk 資源文件名是不夠的。Chunk 的生成還涉及到依賴解析和模塊 ID 分配,這是沒法穩定實質上沒有變化的 chunk 文件的 chunkhash 變更問題的本源,附一個未關閉的相關 issue:
正如問題 [#1315] 描述的那樣:雖然只修改了 app.js 的代碼,但在最終的構建結果中,vendor.js 的 chunkhash 也被修改了,儘管 vendor.js 的內容沒有實質變化。
其實這個場景比較簡單,只生成了 entry 和 vendor 兩個 chunk,形成上述問題的緣由有兩個:
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 的文檔中也提示用這個插件解決。但我能夠負責任的告訴你,這個插件有缺陷……不要使用它,除非你想背黑鍋。
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 並無解決以前的問題:
咱們先來解決第一個問題,第二個下一節解決。
默認,模塊的 ID 是 webpack 根據依賴的收集順序遞增的正整數,這種 ID 分配方式不太穩定,由於修改一個被依賴較多的模塊,依賴這個模塊的 chunks 內容均會跟着模塊的新 ID 一塊兒改變,但實際上咱們只想讓用戶下載有真正改動的 chunk,而不是全部依賴這個新模塊的 chunk 都從新更新。
所以 webpack (1) 默認的模塊 ID 分配不是很合適,咱們須要其餘工具來幫咱們穩定 ID:
OccurrenceOrderPlugin
這個插件能夠改變默認的 ID 決定方式,讓 webpack 以依賴模塊出現的次數決定 ID 的值,次數越多 ID 越小。在依賴項變更不大狀況下,仍是一個比較好的方法,但當依賴出現次數有變化時,輸出的模塊 ID 則可能會有大幅變更(級聯)。(目前 webpack 2 已經將此插件默認啓用 ��)
recordsPath 配置
它會輸出每次構建的「模塊路徑(loaders + module path)」與 ID 鍵值對 JSON,在下次構建時直接使用 JSON 中的 ID。但當修改模塊路徑或 loader 時,ID 會更新。
同時,須要注意的是 webpack.optimize.DedupePlugin()
插件不可與 recordsPath
共存,它會改變存下來的模塊 ID。
NamedModulesPlugin
這個模塊能夠將依賴模塊的正整數 ID 替換爲相對路徑(如:將 4
替換爲 ./node_modules/es6-promise/dist/es6-promise.js
)。
可是有兩個缺點:
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 已經穩定,是時候解決另外一個問題了……
通常場景下,咱們可能不須要作太多的優化,也不用追求持久化緩存,常規配置便可:
爲了節省篇幅,全部配置代碼我會盡可能縮減,文章最後會提供 DEMO,包含完整配置。
{ 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 |
根據公用/使用/更新率來作公共模塊的劃分是比較科學:
咱們可經過指定模塊的入口 chunk,來直接分離模塊。以 Vue 搭建的多入口單頁應用爲例:
{ 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 提供了幾種添加分割點的方法:
require.ensure
require
添加分割點能夠主動將指定的模塊分離成另外一個 chunk,而不是隨當前 chunk 一塊兒打包。對於這幾種狀況處理很是好:
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()
進行按需加載,須要額外的插件:
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:
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 中便可。
模塊有兩種共用狀況:
對於想把全部共用的模塊所有提取的需求,咱們能夠作以下配置:
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 中指定的模塊,而不會把其餘共用的模塊提取進去。
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() })
儘管咱們已經劃分好了 chunks,也提取了公共的模塊,但僅改動一個模塊的代碼仍是會形成 Initial chunk (libs) 的變化。緣由是這個初始塊包含着 webpack runtime,而 runtime 還包含 chunks ID 及其對應 chunkhash 的對象。所以當任何 chunks 內容發生變化,webpack runtime 均會隨之改變。
webpack runtime 中的 chunks 清單
正如文檔 # Manifest File - Code Splitting - Libraries中描述的那樣,咱們能夠經過增長一個指定的公共 chunk 來提取 runtime,從而進一步實現持久化緩存:
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 的命名:
{ 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:
<!-- 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 腳本,提升了頁面的加載速度:內聯 manifest 的 html
這篇文章主要針對 JS 資源的持久化緩存優化,關於 CSS 提取請看 webpack/extract-text-webpack-plugin。
webpack 中對 chunks 作優化的還有這幾個插件:
儘管 webpack 2 還未大量使用,但如今咱們有一個不得不用 webpack 2 的理由 —— Tree Shaking
注意
爲了不
import x from 'foo'
被 babel 轉換爲require
,咱們須要在.babelrc
的 presets 配置中標明"modules": false
:
{ "presets": [ ["latest", { "es2015": { "modules": false } }] ], "plugins": ["transform-runtime", "syntax-dynamic-import"], "comments": false }
webpack 在構建過程當中只會標記出未使用的 exports,並不會直接將 dead code 去掉,由於爲了使工具儘可能通用,webpack 被設計爲:只標註未使用的 imports/exports。真正的清除死代碼工做,交給了 UglifyJS/babili 等工具。
Does webpack include unused imports in the bundle or not?
UglifyJsPlugin 不只能夠將未使用的 exports 清除,還能去掉不少沒必要要的代碼,如無用的條件代碼、未使用的變量、不可達代碼等。
new webpack.optimize.UglifyJsPlugin({ compress: { warnings: true } })
若是打開了 UglifyJsPlugin 的 warning 功能,就能夠在構建結果中看到清除的代碼警告。
所以必須在生產環境中配置 UglifyJsPlugin,並啓用 -p
(production) 環境,才能真正發揮 Tree Shaking 的做用。