Webpack常見面試題總結

xiangzhihong發表於2021-12-12

一、談談你對Webpack的理解

1.1 背景

Webpack 的目標是實現前端專案的模組化,從而更高效地管理和維護專案中的每一個資源。在早期的前端專案中,我們通過檔案劃分的形式來實現模組化,也就是將每個功能及其相關狀態資料各自單獨放到不同的 JS 檔案中。約定每個檔案是一個獨立的模組,然後再將這些js檔案引入到頁面,一個script標籤對應一個模組,然後再呼叫模組化的成員。比如:

<script src="module-a.js"></script>
<script src="module-b.js"></script>

但這種模組化開發的弊端也十分明顯,模組都是在全域性中工作,大量模組成員汙染了環境,模組與模組之間並沒有依賴關係、維護困難、沒有私有空間等問題。隨後,就出現了名稱空間方式,規定每個模組只暴露一個全域性物件,然後模組的內容都掛載到這個物件中。

window.moduleA = {
  method1: function () {
    console.log('moduleA#method1')
  }
}

不過,這種方式也沒有解決第一種方式的依賴等問題。接著,有出現了使用立即執行函式為模組提供私有空間,通過引數的形式作為依賴宣告。

(function ($) {
  var name = 'module-a'

  function method1 () {
    console.log(name + '#method1')
    $('body').animate({ margin: '200px' })
  }
    
  window.moduleA = {
    method1: method1
  }
})(jQuery)

上述的方式早期解決模組的方式,但是仍然存在一些沒有解決的問題。例如,我們是用過script標籤在頁面引入這些模組的,這些模組的載入並不受程式碼的控制,時間一久維護起來也十分的麻煩。

除了模組載入的問題以外,還需要規定模組化的規範,如今流行的則是CommonJS 、ES Modules。

特別是隨著前端專案的越來越大,前端開發也變得十分的複雜,我們經常在開發過程中會遇到如下的問題:

  • 需要通過模組化的方式來開發
  • 使用一些高階的特性來加快我們的開發效率或者安全性,比如通過ES6+、TypeScript開發指令碼邏輯,通過sass、less等方式來編寫css樣式程式碼
  • 監聽檔案的變化來並且反映到瀏覽器上,提高開發的效率
  • JavaScript 程式碼需要模組化,HTML 和 CSS 這些資原始檔也會面臨需要被模組化的問題
  • 開發完成後我們還需要將程式碼進行壓縮、合併以及其他相關的優化

而Webpack的出現,就是為了解決以上問題的。總的來說,Webpack是一個模組打包工具,開發者可以很方面使用Webpack來管理模組依賴,並編譯輸出模組們所需要的靜態檔案。

1.2 Webpack

Webpack 是一個用於現代JavaScript應用程式的靜態模組打包工具,可以很方面的管理模組的惡依賴。

1.2.1 靜態模組

此處的靜態模組指的是開發階段,可以被 Webpack 直接引用的資源(可以直接被獲取打包進bundle.js的資源)。當 Webpack 處理應用程式時,它會在內部構建一個依賴圖,此依賴圖對應對映到專案所需的每個模組(不再侷限js檔案),並生成一個或多個 bundle,如下圖。
在這裡插入圖片描述

1.2.2 Webpack作用

  • 編譯程式碼能力,提高效率,解決瀏覽器相容問題
    在這裡插入圖片描述
  • 模組整合能力,提高效能,可維護性,解決瀏覽器頻繁請求檔案的問題
    在這裡插入圖片描述
  • 萬物皆可模組能力,專案維護性增強,支援不同種類的前端模組型別,統一的模組化方案,所有資原始檔的載入都可以通過程式碼控制。

在這裡插入圖片描述

二、說說webpack的構建流程

webpack 的執行流程是一個序列的過程,它的工作流程就是將各個外掛串聯起來。在執行過程中會廣播事件,外掛只需要監聽它所關心的事件,就能加入到這條webpack機制中,去改變Webpack的運作。

從啟動到結束會依次經歷三大流程:

  • 初始化階段:從配置檔案和 Shell 語句中讀取與合併引數,並初始化需要使用的外掛和配置外掛等執行環境所需要的引數。
  • 編譯構建階段:從 Entry 發出,針對每個 Module 序列呼叫對應的 Loader 去翻譯檔案內容,再找到該 Module 依賴的 Module,遞迴地進行編譯處理。
  • 輸出階段:對編譯後的 Module 組合成 Chunk,把 Chunk 轉換成檔案,輸出到檔案系統。

2.1 初始化階段

初始化階段主要是從配置檔案和 Shell 語句中讀取與合併引數,得出最終的引數。配置檔案預設下為webpack.config.js,也或者通過命令的形式指定配置檔案,主要作用是用於啟用webpack的載入項和外掛。下面是webpack.config.js檔案配置,內容分析如下注釋:

var path = require('path');
var node_modules = path.resolve(__dirname, 'node_modules');
var pathToReact = path.resolve(node_modules, 'react/dist/react.min.js');

module.exports = {
  // 入口檔案,是模組構建的起點,同時每一個入口檔案對應最後生成的一個 chunk。
  entry: './path/to/my/entry/file.js',
  // 檔案路徑指向(可加快打包過程)。
  resolve: {
    alias: {
      'react': pathToReact
    }
  },
  // 生成檔案,是模組構建的終點,包括輸出檔案與輸出路徑。
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
  },
  // 這裡配置了處理各模組的 loader ,包括 css 預處理 loader ,es6 編譯 loader,圖片處理 loader。
  module: {
    loaders: [
      {
        test: /\.js$/,
        loader: 'babel',
        query: {
          presets: ['es2015', 'react']
        }
      }
    ],
    noParse: [pathToReact]
  },
  // webpack 各外掛物件,在 webpack 的事件流中執行對應的方法。
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

