企業級項目:webpack中的性能優化

webpack性能優化概述

很多很多人都認爲性能是一個項目必不可少的,我總結了有關webpack構建項目中的性能優化的幾個方面。在瞭解性能優化之前,最好對webpack編譯原理有所瞭解,方便更深入的學習。
可以參考:大神眼中的webpack構建工具:對編譯原理的分析

本文中性能優化目錄:

  • 構建性能:是指在開發階段的構建性能,而不是生產環境的構建性能,儘可能提高開發效率
    • 減少模塊解析:
    • 優化loader性能
    • 熱替換
  • 傳輸性能:服務端的JS傳輸給客戶端的時間。總代碼量越少,時間越少。文件數量越少,http請求次數越少。
    • 分包
      • 手動分包
      • 自動分包
    • 體積優化
      • 代碼壓縮
      • tree shaking
    • 懶加載
    • gzip
  • 運行性能:在瀏覽器端的運行速度。
    • 運行性能主要在書寫代碼中體現

一、構建性能

1、減少模塊解析

在這裏插入圖片描述
模塊解析包括:抽象語法樹分析、依賴分析、模塊語法替換。如果一個模塊不做模塊解析,那麼經過loaders處理後的代碼就是最後的源碼。但是模塊解析又是必須要做的步驟,那麼如何減少模塊解析?嘿嘿。如果一個模塊中沒有其他依賴就可以不對其進行模塊解析,其實,減少模塊解析主要是針對一些已經打包好的第三方庫,比如jquery等。配置一個模塊不進行解析很簡單,只要在module中配置noParse。一個正則表達式。

module.exports = {
    mode: "development",
    devtool: "source-map",
    module: {
        rules:[],
        noParse: /jquery/
    }
}

2、優化loader性能

(1)減少loader應用範圍

優化loader的性能,其實就是進一步限制loader的應用範圍,對於某些庫,不需要使用loader,比如說babel-loaderbabel-loader是將某些ES2015+轉換爲瀏覽器識別的語法。但是某些庫,本來就沒有使用這麼高版本的語法,使用loader處理完全是浪費時間,所以不需要對其進行loader處理了呀。比如loadsh庫。我們可以通過配置,讓其跳過loader處理。

通過module.rule.excludemodule.rule.include,排除或僅包含需要應用loader的場景

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /lodash/,
                use: "babel-loader"
            }
        ]
    }
}

當然,第三方大部分庫都已經對其進行了babel處理,如果暴力一點,甚至可以排除掉node_modules目錄中的模塊,或僅轉換src目錄的模塊。但是要慎重,排除之前要去官網看看是否已經處理過了。

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                //也可以用 include: /src/,效果相同
                use: "babel-loader"
            }
        ]
    }
}

(2)緩存loader的結果

如果某個文件內容不變,經過相同的loader解析後,解析後的結果也不變,所以我們可以將loader的解析結果保存下來,讓後續的解析直接使用緩存的結果,當然這種方式會增加第一次構建時間

cache-loader可以實現這樣的功能,要將cache-loader放在最前面,雖然放在最前面,但是他可以決定讓後續loader是否運行。具體配置看官方文檔!

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
            {
                loader: "cache-loader",
                options:{
                  cacheDirectory: "./cache" //緩存的目錄
                }
          	}, ...loaders] //其餘的loaders
      },
    ],
  },
};

實際上,loader的運行過程中,還包含一個過程,即pitch

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-l6TffEJ7-1584435464160)(F:\博客\前端工程化\assets\pitch-loader運行過程.png)]

(3)爲loader開啓多線程

如果loader進行處理的過程是一個異步操作的話,可以大大減少處理時間,thread-loader會開啓一個線程池,它會把後續的loader放到線程池的線程中運行,以提高構建效率。因爲後續的loader是放入新的線程池中,就無法使用webpack api、自定義的plugin api,也無法訪問的webpack options。具體把thread-loader放在什麼位置,要根據項目視情況而定,可以傻瓜式測試。但要注意的是,開啓和管理新的線程也是需要時間的。

