wepack——提高工程化(原理篇)

java06051515發表於2019-12-06

webpack 是我們前端工程師必須掌握的一項技能,我們的日常開發已經離不開這個配置。關於這方面的文章已經很多,但還是想把自己的學習過程總結記錄下來。
一共兩篇文章,分為原理篇和實踐篇,從 webpack 構建原理開始,然後基於這個原理之上,明確我們實際工程配置中可以去優化的方向。

  • 構建原理篇,先幫助大家知道整體打包構建的流程,已經瞭解的可以略過這篇,直接看下一篇實踐篇。
  • 構建優化實踐篇,主要從 2 個方向去優化
    • 提升構建速度,也就是減少整個打包構建的時間,
    • 優化構建輸出,也就是減小我們最終構建輸出的檔案體積。

1. webpack 究竟解決了什麼問題

  • 模組化解決方案

在早前web前端只需要一個簡單的 html 頁面,插入幾條script標籤 去引用 js 檔案就可以滿足需求,隨著專案越來越複雜,要實現的功能越來越多,檔案也越來越多,全部都這麼引入已經不再現實,這時候前端模組化就出現了,從AMD、CMD 到現在的 ES6 模組化寫法,我們可以把程式碼拆成一個個 JS 檔案,通過 import 去關聯依賴檔案,最後再通過某個打包工具把這麼多 js 檔案按照依賴關係最終打包成一個或多個 js 檔案在html 頁面去引入。
所以 webpack首要要解決的問題是將多個模組化的 js檔案 按照依賴關係打包為一個或多個檔案,所以我們通常都會說他是一個模組化解決方案

  • 處理資源轉換

    隨著 ES6,ES7,ES8 的出現,還有 vue、react 等前端框架的出現,我們發現這些檔案瀏覽器是不能直接執行的,需要我們中間編譯轉換一下為瀏覽器可執行的檔案,所以這時候 webpack 要做的事情又多了一項,按照依賴打包的同時,還要對原始檔進行編譯轉換處理,也就是我們日常配置的 loader 處理。

  • tree-shaking以及程式碼壓縮

    現在webpack已經支援了對檔案編譯轉換後再進行打包,滿足了我們的基本需求。這時候我們又開始對效能提出了要求,希望打包出的體積越小越好。比如有些檔案雖然整個引用了,但其實真正只用了其中部分程式碼,沒用到的部分希望可以被剔除掉。這種是通過剔除無效程式碼來減小總的打包體積,另外一種方式是通過程式碼壓縮,比如空格、較長的函式名都可以被壓縮。因此webpack支援了 tree-shaking和程式碼壓縮。

  • 程式碼拆分(非同步載入 + 抽出第三方公用庫)

現在 webpack 打包結果是不是做到了極致了呢?不行,我們還是嫌棄最終打包出的檔案體積太大了。這時候懶載入(非同步載入)出現了,你只需要把進入首頁時所需要的所有資源打包為一個檔案輸出就行,這樣進入首頁我只需要載入該檔案就行,其他資原始檔等我真正執行的時候再去載入就可以。就這樣,webpack又支援了非同步載入檔案的拆包功能,這時候我們最終打包出的主檔案只是當前首頁需要的資源。

  • 開發輔助工具的提供

我們對於打包的基本需求以及效能需求終於得到了滿足,又開始追求開發時的體驗了,開發越便捷越好,webpack 就提供了一系列的開發輔助功能,比如 devserver,HMR 等等什麼的幫助我們高效的開發。

現在我們回過頭總結下看,webpack幫我們做了好多事啊。

  • 作為一個模組化解決方案,幫助我們將繁多的 JS 模組化檔案按照依賴關係打包 為一個或多個檔案輸出
  • 支援針對檔案指定檔案進行編譯轉換後再打包
  • 支援針對打包後的內容優化、壓縮處理
    來減小總的檔案體積
  • 支援非同步載入以及其他拆包方式
  • 提供一系列開發輔助工具

現在大家有沒有好奇webpack究竟是怎麼去實現這麼多功能的?

2. webpack 構建原理

  • 原理概述

webpack的構建從處理入口檔案開始著手,首先解析入口檔案,需要 經過 loader轉換編譯這時候就轉換編譯,轉換完了開始分析是否有依賴檔案,有依賴檔案就接著處理依賴檔案,流程和剛剛一致,需要編譯轉換就轉換,然後接著解析依賴檔案是否還有依賴檔案,有再接著處理。就這樣通過入口檔案以及依賴關係,webpack 可以獲取並處理所有的依賴檔案。然後再基於這些檔案做進一步的優化處理,比如 treeshaking 或者 程式碼壓縮,最後生成為我們需要的一個或多個js 檔案。先獻上總的構建原理圖,接下來按照每個模組去闡述。

2.1. 準備工作

