使用 webpack 優化資源

在前端應用的優化中,對加載資源的大小控制極其的重要,大多數時候咱們能作的是在打包編譯的過程對資源進行大小控制、拆分與複用。
本片文章中主要是基於 webpack 打包,以 React、vue 等生態開發的單頁面應用來舉例說明如何從 webpack 打包的層面去處理資源以及緩存,其中主要咱們須要作的是對 webpack 進行配置的優化,同時涉及少許的業務代碼的更改。html

同時對打包資源的分析可使用 webpack-bundle-analyzer 插件,固然可選的分析插件仍是不少的,在本文中主要以該插件來舉例分析。前端

TIP: webpack 版本 @3.6.0 。vue


打包環境與代碼壓縮

首先咱們有一個最基本的 webpack 配置:node

// webpack.config.js
const path = require('path');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
const PROJECT_ROOT = path.resolve(__dirname, './');
 
module.exports = {
entry: {
index: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash:4].js'
},
module: {
rules: [
{
test: /\.js[x]?$/,
use: 'babel-loader',
include: PROJECT_ROOT,
exclude: /node_modules/
}
]
},
plugins: [
new BundleAnalyzerPlugin()
],
resolve: {
extensions: ['.js', '.jsx']
},
};

執行打包能夠看到一個項目的 js 有 1M 以上:react

Hash: e51afc2635f08322670b
Version: webpack 3.6.0
Time: 2769ms
Asset Size Chunks Chunk Names
index.caa7.js 1.3 MB 0 [emitted] [big] index

 

這時候只須要增長插件 DefinePlugin 與 UglifyJSPlugin 便可減小很多的體積,在 plugins 中添加:webpack

// webpack.config.js
...
{
...
plugins: [
new BundleAnalyzerPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
}),
new UglifyJSPlugin({
uglifyOptions: {
ie8: false,
output: {
comments: false,
beautify: false,
},
mangle: {
keep_fnames: true
},
compress: {
warnings: false,
drop_console: true
},
}
})
]
...
}

能夠看到這時候的打包輸出:git

Hash: 84338998472a6d3c5c25
Version: webpack 3.6.0
Time: 9940ms
Asset Size Chunks Chunk Names
index.89c2.js 346 kB 0 [emitted] [big] index

代碼的大小從 1.3M 降到了 346K。github

DefinePlugin

DefinePlugin 容許建立一個在編譯時能夠配置的全局常量。這可能會對開發模式和發佈模式的構建容許不一樣的行爲很是有用。若是在開發構建中,而不在發佈構建中執行日誌記錄,則可使用全局常量來決定是否記錄日誌。這就是 DefinePlugin 的用處,設置它,就能夠忘記開發和發佈構建的規則。web

在咱們的業務代碼和第三方包的代碼中不少時候須要判斷 process.env.NODE_ENV 來作不一樣處理,而在生產環境中咱們顯然不須要非 production 的處理部分。
在這裏咱們設置 process.env.NODE_ENV 爲 JSON.stringify('production'),也就是表示講打包環境設置爲生產環境。以後配合 UglifyJSPlugin 插件就能夠在給生產環境打包的時候去除部分的冗餘代碼。瀏覽器

UglifyJSPlugin

UglifyJSPlugin 主要是用於解析、壓縮 js 代碼,它基於 uglify-es 來對 js 代碼進行處理,它有多種配置選項:https://github.com/webpack-contrib/uglifyjs-webpack-plugin。
經過對代碼的壓縮處理以及去除冗餘,大大減少了打包資源的體積大小。


代碼拆分/按需加載

在如 React 或者 vue 構建的單頁面應用中,對頁面路由與視圖的控制是由前端來實現的,其對應的業務邏輯都在 js 代碼中。
當一個應用設計的頁面和邏輯不少的時候,最終生成的 js 文件資源也會至關大。