module.exports = {
  mode: "development",
  devtool: "source-map",
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          "file-loader",
          "thread-loader", //將thread-loader和babel-loader放入新的線程中
          "babel-loader"
        ]
      }
    ]
  }
};

3、熱替換 HMR

我們在使webpack-dev-server開發服務器時,他會事時的監控代碼變動,不需要重新帶包,但是webpack-dev-server發現代碼變動的時候,瀏覽器會刷新,重新請求所有資源。這顯然不是我們開發最理想的結果,我們更希望,當我們更改一部分代碼的時候,瀏覽器不刷新,只是局部進行替換。熱替換就是實習了局部替換。要注意熱替換不會講題構建的性能,但是它可以降低代碼變動到效果呈現的時間。

使用webpack-dev-server的流程:
在這裏插入圖片描述

使用熱替換的流程:
在這裏插入圖片描述

使用熱替換

  1. 更改配置:
module.exports = {
  devServer:{
    hot:true // 開啓熱替換
  },
  plugins:[ 
    new webpack.HotModuleReplacementPlugin() //使用插件
  ]
}
  1. 更改代碼:隨便一個文件寫入以下代碼,只要保證運行即可
// index.js
if(module.hot){ // 是否開啓了熱更新
  module.hot.accept() // 接受熱更新
}

熱替換原理

當在配置文件中開啓了熱替換後,webpack-dev-server會向打包結果中注入module.hot屬性,所以在上述文件中添加的module.hot代碼。默認情況下,webpack-dev-server不管是否開啓了熱更新,當重新打包後,都會調用location.reload刷新頁面,但是如果運行了module.hot.accept(),就不會再調用loaction.reload()來刷新頁面,而是使用websocket的方式,module.hot.accept()會讓服務器更新的內容通過websocket傳送給瀏覽器,僅僅是傳輸修改的部分。然後將結果交給插件HotModuleReplacementPlugin注入的代碼執行,插件HotModuleReplacementPlugin會根據覆蓋原始代碼,然後讓代碼重新執行。

比如我在這裏修改了一個js文件的模塊導出,當監控到代碼發生變化以後,websocket向客戶端發出了兩個服務,第一個是熱替換的哈希值,不解釋。

在這裏插入圖片描述

第二個就是需要熱替換的代碼,當接收到這個這段代碼的時候,HotModuleReplacementPlugin插件就會根據key值"./src/a.js"來找到模塊的位置,將模塊的value重新覆蓋。

在這裏插入圖片描述
module.hot.accept()的作用是讓webpack-dev-server通過socket管道,把服務器更新的內容發送到瀏覽器
簡單來說,熱替換就是開啓熱替換的webpack-dev-server開發服務器監控到代碼變化,通過websocket從服務的向客戶端發送變化的內容,客戶端接受到變化的內容後替換掉原內容。

樣式熱替換

對於樣式也是可以使用熱替換的,需要使用style-loader,因爲熱替換髮生時HotModuleReplacementPlugin只會簡單的重新運行模塊代碼。因此style-loader的代碼一運行,就會重新設置style元素中的樣式。而mini-css-extract-plugin,由於它生成文件是在構建期間,運行期間並會也無法改動文件,因此它對於熱替換是無效的。

二、傳輸性能

1、手動分包

手動分包的總體思路是先單獨打包公共模塊,公共模塊會被打包成一個動態鏈接庫(ddl),並且形成一個資源清單。然後再根據入口模塊進行正常的打包過程。

在這裏插入圖片描述
當正常打包時,如果發現模塊中使用了資源清單中描述的模塊,如下

//源碼,入口文件index.js
import $ from "jquery"
import _ from "lodash"
_.isArray($(".red"));

由於資源清單中包含jquerylodash兩個模塊,因此打包結果不會出現jquerylodash的源代碼,而是通過導出一個模塊的方式,如下

(function(modules){
  //...
})({
  // index.js文件的打包結果並沒有變化
  "./src/index.js":
  function(module, exports, __webpack_require__){
    var $ = __webpack_require__("./node_modules/jquery/index.js")
    var _ = __webpack_require__("./node_modules/lodash/index.js")
    _.isArray($(".red"));
  },
  // 由於資源清單中存在,jquery的代碼並不會出現在這裏
  "./node_modules/jquery/index.js":
  function(module, exports, __webpack_require__){
    module.exports = jquery;
  },
  // 由於資源清單中存在,lodash的代碼並不會出現在這裏
  "./node_modules/lodash/index.js":
  function(module, exports, __webpack_require__){
    module.exports = lodash;
  }
})

這樣一來,重複代碼就會減少,也就減少了傳輸時的體積。

(1)打包公共模塊

打包公共模塊是一個獨立的打包過程,所以我們通常會重建一個配置文件webpack.dll.config.js,需要兩個過程,首先打包公共模塊,暴露變量名,然後用DllPlugin插件生成資源清單

const path = require("path")
const webpack = require("webpack")
module.exports = {
  mode: "production",
  entry: {//打包公共模塊
    jquery: ["jquery"],
    lodash: ["lodash"]
  },
  output: {
    filename: "dll/[name].js",
    library: "[name]"//
  },
  plugins: [//生成資源清單
    new webpack.DllPlugin({
      path: path.resolve(__dirname, "dll", "[name].manifest.json"), //資源清單的保存位置
      name: "[name]"//資源清單中,暴露的變量名
    })
  ]
};

運行後,即可完成公共模塊打包

npx webpack --config webpack.dll.config.js

(2)使用公共模塊

  1. 在頁面中手動引入公共模塊
<script src="./dll/jquery.js"></script>
<script src="./dll/lodash.js"></script>
  1. 爲了避免把公共模塊清除,需要重新設置clean-webpack-plugin,如果沒有使用你該插件則忽略
new CleanWebpackPlugin({
  // 要清除的文件或目錄
  // 排除掉dll目錄本身和它裏面的文件
  cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})

目錄和文件的匹配規則使用的是globbing patterns

  1. 使用DllReferencePlugin,告訴webpack資源清單的位置,如果遇到導出模塊已經在資源清單中,則不需要再進行打包。
module.exports = {
  plugins:[
    new webpack.DllReferencePlugin({
      manifest: require("./dll/jquery.manifest.json")
    }),
    new webpack.DllReferencePlugin({
      manifest: require("./dll/lodash.manifest.json")
    })
  ]
}

簡單來說,手動打包首先要開啓output.library暴露公共模塊,使用webpack.DllPlugin插件生成資源清單(可以不使用,自己寫),然後在頁面中引入資源清單中的依賴,最後用DllReferencePlugin插件使用資源清單。

在手動打包的過程中,我們需要注意,資源清單是不參與運行的,所以不能把資源清單放在打包目錄中。

手動打包優點

  1. 極大提升自身模塊的打包速度
  2. 極大的縮小了自身文件體積
  3. 有利於瀏覽器緩存第三方庫的公共代碼

缺點

  1. 使用非常繁瑣
  2. 如果第三方庫中包含重複代碼,則效果不太理想

2、自動分包

自動包區別於手動分包的是不需要確定具體爲那個模塊分包,而是從一個宏觀的角度來控制分包,那麼要控制分包,就需要有一個合理的分包策略。webpack4已經放棄了原來用CommonsChunkPlugin實現分包,而是在內部使用SplitChunksPlugin進行分包。

分包流程:分包時,webpack根據分包策略,實現具體的分包,它會開啓一個新的chunk,對分離的模塊進行打包處理。公共代碼會生成新chunk即common,最後打包成budle_common.js 如圖所示:
在這裏插入圖片描述

自動分包原理:自動分包會檢查每個chunk編譯的結果,根據分包策略,找到那些滿足策略的模塊,並生成新的chunk打包這些模塊,再將打包出去的模塊從原來的包中移除,並修改原來包的代碼

(1)分包策略的基本配置

webpack提供了optimization配置項,用於配置一些優化信息,其中splitChunks是分包策略的配置,其中有以下常用配置

  • chunks:該配置項用於配置需要應用分包策略的chunk,有以下三個值,默認時async
    • all: 對於所有的chunk都要應用分包策略,一般來說使用這個值
    • async:僅針對異步chunk應用分包策略
    • initial:僅針對普通chunk應用分包策略
  • maxSize:如果一個要被分出來的包超過了該值,webpack就會盡可能的將其分成多個包。注意:分包的基礎單位是模塊,如果一個完整的模塊超過了該體積,它是無法做到再切割的,因此,儘管使用了這個配置,完全有可能某個包還是會超過這個體積。通常不使用
  • automaticNameDelimiter:新chunk名稱的分隔符,默認值~
  • minChunks:一個模塊至少被多少個chunk使用時,纔會進行分包,默認值1
  • minSize:當分包達到多少字節後才允許被真正的拆分,默認值30000
module.exports = {
  optimization: {
    splitChunks: {
      //分包配置
      chunks: "all",
      //maxSize: 60000
      automaticNameDelimiter: ".",
      minChunks: 2,
      minSize: 30000
    }
  }
}

(2)緩存組

實際上,分包策略是基於緩存組的。每個緩存組提供一套獨有的策略,webpack按照緩存組的優先級依次處理每個緩存組,被緩存組處理過的分包不需要再次分包。默認情況下,webpack提供了兩個緩存組,很多時候,緩存組對於我們來說沒什麼意義,因爲默認的緩存組就已經夠用了。

webpack默認緩存組

module.exports = {
  optimization:{
    splitChunks: {
      chunks:"all",
      //全局配置
      cacheGroups: {
        // 屬性名是緩存組名稱,會影響到分包的chunk名
        // 屬性值是緩存組的配置,緩存組繼承所有的全局配置,也有自己特殊的配置
        vendors: { 
          test: /[\\/]node_modules[\\/]/, // 當匹配到相應模塊時,將這些模塊進行單獨打包
          priority: -10 // 緩存組優先級,優先級越高,該策略越先進行處理,默認值爲0
        },
        default: {
          minChunks: 2,  // 覆蓋全局配置,將最小chunk引用數改爲2
          priority: -20, // 優先級
          reuseExistingChunk: true // 重用已經被分離出去的chunk
        }
      }
    }
  }
}

通過緩存組對公共樣式分離:webpack.config.js

const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
  optimization: {
    splitChunks: {
      chunks: "all",
      cacheGroups: {
        styles: {
          test: /\.css$/, // 匹配樣式模塊
          minSize: 30000, 
          minChunks: 2
        }
      }
    }
  },
  module: {
    rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] }]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: "./public/index.html",
      chunks: ["index"]
    }),
    new MiniCssExtractPlugin({
      filename: "[name].[hash:5].css",
      // chunkFilename是配置來自於分割chunk的文件名
      chunkFilename: "common.[hash:5].css" 
    })
  ]
}

3、代碼壓縮

爲生產環境進行代碼壓縮,減少代碼體積是增加傳輸性能必不可少的環節,進行代碼壓縮同時也可以破壞代碼可讀性,提升破解成本,目前流行的代碼壓縮工具主要有UglifyJsTerser

UglifyJs是一個傳統的代碼壓縮工具,已存在多年,曾經是前端應用的必備工具,但由於它不支持ES6語法,所以目前的流行度已有所下降。

Terser是一個新起的代碼壓縮工具,支持ES6+語法,因此被很多構建工具內置使用。

Terser官網:https://terser.org/

webpack已經內置了Terser,所以我們在啓用生產環境後即可用其進行代碼壓縮。

webpack自動集成了Terser如果你想更改、添加壓縮工具,又或者是想對Terser進行配置,使用下面的webpack配置即可