webpack 將 webpack.config.js 中的各個配置項拷貝到 options 物件中,並載入使用者配置的 plugins。完成上述步驟之後,則開始初始化Compiler編譯物件,該物件掌控者webpack宣告週期,不執行具體的任務,只是進行一些排程工作。

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            beforeCompile: new AsyncSeriesHook(["params"]),
            compile: new SyncHook(["params"]),
            afterCompile: new AsyncSeriesHook(["compilation"]),
            make: new AsyncParallelHook(["compilation"]),
            entryOption: new SyncBailHook(["context", "entry"])
            // 定義了很多不同型別的鉤子
        };
        // ...
    }
}

function webpack(options) {
  var compiler = new Compiler();
  ...// 檢查options,若watch欄位為true,則開啟watch執行緒
  return compiler;
}
...

在上面的程式碼中,Compiler 物件繼承自 Tapable,初始化時定義了很多鉤子函式。

2.2 編譯構建

用上一步得到的引數初始化 Compiler 物件,載入所有配置的外掛,執行物件的 run 方法開始執行編譯。然後,根據配置中的 entry 找出所有的入口檔案,如下。

module.exports = {
  entry: './src/file.js'
}

初始化完成後會呼叫Compiler的run來真正啟動webpack編譯構建流程,主要流程如下:

  • compile:開始編譯
  • make:從入口點分析模組及其依賴的模組,建立這些模組物件
  • build-module:構建模組
  • seal:封裝構建結果
  • emit:把各個chunk輸出到結果檔案

1,compile 編譯

執行了run方法後,首先會觸發compile,主要是構建一個Compilation物件。該物件是編譯階段的主要執行者,主要會依次下述流程:執行模組建立、依賴收集、分塊、打包等主要任務的物件。

2,make 編譯模組

當完成了上述的compilation物件後,就開始從Entry入口檔案開始讀取,主要執行_addModuleChain()函式,原始碼如下:

_addModuleChain(context, dependency, onModule, callback) {
   ...
   // 根據依賴查詢對應的工廠函式
   const Dep = /** @type {DepConstructor} */ (dependency.constructor);
   const moduleFactory = this.dependencyFactories.get(Dep);
   
   // 呼叫工廠函式NormalModuleFactory的create來生成一個空的NormalModule物件
   moduleFactory.create({
       dependencies: [dependency]
       ...
   }, (err, module) => {
       ...
       const afterBuild = () => {
        this.processModuleDependencies(module, err => {
         if (err) return callback(err);
         callback(null, module);
           });
    };
       
       this.buildModule(module, false, null, null, err => {
           ...
           afterBuild();
       })
   })
}

_addModuleChain中接收引數dependency傳入的入口依賴,使用對應的工廠函式NormalModuleFactory.create方法生成一個空的module物件。回撥中會把此module存入compilation.modules物件和dependencies.module物件中,由於是入口檔案,也會存入compilation.entries中。隨後,執行buildModule進入真正的構建模組module內容的過程。

3, build module 完成模組編譯

這個過程的主要呼叫配置的loaders,將我們的模組轉成標準的JS模組。在用 Loader 對一個模組轉換完後,使用 acorn 解析轉換後的內容,輸出對應的抽象語法樹(AST),以方便 Webpack 後面對程式碼的分析。

從配置的入口模組開始,分析其 AST,當遇到require等匯入其它模組語句時,便將其加入到依賴的模組列表,同時對新找出的依賴模組遞迴分析,最終搞清所有模組的依賴關係。

2.3 輸出階段

seal方法主要是要生成chunks,對chunks進行一系列的優化操作,並生成要輸出的程式碼。Webpack 中的 chunk ,可以理解為配置在 entry 中的模組,或者是動態引入的模組。

根據入口和模組之間的依賴關係,組裝成一個個包含多個模組的 Chunk,再把每個 Chunk 轉換成一個單獨的檔案加入到輸出列表。在確定好輸出內容後,根據配置確定輸出的路徑和檔名即可。

output: {
    path: path.resolve(__dirname, 'build'),
        filename: '[name].js'
}

在 Compiler 開始生成檔案前,鉤子 emit 會被執行,這是我們修改最終檔案的最後一個機會。整個過程如下圖所示。
在這裡插入圖片描述

三、Webpack中常見的Loader

3.1 Loader是什麼

Loader本質就是一個函式,在該函式中對接收到的內容進行轉換,返回轉換後的結果。因為 Webpack 只認識 JavaScript,所以 Loader 就成了翻譯官,對其他型別的資源進行轉譯的預處理工作。

預設情況下,在遇到import或者load載入模組的時候,webpack只支援對js檔案打包。像css、sass、png等這些型別的檔案的時候,webpack則無能為力,這時候就需要配置對應的loader進行檔案內容的解析。在載入模組的時候,執行順序如,如下圖所示。
在這裡插入圖片描述
關於配置Loader的方式,有常見的三種方式:

  • 配置方式(推薦):在 webpack.config.js檔案中指定 loader
  • 內聯方式:在每個 import 語句中顯式指定 loader
  • Cli 方式:在 shell 命令中指定它們

3.1 配置方式

關於Loader的配置,我們通常是寫在module.rules屬性中,屬性介紹如下:

  • rules是一個陣列的形式,因此我們可以配置很多個loader。
  • 每一個loader對應一個物件的形式,物件屬性test 為匹配的規則,一般情況為正規表示式。
  • 屬性use針對匹配到檔案型別,呼叫對應的 loader 進行處理。