然而當咱們打開一個 url 對應的頁面時,實際上須要的並不是所有的 js 代碼,所須要的僅是一個主的運行時代碼與該視圖對應的業務邏輯的代碼,在加載下一個視圖的時候再去加載那部分的代碼。
所以,對這方面可作的優化就是對 js 代碼進行按需加載。

懶加載或者按需加載,是一種很好的優化網頁或應用的方式。這種方式其實是先把你的代碼在一些邏輯斷點處分離開,而後在一些代碼塊中完成某些操做後,當即引用或即將引用另一些新的代碼塊。這樣加快了應用的初始加載速度,減輕了它的整體體積,由於某些代碼塊可能永遠不會被加載。

在 webpack 中提供了動態導入的技術來實現代碼拆分,首先在 webpack 的配置中須要去配置拆分出來的每一個子模塊的配置:

// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
const PROJECT_ROOT = path.resolve(__dirname, './');
 
module.exports = {
entry: {
index: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash:4].js',
chunkFilename: '[name].[chunkhash:4].child.js',
},
module: {
rules: [
{
test: /\.js[x]?$/,
use: 'babel-loader',
include: PROJECT_ROOT,
exclude: /node_modules/
}
]
},
plugins: [
new BundleAnalyzerPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
}),
new UglifyJSPlugin({
uglifyOptions: {
ie8: false,
output: {
comments: false,
beautify: false,
},
mangle: {
keep_fnames: true
},
compress: {
warnings: false,
drop_console: true
},
}
}),
],
resolve: {
extensions: ['.js', '.jsx']
},
};

其中主要須要定義的則是 output 中的 chunkFilename,它是導出的拆分代碼的文件名,這裏給它設置爲 [name].[chunkhash:4].child.js,其中的 name 對應模塊名稱或者 id,chunkhash 是模塊內容的 hash。

以後在業務代碼中 webpack 提供了兩種方式來動態導入:

  • import('path/to/module') -> Promise
  • require.ensure(dependencies: String[], callback: function(require), errorCallback: function(error), chunkName: String)

對於最新版本的 webpack 主要推薦使用 import() 的方式 注意:import 使用到了 Promise,所以須要確保代碼中支持了 Promise 的 polyfill 。

// 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);
})

能夠看到打包的信息:

Hash: d6ba79fe5995bcf9fa4d
Version: webpack 3.6.0
Time: 7022ms
Asset Size Chunks Chunk Names
lodash.89f0.child.js 85.4 kB 0 [emitted] lodash
index.316e.js 1.96 kB 1 [emitted] index
[0] ./src/index.js 441 bytes {1} [built]
[2] (webpack)/buildin/global.js 509 bytes {0} [built]
[3] (webpack)/buildin/module.js 517 bytes {0} [built]
+ 1 hidden module

能夠看到打包出來的代碼生成了 index.316e.js 和 lodash.89f0.child.js 兩個文件,後者則是經過 import 來實現拆分的。
import 它接收一個 path 參數,指的是該子模塊對於的路徑,同時還注意到其中能夠添加一行註釋 /* webpackChunkName: "lodash" */,該註釋並不是是無用的,它定義了該子模塊的 name,其對應與 output.chunkFilename 中的 [name]
import 函數返回一個 Promise,當異步加載到子模塊代碼是會執行後續操做,好比更新視圖等。

React 中的按需加載

在 React 配合 React-Router 開發中,每每就須要代碼根據路由按需加載的能力,下面是一個基於 webpack 代碼動態導入技術實現的 React 動態載入的組件:

import React, { Component } from 'react';
 
export default function lazyLoader (importComponent) {
class AsyncComponent extends Component {
state = { Component: null }
 
async componentDidMount () {
const { default: Component } = await importComponent();
 
this.setState({
Component: Component
});
}
 
render () {
const Component = this.state.Component;
 
return Component
? <Component {...this.props} />
: null;
}
}
 
return AsyncComponent;
};

在 Route 中:

<Switch>
<Route exact path="/"
component={lazyLoader(() => import('./Home'))}
/>
<Route path="/about"
component={lazyLoader(() => import('./About'))}
/>
<Route
component={lazyLoader(() => import('./NotFound'))}
/>
</Switch>

在 Route 中渲染的是 lazyLoader 函數返回的組件,該組件在 mount 以後會去執行 importComponent 函數(既:() => import('./About'))動態加載其對於的組件模塊(被拆分出來的代碼),待加載成功以後渲染該組件。

使用該方式打包出來的代碼:

Hash: 02a053d135a5653de985
Version: webpack 3.6.0
Time: 9399ms
Asset Size Chunks Chunk Names
0.db22.child.js 5.82 kB 0 [emitted]
1.fcf5.child.js 4.4 kB 1 [emitted]
2.442d.child.js 3 kB 2 [emitted]
index.1bbc.js 339 kB 3 [emitted] [big] index

抽離 Common 資源

第三方庫的長緩存

首先對於一些比較大的第三方庫,好比在 React 中用到的 react、react-dom、react-router 等等,咱們不但願它們被重複打包,而且在每次版本更新的時候也不但願去改變這部分的資源致使在用戶端從新加載。
在這裏可使用 webpack 的 CommonsChunkPlugin 來抽離這些公共資源;

CommonsChunkPlugin 插件,是一個可選的用於創建一個獨立文件(又稱做 chunk)的功能,這個文件包括多個入口 chunk 的公共模塊。經過將公共模塊拆出來,最終合成的文件可以在最開始的時候加載一次,便存起來到緩存中供後續使用。這個帶來速度上的提高,由於瀏覽器會迅速將公共的代碼從緩存中取出來,而不是每次訪問一個新頁面時,再去加載一個更大的文件。

首先須要在 entry 中新增一個入口用來打包須要抽離出來的庫,這裏將 'react', 'react-dom', 'react-router-dom', 'immutable' 都給單獨打包進 vendor 中;
以後在 plugins 中定義一個 CommonsChunkPlugin 插件,同時將其 name 設置爲 vendor 是它們相關聯,再將 minChunks 設置爲 Infinity 防止其餘代碼被打包進來。

// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
const PROJECT_ROOT = path.resolve(__dirname, './');
 
module.exports = {
entry: {
index: './src0/index.js',
vendor: ['react', 'react-dom', 'react-router-dom', 'immutable']
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash:4].js',
chunkFilename: '[name].[chunkhash:4].child.js',
},
module: {
rules: [
{
test: /\.js[x]?$/,
use: 'babel-loader',
include: PROJECT_ROOT,
exclude: /node_modules/
}
]
},
plugins: [
new BundleAnalyzerPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
}),
new UglifyJSPlugin({
uglifyOptions: {
ie8: false,
output: {
comments: false,
beautify: false,
},
mangle: {
keep_fnames: true
},
compress: {
warnings: false,
drop_console: true
},
}
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: Infinity,
}),
],
resolve: {
extensions: ['.js', '.jsx']
},
};

運行打包能夠看到:

Hash: 34a71fcfd9a24e810c21
Version: webpack 3.6.0
Time: 9618ms
Asset Size Chunks Chunk Names
0.2c65.child.js 5.82 kB 0 [emitted]
1.6e26.child.js 4.4 kB 1 [emitted]
2.e4bc.child.js 3 kB 2 [emitted]
index.4e2f.js 64.2 kB 3 [emitted] index
vendor.5fd1.js 276 kB 4 [emitted] [big] vendor

能夠看到 vendor 被單獨打包出來了。

當咱們改變業務代碼時再次打包:

Hash: cd3f1bc16b28ac97e20a
Version: webpack 3.6.0
Time: 9750ms
Asset Size Chunks Chunk Names
0.2c65.child.js 5.82 kB 0 [emitted]
1.6e26.child.js 4.4 kB 1 [emitted]
2.e4bc.child.js 3 kB 2 [emitted]
index.4d45.js 64.2 kB 3 [emitted] index
vendor.bc85.js 276 kB 4 [emitted] [big] vendor

