前端性能優化:webpack性能調優與Gzip原理

鏈接:https://juejin.im/book/5b936540f265da0a9624b04b

從輸入 URL 到顯示頁面這個過程中,涉及到網絡層面的,有三個主要過程:

  1. DNS 解析
  2. TCP 連接
  3. HTTP 請求/響應

對於 DNS 解析和 TCP 連接兩個步驟,我們前端可以做的努力非常有限。相比之下,HTTP 連接這一層面的優化才是我們網絡優化的核心。因此抓主要矛盾,直接從 HTTP 開始講起。

HTTP 優化有兩個大的方向:

  1. 減少請求次數
  2. 減少單次請求所花費的時間

這兩個優化點直直地指向了我們日常開發中非常常見的操作——資源的壓縮與合併。沒錯,這就是我們每天用構建工具在做的事情。而時下最主流的構建工具無疑是 webpack,所以我們這節的主要任務就是圍繞業界霸主 webpack 來做文章。

webpack 的性能瓶頸

相信每個用過 webpack 的同學都對“打包”和“壓縮”這樣的事情爛熟於心。這些老生常談的特性,更推薦大家去閱讀文檔。

webpack 的優化瓶頸,主要是兩個方面:

  • webpack 的構建過程太花時間
  • webpack 打包的結果體積太大

webpack 優化方案

構建過程提速策略

不要讓 loader 做太多事情——以 babel-loader 為例

babel-loader 無疑是強大的,但它也是慢的。

最常見的優化方式是,用 include 或 exclude 來幫我們避免不必要的轉譯,比如 webpack 官方在介紹 babel-loader 時給出的示例:

module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
前端性能優化:webpack性能調優與Gzip原理

這段代碼幫我們規避了對龐大的 node_modules 文件夾或者 bower_components 文件夾的處理。但通過限定文件範圍帶來的性能提升是有限的。除此之外,如果我們選擇開啟緩存將轉譯結果緩存至文件系統,則至少可以將 babel-loader 的工作效率提升兩倍。要做到這點,我們只需要為 loader 增加相應的參數設定:

loader: 'babel-loader?cacheDirectory=true'

以上都是在討論針對 loader 的配置,但我們的優化範圍不止是 loader 們。

舉個例子,儘管我們可以在 loader 配置時通過寫入 exclude 去避免 babel-loader 對不必要的文件的處理,但是考慮到這個規則僅作用於這個 loader,像一些類似 UglifyJsPlugin 的 webpack 插件在工作時依然會被這些龐大的第三方庫拖累,webpack 構建速度依然會因此大打折扣。所以針對這些龐大的第三方庫,我們還需要做一些額外的努力。

不要放過第三方庫

第三方庫以 node_modules 為代表,它們龐大得可怕,卻又不可或缺。

處理第三方庫的姿勢有很多,其中,Externals 不夠聰明,一些情況下會引發重複打包的問題;而 CommonsChunkPlugin 每次構建時都會重新構建一次 vendor;出於對效率的考慮,這裡為大家推薦

DllPlugin

DllPlugin 是基於 Windows 動態鏈接庫(dll)的思想被創作出來的。這個插件會把第三方庫單獨打包到一個文件中,這個文件就是一個單純的依賴庫。這個依賴庫不會跟著你的業務代碼一起被重新打包,只有當依賴自身發生版本變化時才會重新打包

用 DllPlugin 處理文件,要分兩步走:

  • 基於 dll 專屬的配置文件,打包 dll 庫
  • 基於 webpack.config.js 文件,打包業務代碼

以一個基於 React 的簡單項目為例,我們的 dll 的配置文件可以編寫如下:

const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: {
// 依賴的庫數組
vendor: [
'prop-types',
'babel-polyfill',
'react',
'react-dom',
'react-router-dom',
]
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js',

library: '[name]_[hash]',
},
plugins: [
new webpack.DllPlugin({
// DllPlugin的name屬性需要和libary保持一致
name: '[name]_[hash]',
path: path.join(__dirname, 'dist', '[name]-manifest.json'),
// context需要和webpack.config.js保持一致
context: __dirname,
}),
],
}
前端性能優化:webpack性能調優與Gzip原理

編寫完成之後,運行這個配置文件,我們的 dist 文件夾裡會出現這樣兩個文件:

vendor-manifest.json
vendor.js

vendor.js 不必解釋,是我們第三方庫打包的結果。這個多出來的 vendor-manifest.json,則用於描述每個第三方庫對應的具體路徑,我這裡截取一部分給大家看下:

{
"name": "vendor_397f9e25e49947b8675d",
"content": {
"./node_modules/core-js/modules/_export.js": {
"id": 0,
"buildMeta": {
"providedExports": true
}
},
"./node_modules/prop-types/index.js": {
"id": 1,
"buildMeta": {
"providedExports": true
}
},
...
}
}
前端性能優化:webpack性能調優與Gzip原理

隨後,我們只需在 webpack.config.js 裡針對 dll 稍作配置:

const path = require('path');
const webpack = require('webpack')
module.exports = {
mode: 'production',
// 編譯入口
entry: {
main: './src/index.js'
},
// 目標文件
output: {
path: path.join(__dirname, 'dist/'),
filename: '[name].js'
},
// dll相關配置
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
// manifest就是我們第一步中打包出來的json文件
manifest: require('./dist/vendor-manifest.json'),
})
]
}
前端性能優化:webpack性能調優與Gzip原理

一次基於 dll 的 webpack 構建過程優化,便大功告成了!

Happypack——將 loader 由單進程轉為多進程

大家知道,webpack 是單線程的,就算此刻存在多個任務,你也只能排隊一個接一個地等待處理。這是 webpack 的缺點,好在我們的 CPU 是多核的,Happypack 會充分釋放 CPU 在多核併發方面的優勢,幫我們把任務分解給多個子進程去併發執行,大大提升打包效率。

HappyPack 的使用方法也非常簡單,只需要我們把對 loader 的配置轉移到 HappyPack 中去就好,我們可以手動告訴 HappyPack 我們需要多少個併發的進程:

const HappyPack = require('happypack')
// 手動創建進程池
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })
module.exports = {
module: {
rules: [
...
{
test: /\.js$/,
// 問號後面的查詢參數指定了處理這類文件的HappyPack實例的名字
loader: 'happypack/loader?id=happyBabel',
...
},
],
},
plugins: [
...
new HappyPack({

// 這個HappyPack的“名字”就叫做happyBabel,和樓上的查詢參數遙相呼應
id: 'happyBabel',
// 指定進程池
threadPool: happyThreadPool,
loaders: ['babel-loader?cacheDirectory']
})
],
}
前端性能優化:webpack性能調優與Gzip原理

構建結果體積壓縮

文件結構可視化,找出導致體積過大的原因

這裡為大家介紹一個非常好用的包組成可視化工具——webpack-bundle-analyzer,配置方法和普通的 plugin 無異,它會以矩形樹圖的形式將包內各個模塊的大小和依賴關係呈現出來,格局如官方所提供這張圖所示:

前端性能優化:webpack性能調優與Gzip原理

在使用時,我們只需要將其以插件的形式引入:

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {

plugins: [
new BundleAnalyzerPlugin()
]
}
前端性能優化:webpack性能調優與Gzip原理

拆分資源

這點仍然圍繞 DllPlugin 展開,可參考上文。

刪除冗餘代碼

一個比較典型的應用,就是 Tree-Shaking。

從 webpack2 開始,webpack 原生支持了 ES6 的模塊系統,並基於此推出了 Tree-Shaking。webpack 官方是這樣介紹它的:

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination, or more precisely, live-code import. It relies on ES2015 module import/export for the static structure of its module system.

意思是基於 import/export 語法,Tree-Shaking 可以在編譯的過程中獲悉哪些模塊並沒有真正被使用,這些沒用的代碼,在最後打包的時候會被去除。

舉個例子,假設我的主幹文件(入口文件)是這麼寫的:

import { page1, page2 } from './pages'
// show是事先定義好的函數,大家理解它的功能是展示頁面即可
show(page1)

pages 文件裡,我雖然導出了兩個頁面:

export const page1 = xxx
export const page2 = xxx

但因為 page2 事實上並沒有被用到(這個沒有被用到的情況在靜態分析的過程中是可以被感知出來的),所以打包的結果裡會把這部分:

export const page2 = xxx;

直接刪掉,這就是 Tree-Shaking 幫我們做的事情。

相信大家不難看出,Tree-Shaking 的針對性很強,它更適合用來處理模塊級別的冗餘代碼。至於

粒度更細的冗餘代碼的去除,往往會被整合進 JS 或 CSS 的壓縮或分離過程中。

這裡我們以當下接受度較高的 UglifyJsPlugin 為例,看一下如何在壓縮過程中對碎片化的冗餘代碼(如 console 語句、註釋等)進行自動化刪除:

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
plugins: [
new UglifyJsPlugin({
// 允許併發
parallel: true,
// 開啟緩存
cache: true,
compress: {
// 刪除所有的console語句
drop_console: true,
// 把使用多次的靜態值自動定義為變量
reduce_vars: true,
},
output: {
// 不保留註釋
comment: false,
// 使輸出的代碼儘可能緊湊
beautify: false
}
})
]
}
前端性能優化:webpack性能調優與Gzip原理