下面是一個module.rules的示例程式碼:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          },
          { loader: 'sass-loader' }
        ]
      }
    ]
  }
};

3.2 Loader特性

從上述程式碼可以看到,在處理css模組的時候,use屬性中配置了三個loader分別處理css檔案。因為loader 支援鏈式呼叫,鏈中的每個loader會處理之前已處理過的資源,最終變為js程式碼。順序為相反的順序執行,即上述執行方式為sass-loader、css-loader、style-loader。

除此之外,loader的特性還有如下:

  • Loader 可以是同步的,也可以是非同步的
  • Loader 執行在 Node.js 中,並且能夠執行任何操作
  • 除了常見的通過 package.json 的 main 來將一個 npm 模組匯出為 loader,還可以在 module.rules 中使用 loader 欄位直接引用一個模組
  • 外掛(plugin)可以為 loader 帶來更多特性
  • Loader 能夠產生額外的任意檔案

可以通過 loader 的預處理函式,為 JavaScript 生態系統提供更多能力。使用者現在可以更加靈活地引入細粒度邏輯,例如:壓縮、打包、語言翻譯和更多其他特性。

3.3 常用Loader

在頁面開發過程中,除了需要匯入一些場景js檔案外,還需要配置響應的loader進行載入。WebPack常見的Loader如下:

  • style-loader:將css新增到DOM的內聯樣式標籤style裡,然後通過 dom 操作去載入 css。
  • css-loader :允許將css檔案通過require的方式引入,並返回css程式碼。
  • less-loader: 處理less,將less程式碼轉換成css。
  • sass-loader: 處理sass,將scss/sass程式碼轉換成css。
  • postcss-loader:用postcss來處理css。
  • autoprefixer-loader: 處理css3屬性字首,已被棄用,建議直接使用postcss。
  • file-loader: 分發檔案到output目錄並返回相對路徑。
  • url-loader: 和file-loader類似,但是當檔案小於設定的limit時可以返回一個Data Url。
  • html-minify-loader: 壓縮HTML
  • babel-loader :用babel來轉換ES6檔案到ES。
  • awesome-typescript-loader:將 TypeScript 轉換成 JavaScript,效能優於 ts-loader。
  • eslint-loader:通過 ESLint 檢查 JavaScript 程式碼。
  • tslint-loader:通過 TSLint檢查 TypeScript 程式碼。
  • cache-loader: 可以在一些效能開銷較大的 Loader 之前新增,目的是將結果快取到磁碟裡

下面以css-loader為例子,來說明Loader的使用過程。首先,我們在專案中安裝css-loader外掛。

npm install --save-dev css-loader

然後將規則配置到module.rules中,比如:

rules: [
  ...,
 {
  test: /\.css$/,
    use: {
      loader: "css-loader",
      options: {
     // 啟用/禁用 url() 處理
     url: true,
     // 啟用/禁用 @import 處理
     import: true,
        // 啟用/禁用 Sourcemap
        sourceMap: false
      }
    }
 }
]

四、Webpack中常見的Plugin

4.1 基礎

Plugin就是外掛,基於事件流框架Tapable,外掛可以擴充套件 Webpack 的功能,在 Webpack 執行的生命週期中會廣播出許多事件,Plugin 可以監聽這些事件,在合適的時機通過 Webpack 提供的 API 改變輸出結果。

Webpack中的Plugin也是如此,Plugin賦予其各種靈活的功能,例如打包優化、資源管理、環境變數注入等,它們會執行在 Webpack 的不同階段(鉤子 / 生命週期),貫穿了Webpack整個編譯週期。

在這裡插入圖片描述
使用的時候,通過配置檔案匯出物件中plugins屬性傳入new例項物件即可。