vendor 包一樣被打包出來的,然而它的文件 hash 卻發生了變化,這顯然不符合咱們長緩存的需求。
這是由於 webpack 在使用 CommoChunkPlugin 的時候會生成一段 runtime 代碼(它主要用來處理代碼模塊的映射關係),而哪怕沒有改變 vendor 裏的代碼,這個 runtime 仍然是會跟隨着打包變化的而且打入 verdor 中,因此 hash 就會開始變化了。解決方案則是把這部分的 runtime 代碼也單獨抽離出來,修改以前的 CommonsChunkPlugin 爲:

// webpack.config.js
...
new webpack.optimize.CommonsChunkPlugin({
name: ['vendor', 'runtime'],
minChunks: Infinity,
}),
...

執行打包能夠看到生成的代碼中多了 runtime 文件,同時即便改變業務代碼,vendor 的 hash 值也保持不變了。

固然這段 runtime 實際上很是短,咱們能夠直接 inline 在 html 中,若是使用的是 html-webpack-plugin 插件處理 html,則能夠結合 html-webpack-inline-source-plugin 插件自動處理其 inline。

公共資源抽離

在咱們打包出來的 js 資源包括不一樣入口以及子模塊的 js 資源包,然而它們之間也會重複載入相同的依賴模塊或者代碼,所以能夠經過 CommonsChunkPlugin 插件將它們共同依賴的一些資源打包成一個公共的 js 資源。

// webpack.config.js
plugins: [
new BundleAnalyzerPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
}),
new UglifyJSPlugin({
uglifyOptions: {
ie8: false,
output: {
comments: false,
beautify: false,
},
mangle: {
keep_fnames: true
},
compress: {
warnings: false,
drop_console: true
},
}
}),
new webpack.optimize.CommonsChunkPlugin({
name: ['vendor', 'runtime'],
minChunks: Infinity,
}),
new webpack.optimize.CommonsChunkPlugin({
// ( 公共chunk(commnons chunk) 的名稱)
name: "commons",
// ( 公共chunk 的文件名)
filename: "commons.[chunkhash:4].js",
// (模塊必須被 3個 入口chunk 共享)
minChunks: 3
})
],

能夠看到這裏增長了 commons 的一個打包,當一個資源被三個以及以上 chunk 依賴時,這些資源會被單獨抽離打包到 commons.[chunkhash:4].js 文件。

執行打包,看到結果以下:

Hash: 2577e42dc5d8b94114c8
Version: webpack 3.6.0
Time: 24009ms
Asset Size Chunks Chunk Names
0.2eee.child.js 90.8 kB 0 [emitted]
1.cfbc.child.js 89.4 kB 1 [emitted]
2.557a.child.js 88 kB 2 [emitted]
vendor.66fd.js 275 kB 3 [emitted] [big] vendor
index.688b.js 64.2 kB 4 [emitted] index
commons.a61e.js 1.78 kB 5 [emitted] commons

卻發現這裏的 commons.[chunkhash].js 基本沒有實際內容,然而明明在每一個子模塊中也都依賴了一些相同的依賴。
藉助 webpack-bundle-analyzer 來分析一波:

img

能夠看到三個模塊都依賴了 lodash,然而它並無被抽離出來。

這是由於 CommonsChunkPlugin 中的 chunk 指的是 entry 中的每一個入口,所以對於一個入口拆分出來的子模塊(children chunk)是不生效的。
能夠經過在 CommonsChunkPlugin 插件中配置 children 參數將拆分出來的子模塊的公共依賴也打包進 commons 中:

// webpack.config.js
plugins: [
new BundleAnalyzerPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
}),
new UglifyJSPlugin({
uglifyOptions: {
ie8: false,
output: {
comments: false,
beautify: false,
},
mangle: {
keep_fnames: true
},
compress: {
warnings: false,
drop_console: true
},
}
}),
new webpack.optimize.CommonsChunkPlugin({
name: ['vendor', 'runtime'],
minChunks: Infinity,
}),
new webpack.optimize.CommonsChunkPlugin({
// ( 公共chunk(commnons chunk) 的名稱)
name: "commons",
// ( 公共chunk 的文件名)
filename: "commons.[chunkhash:4].js",
// (模塊必須被 3個 入口chunk 共享)
minChunks: 3
}),
new webpack.optimize.CommonsChunkPlugin({
// (選擇全部被選 chunks 的子 chunks)
children: true,
// (在提取以前須要至少三個子 chunk 共享這個模塊)
minChunks: 3,
})
],

查看打包效果:

img

其子模塊的公共資源都被打包到 index 之中了,並無理想地打包進 commons 之中,仍是由於 commons 對於的是 entry 中的入口模塊,而這裏並未有 3 個 entry 模塊共用資源;
在單入口的應用中能夠選擇去除 commons,而在子模塊的 CommonsChunkPlugin 的配置中配置 async 爲 true

// webpack.config.js
plugins: [
new BundleAnalyzerPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
}),
new UglifyJSPlugin({
uglifyOptions: {
ie8: false,
output: {
comments: false,
beautify: false,
},
mangle: {
keep_fnames: true
},
compress: {
warnings: false,
drop_console: true
},
}
}),
new webpack.optimize.CommonsChunkPlugin({
name: ['vendor', 'runtime'],
minChunks: Infinity,
}),
new webpack.optimize.CommonsChunkPlugin({
// (選擇全部被選 chunks 的子 chunks)
children: true,
// (異步加載)
async: true,
// (在提取以前須要至少三個子 chunk 共享這個模塊)
minChunks: 3,
})
],

查看效果:

img

子模塊的公共資源都被打包到 0.9c90.child.js 中了,該模塊則是子模塊的 commons。


tree shaking

tree shaking 是一個術語,一般用於描述移除 JavaScript 上下文中的未引用代碼(dead-code)。它依賴於 ES2015 模塊系統中的靜態結構特性,例如 import 和 export。這個術語和概念其實是興起於 ES2015 模塊打包工具 rollup。

在咱們引入一個依賴的某個輸出的時候,咱們可能須要的僅僅是該依賴的某一部分代碼,而另外一部分代碼則是 unused 的,若是可以去除這部分代碼,那麼最終打包出來的資源體積也是能夠有可觀的減少。
首先,webpack 中實現 tree shaking 是基於 webpack 內部支持的 es2015 的模塊機制,在大部分時候咱們使用 babel 來編譯 js 代碼,而 babel 會經過本身的模塊加載機制處理一遍,這致使 webpack 中的 tree shaking 處理將會失效。所以在 babel 的配置中須要關閉對模塊加載的處理:

// .babelrc
{
"presets": [
[
"env", {
"modules": false,
}
],
"stage-0"
],
...
}

而後咱們來看下 webpack 是如何處理打包的代碼,舉例有一個入口文件 index.js 和一個 utils.js 文件:

// utils.js
export function square(x) {
return x * x;
}
 
export function cube(x) {
return x * x * x;
}
// index.js
import { cube } from './utils.js';
 
console.log(cube(10));

打包出來的代碼:

// index.bundle.js
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 
"use strict";
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__["a"] = cube;
function square(x) {
return x * x;
}
 
function cube(x) {
return x * x * x;
}

能夠看到僅有 cube 函數被 __webpack_exports__ 導出來,而 square 函數被標記爲 unused harmony export square,然而在打包代碼中既是 square 沒有被導出可是它仍然存在與代碼中,而如何去除其代碼則能夠經過添加 UglifyjsWebpackPlugin 插件來處理。

相關文章
相關標籤/搜索