首先是開始編譯前的準備工作,我們在專案工程裡會配置 webpack.config.js檔案,裡面是我們的一些自定義配置,
webpack 首先會將我們的配置檔案和它自己的預設配置做一個 merge,生成最終的一個配置檔案,其次會將這個最終配置檔案裡的所有外掛plugin在這個時候都註冊好,在這裡要提一下 webpack 的事件機制,他是基於一個 tapable庫做的事件流控制,在整個的編譯過程中暴露出各種hook,而我們寫的 plugin 也就是去註冊監聽了某個 hook,在這個 hook 觸發時,去執行我們的 plugin。(大家要看 webpack 的原始碼,一定要先去看下tapable這個庫的用法,否則看起來會很累,一頭霧水。)。 現在我們配置完成了,plugin也註冊了,終於可以開始工作了。

2.2. 處理入口檔案

前面說了它會從入口檔案開始處理,在我們日常的入口檔案配置中,我們有多個配置方式,可能會有單入口、多入口、甚至動態入口等多種形式。

// 入口檔案
module.exports = {
  // 單入口
  entry: {
    main: './path/to/my/entry/file.js'
  },
  // 多入口
  entry: {
    app: './src/app.js',
    adminApp: './src/adminApp.js'
  },
  //  動態入口
  entry: () => new Promise((resolve) => resolve(['./demo', './demo2']))
};

在 webpack 的處理中多種入口最後都會轉化為同一方法去處理,單入口不用說,多入口我可以先遍歷,再去執行該方法,動態入口,我先執行函式再去處理,最終都會進入到 生成入口檔案 module 例項階段。
大家都說 webpack 中一切檔案都是 module,那 module 是什麼呢,其實他就是一個存了當前檔案所有資訊的一個物件而已,這個檔案包含了以下資訊。

module = {
  type,
  request,
  userRequest,
  rawRequest,
  loaders,
  resource,
  matchResource,
  parser,
  generator,
  resolveOptions
}

2.3 遞迴生成檔案module 例項

  • resolve 階段

我們已經做好了各種入口檔案形式的相容處理了,現在開始真正處理檔案生成 module 例項。首先進入 resolve 階段,它使用了一個 enhanced-resolve 庫。它主要做了什麼呢?想一想我們要處理檔案,首先是不是要先知道檔案在哪裡?在入口檔案的配置中,我們只配了相對路徑,所以我們要先拿到該檔案的絕對路徑位置,拿到位置還不夠,如果這個檔案是 es6 編寫的,我是需要對其轉換的。那我怎麼知道這個檔案是否需要轉換呢,需要的話,又是需要通過哪些 loader 進行轉換呢?這是我們 resolve 階段要處理的問題,通過我們的 resolve 配置和 rules 配置去獲取到當前檔案的絕對路徑和需要經過哪些loader 進行處理,然後將這些資訊存到我們當前這個檔案對應的 module 例項裡面。

resolve: {
    // 位於 src 資料夾下常用模組,建立別名,準確匹配
    alias: {
      xyz$: path.resolve(__dirname, 'path/to/file.js')
    },
    modules: ['node_modules'],  // 查詢範圍的減小
    extensions: ['.js', '.json'],  // import 檔案未加檔案字尾是,webpack 根據 extensions 定義的檔案字尾進行依次查詢
    mainFields: ['loader', 'main'],  // 減少入口檔案的搜尋步驟,設定第三方模組的入口檔案位置
  },
  module: {
    rules: [
      {
        test: /\.js$/, // 匹配的檔案
        use: 'babel-loader',
        // 在此檔案範圍內去查詢
        include: [],
        // 此檔案範圍內不去查詢
        exclude: file => (
          /node_modules/.test(file) &&
          !/\.vue\.js/.test(file)
        )
      }
    ]
  }

在解析對應要執行的 loaders 過程中,需要注意 loaders 組裝的順序,webpack 會優先處理 inline-loader。

import Styles from 'style-loader!css-loader?modules!./styles.css';

webpack 採用正則匹配的方式解析出要執行的 內聯loader。在解析完內聯 loader 後,根據配置的 rules,解析剩餘的 loaders,組裝得到最後的 loaders是一個陣列,內容按照[postLoader, inlineLoader, normalLoader, preLoader]先後順序組合。

注意:這裡提到了幾種不用型別的 loader,除了 inline-loader 前面介紹了,還有postLoader,preLoader是有 enforce 欄位指定的:

 module: {
      rules: [
        {
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: require.resolve('eslint-loader'),
          exclude: /node_modules/
        },
      ]
    }

enforce 可以取值’pre’和’post’,分別對應preLoader和postLoader,沒有設定此欄位的就是normalLoader。

  • 執行 loader 階段

現在檔案基本資訊拿到了,發現需要經過 loader 進行處理,好,那我們下一階段就是去執行他的 loaders,在這裡提一點需要注意的地方,loader 的執行是倒序的,為什麼他是倒序的呢,是因為loader的執行分為 2 個階段,

  • pitching 階段:執行 loader 上的 pitch 方法
  • normal 階段:執行 loader 常規方法