const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通過 npm 安裝
const webpack = require('webpack'); // 訪問內建的外掛
module.exports = {
  ...
  plugins: [
    new webpack.ProgressPlugin(),
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
};

4.2 特性

Plugin從本質上來說,就是一個具有apply方法Javascript物件。apply 方法會被 webpack compiler 呼叫,並且在整個編譯生命週期都可以訪問 compiler 物件。

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('webpack 構建過程開始!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

我們可以使用上面的方式,來獲取Plugin在編譯生命週期鉤子。如下:

  • entry-option :初始化 option
  • compile: 真正開始的編譯,在建立 compilation 物件之前
  • compilation :生成好了 compilation 物件
  • make:從 entry 開始遞迴分析依賴,準備對每個模組進行 build
  • after-compile: 編譯 build 過程結束
  • emit :在將記憶體中 assets 內容寫到磁碟資料夾之前
  • after-emit :在將記憶體中 assets 內容寫到磁碟資料夾之後
  • done: 完成所有的編譯過程
  • failed: 編譯失敗的時候

4.3 常見的Plugin

Weebpack中,常見的plugin有如下一些:

  • define-plugin:定義環境變數 (Webpack4 之後指定 mode 會自動配置)
  • ignore-plugin:忽略部分檔案
  • html-webpack-plugin:簡化 HTML 檔案建立 (依賴於 html-loader)
  • web-webpack-plugin:可方便地為單頁應用輸出 HTML,比 html-webpack-plugin 好用
  • uglifyjs-webpack-plugin:不支援 ES6 壓縮 (Webpack4 以前)
  • terser-webpack-plugin: 支援壓縮 ES6 (Webpack4)
  • webpack-parallel-uglify-plugin: 多程式執行程式碼壓縮,提升構建速度
  • mini-css-extract-plugin: 分離樣式檔案,CSS 提取為獨立檔案,支援按需載入 (替代extract-text-webpack-plugin)
  • serviceworker-webpack-plugin:為網頁應用增加離線快取功能
  • clean-webpack-plugin: 目錄清理
  • ModuleConcatenationPlugin: 開啟 Scope Hoisting
  • speed-measure-webpack-plugin: 可以看到每個 Loader 和 Plugin 執行耗時 (整個打包耗時、每個 Plugin 和 Loader 耗時)
  • webpack-bundle-analyzer: 視覺化 Webpack 輸出檔案的體積 (業務元件、依賴第三方模組)

下面通過clean-webpack-plugin來看一下外掛的使用方法。首先,需要安裝clean-webpack-plugin外掛。

npm install --save-dev clean-webpack-plugin

然後,引入外掛即可使用。

const {CleanWebpackPlugin} = require('clean-webpack-plugin');
module.exports = {
 ...
  plugins: [
    ...,
    new CleanWebpackPlugin(),
    ...
  ]
}

五、Loader和Plugin的區別,以及如何自定義Loader和Plugin

5.1 區別

Loader本質就是一個函式,在該函式中對接收到的內容進行轉換,返回轉換後的結果。因為 Webpack 只認識 JavaScript,所以 Loader 就成了翻譯官,對其他型別的資源進行轉譯的預處理工作。

Plugin就是外掛,基於事件流框架Tapable,外掛可以擴充套件 Webpack 的功能,在 Webpack 執行的生命週期中會廣播出許多事件,Plugin 可以監聽這些事件,在合適的時機通過 Webpack 提供的 API 改變輸出結果。

  • Loader 執行在打包檔案之前,Loader在 module.rules 中配置,作為模組的解析規則,型別為陣列。每一項都是一個 Object,內部包含了 test(型別檔案)、loader、options (引數)等屬性。
  • Plugins 在整個編譯週期都起作用,Plugin在 plugins 中單獨配置,型別為陣列,每一項是一個 Plugin 的例項,引數都通過建構函式傳入。

5.2 自定義Loader

我們知道,Loader本質上來說就是一個函式,函式中的 this 作為上下文會被 webpack 填充,因此我們不能將 loader設為一個箭頭函式。該函式接受一個引數,為 webpack 傳遞給 loader 的檔案源內容。

同時,函式中 this 是由 webpack 提供的物件,能夠獲取當前 loader 所需要的各種資訊。函式中有非同步操作或同步操作,非同步操作通過 this.callback 返回,返回值要求為 string 或者 Buffer,如下。

// 匯出一個函式,source為webpack傳遞給loader的檔案源內容
module.exports = function(source) {
    const content = doSomeThing2JsString(source);
    
    // 如果 loader 配置了 options 物件,那麼this.query將指向 options
    const options = this.query;
    
    // 可以用作解析其他模組路徑的上下文
    console.log('this.context');
    
    /*
     * this.callback 引數:
     * error:Error | null,當 loader 出錯時向外丟擲一個 error
     * content:String | Buffer,經過 loader 編譯後需要匯出的內容
     * sourceMap:為方便除錯生成的編譯後內容的 source map
     * ast:本次編譯生成的 AST 靜態語法樹,之後執行的 loader 可以直接使用這個 AST,進而省去重複生成 AST 的過程
     */
    this.callback(null, content); // 非同步
    return content; // 同步
}

5.3 自定義Plugin

Webpack的Plugin是基於事件流框架Tapable,由於webpack基於釋出訂閱模式,在執行的生命週期中會廣播出許多事件,外掛通過監聽這些事件,就可以在特定的階段執行自己的外掛任務。

同時,Webpack編譯會建立兩個核心物件:compiler和compilation。

  • compiler:包含了 Webpack 環境的所有的配置資訊,包括 options,loader 和 plugin,和 webpack 整個生命週期相關的鉤子.
  • compilation:作為 Plugin 內建事件回撥函式的引數,包含了當前的模組資源、編譯生成資源、變化的檔案以及被跟蹤依賴的狀態資訊。當檢測到一個檔案變化,一次新的 Compilation 將被建立。

如果需要自定義Plugin,也需要遵循一定的規範:

  • 外掛必須是一個函式或者是一個包含 apply 方法的物件,這樣才能訪問compiler例項
  • 傳給每個外掛的 compiler 和 compilation 物件都是同一個引用,因此不建議修改
  • 非同步的事件需要在外掛處理完任務時呼叫回撥函式通知 Webpack 進入下一個流程,不然會卡住

下面是一個自定Plugin的模板:

class MyPlugin {
    // Webpack 會呼叫 MyPlugin 例項的 apply 方法給外掛例項傳入 compiler 物件
  apply (compiler) {
    // 找到合適的事件鉤子,實現自己的外掛功能
    compiler.hooks.emit.tap('MyPlugin', compilation => {
        // compilation: 當前打包構建流程的上下文
        console.log(compilation);
        
        // do something...
    })
  }
}

在 emit 事件被觸發後,代表原始檔的轉換和組裝已經完成,可以讀取到最終將輸出的資源、程式碼塊、模組及其依賴,並且可以修改輸出資源的內容。

六、Webpack 熱更新

6.1 熱更新

Webpack的熱更新又稱熱替換(Hot Module Replacement),縮寫為HMR。這個機制可以做到不用重新整理瀏覽器而將新變更的模組替換掉舊的模組。

HMR的核心就是客戶端從服務端拉去更新後的檔案,準確的說是 chunk diff (chunk 需要更新的部分),實際上 WDS 與瀏覽器之間維護了一個Websocket,當本地資源發生變化時,WDS 會向瀏覽器推送更新,並帶上構建時的 hash,讓客戶端與上一次資源進行對比。客戶端對比出差異後會向 WDS 發起Ajax請求來獲取更改內容(檔案列表、hash),這樣客戶端就可以再借助這些資訊繼續向 WDS 發起jsonp請求獲取該chunk的增量更新。

在Webpack中配置開啟熱模組也非常的簡單,只需要新增如下程式碼即可。

const webpack = require('webpack')
module.exports = {
  // ...
  devServer: {
    // 開啟 HMR 特性
    hot: true
    // hotOnly: true
  }
}

需要說明的是,實現熱更新還需要去指定哪些模組發生更新時進行HRM,因為預設情況下,HRM只對css檔案有效。

if(module.hot){
    module.hot.accept('./util.js',()=>{
        console.log("util.js更新了")
    })
}

6.2 實現原理

首先,我們來看一張圖:
在這裡插入圖片描述
上面圖中涉及了很多不同的概念,如下:

  • Webpack Compile:將 JS 原始碼編譯成 bundle.js
  • HMR Server:用來將熱更新的檔案輸出給 HMR Runtime
  • Bundle Server:靜態資原始檔伺服器,提供檔案訪問路徑
  • HMR Runtime:socket伺服器,會被注入到瀏覽器,更新檔案的變化
  • bundle.js:構建輸出的檔案
  • 在HMR Runtime 和 HMR Server之間建立 websocket,即圖上4號線,用於實時更新檔案變化

整個流程,我們可以將它分為兩個階段:啟動階段和更新階段。

啟動階段的主要工作是,Webpack Compile 將原始碼和 HMR Runtime 一起編譯成 bundle 檔案,傳輸給 Bundle Server 靜態資源伺服器。接下來,我們重點關注下更新階段:

當某一個檔案或者模組發生變化時,webpack 監聽到檔案變化對檔案重新編譯打包,編譯生成唯一的hash值,這個hash 值用來作為下一次熱更新的標識。然後,根據變化的內容生成兩個補丁檔案:manifest(包含了 hash 和 chundId ,用來說明變化的內容)和 chunk.js 模組。

由於socket伺服器在HMR Runtime 和 HMR Server之間建立 websocket連結,當檔案發生改動的時候,服務端會向瀏覽器推送一條訊息,訊息包含檔案改動後生成的hash值,如下圖的h屬性,作為下一次熱更細的標識。

在這裡插入圖片描述

在瀏覽器接受到這條訊息之前,瀏覽器已經在上一次 socket 訊息中已經記住了此時的 hash 標識,這時候我們會建立一個 ajax 去服務端請求獲取到變化內容的 manifest 檔案。mainfest檔案包含重新build生成的hash值,以及變化的模組,對應上圖的c屬性。瀏覽器根據 manifest 檔案獲取模組變化的內容,從而觸發render流程,實現區域性模組更新。

在這裡插入圖片描述

6.3 總結

通過前面的分析,總結Webpack熱模組的步驟如下:

  • 通過webpack-dev-server建立兩個伺服器:提供靜態資源的服務(express)和Socket服務
  • express server 負責直接提供靜態資源的服務(打包後的資源直接被瀏覽器請求和解析)
  • socket server 是一個 websocket 的長連線,雙方可以通訊
  • 當 socket server 監聽到對應的模組發生變化時,會生成兩個檔案.json(manifest檔案)和.js檔案(update chunk)
  • 通過長連線,socket server 可以直接將這兩個檔案主動傳送給客戶端(瀏覽器)
  • 瀏覽器拿到兩個新的檔案後,通過HMR runtime機制,載入這兩個檔案,並且針對修改的模組進行更新

七、Webpack Proxy工作原理

7.1 代理

在專案開發中不可避免會遇到跨越問題,Webpack中的Proxy就是解決前端跨域的方法之一。所謂代理,指的是在接收客戶端傳送的請求後轉發給其他伺服器的行為,webpack中提供伺服器的工具為webpack-dev-server。

7.1.1 webpack-dev-server

webpack-dev-server是 webpack 官方推出的一款開發工具,將自動編譯和自動重新整理瀏覽器等一系列對開發友好的功能全部整合在了一起。同時,為了提高開發者日常的開發效率,只適用在開發階段。在webpack配置物件屬性中配置代理的程式碼如下:

// ./webpack.config.js
const path = require('path')

module.exports = {
    // ...
    devServer: {
        contentBase: path.join(__dirname, 'dist'),
        compress: true,
        port: 9000,
        proxy: {
            '/api': {
                target: 'https://api.github.com'
            }
        }
        // ...
    }
}

其中,devServetr裡面proxy則是關於代理的配置,該屬性為物件的形式,物件中每一個屬性就是一個代理的規則匹配。

屬性的名稱是需要被代理的請求路徑字首,一般為了辨別都會設定字首為 /api,值為對應的代理匹配規則,對應如下:

  • target:表示的是代理到的目標地址。
  • pathRewrite:預設情況下,我們的 /api-hy 也會被寫入到URL中,如果希望刪除,可以使用pathRewrite。
  • secure:預設情況下不接收轉發到https的伺服器上,如果希望支援,可以設定為false。
  • changeOrigin:它表示是否更新代理後請求的 headers 中host地址。

7.2 原理

proxy工作原理實質上是利用http-proxy-middleware 這個http代理中介軟體,實現請求轉發給其他伺服器。比如下面的例子:

const express = require('express');
const proxy = require('http-proxy-middleware');

const app = express();

app.use('/api', proxy({target: 'http://www.example.org', changeOrigin: true}));
app.listen(3000);

// http://localhost:3000/api/foo/bar -> http://www.example.org/api/foo/bar

在上面的例子中,本地地址為http://localhost:3000,該瀏覽器傳送一個字首帶有/api標識的請求到服務端獲取資料,但響應這個請求的伺服器只是將請求轉發到另一臺伺服器中。

7.1 跨域

在開發階段, webpack-dev-server 會啟動一個本地開發伺服器,所以我們的應用在開發階段是獨立執行在 localhost 的一個埠上,而後端服務又是執行在另外一個地址上。所以在開發階段中,由於瀏覽器同源策略的原因,當本地訪問後端就會出現跨域請求的問題。

解決這種問題時,只需要設定webpack proxy代理即可。當本地傳送請求的時候,代理伺服器響應該請求,並將請求轉發到目標伺服器,目標伺服器響應資料後再將資料返回給代理伺服器,最終再由代理伺服器將資料響應給本地,原理圖如下:

在這裡插入圖片描述

在代理伺服器傳遞資料給本地瀏覽器的過程中,兩者同源,並不存在跨域行為,這時候瀏覽器就能正常接收資料。

注意:伺服器與伺服器之間請求資料並不會存在跨域行為,跨域行為是瀏覽器安全策略限制

八、如何藉助Webpack來優化效能

作為一個專案的打包構建工具,在完成專案開發後經常需要利用Webpack對前端專案進行效能優化,常見的優化手段有如下幾個方面:

  • JS程式碼壓縮
  • CSS程式碼壓縮
  • Html檔案程式碼壓縮
  • 檔案大小壓縮
  • 圖片壓縮
  • Tree Shaking
  • 程式碼分離
  • 內聯 chunk

8.1 JS程式碼壓縮

terser是一個JavaScript的解釋、絞肉機、壓縮機的工具集,可以幫助我們壓縮、醜化我們的程式碼,讓bundle更小。在production模式下,webpack 預設就是使用 TerserPlugin 來處理我們的程式碼的。如果想要自定義配置它,配置方法如下。

const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
    ...
    optimization: {
        minimize: true,
        minimizer: [
            new TerserPlugin({
                parallel: true              // 電腦cpu核數-1
            })
        ]
    }
}

TerserPlugin常用的屬性如下:

  • extractComments:預設值為true,表示會將註釋抽取到一個單獨的檔案中,開發階段,我們可設定為 false ,不保留註釋
  • parallel:使用多程式併發執行提高構建的速度,預設值是true,併發執行的預設數量: os.cpus().length - 1
  • terserOptions:設定我們的terser相關的配置:
    compress:設定壓縮相關的選項,mangle:設定醜化相關的選項,可以直接設定為true
    mangle:設定醜化相關的選項,可以直接設定為true
    toplevel:底層變數是否進行轉換
    keep_classnames:保留類的名稱
    keep_fnames:保留函式的名稱

8.2 CSS程式碼壓縮

CSS壓縮通常用於去除無用的空格等,不過因為很難去修改選擇器、屬性的名稱、值等,所以我們可以使用另外一個外掛:css-minimizer-webpack-plugin。配置如下:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
module.exports = {
    // ...
    optimization: {
        minimize: true,
        minimizer: [
            new CssMinimizerPlugin({
                parallel: true
            })
        ]
    }
}

8.3 Html檔案程式碼壓縮

使用HtmlWebpackPlugin外掛來生成HTML的模板時候,可以通過配置屬性minify進行html優化,配置如下。

module.exports = {
    ...
    plugin:[
        new HtmlwebpackPlugin({
            ...
            minify:{
                minifyCSS:false, // 是否壓縮css
                collapseWhitespace:false, // 是否摺疊空格
                removeComments:true // 是否移除註釋
            }
        })
    ]
}

8.4 檔案大小壓縮

對檔案的大小進行壓縮,可以有效減少http傳輸過程中寬頻的損耗,檔案壓縮需要用到 compression-webpack-plugin外掛,配置如下。

new ComepressionPlugin({
    test:/\.(css|js)$/,  // 哪些檔案需要壓縮
    threshold:500, // 設定檔案多大開始壓縮
    minRatio:0.7, // 至少壓縮的比例
    algorithm:"gzip", // 採用的壓縮演算法
})

8.5 圖片壓縮

如果我們對bundle包進行分析,會發現圖片等多媒體檔案的大小是遠遠要比 js、css 檔案要大的,所以圖片壓縮在打包方面也是很重要的。配置可以參考如下的方式:

module: {
  rules: [
    {
      test: /\.(png|jpg|gif)$/,
      use: [
        {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]',
            outputPath: 'images/',
          }
        },
        {
          loader: 'image-webpack-loader',
          options: {
            // 壓縮 jpeg 的配置
            mozjpeg: {
              progressive: true,
              quality: 65
            },
            // 使用 imagemin**-optipng 壓縮 png,enable: false 為關閉
            optipng: {
              enabled: false,
            },
            // 使用 imagemin-pngquant 壓縮 png
            pngquant: {
              quality: '65-90',
              speed: 4
            },
            // 壓縮 gif 的配置
            gifsicle: {
              interlaced: false,
            },
            // 開啟 webp,會把 jpg 和 png 圖片壓縮為 webp 格式
            webp: {
              quality: 75
            }
          }
        }
      ]
    },
  ]
} 

