webpack的打包和性能優化
tree shaking
tree shaking 是一個術語,通常用于描述移除 JavaScript 上下文中的未引用代碼(dead-code)。它依賴于 ES2015 模塊系統中的靜態結構特性,例如 import
和 export
。
所謂的“未引用代碼(dead code)”,也就是說,應該刪除掉未被引用的 export
,但是它仍然被包含在 bundle 中,優化體積
解決方法
將文件標記為無副作用(side-effect-free)
通過 package.json 的 "sideEffects"
屬性來實現的
「副作用」的定義是,在導入時會執行特殊行為的代碼,而不是僅僅暴露一個 export 或多個 export。舉例說明,例如 polyfill,它影響全局作用域,并且通常不提供 export。
// 如果所有代碼都不包含副作用,我們就可以簡單地將該屬性標記為 false,來告知 webpack,它可以安全地刪除未用到的 export 導出
{
"name": "your-project",
"sideEffects": false
}
// 如果你的代碼確實有一些副作用,那么可以改為提供一個數組
{
"name": "your-project",
"sideEffects": [
"./src/some-side-effectful-file.js"
]
}
壓縮輸出
通過如上方式,我們已經可以通過 import
和 export
語法,找出那些需要刪除的“未使用代碼(dead code)”,然而,我們不只是要找出,還需要在 bundle 中刪除它們。為此,我們將使用 -p
(production) 這個 webpack 編譯標記,來啟用 uglifyjs 壓縮插件
注意,
--optimize-minimize
標記也會在 webpack 內部調用UglifyJsPlugin
。
從 webpack 4 開始,也可以通過 "mode"
配置選項輕松切換到壓縮輸出,只需設置為 "production"
。
為了學會使用 tree shaking,你必須……
- 使用 ES2015 模塊語法(即
import
和export
)。 - 在項目
package.json
文件中,添加一個 "sideEffects" 入口。 - 引入一個能夠刪除未引用代碼(dead code)的壓縮工具(minifier)(例如
UglifyJSPlugin
)。
代碼分離
代碼分離是 webpack 中最引人注目的特性之一。此特性能夠把代碼分離到不同的 bundle 中,然后可以按需加載或并行加載這些文件。代碼分離可以用于獲取更小的 bundle,以及控制資源加載優先級,如果使用合理,會極大影響加載時間。(優化加載時間)
入口起點(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 之間包含重復的模塊,那些重復模塊都會被引入到各個 bundle 中。
- 這種方法不夠靈活,并且不能將核心應用程序邏輯進行動態拆分代碼。
以上兩點中,第一點對我們的示例來說無疑是個問題,因為之前我們在 ./src/index.js
中也引入過 lodash
,這樣就在兩個 bundle 中造成重復引用。接著,我們通過使用 CommonsChunkPlugin
來移除重復的模塊。
防止重復(prevent duplication)
將SplitChunksPlugin
允許我們共同的依賴提取到一個現有的條目塊或一個全新的塊。讓我們用它來重復lodash
上一個例子的依賴:
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
配置選項,我們現在應該看到從我們的index.bundle.js
和中刪除了重復的依賴項another.bundle.js
。該插件應該注意到我們已經分離lodash
出一個單獨的塊并從我們的主包中移除了自重。
動態導入(dynamic imports)
當涉及到動態代碼拆分時,webpack 提供了兩個類似的技術。對于動態導入,第一種,也是優先選擇的方式是,使用符合 ECMAScript 提案 的 import()
語法。第二種,則是使用 webpack 特定的 require.ensure
。
import()
調用會在內部用到 promises。如果在舊有版本瀏覽器中使用import()
,記得使用 一個 polyfill 庫(例如 es6-promise 或 promise-polyfill),來 shimPromise
。
現在,我們不再使用靜態導入 lodash
,而是通過使用動態導入來分離一個 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
。這樣做會導致我們的 bundle 被命名為 lodash.bundle.js
,而不是 [id].bundle.js
。想了解更多關于 webpackChunkName
和其他可用選項,請查看 import()
相關文檔。讓我們執行 webpack,查看 lodash
是否會分離到一個單獨的 bundle:
由于 import()
會返回一個 promise,因此它可以和 async
函數一起使用。但是,需要使用像 Babel 這樣的預處理器和Syntax Dynamic Import Babel Plugin。下面是如何通過 async
函數簡化代碼:
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
進行文件名替換,可以確保瀏覽器獲取到修改后的文件。[hash]
替換可以用于在文件名中包含一個構建相關(build-specific)的 hash,但是更好的方式是使用 [chunkhash]
替換,在文件名中包含一個 chunk 相關(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 的名稱是它內容(通過 hash)的映射。如果我們不做修改,然后再次運行構建,我們以為文件名會保持不變。然而,如果我們真的運行,可能會發現情況并非如此:(譯注:這里的意思是,如果不做修改,文件名可能會變,也可能不會。)
提取模板(Extracting Boilerplate)
SplitChunksPlugin
可以使用它將模塊拆分為單獨的包。webpack提供了一個優化功能,它根據提供的選項將運行時代碼拆分為單獨的塊,只需使用optimization.runtimeChunk
set來single
創建一個運行時包:
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
)提取到單獨的 vendor
chunk 文件中,是比較推薦的做法,這是因為,它們很少像本地的源代碼那樣頻繁修改。因此通過實現以上步驟,利用客戶端的長效緩存機制,可以通過命中緩存來消除請求,并減少向服務器獲取資源,同時還能保證客戶端代碼和服務器端代碼版本一致。
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'
}
}
}
}
}
模塊標識符(Module Identifiers)
-
main
捆綁包因其新內容而發生變化。 - 該
vendor
包更改,因為它module.id
改變了。 - 而且,
runtime
捆綁包已更改,因為它現在包含對新模塊的引用。
第一個和最后一個是預期的 - 這是vendor
我們想要解決的哈希值。幸運的是,我們可以使用兩個插件來解決此問題。第一個是NamedModulesPlugin
,它將使用模塊的路徑而不是數字標識符。雖然此插件在開發期間對于更易讀的輸出非常有用,但運行起來需要更長的時間。第二個選項是HashedModuleIdsPlugin
,建議用于生產構建:
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'
}
}
}
}
};
如果改變項目代碼,依賴不變的保持runtime 和vendor的id不變,緩存
shimming
webpack
編譯器(compiler)能夠識別遵循 ES2015 模塊語法、CommonJS 或 AMD 規范編寫的模塊。然而,一些第三方的庫(library)可能會引用一些全局依賴(例如 jQuery
中的 $
)。這些庫也可能創建一些需要被導出的全局變量。這些“不符合規范的模塊”就是 shimming 發揮作用的地方。
我們不推薦使用全局的東西!在 webpack 背后的整個概念是讓前端開發更加模塊化。也就是說,需要編寫具有良好的封閉性(well contained)、彼此隔離的模塊,以及不要依賴于那些隱含的依賴模塊(例如,全局變量)。請只在必要的時候才使用本文所述的這些特性。
shimming 另外一個使用場景就是,當你希望 polyfill 瀏覽器功能以支持更多用戶時。在這種情況下,你可能只想要將這些 polyfills 提供給到需要修補(patch)的瀏覽器(也就是實現按需加載)。
shimming 全局變量
使用 ProvidePlugin
后,能夠在通過 webpack 編譯的每個模塊中,通過訪問一個變量來獲取到 package 包。如果 webpack 知道這個變量在某個模塊中被使用了,那么 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'
+ })
+ ]
};
本質上,我們所做的,就是告訴 webpack……
如果你遇到了至少一處用到
lodash
變量的模塊實例,那請你將lodash
package 包引入進來,并將其提供給需要用到它的模塊。
我們還可以使用 ProvidePlugin
暴露某個模塊中單個導出值,只需通過一個“數組路徑”進行配置(例如 [module, child, ...children?]
)。所以,讓我們做如下設想,無論 join
方法在何處調用,我們都只會得到的是 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
庫中的其他沒用到的部分去除。
細粒度 shimming
一些傳統的模塊依賴的 this
指向的是 window
對象。在接下來的用例中,調整我們的 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());
當模塊運行在 CommonJS 環境下這將會變成一個問題,也就是說此時的 this
指向的是 module.exports
。在這個例子中,你可以通過使用 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
讓我們假設,某個庫(library)創建出一個全局變量,它期望用戶使用這個變量。為此,我們可以在項目配置中,添加一個小模塊來演示說明:
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'); }
}
你可能從來沒有在自己的源碼中做過這些事情,但是你也許遇到過一個老舊的庫(library),和上面所展示的代碼類似。在這個用例中,我們可以使用 exports-loader
,將一個全局變量作為一個普通的模塊來導出。例如,為了將 file
導出為 file
以及將 helpers.parse
導出為 parse
,做如下調整:
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']
})
]
};
現在從我們的 entry 入口文件中(即 src/index.js
),我們能 import { file, parse } from './globals.js';
,然后一切將順利進行。
加載 polyfills
目前為止我們所討論的所有內容都是處理那些遺留的 package 包,讓我們進入到下一個話題: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
綁定到變量。這是因為只需在基礎代碼(code base)之外,再額外執行 polyfills,這樣我們就可以假定代碼中已經具有某些原生功能。
polyfills 雖然是一種模塊引入方式,但是并不推薦在主 bundle 中引入 polyfills,因為這不利于具備這些模塊功能的現代瀏覽器用戶,會使他們下載體積很大、但卻不需要的腳本文件。
讓我們把 import
放入一個新文件,并加入 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']
})
]
};
如此之后,我們可以在代碼中添加一些邏輯,根據條件去加載新的 polyfills.bundle.js
文件。你該如何決定,依賴于那些需要支持的技術以及瀏覽器。我們將做一些簡單的試驗,來確定是否需要引入這些 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>
現在,我們能在 entry 入口文件中,通過 fetch
獲取一些數據:
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))
當我們開始執行構建時,polyfills.bundle.js
文件將會被載入到瀏覽器中,然后所有代碼將正確無誤的在瀏覽器中執行。請注意,以上的這些設定可能還會有所改進,我們只是對于如何解決「將 polyfills 提供給那些需要引入它的用戶」這個問題,向你提供一個很棒的想法。