[前端進階課] 構建自己的 webpack 知識體系

webpack

webpack 最出色的功能之一就是,除了 JavaScript,還可以通過 loader 引入任何其他類型的文件

Webpack 核心概念:

  • Entry(入口):Webpack 執行構建的第一步將從 Entry 開始,可抽象成輸入。
  • Output(出口):指示 webpack 如何去輸出、以及在哪裡輸出
  • Module(模組):在 Webpack 里一切皆模組,一個模組對應著一個文件。Webpack 會從配置的 Entry 開始遞歸找出所有依賴的模組。
  • Chunk(程式碼塊):一個 Chunk 由多個模組組合而成,用於程式碼合併與分割。
  • Loader(模組轉換器):用於把模組原內容按照需求轉換成新內容。
  • Plugin(擴展插件):在 Webpack 構建流程中的特定時機會廣播出對應的事件,插件可以監聽這些事件,並改變輸出結果

配置項

  1. 入口 Entry
entry: {
  a: "./app/entry-a",
  b: ["./app/entry-b1", "./app/entry-b2"]
},

多入口可以通過 HtmlWebpackPlugin 分開注入

plugins: [
  new HtmlWebpackPlugin({
    chunks: ['a'],
    filename: 'test.html',
    template: 'src/assets/test.html'
  })
]
  1. 出口 Output

修改路徑相關

  • publicPath:並不會對生成文件的目錄造成影響,主要是對你的頁面裡面引入的資源的路徑做對應的補全
  • filename:能修改文件名,也能更改文件目錄

導出庫相關

  • library: 導出庫的名稱
  • libraryTarget: 通用模板定義方式
  1. 模組 Module

webpack 一切皆模組,配置項 Module,定義模組的各種操作,

Module 主要配置:

  • loader: 各種模組轉換器
  • extensions:使用的擴展名
  • alias:別名、例如:vue-cli 常用的 @ 出自此處
  1. 其他
  • plugins: 插件列表
  • devServer:開發環境相關配置,譬如 proxy
  • externals:打包排除模組
  • target:包應該運行的環境,默認 web

Webpack 執行流程

webpack從啟動到結束會依次執行以下流程:

  1. 初始化:解析webpack配置參數,生產 Compiler 實例
  2. 註冊插件:調用插件的apply方法,給插件傳入compiler實例的引用,插件通過compiler調用Webpack提供的API,讓插件可以監聽後續的所有事件節點。
  3. 入口:讀取入口文件
  4. 解析文件:使用loader將文件解析成抽象語法樹 AST
  5. 生成依賴圖譜:找出每個文件的依賴項(遍歷)
  6. 輸出:根據轉換好的程式碼,生成 chunk
  7. 生成最後打包的文件

ps:由於 webpack 是根據依賴圖動態載入所有的依賴項,所以,每個模組都可以明確表述自身的依賴,可以避免打包未使用的模組。

Babel

Babel 是一個工具鏈,主要用於將 ECMAScript 2015+ 版本的程式碼轉換為向後兼容的 JavaScript 語法,以便能夠運行在當前和舊版本的瀏覽器或其他環境中:

Babel 內部所使用的語法解析器是 Babylon

主要功能

  • 語法轉換
  • 通過 Polyfill 方式在目標環境中添加缺失的特性 (通過 @babel/polyfill 模組)
  • 源碼轉換 (codemods)

主要模組

  • @babel/parser:負責將程式碼解析為抽象語法樹
  • @babel/traverse:遍歷抽象語法樹的工具,我們可以在語法樹中解析特定的節點,然後做一些操作
  • @babel/core:程式碼轉換,如ES6的程式碼轉為ES5的模式

Webpack 打包結果

在使用 webpack 構建的典型應用程式或站點中,有三種主要的程式碼類型:

  1. 源碼:你或你的團隊編寫的源碼。
  2. 依賴:你的源碼會依賴的任何第三方的 library 或 “vendor” 程式碼。
  3. 管理文件:webpackruntime 使用 manifest 管理所有模組的交互。

runtime:在模組交互時,連接模組所需的載入和解析邏輯。包括瀏覽器中的已載入模組的連接,以及懶載入模組的執行邏輯。

manifest:當編譯器(compiler)開始執行、解析和映射應用程式時,它會保留所有模組的詳細要點。這個數據集合稱為 “Manifest”,
當完成打包並發送到瀏覽器時,會在運行時通過 Manifest 來解析和載入模組。無論你選擇哪種模組語法,那些 import 或 require 語句現在都已經轉換為 webpack_require 方法,此方法指向模組標識符(module identifier)。通過使用 manifest 中的數據,runtime 將能夠查詢模組標識符,檢索出背後對應的模組。

其中:

  • importrequire 語句會轉換為 __webpack_require__
  • 非同步導入會轉換為 require.ensure(在Webpack 4 中會使用 Promise 封裝)

比較

  • gulp 是任務執行器(task runner):就是用來自動化處理常見的開發任務,例如項目的檢查(lint)、構建(build)、測試(test)
  • webpack 是打包器(bundler):幫助你取得準備用於部署的 JavaScript 和樣式表,將它們轉換為適合瀏覽器的可用格式。例如,JavaScript 可以壓縮、拆分 chunk 和懶載入,

實現一個 loader

loader 就是一個js文件,它導出了一個返回了一個 buffer 或者 string 的函數;

譬如:

// log-loader.js
module.exports = function (source) {
  console.log('test...', source)
  return source
}

在 use 時,如果 log-loader 並沒有在 node_modules 中,那麼可以使用路徑導入。

實現一個 plugin

plugin: 是一個含有 apply 方法的

譬如:

class DemoWebpackPlugin {
    constructor () {
        console.log('初始化 插件')
    }
    apply (compiler) {
    }
}

module.exports = DemoWebpackPlugin

apply 方法中接收一個 compiler 參數,也就是 webpack實例。由於該參數的存在 plugin 可以很好的運用 webpack 的生命周期鉤子,在不同的時間節點做一些操作。

Webpack 優化概況

Webpack 加快打包速度的方法

  1. 使用 includeexclude 加快文件查找速度
  2. 使用 HappyPack 開啟多進程 Loader 轉換
  3. 使用 ParallelUglifyPlugin 開啟多進程 JS 壓縮
  4. 使用 DllPlugin + DllReferencePlugin 分離打包
    1. 項目程式碼 分離打包
    2. 需要 dll 映射文件
  5. 配置快取(插件自帶 loader,不支援的可以用 cache-loader

Webpack 加快程式碼運行速度方法

  1. 程式碼壓縮
  2. 抽離公共模組
  3. 懶載入模組
  4. 將小圖片轉成 base64 以減少請求
  5. 預取(prefetch) || 預載入(preload)
  6. 精靈圖
  7. webpack-bundle-analyzer 程式碼分析

Webpack 優化細節

webpack 4.6.0+增加了對預取和預載入的支援。

動態導入

  import(/* webpackChunkName: "lodash" */ 'lodash')

  // 注釋中的使用webpackChunkName。
  // 這將導致我們單獨的包被命名,lodash.bundle.js
  // 而不是just [id].bundle.js。

預取(prefetch):將來可能需要一些導航資源

  • 只要父chunk載入完成,webpack就會添加 prefetch
  import(/* webpackPrefetch: true */ 'LoginModal');

  // 將<link rel="prefetch" href="login-modal-chunk.js">其附加在頁面的開頭

預載入(preload):當前導航期間可能需要資源

  • preload chunk 會在父 chunk 載入時,以並行方式開始載入
  • 不正確地使用 webpackPreload 會有損性能,
  import(/* webpackPreload: true */ 'ChartingLibrary');

  // 在載入父 chunk 的同時
  // 還會通過 <link rel="preload"> 請求 charting-library-chunk
DllPlugin + DllReferencePlugin

為了極大減少構建時間,進行分離打包。

DllReferencePlugin 和 DLL插件DllPlugin 都是在_另外_的 webpack 設置中使用的。

DllPlugin這個插件是在一個額外的獨立的 webpack 設置中創建一個只有 dll 的 bundle(dll-only-bundle)。 這個插件會生成一個名為 manifest.json 的文件,這個文件是用來讓 DLLReferencePlugin 映射到相關的依賴上去的。

webpack.vendor.config.js

  new webpack.DllPlugin({
    context: __dirname,
    name: "[name]_[hash]",
    path: path.join(__dirname, "manifest.json"),
  })

webpack.app.config.js

  new webpack.DllReferencePlugin({
    context: __dirname,
    manifest: require("./manifest.json"),
    name: "./my-dll.js",
    scope: "xyz",
    sourceType: "commonjs2"
  })
CommonsChunkPlugin

通過將公共模組拆出來,最終合成的文件能夠在最開始的時候載入一次,便存到快取中供後續使用。這個帶來速度上的提升,因為瀏覽器會迅速將公共的程式碼從快取中取出來,而不是每次訪問一個新頁面時,再去載入一個更大的文件。

如果把公共文件提取出一個文件,那麼當用戶訪問了一個網頁,載入了這個公共文件,再訪問其他依賴公共文件的網頁時,就直接使用文件在瀏覽器的快取,這樣公共文件就只用被傳輸一次。

  entry: {
    vendor: ["jquery", "other-lib"], // 明確第三方庫
    app: "./entry"
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: "vendor",
      // filename: "vendor.js"
      // (給 chunk 一個不同的名字)

      minChunks: Infinity,
      // (隨著 entry chunk 越來越多,
      // 這個配置保證沒其它的模組會打包進 vendor chunk)
    })
  ]

  // 打包後的文件
  <script src="vendor.js" charset="utf-8"></script>
  <script src="app.js" charset="utf-8"></script>
UglifyJSPlugin

基本上腳手架都包含了該插件,該插件會分析JS程式碼語法樹,理解程式碼的含義,從而做到去掉無效程式碼、去掉日誌輸入程式碼、縮短變數名等優化。

  const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
  //...
  plugins: [
      new UglifyJSPlugin({
          compress: {
              warnings: false,  //刪除無用程式碼時不輸出警告
              drop_console: true,  //刪除所有console語句,可以兼容IE
              collapse_vars: true,  //內嵌已定義但只使用一次的變數
              reduce_vars: true,  //提取使用多次但沒定義的靜態值到變數
          },
          output: {
              beautify: false, //最緊湊的輸出,不保留空格和製表符
              comments: false, //刪除所有注釋
          }
      })
  ]
ExtractTextPlugin + PurifyCSSPlugin

ExtractTextPlugin 從 bundle 中提取文本(CSS)到單獨的文件,PurifyCSSPlugin純化CSS(其實用處沒多大)

  module.exports = {
    module: {
      rules: [
        {
          test: /\.css$/,
          loader: ExtractTextPlugin.extract({
            fallback: 'style-loader',
            use: [
              {
                loader: 'css-loader',
                options: {
                  localIdentName: 'purify_[hash:base64:5]',
                  modules: true
                }
              }
            ]
          })
        }
      ]
    },
    plugins: [
      ...,
      new PurifyCSSPlugin({
        purifyOptions: {
          whitelist: ['*purify*']
        }
      })
    ]
  };
DefinePlugin

DefinePlugin能夠自動檢測環境變化,效率高效。

在前端開發中,在不同的應用環境中,需要不同的配置。如:開發環境的API Mocker、測試流程中的數據偽造、列印調試資訊。如果使用人工處理這些配置資訊,不僅麻煩,而且容易出錯。

使用DefinePlugin配置的全局常量

注意,因為這個插件直接執行文本替換,給定的值必須包含字元串本身內的實際引號。通常,有兩種方式來達到這個效果,使用 ' "production" ', 或者使用 JSON.stringify('production')

    new webpack.DefinePlugin({

        // 當然,在運行node伺服器的時候就應該按環境來配置文件
        // 下面模擬的測試環境運行配置

        'process.env':JSON.stringify('dev'),
        WP_CONF: JSON.stringify('dev'),
    }),

測試DefinePlugin:編寫

    if (WP_CONF === 'dev') {
        console.log('This is dev');
    } else {
        console.log('This is prod');
    }

打包後WP_CONF === 'dev'會編譯為false

    if (false) {
        console.log('This is dev');
    } else {
        console.log('This is prod');
    }
清除不可達程式碼

當使用了DefinePlugin插件後,打包後的程式碼會有很多冗餘。可以通過UglifyJsPlugin清除不可達程式碼

    [
        new UglifyJsPlugin({
            uglifyOptions: {
            compress: {
                warnings: false, // 去除warning警告
                dead_code: true, // 去除不可達程式碼
            },
            warnings: false
            }
        })
    ]

最後的打包打包程式碼會變成console.log('This is prod')

附Uglify文檔://github.com/mishoo/UglifyJS2

使用DefinePlugin區分環境 + UglifyJsPlugin清除不可達程式碼,以減輕打包程式碼體積

HappyPack

HappyPack可以開啟多進程Loader轉換,將任務分解給多個子進程,最後將結果發給主進程。

使用

  exports.plugins = [
    new HappyPack({
      id: 'jsx',
      threads: 4,
      loaders: [ 'babel-loader' ]
    }),

    new HappyPack({
      id: 'styles',
      threads: 2,
      loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
    })
  ];

  exports.module.rules = [
    {
      test: /\.js$/,
      use: 'happypack/loader?id=jsx'
    },

    {
      test: /\.less$/,
      use: 'happypack/loader?id=styles'
    },
  ]
ParallelUglifyPlugin

ParallelUglifyPlugin可以開啟多進程壓縮JS文件

  import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';

  module.exports = {
    plugins: [
      new ParallelUglifyPlugin({
        test,
        include,
        exclude,
        cacheDir,
        workerCount,
        sourceMap,
        uglifyJS: {
        },
        uglifyES: {
        }
      }),
    ],
  };
BundleAnalyzerPlugin

webpack打包結果分析插件

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

減小文件搜索範圍,從而提升速度

示例

  {
    test: /\.css$/,
    include: [
      path.resolve(__dirname, "app/styles"),
      path.resolve(__dirname, "vendor/styles")
    ]
  }
外部擴展(externals)

這玩意不是插件,是wenpack的配置選項

externals 配置選項提供了「從輸出的 bundle 中排除依賴」的方法。相反,所創建的 bundle 依賴於那些存在於用戶環境(consumer’s environment)中的依賴。此功能通常對 library 開發人員來說是最有用的,然而也會有各種各樣的應用程式用到它。

  entry: {
    entry: './src/main.js',
    vendor: ['vue', 'vue-router', 'vuex']
  },
  externals: {
    // 從輸出的 bundle 中排除 echarts 依賴
    echarts: 'echarts',
  }

Webpack HMR 原理解析

Hot Module Replacement(簡稱 HMR)

包含以下內容:

  1. 熱更新圖
  2. 熱更新步驟講解

第一步:webpack 對文件系統進行 watch 打包到記憶體中

webpack-dev-middleware 調用 webpack 的 api 對文件系統 watch,當文件發生改變後,webpack 重新對文件進行編譯打包,然後保存到記憶體中。

webpack 將 bundle.js 文件打包到了記憶體中,不生成文件的原因就在於訪問記憶體中的程式碼比訪問文件系統中的文件更快,而且也減少了程式碼寫入文件的開銷。

這一切都歸功於memory-fs,memory-fs 是 webpack-dev-middleware 的一個依賴庫,webpack-dev-middleware 將 webpack 原本的 outputFileSystem 替換成了MemoryFileSystem 實例,這樣程式碼就將輸出到記憶體中。

webpack-dev-middleware 中該部分源碼如下:

  // compiler
  // webpack-dev-middleware/lib/Shared.js
  var isMemoryFs = !compiler.compilers &&
                  compiler.outputFileSystem instanceof MemoryFileSystem;
  if(isMemoryFs) {
      fs = compiler.outputFileSystem;
  } else {
      fs = compiler.outputFileSystem = new MemoryFileSystem();
  }
第二步:devServer 通知瀏覽器端文件發生改變

在啟動 devServer 的時候,sockjs 在服務端和瀏覽器端建立了一個 webSocket 長連接,以便將 webpack 編譯和打包的各個階段狀態告知瀏覽器,最關鍵的步驟還是 webpack-dev-server 調用 webpack api 監聽 compile的 done 事件,當compile 完成後,webpack-dev-server通過 _sendStatus 方法將編譯打包後的新模組 hash 值發送到瀏覽器端。

  // webpack-dev-server/lib/Server.js
  compiler.plugin('done', (stats) => {
    // stats.hash 是最新打包文件的 hash 值
    this._sendStats(this.sockets, stats.toJson(clientStats));
    this._stats = stats;
  });
  ...
  Server.prototype._sendStats = function (sockets, stats, force) {
    if (!force && stats &&
    (!stats.errors || stats.errors.length === 0) && stats.assets &&
    stats.assets.every(asset => !asset.emitted)
    ) { return this.sockWrite(sockets, 'still-ok'); }
    // 調用 sockWrite 方法將 hash 值通過 websocket 發送到瀏覽器端
    this.sockWrite(sockets, 'hash', stats.hash);
    if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } 
    else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); }      else { this.sockWrite(sockets, 'ok'); }
  };
第三步:webpack-dev-server/client 接收到服務端消息做出響應

webpack-dev-server 修改了webpack 配置中的 entry 屬性,在裡面添加了 webpack-dev-client 的程式碼,這樣在最後的 bundle.js 文件中就會接收 websocket 消息的程式碼了。

webpack-dev-server/client 當接收到 type 為 hash 消息後會將 hash 值暫存起來,當接收到 type 為 ok 的消息後對應用執行 reload 操作。

在 reload 操作中,webpack-dev-server/client 會根據 hot 配置決定是刷新瀏覽器還是對程式碼進行熱更新(HMR)。程式碼如下:

  // webpack-dev-server/client/index.js
  hash: function msgHash(hash) {
      currentHash = hash;
  },
  ok: function msgOk() {
      // ...
      reloadApp();
  },
  // ...
  function reloadApp() {
    // ...
    if (hot) {
      log.info('[WDS] App hot update...');
      const hotEmitter = require('webpack/hot/emitter');
      hotEmitter.emit('webpackHotUpdate', currentHash);
      // ...
    } else {
      log.info('[WDS] App updated. Reloading...');
      self.location.reload();
    }
  }
第四步:webpack 接收到最新 hash 值驗證並請求模組程式碼

首先 webpack/hot/dev-server(以下簡稱 dev-server) 監聽第三步 webpack-dev-server/client 發送的 webpackHotUpdate 消息,調用 webpack/lib/HotModuleReplacement.runtime(簡稱 HMR runtime)中的 check 方法,檢測是否有新的更新。

在 check 過程中會利用 webpack/lib/JsonpMainTemplate.runtime(簡稱 jsonp runtime)中的兩個方法 hotDownloadManifest 和 hotDownloadUpdateChunk。

hotDownloadManifest 是調用 AJAX 向服務端請求是否有更新的文件,如果有將發更新的文件列表返回瀏覽器端。該方法返回的是最新的 hash 值。

hotDownloadUpdateChunk 是通過 jsonp 請求最新的模組程式碼,然後將程式碼返回給 HMR runtime,HMR runtime 會根據返回的新模組程式碼做進一步處理,可能是刷新頁面,也可能是對模組進行熱更新。該 方法返回的就是最新 hash 值對應的程式碼塊。

最後將新的程式碼塊返回給 HMR runtime,進行模組熱更新。

附:為什麼更新模組的程式碼不直接在第三步通過 websocket 發送到瀏覽器端,而是通過 jsonp 來獲取呢?

我的理解是,功能塊的解耦,各個模組各司其職,dev-server/client 只負責消息的傳遞而不負責新模組的獲取,而這些工作應該有 HMR runtime 來完成,HMR runtime 才應該是獲取新程式碼的地方。再就是因為不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模組熱更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它沒有使用 websocket,而是使用的 EventSource。綜上所述,HMR 的工作流中,不應該把新模組程式碼放在 websocket 消息中。

第五步:HotModuleReplacement.runtime 對模組進行熱更新

這一步是整個模組熱更新(HMR)的關鍵步驟,而且模組熱更新都是發生在HMR runtime 中的 hotApply 方法中

  // webpack/lib/HotModuleReplacement.runtime
  function hotApply() {
      // ...
      var idx;
      var queue = outdatedModules.slice();
      while(queue.length > 0) {
          moduleId = queue.pop();
          module = installedModules[moduleId];
          // ...
          // remove module from cache
          delete installedModules[moduleId];
          // when disposing there is no need to call dispose handler
          delete outdatedDependencies[moduleId];
          // remove "parents" references from all children
          for(j = 0; j < module.children.length; j++) {
              var child = installedModules[module.children[j]];
              if(!child) continue;
              idx = child.parents.indexOf(moduleId);
              if(idx >= 0) {
                  child.parents.splice(idx, 1);
              }
          }
      }
      // ...
      // insert new code
      for(moduleId in appliedUpdate) {
          if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
              modules[moduleId] = appliedUpdate[moduleId];
          }
      }
      // ...
  }

模組熱更新的錯誤處理,如果在熱更新過程中出現錯誤,熱更新將回退到刷新瀏覽器,這部分程式碼在 dev-server 程式碼中,簡要程式碼如下:

  module.hot.check(true).then(function(updatedModules) {
    if(!updatedModules) {
        return window.location.reload();
    }
    // ...
  }).catch(function(err) {
      var status = module.hot.status();
      if(["abort", "fail"].indexOf(status) >= 0) {
          window.location.reload();
      }
  });
第六步:業務程式碼需要做些什麼?

當用新的模組程式碼替換老的模組後,但是我們的業務程式碼並不能知道程式碼已經發生變化,也就是說,當 hello.js 文件修改後,我們需要在 index.js 文件中調用 HMR 的 accept 方法,添加模組更新後的處理函數,及時將 hello 方法的返回值插入到頁面中。程式碼如下

  // index.js
  if(module.hot) {
      module.hot.accept('./hello.js', function() {
          div.innerHTML = hello()
      })
  }

最後

  1. 覺得有用的請點個贊
  2. 本文內容出自 //github.com/zhongmeizhi/FED-note
  3. 歡迎關注公眾號「前端進階課」認真學前端,一起進階。