webpack的打包和性能優(yōu)化
tree shaking
tree shaking 是一個(gè)術(shù)語,通常用于描述移除 JavaScript 上下文中的未引用代碼(dead-code)。它依賴于 ES2015 模塊系統(tǒng)中的靜態(tài)結(jié)構(gòu)特性,例如 import 和 export。
所謂的“未引用代碼(dead code)”,也就是說,應(yīng)該刪除掉未被引用的 export,但是它仍然被包含在 bundle 中,優(yōu)化體積
解決方法
將文件標(biāo)記為無副作用(side-effect-free)
通過 package.json 的 "sideEffects" 屬性來實(shí)現(xiàn)的
「副作用」的定義是,在導(dǎo)入時(shí)會(huì)執(zhí)行特殊行為的代碼,而不是僅僅暴露一個(gè) export 或多個(gè) export。舉例說明,例如 polyfill,它影響全局作用域,并且通常不提供 export。
// 如果所有代碼都不包含副作用,我們就可以簡單地將該屬性標(biāo)記為 false,來告知 webpack,它可以安全地刪除未用到的 export 導(dǎo)出
{
"name": "your-project",
"sideEffects": false
}
// 如果你的代碼確實(shí)有一些副作用,那么可以改為提供一個(gè)數(shù)組
{
"name": "your-project",
"sideEffects": [
"./src/some-side-effectful-file.js"
]
}
壓縮輸出
通過如上方式,我們已經(jīng)可以通過 import 和 export 語法,找出那些需要?jiǎng)h除的“未使用代碼(dead code)”,然而,我們不只是要找出,還需要在 bundle 中刪除它們。為此,我們將使用 -p(production) 這個(gè) webpack 編譯標(biāo)記,來啟用 uglifyjs 壓縮插件
注意,
--optimize-minimize標(biāo)記也會(huì)在 webpack 內(nèi)部調(diào)用UglifyJsPlugin。
從 webpack 4 開始,也可以通過 "mode" 配置選項(xiàng)輕松切換到壓縮輸出,只需設(shè)置為 "production"。
為了學(xué)會(huì)使用 tree shaking,你必須……
- 使用 ES2015 模塊語法(即
import和export)。 - 在項(xiàng)目
package.json文件中,添加一個(gè) "sideEffects" 入口。 - 引入一個(gè)能夠刪除未引用代碼(dead code)的壓縮工具(minifier)(例如
UglifyJSPlugin)。
代碼分離
代碼分離是 webpack 中最引人注目的特性之一。此特性能夠把代碼分離到不同的 bundle 中,然后可以按需加載或并行加載這些文件。代碼分離可以用于獲取更小的 bundle,以及控制資源加載優(yōu)先級(jí),如果使用合理,會(huì)極大影響加載時(shí)間。(優(yōu)化加載時(shí)間)
入口起點(diǎn)(entry points)
這是迄今為止最簡單、最直觀的分離代碼的方式。
project
webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
+ |- another-module.js
|- /node_modules
another-module.js
import _ from 'lodash';
console.log(
_.join(['Another', 'module', 'loaded!'], ' ')
);
webpack.config.js
const path = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
index: './src/index.js',
another: './src/another-module.js'
},
plugins: [
new HTMLWebpackPlugin({
title: 'Code Splitting'
})
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
}
};
這種方法存在一些問題:
- 如果入口 chunks 之間包含重復(fù)的模塊,那些重復(fù)模塊都會(huì)被引入到各個(gè) bundle 中。
- 這種方法不夠靈活,并且不能將核心應(yīng)用程序邏輯進(jìn)行動(dòng)態(tài)拆分代碼。
以上兩點(diǎn)中,第一點(diǎn)對我們的示例來說無疑是個(gè)問題,因?yàn)橹拔覀冊?./src/index.js 中也引入過 lodash,這樣就在兩個(gè) bundle 中造成重復(fù)引用。接著,我們通過使用 CommonsChunkPlugin 來移除重復(fù)的模塊。
防止重復(fù)(prevent duplication)
將SplitChunksPlugin允許我們共同的依賴提取到一個(gè)現(xiàn)有的條目塊或一個(gè)全新的塊。讓我們用它來重復(fù)lodash上一個(gè)例子的依賴:
webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
index: './src/index.js',
another: './src/another-module.js'
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
},
optimization: {
splitChunks: {
chunks: 'all'
}
}
};
有了optimization.splitChunks配置選項(xiàng),我們現(xiàn)在應(yīng)該看到從我們的index.bundle.js和中刪除了重復(fù)的依賴項(xiàng)another.bundle.js。該插件應(yīng)該注意到我們已經(jīng)分離lodash出一個(gè)單獨(dú)的塊并從我們的主包中移除了自重。
動(dòng)態(tài)導(dǎo)入(dynamic imports)
當(dāng)涉及到動(dòng)態(tài)代碼拆分時(shí),webpack 提供了兩個(gè)類似的技術(shù)。對于動(dòng)態(tài)導(dǎo)入,第一種,也是優(yōu)先選擇的方式是,使用符合 ECMAScript 提案 的 import() 語法。第二種,則是使用 webpack 特定的 require.ensure。
import()調(diào)用會(huì)在內(nèi)部用到 promises。如果在舊有版本瀏覽器中使用import(),記得使用 一個(gè) polyfill 庫(例如 es6-promise 或 promise-polyfill),來 shimPromise。
現(xiàn)在,我們不再使用靜態(tài)導(dǎo)入 lodash,而是通過使用動(dòng)態(tài)導(dǎo)入來分離一個(gè) chunk:
src/index.js
function getComponent() {
return import(/* webpackChunkName: "lodash" */ 'lodash').then(_ => {
var element = document.createElement('div');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}).catch(error => 'An error occurred while loading the component');
}
getComponent().then(component => {
document.body.appendChild(component);
})
注意,在注釋中使用了 webpackChunkName。這樣做會(huì)導(dǎo)致我們的 bundle 被命名為 lodash.bundle.js ,而不是 [id].bundle.js 。想了解更多關(guān)于 webpackChunkName 和其他可用選項(xiàng),請查看 import() 相關(guān)文檔。讓我們執(zhí)行 webpack,查看 lodash 是否會(huì)分離到一個(gè)單獨(dú)的 bundle:
由于 import() 會(huì)返回一個(gè) promise,因此它可以和 async 函數(shù)一起使用。但是,需要使用像 Babel 這樣的預(yù)處理器和Syntax Dynamic Import Babel Plugin。下面是如何通過 async 函數(shù)簡化代碼:
src/index.js
async function getComponent() {
var element = document.createElement('div');
const _ = await import(/* webpackChunkName: "lodash" */ 'lodash');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}
getComponent().then(component => {
document.body.appendChild(component);
});
緩存
輸出文件的文件名(Output Filenames)
通過使用 output.filename 進(jìn)行文件名替換,可以確保瀏覽器獲取到修改后的文件。[hash] 替換可以用于在文件名中包含一個(gè)構(gòu)建相關(guān)(build-specific)的 hash,但是更好的方式是使用 [chunkhash] 替換,在文件名中包含一個(gè) chunk 相關(guān)(chunk-specific)的哈希
project
webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
|- /node_modules
webpack.config.js
const path = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
title: 'Caching'
})
],
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist')
}
};
bundle 的名稱是它內(nèi)容(通過 hash)的映射。如果我們不做修改,然后再次運(yùn)行構(gòu)建,我們以為文件名會(huì)保持不變。然而,如果我們真的運(yùn)行,可能會(huì)發(fā)現(xiàn)情況并非如此:(譯注:這里的意思是,如果不做修改,文件名可能會(huì)變,也可能不會(huì)。)
提取模板(Extracting Boilerplate)
SplitChunksPlugin可以使用它將模塊拆分為單獨(dú)的包。webpack提供了一個(gè)優(yōu)化功能,它根據(jù)提供的選項(xiàng)將運(yùn)行時(shí)代碼拆分為單獨(dú)的塊,只需使用optimization.runtimeChunkset來single創(chuàng)建一個(gè)運(yùn)行時(shí)包:
webpack.config.js
const path = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
title: 'Caching'
],
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist')
},
optimization: {
runtimeChunk: 'single'
}
};
將第三方庫(library)(例如 lodash 或 react)提取到單獨(dú)的 vendor chunk 文件中,是比較推薦的做法,這是因?yàn)?,它們很少像本地的源代碼那樣頻繁修改。因此通過實(shí)現(xiàn)以上步驟,利用客戶端的長效緩存機(jī)制,可以通過命中緩存來消除請求,并減少向服務(wù)器獲取資源,同時(shí)還能保證客戶端代碼和服務(wù)器端代碼版本一致。
webpack.config.js
var path = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
title: 'Caching'
}),
],
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist')
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
}
模塊標(biāo)識(shí)符(Module Identifiers)
-
main捆綁包因其新內(nèi)容而發(fā)生變化。 - 該
vendor包更改,因?yàn)樗?code>module.id改變了。 - 而且,
runtime捆綁包已更改,因?yàn)樗F(xiàn)在包含對新模塊的引用。
第一個(gè)和最后一個(gè)是預(yù)期的 - 這是vendor我們想要解決的哈希值。幸運(yùn)的是,我們可以使用兩個(gè)插件來解決此問題。第一個(gè)是NamedModulesPlugin,它將使用模塊的路徑而不是數(shù)字標(biāo)識(shí)符。雖然此插件在開發(fā)期間對于更易讀的輸出非常有用,但運(yùn)行起來需要更長的時(shí)間。第二個(gè)選項(xiàng)是HashedModuleIdsPlugin,建議用于生產(chǎn)構(gòu)建:
const path = require('path');
+ const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
title: 'Caching'
}),
+ new webpack.HashedModuleIdsPlugin()
],
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist')
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
};
如果改變項(xiàng)目代碼,依賴不變的保持runtime 和vendor的id不變,緩存
shimming
webpack 編譯器(compiler)能夠識(shí)別遵循 ES2015 模塊語法、CommonJS 或 AMD 規(guī)范編寫的模塊。然而,一些第三方的庫(library)可能會(huì)引用一些全局依賴(例如 jQuery 中的 $)。這些庫也可能創(chuàng)建一些需要被導(dǎo)出的全局變量。這些“不符合規(guī)范的模塊”就是 shimming 發(fā)揮作用的地方。
我們不推薦使用全局的東西!在 webpack 背后的整個(gè)概念是讓前端開發(fā)更加模塊化。也就是說,需要編寫具有良好的封閉性(well contained)、彼此隔離的模塊,以及不要依賴于那些隱含的依賴模塊(例如,全局變量)。請只在必要的時(shí)候才使用本文所述的這些特性。
shimming 另外一個(gè)使用場景就是,當(dāng)你希望 polyfill 瀏覽器功能以支持更多用戶時(shí)。在這種情況下,你可能只想要將這些 polyfills 提供給到需要修補(bǔ)(patch)的瀏覽器(也就是實(shí)現(xiàn)按需加載)。
shimming 全局變量
使用 ProvidePlugin 后,能夠在通過 webpack 編譯的每個(gè)模塊中,通過訪問一個(gè)變量來獲取到 package 包。如果 webpack 知道這個(gè)變量在某個(gè)模塊中被使用了,那么 webpack 將在最終 bundle 中引入我們給定的 package。讓我們先移除 lodash 的 import 語句,并通過插件提供它:
src/index.js
- import _ from 'lodash';
-
function component() {
var element = document.createElement('div');
- // Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());
webpack.config.js
const path = require('path');
+ const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
- }
+ },
+ plugins: [
+ new webpack.ProvidePlugin({
+ _: 'lodash'
+ })
+ ]
};
本質(zhì)上,我們所做的,就是告訴 webpack……
如果你遇到了至少一處用到
lodash變量的模塊實(shí)例,那請你將lodashpackage 包引入進(jìn)來,并將其提供給需要用到它的模塊。
我們還可以使用 ProvidePlugin 暴露某個(gè)模塊中單個(gè)導(dǎo)出值,只需通過一個(gè)“數(shù)組路徑”進(jìn)行配置(例如 [module, child, ...children?])。所以,讓我們做如下設(shè)想,無論 join 方法在何處調(diào)用,我們都只會(huì)得到的是 lodash 中提供的 join 方法。
src/index.js
function component() {
var element = document.createElement('div');
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());
webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new webpack.ProvidePlugin({
- _: 'lodash'
+ join: ['lodash', 'join']
})
]
};
這樣就能很好的與 tree shaking 配合(壓縮),將 lodash 庫中的其他沒用到的部分去除。
細(xì)粒度 shimming
一些傳統(tǒng)的模塊依賴的 this 指向的是 window 對象。在接下來的用例中,調(diào)整我們的 index.js:
function component() {
var element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
+
+ // Assume we are in the context of `window`
+ this.alert('Hmmm, this probably isn\'t a great idea...')
return element;
}
document.body.appendChild(component());
當(dāng)模塊運(yùn)行在 CommonJS 環(huán)境下這將會(huì)變成一個(gè)問題,也就是說此時(shí)的 this 指向的是 module.exports。在這個(gè)例子中,你可以通過使用 imports-loader 覆寫 this:
webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
+ module: {
+ rules: [
+ {
+ test: require.resolve('index.js'),
+ use: 'imports-loader?this=>window'
+ }
+ ]
+ },
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join']
})
]
};
全局 exports
讓我們假設(shè),某個(gè)庫(library)創(chuàng)建出一個(gè)全局變量,它期望用戶使用這個(gè)變量。為此,我們可以在項(xiàng)目配置中,添加一個(gè)小模塊來演示說明:
project
webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
+ |- globals.js
|- /node_modules
src/globals.js
var file = 'blah.txt';
var helpers = {
test: function() { console.log('test something'); },
parse: function() { console.log('parse something'); }
}
你可能從來沒有在自己的源碼中做過這些事情,但是你也許遇到過一個(gè)老舊的庫(library),和上面所展示的代碼類似。在這個(gè)用例中,我們可以使用 exports-loader,將一個(gè)全局變量作為一個(gè)普通的模塊來導(dǎo)出。例如,為了將 file 導(dǎo)出為 file 以及將 helpers.parse 導(dǎo)出為 parse,做如下調(diào)整:
webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: require.resolve('index.js'),
use: 'imports-loader?this=>window'
- }
+ },
+ {
+ test: require.resolve('globals.js'),
+ use: 'exports-loader?file,parse=helpers.parse'
+ }
]
},
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join']
})
]
};
現(xiàn)在從我們的 entry 入口文件中(即 src/index.js),我們能 import { file, parse } from './globals.js';,然后一切將順利進(jìn)行。
加載 polyfills
目前為止我們所討論的所有內(nèi)容都是處理那些遺留的 package 包,讓我們進(jìn)入到下一個(gè)話題:polyfills。
有很多方法來載入 polyfills。例如,要引入 babel-polyfill 我們只需要如下操作:
npm install --save babel-polyfill
然后使用 import 將其添加到我們的主 bundle 文件:
src/index.js
+ import 'babel-polyfill';
+
function component() {
var element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());
請注意,我們沒有將
import綁定到變量。這是因?yàn)橹恍柙诨A(chǔ)代碼(code base)之外,再額外執(zhí)行 polyfills,這樣我們就可以假定代碼中已經(jīng)具有某些原生功能。
polyfills 雖然是一種模塊引入方式,但是并不推薦在主 bundle 中引入 polyfills,因?yàn)檫@不利于具備這些模塊功能的現(xiàn)代瀏覽器用戶,會(huì)使他們下載體積很大、但卻不需要的腳本文件。
讓我們把 import 放入一個(gè)新文件,并加入 whatwg-fetch polyfill:
npm install --save whatwg-fetch
src/index.js
- import 'babel-polyfill';
-
function component() {
var element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());
project
webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
|- globals.js
+ |- polyfills.js
|- /node_modules
src/polyfills.js
import 'babel-polyfill';
import 'whatwg-fetch';
webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
- entry: './src/index.js',
+ entry: {
+ polyfills: './src/polyfills.js',
+ index: './src/index.js'
+ },
output: {
- filename: 'bundle.js',
+ filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: require.resolve('index.js'),
use: 'imports-loader?this=>window'
},
{
test: require.resolve('globals.js'),
use: 'exports-loader?file,parse=helpers.parse'
}
]
},
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join']
})
]
};
如此之后,我們可以在代碼中添加一些邏輯,根據(jù)條件去加載新的 polyfills.bundle.js 文件。你該如何決定,依賴于那些需要支持的技術(shù)以及瀏覽器。我們將做一些簡單的試驗(yàn),來確定是否需要引入這些 polyfills:
dist/index.html
<!doctype html>
<html>
<head>
<title>Getting Started</title>
+ <script>
+ var modernBrowser = (
+ 'fetch' in window &&
+ 'assign' in Object
+ );
+
+ if ( !modernBrowser ) {
+ var scriptElement = document.createElement('script');
+
+ scriptElement.async = false;
+ scriptElement.src = '/polyfills.bundle.js';
+ document.head.appendChild(scriptElement);
+ }
+ </script>
</head>
<body>
<script src="index.bundle.js"></script>
</body>
</html>
現(xiàn)在,我們能在 entry 入口文件中,通過 fetch 獲取一些數(shù)據(jù):
src/index.js
function component() {
var element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());
+
+ fetch('https://jsonplaceholder.typicode.com/users')
+ .then(response => response.json())
+ .then(json => {
+ console.log('We retrieved some data! AND we\'re confident it will work on a variety of browser distributions.')
+ console.log(json)
+ })
+ .catch(error => console.error('Something went wrong when fetching this data: ', error))
當(dāng)我們開始執(zhí)行構(gòu)建時(shí),polyfills.bundle.js 文件將會(huì)被載入到瀏覽器中,然后所有代碼將正確無誤的在瀏覽器中執(zhí)行。請注意,以上的這些設(shè)定可能還會(huì)有所改進(jìn),我們只是對于如何解決「將 polyfills 提供給那些需要引入它的用戶」這個(gè)問題,向你提供一個(gè)很棒的想法。