天天看點

webpack性能調優與Gzip原理webpack的性能瓶頸webpack優化方案 Gzip壓縮原理

從上一章可以看出, 我們輸入URL到顯示頁面這個過程中,涉及到的網絡層面有三個主要過程:

  • DNS解析
  • TCP連接配接
  • HTTP請求/響應

對于DNS解析和TCP連接配接兩個步驟,我們前端可以做的努力非常有限。相比之下, HTTP連接配接這一層面的優化才是我們網絡優化的核心。 

HTTP優化有兩個大的方向:

  • 減少請求次數
  • 減少單詞請求所花費的時間

這兩個優化點直直的指向了我們日常開發中非常常見的操作——資源的壓縮與合并。這就是我們每天用建構工具在做的事情。而試下最主流的建構工具無疑是webpack。

webpack的性能瓶頸

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

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

webpack優化方案

建構過程提速政策

不要讓loader做太多的事情——以babl-lolader為例

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']
        }
      }
    }
  ]
}
           

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

loader:'babel-loader?cacheDirectory=true'
           

以上都是讨論正對loader的配置,但我們的優化範圍不隻是loader們。

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

不要放過第三方庫

第三方庫以node_modulea為代表, 他們龐大的可怕,卻又不可或缺。

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

DllPlugin是基于Windows動态連結庫dll的思想被創作出來的。這個插件會把第三方庫單獨打包到一個檔案中,這個檔案就是一個單純的依賴庫,這個依賴庫不會跟着你的業務代碼一起呗重新打包,隻有當依賴自身發生版本變化時才會重新打包。

用DllPlugin處理檔案, 要分兩步走:

  • 基于dll專屬的配置檔案,打包dll庫
  • 基于webpack.confiig.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,
      }),
    ],
}
           

編譯完成後, 運作這個配置檔案,我們的dist檔案夾裡會出現這樣兩個檔案:

vendor-mainfest.json
vendor.js
           

vendor.js不必解釋,使我們第三方庫打包的結果。這個多出來的vendor-mainfest.json,則用于描述每個第三方庫對應的具體路徑

随後, 隻需要在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'),
    })
  ]
}
           

一次基于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']
    })
  ],
}
           

建構結果體積壓縮

檔案結構可視化,找出導緻體積過大的原因

這裡介紹一個非常好用的包組成可視化工具——wbpack-bundle-analyzer,配置方法和普通的plugin無異,他會以矩形樹圖的形式将包内各個子產品的大小和依賴關系呈現出來。

在使用時, 隻需要将其以插件的形式引入:

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}
           

拆分資源

這點仍然圍繞DllPlugin展開

删除備援代碼

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

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
     }
   })
 ]
}
           

這段手動引入 UglifyJsPlugin 的代碼其實是 webpack3 的用法,webpack4 現在已經預設使用 uglifyjs-webpack-plugin 對代碼做壓縮了——在 webpack4 中,是通過配置 optimization.minimize 與 optimization.minimizer 來自定義壓縮相關的操作的。

這裡也引出了學習性能優化的一個核心的理念——用什麼工具,怎麼用

按需加載

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

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

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

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

import BugComponent from '../pages/BugComponent'
...
<Route path="/bug" component={BugComponent}>
           

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

首先 webpack 的配置檔案要走起來:

output: {
    path: path.join(__dirname, '/../dist'),
    filename: 'app.js',
    publicPath: defaultSettings.publicPath,
    // 指定 chunkFilename
    chunkFilename: '[name].[chunkhash:5].chunk.js',
},
           

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

const getComponent => (location, cb) {
  require.ensure([], (require) => {
    cb(null, require('../pages/BugComponent').default)
  }, 'bug')
},
...
<Route path="/bug" getComponent={getComponent}>
           

核心就是這個方法:

require.ensure(dependencies, callback, chunkName)
           

工具永遠在疊代,唯有掌握核心思想,才可以真正做到舉一反三——唯“心”不破!

Gzip壓縮原理

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

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

accept-encoding:gzip
           
HTTP 壓縮是一種内置到網頁伺服器和網頁用戶端中以改進傳輸速度和帶寬使用率的方式。在使用 HTTP 壓縮的情況下,HTTP 資料在從伺服器發送前就已壓縮:相容的浏覽器将在下載下傳所需的格式前宣告支援何種方法給伺服器;不支援壓縮方法的浏覽器将下載下傳未經壓縮的資料。最常見的壓縮方案包括 Gzip 和 Deflate。

HTTP 壓縮就是以縮小體積為目的,對 HTTP 内容進行重新編碼的過程

Gzip 的核心就是 Deflate,目前我們壓縮檔案用得最多的就是 Gzip。可以說,Gzip 就是 HTTP 壓縮的經典例題。

webpack 的 Gzip 和服務端的 Gzip

一般來說,Gzip 壓縮是伺服器的活兒:伺服器了解到我們這邊有一個 Gzip 壓縮的需求,它會啟動自己的 CPU 去為我們完成這個任務。而壓縮檔案這個過程本身是需要耗費時間的,大家可以了解為我們以伺服器壓縮的時間開銷和 CPU 開銷(以及浏覽器解析壓縮檔案的開銷)為代價,省下了一些傳輸過程中的時間開銷。

既然存在着這樣的交換,那麼就要求我們學會權衡。伺服器的 CPU 性能不是無限的,如果存在大量的壓縮需求,伺服器也扛不住的。伺服器一旦是以慢下來了,使用者還是要等。Webpack 中 Gzip 壓縮操作的存在,事實上就是為了在建構過程中去做一部分伺服器的工作,為伺服器分壓。

是以,這兩個地方的 Gzip 壓縮,誰也不能替代誰。它們必須和平共處,好好合作。作為開發者,我們也應該結合業務壓力的實際強度情況,去做好這其中的權衡。

繼續閱讀