webpack 性能調優與 Gzip 原理

webpack 性能調優與 Gzip 原理

從本節開始,咱們進入網絡層面的性能優化世界。前端

你們能夠從第一節的示意圖中看出,咱們從輸入 URL 到顯示頁面這個過程當中,涉及到網絡層面的,有三個主要過程:node

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

對於 DNS 解析和 TCP 鏈接兩個步驟,咱們前端能夠作的努力很是有限。相比之下,HTTP 鏈接這一層面的優化纔是咱們網絡優化的核心。所以咱們開門見山,抓主要矛盾,直接從 HTTP 開始講起。react

HTTP 優化有兩個大的方向:webpack

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

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

webpack 的性能瓶頸

相信每一個用過 webpack 的同窗都對「打包」和「壓縮」這樣的事情爛熟於心。這些老生常談的特性,我更推薦你們去閱讀文檔。而關於 webpack 的詳細操做,則推薦你們讀讀這本 關於 webpack 的掘金小冊,這裏咱們把注意力放在 webpack 的性能優化上。算法

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

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

webpack 優化方案

構建過程提速策略

不要讓 loader 作太多事情——以 babel-loader 爲例

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

最多見的優化方式是,用 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_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,
      }),
    ],
}

編寫完成以後,運行這個配置文件,咱們的 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.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']
    })
  ],
}

構建結果體積壓縮

文件結構可視化,找出致使體積過大的緣由

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

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

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

拆分資源

這點仍然圍繞 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
     }
   })
 ]
}

有心的同窗會注意到,這段手動引入 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 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)

這是一個異步的方法,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

相信不少同窗對 Gzip 也是瞭解到這裏。之因此爲你們開這個彩蛋性的小節,毫不是出於炫技要來給你們展現一下 Gzip 的壓縮算法,而是想和你們聊一個和咱們前端關係更密切的話題:HTTP 壓縮。

HTTP 壓縮是一種內置到網頁服務器和網頁客戶端中以改進傳輸速度和帶寬利用率的方式。在使用 HTTP 壓縮的狀況下,HTTP 數據在從服務器發送前就已壓縮:兼容的瀏覽器將在下載所需的格式前宣告支持何種方法給服務器;不支持壓縮方法的瀏覽器將下載未經壓縮的數據。最多見的壓縮方案包括 Gzip 和 Deflate。

以上是摘自百科的解釋,事實上,你們能夠這麼理解:

HTTP 壓縮就是以縮小體積爲目的,對 HTTP 內容進行從新編碼的過程

Gzip 的內核就是 Deflate,目前咱們壓縮文件用得最多的就是 Gzip。能夠說,Gzip 就是 HTTP 壓縮的經典例題。

該不應用 Gzip

若是你的項目不是極端迷你的超小型文件,我都建議你試試 Gzip。

有的同窗或許存在這樣的疑問:壓縮 Gzip,服務端要花時間;解壓 Gzip,瀏覽器要花時間。中間節省出來的傳輸時間,真的那麼可觀嗎?

答案是確定的。若是你手上的項目是 1k、2k 的小文件,那確實有點高射炮打蚊子的意思,不值當。但更多的時候,咱們處理的都是具有必定規模的項目文件。實踐證實,這種狀況下壓縮和解壓帶來的時間開銷相對於傳輸過程當中節省下的時間開銷來講,能夠說是微不足道的。

Gzip 是萬能的嗎

首先要認可 Gzip 是高效的,壓縮後一般能幫咱們減小響應 70% 左右的大小。

但它並不是萬能。Gzip 並不保證針對每個文件的壓縮都會使其變小。

Gzip 壓縮背後的原理,是在一個文本文件中找出一些重複出現的字符串、臨時替換它們,從而使整個文件變小。根據這個原理,文件中代碼的重複率越高,那麼壓縮的效率就越高,使用 Gzip 的收益也就越大。反之亦然。

webpack 的 Gzip 和服務端的 Gzip

通常來講,Gzip 壓縮是服務器的活兒:服務器瞭解到咱們這邊有一個 Gzip 壓縮的需求,它會啓動本身的 CPU 去爲咱們完成這個任務。而壓縮文件這個過程自己是須要耗費時間的,你們能夠理解爲咱們以服務器壓縮的時間開銷和 CPU 開銷(以及瀏覽器解析壓縮文件的開銷)爲代價,省下了一些傳輸過程當中的時間開銷。

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

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

小結

說了這麼多,咱們都在討論文件——準確地說,是文本文件及其構建過程的優化。

但一個完整的現代前端應用,除了要包含 HTML、CSS 和 JS,每每還須要藉助圖片來提升用戶的視覺體驗。而圖片優化的思路、場景與措施,又是另一個說來話長的故事了。下面,咱們就一塊兒進入圖片的小天地,一窺究竟。

相關文章
相關標籤/搜索