8.6 Tree Shaking

Tree Shaking 是一個術語,在計算機中表示消除死程式碼,依賴於ES Module的靜態語法分析。在webpack實現Trss shaking有兩種不同的方案:

  • usedExports:通過標記某些函式是否被使用,之後通過Terser來進行優化的
  • sideEffects:跳過整個模組/檔案,直接檢視該檔案是否有副作用

usedExports的配置方法很簡單,只需要將usedExports設為true即可,如下。

module.exports = {
    ...
    optimization:{
        usedExports
    }
}

而sideEffects則用於告知webpack compiler在編譯時哪些模組有副作用,配置方法是在package.json中設定sideEffects屬性。如果sideEffects設定為false,就是告知webpack可以安全的刪除未用到的exports,如果有些檔案需要保留,可以設定為陣列的形式。

"sideEffecis":[    "./src/util/format.js",    "*.css" // 所有的css檔案]

8.7 程式碼分離

預設情況下,所有的JavaScript程式碼(業務程式碼、第三方依賴、暫時沒有用到的模組)在首頁全部都載入,就會影響首頁的載入速度。如果可以分出出更小的bundle,以及控制資源載入優先順序,從而優化載入效能。

程式碼分離可以通過splitChunksPlugin來實現,該外掛webpack已經預設安裝和整合,只需要配置即可。

module.exports = {   
 ...    
    optimization:{    
        splitChunks:{       
             chunks:"all"     
                }  
        }}

splitChunks有如下幾個屬性:

  • Chunks:對同步程式碼還是非同步程式碼進行處理
  • minSize: 拆分包的大小, 至少為minSize,如何包的大小不超過minSize,這個包不會拆分
  • maxSize: 將大於maxSize的包,拆分為不小於minSize的包
  • minChunks:被引入的次數,預設是1

8.8 內聯chunk

可以通過InlineChunkHtmlPlugin外掛將一些chunk的模組內聯到html,如runtime的程式碼(對模組進行解析、載入、模組資訊相關的程式碼),程式碼量並不大但是必須載入的,比如:

const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
     module.exports = {  
           ...    plugin:[      
              new InlineChunkHtmlPlugin(HtmlWebpackPlugin,[/runtime.+\.js/]}

總結一下,Webpack對前端效能的優化,主要是通過檔案體積大小入手,主要的措施有分包、減少Http請求次數等。

九、提高Webpack的構建速度

隨著功能和業務程式碼越來越多,相應的 Webpack 的構建時間也會越來越久,構建的效率也會越來越低,那如何提升Webpack 構建速度,是前端工程化的重要一環。常用的手段有如下一些:

  • 優化 loader 配置
  • 合理使用 resolve.extensions
  • 優化 resolve.modules
  • 優化 resolve.alias
  • 使用 DLLPlugin 外掛
  • 使用 cache-loader
  • terser 啟動多執行緒
  • 合理使用 sourceMap

9.1 優化 Loader 配置

在使用Loader時,可以通過配置include、exclude、test屬性來匹配檔案,通過include、exclude來規定匹配應用的loader。例如,下面是ES6 專案中配置 babel-loader 的例子:

module.exports = {
  module: {
    rules: [
      {
        // 如果專案原始碼中只有 js 檔案就不要寫成 /\.jsx?$/,提升正規表示式效能
        test: /\.js$/,
        // babel-loader 支援快取轉換出的結果,通過 cacheDirectory 選項開啟
        use: ['babel-loader?cacheDirectory'],
        // 只對專案根目錄下的 src 目錄中的檔案採用 babel-loader
        include: path.resolve(__dirname, 'src'),
      },
    ]
  },
};

9.2 合理resolve.extensions

在開發中,我們會有各種各樣的模組依賴,這些模組可能來自第三方庫,也可能是自己編寫的, resolve可以幫助Webpack從每個 require/import 語句中,找到需要引入到合適的模組程式碼。

具體來說,通過resolve.extensions是解析到檔案時自動新增擴充名,預設情況如下:

module.exports = {
    ...
    extensions:[".warm",".mjs",".js",".json"]
}

當我們引入檔案的時候,若沒有檔案字尾名,則會根據陣列內的值依次查詢。所以,處理配置的時候,不要隨便把所有字尾都寫在裡面。

9.3 優化 resolve.modules

resolve.modules 用於配置 webpack 去哪些目錄下尋找第三方模組,預設值為['node_modules']。所以,在專案構建時,可以通過指明存放第三方模組的絕對路徑來減少尋找的時間。

module.exports = {
  resolve: {
    modules: [path.resolve(__dirname, 'node_modules')]   // __dirname 表示當前工作目錄
  },
};

9.4 優化 resolve.alias

alias給一些常用的路徑起一個別名,特別當我們的專案目錄結構比較深的時候,一個檔案的路徑可能是./../../的形式,通過配置alias以減少查詢過程。

module.exports = {
    ...
    resolve:{
        alias:{
            "@":path.resolve(__dirname,'./src')
        }
    }
}

9.5 使用 DLL Plugin 外掛

DLL全稱是動態連結庫,是為軟體在winodw種實現共享函式庫的一種實現方式,而Webpack也內建了DLL的功能,為的就是可以共享,不經常改變的程式碼,抽成一個共享的庫。使用步驟分成兩部分:

  • 打包一個 DLL 庫
  • 引入 DLL 庫

打包一個 DLL 庫

Webpack內建了一個DllPlugin可以幫助我們打包一個DLL的庫檔案,如下。

module.exports = {
    ...
    plugins:[
        new webpack.DllPlugin({
            name:'dll_[name]',
            path:path.resolve(__dirname,"./dll/[name].mainfest.json")
        })
    ]
}

引入 DLL 庫

首先,使用 webpack 自帶的 DllReferencePlugin 外掛對 mainfest.json 對映檔案進行分析,獲取要使用的DLL庫。然後,再通過AddAssetHtmlPlugin外掛,將我們打包的DLL庫引入到Html模組中。

module.exports = {
    ...
    new webpack.DllReferencePlugin({
        context:path.resolve(__dirname,"./dll/dll_react.js"),
        mainfest:path.resolve(__dirname,"./dll/react.mainfest.json")
    }),
    new AddAssetHtmlPlugin({
        outputPath:"./auto",
        filepath:path.resolve(__dirname,"./dll/dll_react.js")
    })
}

9.6 合理使用使用 cache-loader

在一些效能開銷較大的 loader 之前新增 cache-loader,以將結果快取到磁碟裡,顯著提升二次構建速度。比如:

module.exports = {
    module: {
        rules: [
            {
                test: /\.ext$/,
                use: ['cache-loader', ...loaders],
                include: path.resolve('src'),
            },
        ],
    },
};

需要說明的是,儲存和讀取這些快取檔案會有一些時間開銷,所以請只對效能開銷較大的 loader 使用此 loader。

9.7 開啟多執行緒

開啟多程式並行執行可以提高構建速度,配置如下:

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: true,          //開啟多執行緒
      }),
    ],
  },
};

十、 除了Webpack外,你還了解哪些模組管理工具

模組化是一種處理複雜系統分解為更好的可管理模組的方式。可以用來分割、組織和打包應用。每個模組完成一個特定的子功能,所有的模組按某種方法組裝起來,成為一個整體。

在前端領域中,除了Webpack外,比較流行的模組打包工具還包括Rollup、Parcel、snowpack和最近風靡的Vite。

10.1 Rollup

Rollup 是一款 ES Modules 打包器,可以將小塊程式碼編譯成大塊複雜的程式碼,例如 library 或應用程式。從作用上來看,Rollup 與 Webpack 非常類似。不過相比於 Webpack,Rollup 要小巧的多。現在很多苦都使用它進行打包,比如:Vue、React和three.js等。

使用之前,可以使用npm install --global rollup 命令進行安裝。Rollup 可以通過命令列介面(command line interface)配合可選配置檔案(optional configuration file)來呼叫,或者可以通過 JavaScript API來呼叫。執行 rollup --help 可以檢視可用的選項和引數。

下面通過一個簡單的例子,說明如何使用Rollup進行打包。首先,新建如下幾個檔案:

// ./src/messages.js
export default {
  hi: 'Hello World'
}

// ./src/logger.js
export const log = msg => {
  console.log('---------- INFO ----------')
  console.log(msg)
  console.log('--------------------------')
}

export const error = msg => {
  console.error('---------- ERROR ----------')
  console.error(msg)
  console.error('---------------------------')
}

// ./src/index.js
import { log } from './logger'
import messages from './messages'
log(messages.hi)

最後,再使用下面的命令打包即可。

npx rollup ./src/index.js --file ./dist/bundle.js

參考:Rollup.js

10.2 Parcel

Parcel ,是一款完全零配置的前端打包器,它提供了 “傻瓜式” 的使用體驗,只需瞭解簡單的命令,就能構建前端應用程式。

使用Parcel的流程如下:

  1. 建立目錄並使用npm init -y初始化package.json
  2. 安裝模組npm i parcel-bundler --save-dev
  3. 建立src/index.html檔案作為入口檔案,雖然Parcel支援任意檔案為打包入口,但是還是推薦我們使用HTML檔案作為打包入口,官方理由是HTML是瀏覽器執行的入口,故應該使用HTML作為打包入口。

使用之前,需要在package.json中配置指令碼,如下:

"scripts": {
    "parcel": "parcel"
},

Parcel 跟 Webpack 一樣都支援以任意型別檔案作為打包入口,但建議使用HTML檔案作為入口,如下。

<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="main.js"></script>
</body>
</html>

然後,在main.js檔案通過ES Moudle方法匯入其他模組成員。

