【工程】webpack 系列 —效能優化

語言: CN / TW / HK

theme: cyanosis highlight: atom-one-dark


打算系統的整理一下,webpack 的一些知識點,也是時候結合專案中使用的一些 案例,做一些總結了。

webpack系列 打算從 webpack核心功能 -> 常用擴充套件 -> CSS 工程化 -> JS 相容性 -> 效能優化 這幾個方面開始記錄。

以及結合一些案例,方便大家閱讀和實踐,以備 開箱即用

倉庫地址:PantherVkin/webpack-note (github.com)

效能優化概述

效能優化主要從下面三個維度入手:

image.png

image.png

  1. 構建效能

這裡所說的構建效能,是指在開發階段的構建效能,而不是生產環境的構建效能。

優化的目標,是降低從打包開始,到程式碼效果呈現所經過的時間

構建效能會影響開發效率。構建效能越高,開發過程中時間的浪費越少。

  1. 傳輸效能

傳輸效能是指,打包後的JS程式碼傳輸到瀏覽器經過的時間。

在優化傳輸效能時要考慮到:

  • 總傳輸量:所有需要傳輸的JS檔案的內容加起來,就是總傳輸量,重複程式碼越少,總傳輸量越少

  • 檔案數量:當訪問頁面時,需要傳輸的JS檔案數量,檔案數量越多,http請求越多,響應速度越慢

  • 瀏覽器快取:JS檔案會被瀏覽器快取,被快取的檔案不會再進行傳輸

  • 執行效能

執行效能是指,JS程式碼在瀏覽器端的執行速度。

它主要取決於我們如何書寫高效能的程式碼

構建效能

減少模組解析

  1. 什麼叫做模組解析?

image.png

模組解析包括:抽象語法樹分析、依賴分析、模組語法替換

  1. 不做模組解析會怎樣?

image.png

如果某個模組不做解析,該模組經過loader處理後的程式碼就是最終程式碼。

如果沒有loader對該模組進行處理,該模組的原始碼就是最終打包結果的程式碼。

如果不對某個模組進行解析,可以縮短構建時間

  1. 哪些模組不需要解析?

模組中無其他依賴:一些已經打包好的第三方庫,比如jquery。

  1. 如何讓某個模組不要解析?

  2. 配置module.noParse,它是一個正則,被正則匹配到的模組不會解析。

js module.exports = { mode: "development", module: { noParse: /test/ } }

  • 完整案例

image.png

webpack-note/examples/5.1-減少模組解析 at master · PantherVkin/webpack-note (github.com)

優化loader效能

限制loader的應用範圍

對於某些庫,不使用loader ?

例如:babel-loader可以轉換ES6或更高版本的語法,可是有些庫本身就是用ES5語法書寫的,不需要轉換,使用babel-loader反而會浪費構建時間。

lodash就是這樣的一個庫。

lodash是在ES5之前出現的庫,使用的是ES3語法。

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

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

  1. 如果暴力一點,甚至可以排除掉node_modules目錄中的模組,或僅轉換src目錄的模組。

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

這種做法是對loader的範圍進行進一步的限制,和noParse不衝突

快取loader的結果

我們可以基於一種假設:

如果某個檔案內容不變,經過相同的loader解析後,解析後的結果也不變。

於是,可以將loader的解析結果儲存下來,讓後續的解析直接使用儲存的結果。

  1. cache-loader可以實現這樣的功能。

js module.exports = { module: { rules: [ { test: /\.js$/, use: ['cache-loader', ...loaders] }, ], }, };

  1. loader 的 pitch 過程

有趣的是,cache-loader放到最前面,卻能夠決定後續的loader是否執行

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

image.png

loaderN.pitch 有返回值,則把返回的原始碼 交給上一個loader。

如果沒有返回值,繼續 下一個 loader(N+1).pitch

``js function loader(source) { returnnew source` }

loader.pitch = function (filePath) { // 可返回可不返回 // 如果返回,返回原始碼 }

module.exports = loader ```

  1. cache-loader 的原理

    同上。

  2. 完整案例

image.png

webpack-note/webpack.config.js at master · PantherVkin/webpack-note (github.com)

為loader的執行開啟多執行緒

thread-loader會開啟一個執行緒池,執行緒池中包含適量的執行緒。

它會把後續的loader放到執行緒池的執行緒中執行,以提高構建效率。

由於後續的loader會放到新的執行緒中,所以,後續的loader不能:

  • 使用 webpack api 生成檔案
  • 無法使用自定義的 plugin api
  • 無法訪問 webpack options

在實際的開發中,可以進行測試,來決定thread-loader放到什麼位置。

特別注意,開啟和管理執行緒需要消耗時間,在小型專案中使用thread-loader反而會增加構建時間。

熱替換 HMR

當使用webpack-dev-server時,考慮程式碼改動到效果呈現的過程。

image.png

而使用了熱替換後,流程發生了變化。

熱替換並不能降低構建時間(可能還會稍微增加),但可以降低程式碼改動到效果呈現的時間

image.png

使用和原理

  1. 更改配置

js module.exports = { devServer:{ hot:true // 開啟HMR }, plugins:[ // 可選 new webpack.HotModuleReplacementPlugin() ] }

  1. 更改程式碼

```js // index.js

if(module.hot){ // 是否開啟了熱更新 module.hot.accept() // 接受熱更新 } ```

首先,這段程式碼會參與最終執行!

當開啟了熱更新後,webpack-dev-server會向打包結果中注入module.hot屬性。

預設情況下,webpack-dev-server不管是否開啟了熱更新,當重新打包後,都會呼叫。location.reload重新整理頁面。

但如果運行了module.hot.accept(),將改變這一行為。

module.hot.accept()的作用是讓webpack-dev-server通過socket管道,把伺服器更新的內容傳送到瀏覽器。

image.png

然後,將結果交給外掛HotModuleReplacementPlugin注入的程式碼執行。

外掛HotModuleReplacementPlugin會根據覆蓋原始程式碼,然後讓程式碼重新執行。

所以,熱替換髮生在程式碼執行期

樣式熱替換

對於樣式也是可以使用熱替換的,但需要使用style-loader

因為熱替換髮生時,HotModuleReplacementPlugin只會簡單的重新執行模組程式碼。

因此style-loader的程式碼一執行,就會重新設定style元素中的樣式。 ```js const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = { mode: "development", devtool: "source-map", devServer: { open: true, hot: true }, module:{ rules:[ {test:/.css$/, use:["style-loader", "css-loader"]} ] }, plugins: [ new HtmlWebpackPlugin({ template: "./public/index.html" }) ] }; ```

mini-css-extract-plugin,由於它生成檔案是在構建期間,執行期間並會也無法改動檔案,因此它對於熱替換是無效的。

傳輸效能

手動分包

基本原理

  1. 先單獨的打包公共模組

暴露出全域性變數

image.png

公共模組會被打包成為動態連結庫(dll Dynamic Link Library),並生成資源清單。

  1. 根據入口模組進行正常打包

打包時,如果發現模組中使用了資源清單中描述的模組,則不會形成下面的程式碼結構。

js //原始碼,入口檔案index.js import $ from "jquery" import _ from "lodash" _.isArray($(".red"));

由於資源清單中包含jquerylodash兩個模組,因此打包結果的大致格式是:

js (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 單獨打包公共模組,暴露全域性變數名

js // webpack.dll.config.js module.exports = { mode: "production", entry: { jquery: ["jquery"], lodash: ["lodash"] }, output: { filename: "dll/[name].js", library: "[name]" // 每個bundle 暴露的全域性變數名 } };

  1. 利用DllPlugin生成資源清單

```js // webpack.dll.config.js const webpack = require("webpack"); const path = require("path");

module.exports = { mode: "production", entry: { jquery: ["jquery"], lodash: ["lodash"] }, output: { filename: "dll/[name].js", library: "[name]" // 每個bundle暴露的全域性變數名 }, plugins: [ new webpack.DllPlugin({ path: path.resolve(__dirname, "dll", "[name].manifest.json"), name: "[name]" }) ] }; ```

  1. 執行後,即可完成公共模組打包 js $ npx webpack --config webpack.dll.config.js

image.png

使用公共模組

根據入口模組進行正常打包

  1. index.html 頁面中手動引入公共模組

或者使用CDN

```html

```

  1. webpack.config.js 重新設定clean-webpack-plugin

如果使用了外掛clean-webpack-plugin,為了避免它把公共模組清除,需要做出以下配置。

js new CleanWebpackPlugin({ // 要清除的檔案或目錄 // 排除掉dll目錄本身和它裡面的檔案 cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*'] })

目錄和檔案的匹配規則使用的是globbing patterns

  1. webpack.config.js 使用DllReferencePlugin控制打包結果

動態連結庫引用外掛。

發現資源清單中有的模組,不會打包到最終程式碼,把這個模組忽略掉,直接匯出 全域性變數。

js module.exports = { plugins:[ new webpack.DllReferencePlugin({ manifest: require("./dll/jquery.manifest.json") }), new webpack.DllReferencePlugin({ manifest: require("./dll/lodash.manifest.json") }) ] }

  1. 完整案例

webpack-note/examples/5.3-手動分包 at master · PantherVkin/webpack-note (github.com)

總結

手動打包的過程

  1. 開啟output.library暴露公共模組

  2. DllPlugin建立資源清單

  3. DllReferencePlugin使用資源清單

手動打包的注意事項

  1. 資源清單不參與執行,可以不放到打包目錄中

  2. 記得手動引入公共JS,以及避免被刪除

  3. 不要對小型的公共JS庫使用

優點

  1. 極大提升自身模組的打包速度
  2. 極大的縮小了自身檔案體積
  3. 有利於瀏覽器快取第三方庫的公共程式碼

缺點

  1. 使用非常繁瑣

  2. 如果第三方庫中包含重複程式碼,則效果不太理想

自動分包

基本原理

不同與手動分包,自動分包是從實際的角度出發,從一個更加巨集觀的角度來控制分包,而一般不對具體哪個包要分出去進行控制。

因此使用自動分包,不僅非常方便,而且更加貼合實際的開發需要。

要控制自動分包,關鍵是要配置一個合理的分包策略

有了分包策略之後,不需要額外安裝任何外掛,webpack會自動的按照策略進行分包。

實際上,webpack在內部是使用SplitChunksPlugin進行分包的。

image.png

從分包流程中至少可以看出以下幾點:

  • 分包策略至關重要,它決定了如何分包

  • 分包時,webpack開啟了一個新的chunk,對分離的模組進行打包

  • 打包結果中,公共的部分被提取出來形成了一個單獨的檔案,它是新chunk的產物

分包策略的配置

webpack提供了optimization配置項,用於配置一些優化資訊。

其中splitChunks是分包策略的配置。

js module.exports = { optimization: { splitChunks: { // 分包策略 } } }

事實上,分包策略有其預設的配置,我們只需要輕微的改動,即可應對大部分分包場景。

  1. chunks

該配置項用於配置需要應用分包策略的chunk。

我們知道,分包是從已有的chunk中分離出新的chunk,那麼哪些chunk需要分離呢。

chunks有三個取值,分別是:

  • all: 對於所有的chunk都要應用分包策略
  • async:【預設】僅針對非同步chunk應用分包策略
  • initial:僅針對普通chunk應用分包策略

所以,你只需要配置chunksall即可。

  1. maxSize

該配置可以控制包的最大位元組數

如果某個包(包括分出來的包)超過了該值,則webpack會盡可能的將其分離成多個包。

但是不要忽略的是,分包的基礎單位是模組,如果一個完整的模組超過了該體積,它是無法做到再切割的,因此,儘管使用了這個配置,完全有可能某個包還是會超過這個體積。

另外,該配置看上去很美妙,實際意義其實不大。

因為分包的目的是提取大量的公共程式碼,從而減少總體積和充分利用瀏覽器快取。

雖然該配置可以把一些包進行再切分,但是實際的總體積和傳輸量並沒有發生變化。

如果要進一步減少公共模組的體積,只能是壓縮和tree shaking

  1. 分包策略的其他配置

如果不想使用其他配置的預設值,可以手動進行配置:

  • automaticNameDelimiter:新chunk名稱的分隔符,預設值~
  • minChunks:一個模組被多少個chunk使用時,才會進行分包,預設值1
  • minSize:當分包達到多少位元組後才允許被真正的拆分,預設值30000

快取組

之前配置的分包策略是全域性的。

而實際上,分包策略是基於快取組的

每個快取組提供一套獨有的策略,webpack按照快取組的優先順序依次處理每個快取組,被快取組處理過的分包不需要再次分包。

預設情況下,webpack提供了兩個快取組:

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

很多時候,快取組對於我們來說沒什麼意義,因為預設的快取組就已經夠用了。

但是我們同樣可以利用快取組來完成一些事情,比如對公共樣式的抽離。

js module.exports = { optimization: { splitChunks: { chunks: "all", cacheGroups: { styles: { test: /\.css$/, // 匹配樣式模組 minSize: 0, // 覆蓋預設的最小尺寸,這裡僅僅是作為測試 minChunks: 2 // 覆蓋預設的最小chunk引用數 } } } }, 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" }) ] }

  1. 完整案例

webpack-note/examples/5.4-自動分包 at master · PantherVkin/webpack-note (github.com)

配合多頁應用

雖然現在單頁應用是主流,但免不了還是會遇到多頁應用。

由於在多頁應用中需要為每個html頁面指定需要的chunk,這就造成了問題。

js new HtmlWebpackPlugin({ template: "./public/index.html", chunks: ["index~other", "vendors~index~other", "index"] })

我們必須手動的指定被分離出去的chunk名稱,這不是一種好辦法。

幸好html-webpack-plugin的新版本中解決了這一問題。

shell $ npm i -D html-webpack-plugin@next

做出以下配置即可:

js new HtmlWebpackPlugin({ template: "./public/index.html", chunks: ["index"] })

它會自動的找到被index分離出去的chunk,並完成引用。

目前這個版本仍處於測試解決,還未正式釋出。

原理

自動分包的原理其實並不複雜,主要經過以下步驟:

  1. 檢查每個chunk編譯的結果
  2. 根據分包策略,找到那些滿足策略的模組
  3. 根據分包策略,生成新的chunk打包這些模組(程式碼有所變化)
  4. 打包出去的模組從原始包中移除,並修正原始包程式碼

在程式碼層面,有以下變動

  1. 分包的程式碼中,加入一個全域性變數,型別為陣列,其中包含公共模組的程式碼
  2. 原始包的程式碼中,使用陣列中的公共程式碼

程式碼壓縮

  1. 為什麼要進行程式碼壓縮?

減少程式碼體積;破壞程式碼的可讀性,提升破解成本。

  1. 什麼時候要進行程式碼壓縮?

生產環境。

  1. 使用什麼壓縮工具?

目前最流行的程式碼壓縮工具主要有兩個:UglifyJsTerser

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

Terser是一個新起的程式碼壓縮工具,支援ES6+語法,因此被很多構建工具內建使用。webpack安裝後會內建Terser,當啟用生產環境後即可用其進行程式碼壓縮。

Terser

Terser的官網可嘗試它的壓縮效果。

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

webpack+Terser

webpack自動集成了Terser。

如果你想更改、新增壓縮工具,又或者是想對Terser進行配置,使用下面的webpack配置即可。

```js const TerserPlugin = require('terser-webpack-plugin'); const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = { optimization: { // 是否要啟用壓縮,預設情況下,生產環境會自動開啟 minimize: true, minimizer: [ // 壓縮時使用的外掛,可以有多個 new TerserPlugin(), new OptimizeCSSAssetsPlugin() ], }, }; ```

tree shaking

壓縮可以移除模組內部的無效程式碼。

tree shaking 可以移除模組之間的無效程式碼。

  1. 背景?

某些模組匯出的程式碼並不一定會被用到。

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

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

js // index.js import {add} from "./myMath" console.log(add(1,2));

tree shaking 用於移除掉不會用到的匯出。

  1. 使用

webpack2開始就支援了tree shaking

只要是生產環境,tree shaking自動開啟。

原理

webpack會從入口模組出發尋找依賴關係。

當解析一個模組時,webpack會根據ES6的模組匯入語句來判斷,該模組依賴了另一個模組的哪個匯出。

webpack之所以選擇ES6的模組匯入語句,是因為ES6模組有以下特點:

  1. 匯入匯出語句只能是頂層語句
  2. import的模組名只能是字串常量
  3. import繫結的變數是不可變的

這些特徵都非常有利於分析出穩定的依賴。

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

所以,如果你依賴的是一個匯出的物件,由於JS語言的動態特性,以及webpack還不夠智慧,為了保證程式碼正常執行,它不會移除物件中的任何資訊。

因此,我們在編寫程式碼的時候,儘量

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

依賴分析完畢後,webpack會根據每個模組每個匯出是否被使用,標記其他匯出為dead code,然後交給程式碼壓縮工具處理。

程式碼壓縮工具最終移除掉那些dead code程式碼。

使用第三方庫

某些第三方庫可能使用的是commonjs的方式匯出,比如lodash

又或者沒有提供普通的ES6方式匯出。

對於這些庫,tree shaking是無法發揮作用的。

因此要尋找這些庫的es6版本,好在很多流行但沒有使用的ES6的第三方庫,都發布了它的ES6版本,比如lodash-es

作用域分析

tree shaking本身並沒有完善的作用域分析,可能導致在一些dead code函式中的依賴仍然會被視為依賴。

外掛webpack-deep-scope-plugin(個人開發的)提供了作用域分析,可解決這些問題。

副作用問題

webpack在tree shaking的使用,有一個原則:一定要保證程式碼正確執行

在滿足該原則的基礎上,再來決定如何tree shaking

因此,當webpack無法確定某個模組是否有副作用時,它往往將其視為有副作用 。

因此,某些情況可能並不是我們所想要的 。

```js //common.js var n = Math.random();

//index.js import "./common.js" ```

雖然我們根本沒用有common.js的匯出,但webpack擔心common.js有副作用,如果去掉會影響某些功能。

如果要解決該問題,就需要標記該檔案是沒有副作用的。

package.json中加入sideEffects

json { "sideEffects": false }

有兩種配置方式:

  • false

    當前工程中,所有模組都沒有副作用。

    注意,這種寫法會影響到某些css檔案的匯入。

  • 陣列

    設定哪些檔案擁有副作用,例如:["!src/common.js"],表示只要不是src/common.js的檔案,都有副作用。

    這種方式我們一般不處理,通常是一些第三方庫在它們自己的package.json中標註。

css tree shaking

webpack無法對css完成tree shaking,因為csses6沒有半毛錢關係。

因此對csstree shaking需要其他外掛完成。

例如:purgecss-webpack-plugin

注意:purgecss-webpack-plugincss module無能為力。

懶載入

  1. 案例

點選之後載入。

js const btn = document.querySelector('button') btn.onclick = async function () { //動態載入 //import 是ES6的草案 //瀏覽器會使用JSOP的方式遠端去讀取一個js模組 //import()會返回一個promise (* as obj) // const { chunk } = await import(/* webpackChunkName:"lodash" */"lodash-es"); const {chunk} = await import('./util') const result = chunk([3, 5, 6, 7, 87], 2) console.log(result) }

image.png

  1. 原理

點選後把遠端的模組放到 webpackJsonp 陣列中,這樣主模組就能使用了。

  1. 完整案例

webpack-note/examples/5.5-懶載入 at master · PantherVkin/webpack-note (github.com)

gzip

gzip是一種壓縮檔案的演算法。

  1. B/S結構中的壓縮傳輸

image.png

優點:傳輸效率可能得到大幅提升

缺點:伺服器的壓縮需要時間,客戶端的解壓需要時間

  1. 使用webpack進行預壓縮

使用compression-webpack-plugin外掛對打包結果進行預壓縮,可以移除伺服器的壓縮時間

image.png

js const {CleanWebpackPlugin} = require('clean-webpack-plugin') constCmpressionWebpackPlugin = require('compression-webpack-plugin') module.exports = { mode: 'production', optimization: { splitChunks: { chunks: 'all' } }, plugins: [ newCleanWebpackPlugin(), newCmpressionWebpackPlugin({ test: /\.js/, minRatio: 0.5 }) ] }

image.png

打包結果分析

  1. 安裝 js $ npm i -D webpack-bundle-analyzer

  2. webpack.config.js

```js const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const WebpackBundleAnalyzer = require("webpack-bundle-analyzer") .BundleAnalyzerPlugin;

module.exports = { mode: "production", optimization: { splitChunks: { chunks: "all" } }, plugins: [new CleanWebpackPlugin(), new WebpackBundleAnalyzer()] }; ```