下面給了一個demo,我們看看執行順序:

module.exports = {
  //...
  module: {
    rules: [
      {
        //...
        use: [
          'a-loader',
          'b-loader',
          'c-loader'
        ]
      }
    ]
  }
};

我們定義了 3 個 loader,a-loader, b-loader, c-loader,它的執行順序是 a.pitch-> b.pitch-> c.pitch-> c-loader-> b-loader-> a-loader。

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

之所以有這個設定,是因為中間存在一個邏輯,在 pitch 的執行過程中,一但出現了返回結果,後面的 loader 和 pitch 都不會執行。

比如說 a-loader的 pitch 執行返回的結果,那 b 和 c 的 pitch 和 loader 都不會執行,直接跳到 a-loader 的執行上。

在前面我們提到 loaders 的組裝順序是[postLoader, inlineLoader, normalLoader, preLoader],最終對應的執行順序如下:

  1. pitching 階段: postLoader.pitch -> inlineLoader.pitch -> normalLoader.pitch -> preLoader.pitch
  2. normal 階段: preLoader -> normalLoader -> inlineLoader -> postLoader

執行完 loader 後,也就是對檔案做了編譯轉換,使其變成了最終可以被瀏覽器執行的程式碼。

  • parse 階段

這時候我們要開始處理依賴了,那我怎麼知道當前檔案有依賴呢?webpack 是採用將loader 執行過後的原始檔source轉換為AST 去分析依賴。這裡使用了acorn 庫,將source生成對應的 AST。生成的 AST 劃分為 3部分,ImportDeclaration、FunctionDeclaration和VariablesDeclaration,接下來遍歷 AST 去收集依賴。
找到 import 等關鍵字去達到依賴收集的目的。

  • 遞迴處理依賴階段

基於我們解析到的依賴檔案,我們要開始遞迴處理依賴了,又回到了我們處理入口檔案的整個流程,去生成依賴檔案的 module 例項,再執行 對應loader。就這樣 webpack 遞迴處理了所有的依賴檔案並完成了所有檔案的轉換。
前面說了,我們最終是要把這些檔案打包為一個或者多個檔案輸出的,那接下來是不是要對這些檔案做一個整合和優化處理?

2.4 生成 chunk

  • 生成 Module-graph

由於這個時候我們是已經拿到了所有檔案的 module 例項以及依賴關係,可以先建立基本的一個 module-graph 了,下面我給了一個 demo,a.js作為入口檔案,紅色的是非同步依賴,綠色的是同步依賴。

  • 生成 Basic-chunk-graph

在講具體的拆包之前,先描述下 module 、chunk和 chunkgroup 之間的關係:
如圖所示,chunkGroup 包含多個 chunk,chunk 包含多個 module。webpack 會先劃分出 chunkGroup,然後再根據使用者自定義的拆包配置,從 chunkGroup 中拆出多個 chunk 最為最終的檔案輸出。


現在要基於Module-graph進行一個分包操作,分包的依據是非同步依賴。首先入口檔案會作為一個chunk-group,在分析依賴的過程中解析到非同步依賴就回去劃分 chunk-group,可以看到最後劃分了 4 個chunk-group 。

  • 生成最終的 chunk-graph wepack——提高工程化(原理篇)

我們可以分析下這4 個 chunk-group, 裡面有的模組存在多次引用,比如 chunk-group2 只有個 d.js,chunk-group1裡面已經包含了 d.js,這時候 chunkgroup2 會被剔除,就這樣最後只剩 2 個 chunk-group。這時候集合我們的optimization 配置,來劃分最終的輸出 chunk 檔案。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

2.6 優化

我們已經對所有的 module 例項進行了劃分為一個個的 chunk,這時要遍歷 chunk做一些優化操作

  • 首先生成對應的 moduleId ,不做任何配置的話,預設採用以自增 id 的方式,這裡推薦 hash 的方式,有利於快取
  • 基於生成的 moduleId進行排序
  • 接著類似於 module 的操作,對應生成 chunkId ,並根據 chunkId進行排序。
  • 分別為 module 和chunk 生成hash
module.exports = {
  //...
  optimization: {
    moduleIds: 'hashed'
  }
};

2.5 生成檔案

基於前面已經優化的chunk,現在終於到了最後的生成打包檔案環節了。 webpack 把這些檔案按照內建的 template 渲染生成最終的打包檔案。

3. 總結

總結一下 webpack 的整個構建打包過程,首先通過依賴關係和 loader 配置獲取經過編譯轉換後的所有module 例項,然後再根據配置進行拆分為一個或多個chunk,最後按照內建的template 渲染出最終的檔案輸出。

作者:吳海元

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559758/viewspace-2667270/,如需轉載,請註明出處,否則將追究法律責任。

相關文章