// ./src/main.js
import { log } from './logger'
log('hello parcel')

// ./src/logger.js
export const log = msg => {
  console.log('---------- msg ----------')
  console.log(msg)
}

然後,使用如下的命令即可打包:

npx parcel src/index.html

執行命令後,Parcel不僅打包了應用,同時也啟動了一個開發伺服器,跟webpack Dev Server的效果是一樣的。

10.3 Vite

Vite是Vue的作者尤雨溪開發的Web開發構建工具,它是一個基於瀏覽器原生ES模組匯入的開發伺服器,在開發環境下,利用瀏覽器去解析import,在伺服器端按需編譯返回,完全跳過了打包這個概念,伺服器隨啟隨用。同時不僅對Vue檔案提供了支援,還支援熱更新,而且熱更新的速度不會隨著模組增多而變慢。

Vite具有以下特點:

  • 快速的冷啟動
  • 即時熱模組更新(HMR,Hot Module Replacement)
  • 真正按需編譯

Vite由兩部分組成:

  • 一個開發伺服器,它基於 原生 ES 模組 提供了豐富的內建功能,如速度快到驚人的 [模組熱更新HMR。
  • 一套構建指令,它使用 Rollup打包你的程式碼,並且它是預配置的,可以輸出用於生產環境的優化過的靜態資源。

Vite在開發階段可以直接啟動開發伺服器,不需要進行打包操作,也就意味著不需要分析模組的依賴、不需要編譯,因此啟動速度非常快。當瀏覽器請求某個模組的時候,根據需要對模組的內容進行編譯,大大縮短了編譯時間。工作原理如下圖所示。
在這裡插入圖片描述
在熱模組HMR方面,當修改一個模組的時候,僅需讓瀏覽器重新請求該模組即可,無須像Webpack那樣需要把該模組的相關依賴模組全部編譯一次,因此效率也更高。

相關文章