有心的同學會注意到,這段手動引入 UglifyJsPlugin 的代碼其實是 webpack3 的用法,webpack4 現在已經默認使用 uglifyjs-webpack-plugin 對代碼做壓縮了——在 webpack4 中,我們是通過配置 optimization.minimize 與 optimization.minimizer 來自定義壓縮相關的操作的。

這裡也引出了我們學習性能優化的一個核心的理念——用什麼工具,怎麼用,並不是我們這本文的重點,因為所有的工具都存在用法迭代的問題。但現在大家知道了在打包的過程中做一些如上文所述的“手腳”可以實現打包結果的最優化,那下次大家再去執行打包操作,會不會對這個操作更加留心,從而自己去尋找彼時操作的具體實現方案呢?我最希望大家掌握的技能就是,先在腦海中留下“這個xx操作是對的,是有用的”,在日後的實踐中,可以基於這個認知去尋找把正確的操作落地的具體方案。

按需加載

大家想象這樣一個場景。我現在用 React 構建一個單頁應用,用 React-Router 來控制路由,十個路由對應了十個頁面,這十個頁面都不簡單。如果我把這整個項目打一個包,用戶打開我的網站時,會發生什麼?有很大機率會卡死,對不對?更好的做法肯定是先給用戶展示主頁,其它頁面等請求到了再加載。當然這個情況也比較極端,但卻能很好地引出按需加載的思想:

  • 一次不加載完所有的文件內容,只加載此刻需要用到的那部分(會提前做拆分)
  • 當需要更多內容時,再對用到的內容進行即時加載

好,既然說到這十個 Router 了,我們就拿其中一個開刀,假設我這個 Router 對應的組件叫做 BugComponent,來看看我們如何利用 webpack 做到該組件的按需加載。

當我們不需要按需加載的時候,我們的代碼是這樣的:

import BugComponent from '../pages/BugComponent'
...
<route>
/<route>

為了開啟按需加載,我們要稍作改動。

首先 webpack 的配置文件要走起來:

output: {
path: path.join(__dirname, '/../dist'),
filename: 'app.js',
publicPath: defaultSettings.publicPath,
// 指定 chunkFilename
chunkFilename: '[name].[chunkhash:5].chunk.js',
},
前端性能優化:webpack性能調優與Gzip原理

路由處的代碼也要做一下配合:

const getComponent => (location, cb) {
require.ensure([], (require) => {
cb(null, require('../pages/BugComponent').default)
}, 'bug')
},
...
<route>
/<route>
前端性能優化:webpack性能調優與Gzip原理

對,核心就是這個方法:

require.ensure(dependencies, callback, chunkName)

這是一個異步的方法,webpack 在打包時,BugComponent 會被單獨打成一個文件,只有在我們跳轉 bug 這個路由的時候,這個異步方法的回調才會生效,才會真正地去獲取 BugComponent 的內容。這就是按需加載。

按需加載的粒度,還可以繼續細化,細化到更小的組件、細化到某個功能點,都是 ok 的。

等等,這和說好的不一樣啊?不是說 Code-Splitting 才是 React-Router 的按需加載實踐嗎?沒錯,在 React-Router4 中,我們確實是用 Code-Splitting 替換掉了樓上這個操作。而且如果有使用過 React-Router4 實現過路由級別的按需加載的同學,可能會對 React-Router4 裡用到的一個叫“Bundle-Loader”的東西印象深刻。我想很多同學讀到按需加載這裡,心裡的預期或許都是時下大熱的 Code-Splitting,而非我呈現出來的這段看似“陳舊”的代碼。

但是,如果大家稍微留個心眼,去看一下 Bundle Loader 並不長的源代碼的話,你會發現它竟然還是使用 require.ensure 來實現的——這也是我要把 require.ensure 單獨拎出來的重要原因。所謂按需加載,根本上就是在正確的時機去觸發相應的回調。理解了這個 require.ensure 的玩法,大家甚至可以結合業務自己去修改一個按需加載模塊來用。

這也應了我之前跟大家強調那段話,工具永遠在迭代,唯有掌握核心思想,才可以真正做到舉一反三——唯“心”不破!

Gzip 壓縮原理

前面說了不少 webpack 的故事,目的還是幫大家更好地實現壓縮和合並。說到壓縮,可不只是構建工具的專利。我們日常開發中,其實還有一個便宜又好用的壓縮操作:開啟 Gzip。

具體的做法非常簡單,只需要你在你的 request headers 中加上這麼一句:

accept-encoding:gzip


分享到:


相關文章: