Google出品 – 利用 webpack 做 web 性能優化
介紹
現代 Web 應用經常用到 bunding tool 用于創建一個生產環境的打包文件(例如腳本、樣式等),這個打包文件是已經優化完,并且最小化完成的,并且能夠被用戶用更少的時間下載到。在這篇文章中,我們將會利用 webpack 來貫穿如何優化網站資源。這樣可以幫助用戶對于你的網站得到更快地加載和體驗。
webpack 目前是最流行的打包工具之一,深入地利用他的特點去優化代碼,拆分腳本成重要和非重要部分還有剔除無用的代碼能夠保證你的引用有最小的帶寬和進程消耗。
Note: 我們創建了一個練習用的引用來演示優化的描述。盡力擠出最多的時間來練習這些 tips webpack-training-project
讓我們從現代 web 應用中最耗費資源之一的 Javascript 開始。
- 減小前端體積
- 利用長期緩存
- 監控并分析應用
- 總結
Decrease Front-end Size 減少前端體積
作者 Ivan Akulov
當你正在優化一個應用時最初第一件事就是盡可能地讓它體積減小。下面就是利用 webpack 如何做。
Enable minification 啟用最小化
最小化是通過去除多余空格、縮短變量名等方式壓縮代碼。例如:
// Original code
function map(array, iteratee) {
let index = -1;
const length = array == null ? 0 : array.length;
const result = new Array(length);
while (++index < length) {
result[index] = iteratee(array[index], index, array);
}
return result;
}
to
// Minified code
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l}
Webpack 支持兩種方式最小化代碼:UglifyJS 插件和 loader-specific options 。他們可以同時使用。
The UglifyJS plugin 在 bundle 層級中起作用,在編譯之后壓縮 bundle。下面來展示如何工作:
- 你的代碼:
// comments.js
import './comments.css';
export function render(data, target) {
console.log('Rendered!');
}
- Webpack 打包大致成如下:
// bundle.js (part of)
"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony export (immutable) */ __webpack_exports__["render"] = render;
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
__webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
function render(data, target) {
console.log('Rendered!');
}
- 使用 UglifyJS 插件大致編譯成如下:
// minified bundle.js (part of)
"use strict";function t(e,n){console.log("Rendered!")}
Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
插件集成在 webpack 中,把它的配置在 plugins 中就可以啟用:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.optimize.UglifyJsPlugin(),
],
};
第二種方式 loader-specific options 利用 loader options,可以壓縮 Uglify 插件無法最小化的部分。舉例,當你利用 css-loader 引入一個 CSS 文件時,文件會編譯成一個字符串:
/* comments.css */
.comment {
color: black;
}
to
// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n color: black;\r\n}",""]);
UglifyJS 由于這是一個字符串不能壓縮這段代碼。要最小化這個 css 文件內容,我們需要配置 loader
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
{ loader: 'css-loader', options: { minimize: true } },
],
},
],
},
};
{ minimize: true }
Note: UglifyJS 插件不能編譯 ES2015+(ES2016),這意味著如果你的 diamante 中使用類、箭頭函數和一些新特性語法,不能編譯成 ES5,插件會拋異常。
如果需要編譯新語法,要使用 uglifyjs-webpack-plugin 包。也是集成在 webpack 中相同的插件,但是更新一些,能夠有能力編譯 ES2015+。
Further reading
- The UglifyJsPlugin docs
- Other popular minifiers: Babel Minify , Google Closure Compiler
Specify NODE_ENV=production 明確生產環境信息
減小前端體積的另外一個方法就是在代碼中將 NODE_ENV 環境變量 設置成 production 。
Libraries 會讀取 NODE_ENV 變量判斷他們應該在那種模式下工作 – 開發模式 or 生成模式。很多庫會基于這個變量有不同的表現。舉個例子,當 NODE_ENV 沒有設置成 production ,Vue.js 會做額外的檢查并且輸出一些警告:
// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
warn('props must be strings when using array syntax.');
}
// …
React 也是類似 – 開發模式下 build 帶有一些警告:
// react/index.js
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
// react/cjs/react.development.js
// …
warning$3(
componentClass.getDefaultProps.isReactClassApproved,
'getDefaultProps is only used on classic React.createClass ' +
'definitions. Use a static property named `defaultProps` instead.'
);
// …
這些檢查和警告通常在生產環境下不必要的,但是他們仍然保留在代碼中并且會增加庫的體積。通過配置 webpack 的 DefinePlugin 來刪除他們:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"',
}),
new webpack.optimize.UglifyJsPlugin(),
],
};
DefinePlugin 用確定的變量替換所有存在的說明變量。利用下面配置:
- DefinePlugin 將用 "production" 替換到 process.env.NODE_ENV :
// vue/dist/vue.runtime.esm.js
if (typeof val === 'string') {
name = camelize(val);
res[name] = { type: null };
} else if (process.env.NODE_ENV !== 'production') {
warn('props must be strings when using array syntax.');
}
to
// vue/dist/vue.runtime.esm.js
if (typeof val === 'string') {
name = camelize(val);
res[name] = { type: null };
} else if ("production" !== 'production') {
warn('props must be strings when using array syntax.');
}
Note: 如果你偏向有通過 CLI 配置變量,可以查看一下 EnvironmentPlugin 。它和 DefinePlugin 類似,但讀環境并且自動替換 process.env 表達式。
2. UglifyJS 會移除掉所有 if 分支 – 因為 "production" !== 'production' 永遠返回 false ,插件理解代碼內的判斷分支將永遠不會執行:
// vue/dist/vue.runtime.esm.js
if (typeof val === 'string') {
name = camelize(val);
res[name] = { type: null };
} else if ("production" !== 'production') {
warn('props must be strings when using array syntax.');
}
to
// vue/dist/vue.runtime.esm.js (without minification)
if (typeof val === 'string') {
name = camelize(val);
res[name] = { type: null };
}
Note: 不一定強制要求使用 UglifyJSPlugin 。你可以使用其他不同的最小化工具,這些頁支持移除無用代碼(例如,the Babel Minify plugin or the Google Closure Compiler plugin )
Further Reading
- What “environment variables” are
- Webpack docs about: DefinePlugin , EnvironmentPlugin
Use ES Modules 使用 ES 模塊
下面這個方式利用 ES modules 減小前端體積。
當你使用 ES module,webpack 有能力去做 tree-shaking。Tree-shaking 貫穿整個依賴樹,檢查那些依賴被使用,移除無用依賴。因此,如果你使用 ES module 語法,webpack 可以排除掉無用代碼:
1. 一個有多個 export 的文件,但是 app 只需要其中一個:
// comments.js
export const render = () => { return 'Rendered!'; };
export const commentRestEndpoint = '/rest/comments';
// index.js
import { render } from './comments.js';
render();
- webpack 理解 commentRestEndPoint 沒有使用,同時不能在一個 bundle 中生成單獨的 export:
// bundle.js (part that corresponds to comments.js)
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
const render = () => { return 'Rendered!'; };
/* harmony export (immutable) */ __webpack_exports__["a"] = render;
const commentRestEndpoint = '/rest/comments';
/* unused harmony export commentRestEndpoint */
})
- UglifyJSPlugin 移除無用變量:
// bundle.js (part that corresponds to comments.js)
(function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
如果他們都是有 ES module 編寫,就是與一些庫并存時也是生效的。
Note: 在 webpack 中,tree-shaking 沒有 minifier 是無法生效的。 webpack 僅僅移除了沒有被用到的 export 變量; UglifyJSPlugin 才會移除無用代碼。所以如果你編譯打包時沒有使用 minifier,打包后體積并不會更小。你也可以不一定使用這個插件。其他最小化的插件也支持移除 dead code(例如: Babel Minify plugin or Google Closure Compiler plugin )
Warning: 不要將 ES module 編譯到 CommonJS 中。 如果你使用 Babel babel-preset-env or babel-preset-es2015 ,檢查一下當前的配置。默認情況下, ES import and export to CommonJS require and module.exports 。通過設置 option 來禁止掉 Pass the { modules: false } option 。
Futher reading
- “ES6 Modules in depth”
- Webpack docs about tree shaking
Optimize images 優化圖片
圖片基本會占局頁面一半以上體積。雖然它們不像 JavaScript 那么重要(比如它們不會阻止頁面渲染),但圖片仍然會占用掉一大部分帶寬。利用 url-loader , svg-url-loader 和 image-webpack-loader 來在 webpack 中進行優化。
url-loader 允許將小靜態文件打包進 app。沒有配置,他需要通過 file,將它放在編譯后的打包 bundle 內并返回一個這個文件的 url。然而,如果我們注明 limit 選項,它將會 encode 成更小的文件 base64 文件 url。這是可以將圖片放在Javascript 代碼中,同時節省 HTTP 請求:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(jpe?g|png|gif)$/,
loader: 'url-loader',
options: {
// Inline files smaller than 10 kB (10240 bytes)
limit: 10 * 1024,
},
},
],
}
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`
Note: 內聯圖片減少了獨立請求的數量,這是很好的方式( even with HTTP/2 ),但是會增加 bundle下載和轉換的時間和內存的消耗。一定要確保不要嵌入超大圖片或者較多的圖片 – 否則增加的 bundle 的時間將會掩蓋做成內聯圖片的收益。
svg-url-loader 與 url-loader 類似 – 都是將使用 URL encoding encode 文件。這對對于 SVG 圖片很奏效 – 因為 SVG 文件是文本,encoding 在體積上更有效率:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.svg$/,
loader: 'svg-url-loader',
options: {
// Inline files smaller than 10 kB (10240 bytes)
limit: 10 * 1024,
// Remove the quotes from the url
// (they’re unnecessary in most cases)
noquotes: true,
},
},
],
},
};
Note: svg-url-loader 擁有改善 IE 瀏覽器支持的 options,但是在其他瀏覽器中更糟糕。如果你需要兼容 IE 瀏覽器, 設置 iesafe: true 選項
image-webpack-loader 壓縮圖片使之變小。它支持 JPG,PNG,GIF 和 SVG,因為我們將會使用它所有類型。
這個 loader 不會將圖片嵌入在應用內,因此它必須與 url-loader 和 svg-url-loader 配合使用。避免復制粘貼到相同的 rules 中(一個用于 JPG/PNG/GIF 圖片,另一個用于 SVG 圖片),我們來使用 enforce: pre 作為單獨的一個 rule 涵蓋這個 loader:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(jpe?g|png|gif|svg)$/,
loader: 'image-webpack-loader',
// This will apply the loader before the other ones
enforce: 'pre',
},
],
},
};
默認 loader 的設置就已經可以了 – 但是如果你想要更深入的配置,查看 the plugin options 。為了選擇哪些 options 需要明確,可以查看 Addy Osmani 的 guide on image optimization
Further reading
Optimize dependencies 優化依賴
平均一半以上的 Javascript 體積大小來源于依賴包,并且這些可能都不是必要的。
舉一個例子來說,Lodash(v4.17.4)增加了最小化代碼的 72KB 大小到 bundle 中。但是如果你僅僅用到它的20個方法,大于 65 KB 沒有用處。
另外一個例子就是 Moment.js。 V2.19.1版本最小化后有 223KB,體積巨大 – 截至2017年10月一個頁面內的 Javascript 平均體積是 452KB。但是,本地文件的體積占 170KB。如果你沒有用到 多語言版 Moment.js,這些文件都會沒有目的地使 bundle 更臃腫。
所有這些依賴都可以被輕易優化。我們在 Github repo 手機了優化的建議, check it out !
Enable module concatenation for ES modules (aka scope hoisting)
當你構建一個 bundle 時,webpack 將每一個 module 封裝進 function 中:
// index.js
import {render} from './comments.js';
render();
// comments.js
export function render(data, target) {
console.log('Rendered!');
}
to
// bundle.js (part of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();
}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_exports__["a"] = render;
function render(data, target) {
console.log('Rendered!');
}
})
在以前,這是使 CommonJS/AMD modules 互相分離所必須的。但是,這回增加體積同時性能堪憂。
Webpack 2 介紹了 ES modules 的支持,不像 CommonJS 和 AMD modules 一樣,而是能夠不用將每一個 module 用 function 封裝起來。同時 Webpack 3 利用 ModuleConcatenationPlugin 完成這樣一個 bundle,下面是例子:
// index.js
import {render} from './comments.js';
render();
// comments.js
export function render(data, target) {
console.log('Rendered!');
}
to
// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files
// 與前面的代碼不同,這個 bundle 只有一個 module,同時包含兩個文件
// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
// CONCATENATED MODULE: ./comments.js
function render(data, target) {
console.log('Rendered!');
}
// CONCATENATED MODULE: ./index.js
render();
})
看到區別了嗎?在這個 bundle 中, module 0 需要 module 1 的 render 方法。使用 ModuleConcatenationPlugin , require 被直接簡單的替換成 require 函數,同時 module 1 被刪除刪除掉了。這個 bundle 擁有更少的 modules,就有更少的 modules 損耗!
啟用這個功能,可以在插件列表中增加 ModuleConcatenationPlugin :
// webpack.config.js
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.optimize.ModuleConcatenationPlugin(),
],
};
Note:想要知道為什么這個功能不是默認啟用?Concatenating modules 很棒, 但是他會增加編譯的時間同時破壞 module 的熱更新 。這就是為什么只在生產環境中啟用的原因了。
Further reading
- Webpack docs for the ModuleConcatenationPlugin
- “Brief introduction to scope hoisting”
- Detailed description of what this plugin does
Use externals if you have both webpack and non-webpack code 如果代碼中包含 webpack 和非 webpack 的代碼要使用 externals
你可能擁有一個體積龐大的工程,其中一部分代碼可以使用 webpack 編譯,而有一些代碼又不能。比如一個視頻網站,播放器的 widget 可能通過 webpack 編譯,但是其周圍頁面區域可能不是:
如果兩部分代碼有相同的依賴,你可以共享這些依賴以便減少重復下載耗時。 the webpack’s externals option 就干了這件事 – 它用變量或者外部引用來替代 modules。
如果依賴是掛載到 window
如果你的非 webpack 代碼依靠這些依賴,這些依賴是掛載 window 上的變量,可以將依賴名稱 alias 成變量名:
// webpack.config.js
module.exports = {
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
},
};
利用這個配置,webpack 將不會打包 react 和 react-dom 包。取而代之,他們會被替換成下面這個樣子:
// bundle.js (part of)
(function(module, exports) {
// A module that exports `window.React`. Without `externals`,
// this module would include the whole React bundle
module.exports = React;
}),
(function(module, exports) {
// A module that exports `window.ReactDOM`. Without `externals`,
// this module would include the whole ReactDOM bundle
module.exports = ReactDOM;
})
如果依賴是當做 AMD 包被加載
如果你的非 webpack 代碼沒有將依賴暴露掛載到 window 上,這就更復雜了。但是如果非 webpack 代碼使用 AMD 包的形式消費了這些依賴,你仍然可以避免重復的代碼加載兩次。
具體如何做呢?將 webpack 代碼編譯成一個 AMD module 同時又名成一個庫 URLs:
// webpack.config.js
module.exports = {
output: { libraryTarget: 'amd' },
externals: {
'react': { amd: '/libraries/react.min.js' },
'react-dom': { amd: '/libraries/react-dom.min.js' },
},
};
Webpack 將會把 bundle 包裝進 define() 同時讓它依賴于這些URLs:
// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () { … });
如果非 webpack 代碼使用相同的 URLs 加載依賴,這些文件將會加載一次 – 多余的請求會使用緩存。
Note:webpack 只是替換那些 externals 對象中的準確匹配的 keys 的引用。這意味著如果你的代碼這樣寫 import React from 'react/umd/react.production.min.js' ,這個庫是不會被 bundle 排除掉的。這是因為 – webpack 并不知道 import 'react' 和 import 'react/umd/react.production.min.js' 是同一個庫,這樣比較謹慎。
Further reading
- Webpack docs on externals
Summing up 總結
- Minimize your code with the UglifyJsPlugin and loader options
- Remove the development-only code with the DefinePlugin
- Use ES modules to enable tree shaking
- Compress images
- Apply dependency-specific optimizations
- Enable module concatenation
- Use externals if this makes sense for you
Make use of long-term caching 利用好長時緩存
作者 Ivan Akulov
在做完優化應用體積之后的下一步提升應用加載時間的就是緩存。在客戶端中使用緩存作為應用的一部分同時每一次減少重新下載。
Use bundle versioning and cache headers 使用 bundle 版本和緩存頭信息
做緩存通用的解決辦法:
1. 告訴瀏覽器緩存一個文件很長時間(比如一年)
# Server header
Cache-Control: max-age=31536000
Note:如果你不熟悉 Cache-Control 做了什么,你可以看一下Jake Archibald 的精彩博文 on caching best practices
2.當文件改變需要強制重新下載時候去重命名這些文件
<!-- Before the change -->
<script src="./index-v15.js"></script>
<!-- After the change -->
<script src="./index-v16.js"></script>
這些方法告訴瀏覽器下載這些 JS 文件,緩存起來。瀏覽器將會只在文件名變化是才會請求網絡(或者是緩存失效)。
使用 webpack,你也可以做同樣的事,但是是可以使用版本號來解決,你需要明確這個文件的 hash。使用 [chunkhash] 可以將 hash 值包含進文件名中:
// webpack.config.js
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.[chunkhash].js',
// → bundle.8e0d62a03.js
},
};
Note: webpack 可能會生成不同的 hash 就是 bundle 相同 – 比如你重名了了一個文件或者重新在不同的操作系統下編譯了一個 bundle。 This is a bug.
如果你需要將文件名發送給客戶端,也可以使用 HtmlWebpackPlugin 或者 WebpackManifestPlugin 。
HtmlWebpackPlugin 很簡單,但是靈活性欠缺一些。編譯時,插件會生成一個 HTML 文件,這其中包括所有的編譯后的資源文件。如果你的業務邏輯不復雜,這就非常適合你:
<!-- index.html -->
<!doctype html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>
WebpackManifestPlugin 更靈活一些,它可以幫助你解決業務負責的部分。編譯時它會生成一個 JSON 文件,這文件保存這沒有 hash 值文件與有 hash 文件之間的映射。服務端利用這個 JSON 可以識別出那個文件有效:
// manifest.json
{
"bundle.js": "bundle.8e0d62a03.js"
}
Further reading
- Jake Archibald about caching best practices
Extract dependencies and runtime into a separate file 外部依賴和獨立文件運行時
Dependencies 依賴包
App 依賴通常情況下趨向于比實際 app 內代碼中更少的變化。如果你將他們移到獨立的文件中,瀏覽器將可以把他們獨立緩存起來 – 同時不會每次 app 代碼改變時重新下載。
Key Term: 在 webpack 的技術中,利用 app 代碼拆分文件被稱為 chunks 。我們后面會用到這個名詞。
為了將依賴包提取到單獨的 chunk 中,下面分為三步:
- 使用 [name].[chunkname].js 替換 output 的文件名:
// webpack.config.js
module.exports = {
output: {
// Before
filename: 'bundle.[chunkhash].js',
// After
filename: '[name].[chunkhash].js',
},
};
當 webpack 構建應用時,它會用一個帶有 chunk 的名稱來替換 [name] 。如果沒有添加 [name] 部分,我們不得不通過 chunks 之間的 hash 區別來比較他們的區別 – 那就太難了!
- 將 entry 轉成一個對象:
// webpack.config.js
module.exports = {
// Before
entry: './index.js',
// After
entry: {
main: './index.js',
},
};
在這段代碼中,”main” 對象是一個 chunk 的名字。這個名字將會被步驟 1 里面的 [name] 代替。目前為止,如果你構建一個 app,chunk 就會包括整個 app 的代碼 – 就像我們沒有做這些步驟一樣。但是很快就會產生變化。
- 添加 CommonsChunkPlugin :
// webpack.config.js
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
// A name of the chunk that will include the dependencies.
// This name is substituted in place of [name] from step 1
name: 'vendor',
// A function that determines which modules to include into this chunk
minChunks: module => module.context &&
module.context.includes('node_modules'),
}),
],
};
這個插件將包括全部 node_modules 路徑下的 modules 同時將他們移到一個單獨的文件中,這個文件被稱為 vendor.[chunkhash].js 。
完成了上面的步驟,每一次 build 都將生成兩個文件。瀏覽器會將他們單獨緩存 – 以便代碼該生改變時重新下載。
$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
Asset Size Chunks Chunk Names
./main.00bab6fd3100008a42b0.js 82 kB 0 [emitted] main
./vendor.d9e134771799ecdf9483.js 47 kB 1 [emitted] vendor
Webpack runtime code
不幸的是,僅僅抽取 vendor 是不夠的。如果你試圖在應用代碼中修改一些東西:
// index.js
…
…
// E.g. add this:
console.log('Wat');
你會注意到 vendor 的 hash 值也會改變:
Asset Size Chunks Chunk Names
./vendor.d9e134771799ecdf9483.js 47 kB 1 [emitted] vendor
to
Asset Size Chunks Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js 47 kB 1 [emitted] vendor
發生這樣的事是因為 webpack 打包時,一部分 modules 的代碼,擁有 a runtime – 管理模塊執行一部分代碼。當你將代碼拆分成多個文件時,這小部分代碼在 chunk ids 和 匹配的文件之間開始了一個映射:
// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
"0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";
Webpack 將最新生成的 chunk 包含了這個 runtime 內,這個 chunk 就是我們代碼中的 vendor 。與此同時每一次任何 chunk 的改變,這一小部分代碼也改變,導致整個 vendor chunk 也改變、
為了解決這個問題,我們將這個 runtime 轉義到一個獨立的文件中,通過 CommonsChunkPlugin 創建一個額外的空的 chunk:
// webpack.config.js
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: module => module.context &&
module.context.includes('node_modules'),
}),
// This plugin must come after the vendor one (because webpack
// includes runtime into the last chunk)
new webpack.optimize.CommonsChunkPlugin({
name: 'runtime',
// minChunks: Infinity means that no app modules
// will be included into this chunk
minChunks: Infinity,
}),
],
};
完成這一部分改變,每一次 build 都將生成三個文件:
$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
Asset Size Chunks Chunk Names
./main.00bab6fd3100008a42b0.js 82 kB 0 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 1 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
將他們反過來順序添加到 index.html 中,你就搞定了:
<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>
Further reading
- Webpack guide on long term caching
- Webpack docs about webpack runtime and manifest
- “Getting the most out of the CommonsChunkPlugin”
Inline webpack runtime to save an extra HTTP request 內聯 webpack runtime 節省額外的 HTTP 請求
為了做的更好,盡力把 webpack runtime 內聯在 HTML 請求里。下面舉例:
<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
這樣做:
<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>
這個 runtime 很小,內聯它可以幫助你節省 HTTP 請求(尤其對 HTTP/1 重要;但是在 HTTP/2 就沒有那么重要了,但是仍能夠提高效率)。
下面就來看看如何做。
如果使用 HtmlWebpackPlugin 來生成 HTML
如果使用 HtmlWebpackPlugin 來生成 HTML 文件, InlineChunkWebpackPlugin 就足夠了。
如果使用自己的定制服務邏輯來生成 HTML
- 將 runtime 名稱成靜態明確的文件名:
// webpack.config.js
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'runtime',
minChunks: Infinity,
filename: 'runtime.js',
// → Now the runtime file will be called
// “runtime.js”, not “runtime.79f17c27b335abc7aaf4.js”
}),
],
};
- 將方便的方式將 runtime.js 嵌入進去。比如:Node.js 和 Express
// server.js
const fs = require('fs');
const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
app.get('/', (req, res) => {
res.send(`
…
${runtimeContent}
…
`);
});
懶加載
有時候,頁面擁有或多或少的部分:
- 如果你在 油Tube 上加載一個視頻頁面,相比評論區域你更在乎視頻區域。這就是視頻要比評論區域重要。
- 如果你在一個新聞網站打開一個報道,相比廣告區域你更關心文章的內容。這就是文字比廣告更重要。
在這些案例中,通過僅下載最重要的部分,懶加載剩余區域能夠提升最初的加載性能。使用 the import() function 和 code-splitting 解決這個問題:
// videoPlayer.js
export function renderVideoPlayer() { … }
// comments.js
export function renderComments() { … }
// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();
// …Custom event listener
onShowCommentsClick(() => {
import('./comments').then((comments) => {
comments.renderComments();
});
});
import() 明確表示你期望動態地加載獨立的 module。當 webpack 看到 import('./module.js') 時,他就會將這個 module 移到獨立的 chunk 中:
$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./main.f7e53d8e13e9a2745d6d.js 60 kB 1 [emitted] main
./vendor.4f14b6326a80f4752a98.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
并且只在代碼執行到 import() 才會下載。
這將會讓 main bundle 更小,提升初始加載的時間。更重要的是改進緩存 – 如果你修改 main chunk 的代碼,其他部分的 chunk 也不會受影響。
Note: 如果使用 Babel 編譯代碼,你會因為 Babel 還不認識 import() 而遇到語法錯誤拋出來。可以使用 syntax-dynamic-import 解決這個錯誤。
Further reading
- Webpack docs for the import() function
- The JavaScript proposal for implementing the import() syntax
Split the code into routes and pages 拆分代碼到路由和頁面中
如果你的應用擁有多個路由或者頁面,但是代碼中只有單獨一個 JS 文件(一個單獨的 main chunk),這看起來你正在每一個請求中節省額外的 bytes 帶寬。舉個例子,當用戶正在訪問你網站的首頁:
他們并不需要加載另外不同的頁面上渲染文章標題的的代碼 – 但是他們還是會加載到這段代碼。更嚴重的是如果用戶經常只訪問首頁,同時你還經常改變渲染文章標題的代碼,webpack 將會對整個 bundle 失效 – 用戶每次都會重復下載全部 app 的代碼。
如果我們將代碼拆分到頁面里(或者單頁面應用的路由里),用戶就會下載對他有意義的代碼。更好的是,瀏覽器也會更好地緩存代碼:當你改變首頁的代碼時,webpack 只會讓相匹配的 chunk 失效。
For single-page apps 對于單頁面應用
通過路由拆分帶頁面引用,使用 import() (看看 “Lazy-load code that you don’t need right now” 這部分)。如果你在使用一個框架,現在已經有成熟的方案:
- “Code Splitting” in react-router ‘s docs (for React)
- “Lazy Loading Routes” in vue-router ‘s docs (for Vue.js)
For traditional multi-page apps 對于傳統的多頁面應用
通過頁面拆分傳統多頁面應用,可以使用 webpack 的 entry points 。如果你的應用有三種頁面:主頁、文章頁、用戶賬戶頁,那就分廠三個 entries:
// webpack.config.js
module.exports = {
entry: {
home: './src/Home/index.js',
article: './src/Article/index.js',
profile: './src/Profile/index.js'
},
};
對于每一個 entry 文件,webpack 將構建出獨立的依賴樹,并且聲稱一個 bundle,它將通過 entry 來只包括用到的 modules:
$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./home.91b9ed27366fe7e33d6a.js 18 kB 1 [emitted] home
./article.87a128755b16ac3294fd.js 32 kB 2 [emitted] article
./profile.de945dc02685f6166781.js 24 kB 3 [emitted] profile
./vendor.4f14b6326a80f4752a98.js 46 kB 4 [emitted] vendor
./runtime.318d7b8490a7382bf23b.js 1.45 kB 5 [emitted] runtime
因此,如果僅僅是文章頁使用 Lodash , home 和 profile 的 bundle 將不會包含 lodash – 同時用戶也不會在訪問首頁的時候下載到這個庫。
拆分依賴樹也有缺點。如果兩個 entry points 都用到了 loadash ,同時你沒有在 vendor 移除掉依賴,兩個 entry points 將包括兩個重復的 lodash 。我們使用 CommonsChunkPlugin 解決這個問題 – 它會將通用的依賴轉移到一個獨立的文件中:
// webpack.config.js
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
// A name of the chunk that will include the common dependencies
name: 'common',
// The plugin will move a module into a common file
// only if it’s included into `minChunks` chunks
// (Note that the plugin analyzes all chunks, not only entries)
minChunks: 2, // 2 is the default value
}),
],
};
隨意使用 minChunks 的值來找到最優的選項。通常情況下,你想要它盡可能體積小,但它會增加 chunks 的數量。舉個例子,3 個 chunk, minChunks 可能是 2 個,但是 30 個 chunk,它可能是 8 個 – 因為如果你把它設置成 2 ,過多的 modules 將會打包進一個通用文件中,文件更臃腫。
Further reading
- Webpack docs about the concept of entry points
- Webpack docs about the CommonsChunkPlugin
- “Getting the most out of the CommonsChunkPlugin”
Make module ids more stable 讓 module ide 更穩定
當編譯代碼時,webpack 會分配給每一個 module 一個 ID。之后,這些 ID 就會被 require() 引用到 bundle 內部。你可以在編譯輸出的右側在 moudle 路徑之前看到這些 ID:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./main.4e50a16675574df6a9e9.js 60 kB 1 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
here
[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module
默認情況下,這些 ID 是使用計數器計算出來的(比如第一個 module 是 ID 0,第二個 moudle 就是 ID 1,以此類推)。這樣的問題就在于當你新增一個 module 事,它會出現在原來 module 列表中的中間,改變后面所有 module 的 ID:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.5c82c0f337fcb22672b5.js 22 kB 0 [emitted]
./main.0c8b617dfc40c2827ae3.js 82 kB 1 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
↓ 我們增加一個新 module
[4] ./webPlayer.js 24 kB {1} [built]
↓ 現在看這里做了什么! comments.js 現在的 ID 由 4 變成了 5
[5] ./comments.js 58 kB {0} [built]
↓ ads.js 的 ID 由 5 變成 6
[6] ./ads.js 74 kB {1} [built]
+ 1 hidden module
這將使包含或依賴于具有更改ID的模塊的所有塊無效 – 即使它們的實際代碼沒有更改。在我們的代碼中, 0 這個 chunk 和 main chunk 都會失效 – 只有 main 才應該失效。
使用 HashedModuleIdsPlugin 插件改變module ID 如何計算來解決這個問題。它利用 module 路徑的 hash 來替換掉計數器:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.6168aaac8461862eab7a.js 22.5 kB 0 [emitted]
./main.a2e49a279552980e3b91.js 60 kB 1 [emitted] main
./vendor.ff9f7ea865884e6a84c8.js 46 kB 2 [emitted] vendor
./runtime.25f5d0204e4f77fa57a1.js 1.45 kB 3 [emitted] runtime
↓ Here
[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
+ 1 hidden module
有了這個方法,只有你重命名護著刪除這個 moudle 它的 ID 才會變化。新的 modules 不會因為 module ID 互相影響。
啟用這個插件,在配置中增加 plugins :
// webpack.config.js
module.exports = {
plugins: [
new webpack.HashedModuleIdsPlugin(),
],
};
Further reading
- Webpack docs about the HashedModuleIdsPlugin
Summing up
- Cache the bundle and differentiate between them by changing their names
- Split the bundle into app code, vendor code and runtime
- Inline the runtime to save an HTTP request
- Lazy-load non-critical code with import
- Split code by routes/pages to avoid loading unnecessary stuff
Monitor and analyze the app 監控并分析
作者 Ivan Akulov
即使當你配置好你的 webpack 讓你的引用盡可能體積較小的時候,跟蹤這個應用就非常重要,同時了解里面包含了什么。除此之外,你安裝一個依賴,它將讓你的 app 增加兩倍大小 – 但并沒有注意到這個問題!
這一部分就來講解一些能夠幫助你理解你的 bundle 的工具。
Keep track of the bundle size 跟蹤打包的體積
在開發時可以使用 webpack-dashboard 和命令行 bundlesize 來監控 app 的體積。
webpack-dashboard
webpack-dashboard 可以通過依賴體積大小、進程和其他細節來改進 webpack 的輸出。
這個 dashborad 幫助我們跟蹤大型依賴 – 如果你增加一個依賴,你就立刻能在 Modules section 始終看到它!
啟用這個功能,需要安裝 webpack-dashboard 包:
npm install webpack-dashboard --save-dev
同時在配置的 plugins 增加:
// webpack.config.js
const DashboardPlugin = require('webpack-dashboard/plugin');
module.exports = {
plugins: [
new DashboardPlugin(),
],
};
或者如果正在使用基于 Express dev server 可以使用 compiler.apply() :
compiler.apply(new DashboardPlugin());
多嘗試 dashboard 找出改進的地方!比如,在 modules section 滾動找到那個庫體積過大,把它替換成小的可替代的庫。
bundlesize
bundlesize 可以驗證 webpack assets 不超過指定的大小。通過自動化 CI 就可以知曉 app 是否變的過于臃腫:
配置如下:
Find out the maximum sizes找出最大體積
- 分析 app 盡可能減小體積,執行生產環境的 build。
- 在 package.json 中增加 bundlesize 部分:
// package.json
{
"bundlesize": [
{
"path": "./dist/*"
}
]
}
- 使用 npx 執行 bundlesize :
npx bundlesize
它就會將每一個文件的 gzip 壓縮后的體積打印出來:
PASS ./dist/icon256.6168aaac8461862eab7a.png: 10.89KB PASS./dist/icon512.c3e073a4100bd0c28a86.png: 13.1KB PASS./dist/main.0c8b617dfc40c2827ae3.js: 16.28KB PASS./dist/vendor.ff9f7ea865884e6a84c8.js: 31.49KB
- 每一個體積增加10-20%,你將得到最大體積。這個10-20%的幅度可以讓你像往常一樣開發應用程序,同時警告你,當它的大小增長太多。
Enable bundlesize 啟用 bundlesize
5.安裝 bundlesize 開發依賴
npm install bundlesize --save-dev
6.在 package.json 中的 bundlesize 部分,聲明具體的最大值。對于某一些文件(比如圖片),你可以單獨根據文件類型來設置最大體積大小,而不需要根據每一個文件:
// package.json
{
"bundlesize": [
{
"path": "./dist/*.png",
"maxSize": "16 kB",
},
{
"path": "./dist/main.*.js",
"maxSize": "20 kB",
},
{
"path": "./dist/vendor.*.js",
"maxSize": "35 kB",
}
]
}
7.增加一個 npm 腳本來執行檢查:
// package.json
{
"scripts": {
"check-size": "bundlesize"
}
}
8.配置自動化 CI 來在每一次 push 時執行 npm run check-size 做檢查。(如果你在 Github 上開發項目,直接可以使用 integrate bundlesize with GitHub 。)
這就全部了!現在如果你運行 npm run check-size 或者 push 代碼,你就會看到輸出的文件是否足夠小:
或者下面失敗的情況
Further reading
- Alex Russell about the real-world loading time we should target
Analyze why the bundle is so large 分析 bundle 為什么這么大
你想要深挖 bundle 內,看看里面具體哪些 module 占用多大空間。 webpack-bundle-analyzer
(Screen recording from github.com/webpack-contrib/webpack -bundle-analyzer )
webpack-bundle-analyzer 可以掃描 bundle 同時構建一個查看內部的可視化窗口。使用這個可視化工具找到過大或者不必要的依賴。
使用這個分析器,需要安裝 webpack-bundle-analyzer 包:
npm install webpack-bundle-analyzer --save-dev
在 config 中增加插件:
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin(),
],
};
運行生產環境的 build。這個插件就會在瀏覽器中打開一個顯示狀態的頁面。
默認情況下,這個頁面會顯示語法分析后的文件體積(在 bundle 出現的文件)。您可能想比較 gzip 的大小,因為這更接近實際用戶的體驗;使用左邊的邊欄來切換尺寸。
Note: 如果你使用 ModuleConcatenationPlugin ,它可能在webpack-bundle-analyzer輸出時合并一部分 module,使得報告小一些細節。如果你使用這個插件,在執行分析的時候需要禁用掉。
下面是報告中需要看什么:
- 大型依賴 為什么體積這么大?是否有更小的替代包(比如 Preact 替代 React)?用了全部代碼(比如 Moment.js 包含大量的本地變量 that are often not used and could be dropped )?
- 重復依賴 是否在不同文件中看到相同的庫?(使用 CommonsChunkPlugin 將他們移到一個通用文件內)亦或是在同一個庫中 bundle 擁有多個版本?
- 相似依賴 是否存在有相似功能的相似庫存在?(比如 moment 和 date-fns 或者 lodash 和 lodash-es )盡力匯總成一個。
同樣的,也可以看看 Sean Larkin 的文章 great analysis of webpack bundles 。
Summing up
- Use webpack-dashboard and bundlesize to stay tuned of how large your app is
- Dig into what builds up the size with webpack-bundle-analyzer
Conclusion
總結一下:
- 剔除不必要的體積 把所有的都壓縮,剔除無用代碼,增加依賴是保持謹慎小心。
- 通過路由拆分代碼 只在真正需要的時候才加載,其他的部分做來加載。
- 緩存代碼 應用程序的某些部分更新頻率低于其他部分,將這些部分拆分成文件,以便在必要時僅重新下載。
- 跟蹤體積大小 使用 webpack-dashboard 和 webpack-bundle-analyzer 監控你的 app。每隔幾個月重新檢查一下你的應用的性能。
Webpack 不僅僅是一個幫助你創建 app 更快的工具。它還幫助是你的 app 成為 a Progressive Web App ,你的引用擁有更好的體檢自動化的填充工具就像 Lighthouse 根據環境給出建議。
不要忘記閱讀 webpack docs – 里面提供了大量的優化的信息。
記得練習一下 with the training app !
來自:https://jdc.jd.com/archives/212022