const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
  optimization: {
    // 是否要啓用壓縮,默認情況下,生產環境會自動開啓
    minimize: true, 
    minimizer: [ // 壓縮時使用的插件,可以有多個
      new TerserPlugin(), 
      new OptimizeCSSAssetsPlugin()
    ],
  },
};

4、tree shaking

代碼壓縮可以移除模塊內部的無效代碼,而tree shaking可以移除模塊之間的無效代碼。比如說

// myMath.js
export function add(a, b){
  console.log("add")
  return a+b;
}

export function sub(a, b){
  console.log("sub")
  return a-b;
}

這個工具模塊有兩個導出方法,但是整個項目只使用了add,如果在打包的時候兩個方法都打包的話無疑會增加無效代碼量,tree shaking的作用就是移除無效的代碼塊。webpack2開始就支持了tree shaking。只要是生產環境,tree shaking自動開啓

tree shaking工作原理webpack會從入口模塊出發尋找依賴關係,當解析一個模塊時,webpack會根據ES6的模塊導入語句來判斷,該模塊依賴了另一個模塊的哪個導出。依賴分析完畢後,webpack會根據每個模塊每個導出是否被使用,標記未使用的導出爲dead code,然後交給代碼壓縮工具處理。代碼壓縮工具最終移除掉那些dead code代碼

在具體分析依賴時,webpack堅持的原則是:保證代碼正常運行,然後再儘量tree shaking

我們在書寫導入導出時,儘量使用以下方式:

  • 使用export xxx導出,而不使用export default {xxx}導出
  • 使用import {xxx} from "xxx"導入,而不使用import xxx from "xxx"導入

所以,如果你依賴的是一個導出的對象,由於JS語言的動態特性,以及webpack還不夠智能,爲了保證代碼正常運行,它不會移除對象中的任何信息。

ES6的模塊導入語句:使用ES6的模塊導入語句,有利於更好的分析依賴,是因爲ES6模塊有以下特點:

  • 導入導出語句只能是頂層語句

  • import的模塊名只能是字符串常量

  • import綁定的變量是不可變的

使用第三方庫tree shaking注意

某些第三方庫可能使用的是commonjs的方式導出,比如lodash又或者沒有提供普通的ES6方式導出。對於這些庫,tree shaking是無法發揮作用的。但好在很多流行的庫都發布了它的ES6版本,比如lodash-es。我們在使用loadsh的時候可以使用lodash-es

副作用函數(side effect):函數運行過程中,可能會對外部環境造成影響的功能。如果函數中包含以下代碼,該函數叫做副作用函數:異步代碼localStorage對外部數據的修改

純函數(pure function):如果一個函數沒有副作用,同時,函數的返回結果僅依賴參數,則該函數叫做

webpack堅持的原則是:保證代碼正常運行,然後再儘量tree shaking。因此當webpack無法確定某個模塊是否有副作用時,它往往將其視爲有副作用。當我們知道某個導出沒有副作用,但是webpack擔心common.js有副作用,如果去掉會影響某些功能,這時候我們就需要標記該文件是沒有副作用的。當然,第三方插件中,一般都已經標記過了,我們無需自己添加。

package.json中加入sideEffects

{
    "sideEffects": false
    //"sideEffects": ["!src/common.js"]
}

5、懶加載

懶加載就是動態加載,按需加載,當我們需要的時候再加載。使用import()語法。import()會返回一個promise

if(Math.random()<0.5){
	const {add} =await import("./utils.js")
	const result = add(1,3)
}

此時,utils工具類是等到要執行時纔會引入,而不會變成頂層語句直接執行。當我們執行到import時纔會到服務端請求該模塊的js文件,而不是在頁面加載的時候就去請求,可以減少首頁加載時間過長

6、gzip

gzip是一種壓縮文件的算法,當我們js文件過大的時候,就可以使用gzip的方式,對文件進行壓縮,配合服務器端進行使用。

具體使用參照1:
webpack-dev-server開發服務器 和 webpack中常用plugin和loader一文中的compression-webpack-plugin插件
具體使用參照2:
webpack+nginx實現gzip壓縮解決vue首屏加載過慢

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章