Webpack5 上手測評

你們必定看過不少電子設備開箱測評,今天咱們也來跑一個軟件新版的上手測評 —— Webpack 5!javascript

從 2017 年發出關於 v5 的投票開始,到 2019 年 10 月發佈第一個 beta 版本,目前是 5.0.0-beta.16。如今在收集使用反饋、生態升級的過程當中,相信不久後就能夠正式發佈了。此次升級重點: 性能改進、Tree Shaking、Code Generation、Module Federation。html

下面咱們跟着 Changelog 來動手,測測重點內容~前端

優化持久緩存

首先簡單說 Webpack 中 graph 的概念:java

Webpack 在執行的時候,以配置的 entry 爲入口,遞歸解析文件依賴,構建一個 graph,記錄代碼中各個 module 之間的關係。 每當有文件更新的時候, 遞歸過程會重來,graph 發生改變。node

若是簡單粗暴地重建 graph 再編譯,會有很大的性能開銷。 Webpack 利用緩存實現增量編譯,從而提高構建性能。react

緩存(內存 / 磁盤兩種形式)中的主要內容是 module objects,在編譯的時候會將 graph 以二進制或者 json 文件存儲在硬盤上。每當代碼變化、模塊之間依賴關係改變致使 graph 改變時, Webpack 會讀取記錄作增量編譯。webpack

以前可使用 loader 設置緩存:git

  1. 使用 cache-loader 能夠將編譯結果寫入硬盤緩存,Webpack 再次構建時若是文件沒有發生變化則會直接拉取緩存
  2. 還有一部分 loader 自帶緩存配置,好比 babel-loader,能夠配置參數 cacheDirectory 使用緩存,將每次的編譯結果寫進磁盤(默認在 node_modules/.cache/babel-loader 目錄)

v5 中緩存默認是 memory,你能夠修改設置寫入硬盤:github

module.exports = {
  cache: {
    type: 'filesystem',
    // cacheDirectory 默認路徑是 node_modules/.cache/webpack
    cacheDirectory: path.resolve(__dirname, '.temp_cache')
  }
};
複製代碼

注: 對大部分 node_modules 哈希處理以構建依賴項,代價昂貴,還下降 Webpack 執行速度。爲避免這種狀況出現,Webpack 加入了一些優化,默認會跳過 node_modules,並使用 package.json 中的 versionname 做爲數據源。web

優化長期緩存

Webpack 5 針對 moduleIdchunkId 的計算方式進行了優化,增長肯定性的 moduleId 和 chunkId 的生成策略。moduleId 根據上下文模塊路徑,chunkId 根據 chunk 內容計算,最後爲 moduleId 和 chunkId 生成 3 - 4 位的數字 id,實現長期緩存,生產環境下默認開啓。

  1. 對比原來的 moduleId

原來的 moduleId 默認值是自增 id,容易致使文件緩存失效。 在 v4 以前,能夠安裝 HashedModuleIdsPlugin 插件覆蓋默認的 moduleId 規則, 它會使用模塊路徑生成的 hash 做爲 moduleId。 在 v4 中,能夠配置 optimization.moduleIds = 'hashed'

  1. 對比原來的 chunkId

原來的 chunkId 默認值自增 id。 好比這樣的配置下,若是有新的 entry 增長,chunk 數量也會跟着增長,chunkId 也會遞增。 以前能夠安裝 NamedChunksPlugin 插件來穩定 chunkId;或者配置 optimization.chunkIds = 'named'

NodeJS 的 polyfill 腳本被移除

最開始,Webpack 目標是容許在瀏覽器中運行 Node 模塊。可是如今在 Webpack 看來,大多模塊就是專門爲前端開發的。在 v4 及之前的版本中,對於大多數的 Node 模塊會自動添加 polyfill 腳本,polyfill 會加到最終的 bundle 中,其實一般狀況下是沒有必要的。在 v5 中將中止這一行爲。

好比如下一段代碼:

// index.js
import sha256 from 'crypto-js/sha256';
 
const hashDigest = sha256('hello world');
console.log(hashDigest);
複製代碼

在 v4 中,會主動添加 crypto 的 polyfill,也就是 crypto-browserify。咱們運行的代碼是不須要的,反而最後的包變大,編譯結果 417 kb

在 v5 中,若是遇到了這樣的狀況,會提示你進行確認。若是確認不須要 node polyfill,按照提示 alias 設置爲 false 便可。最後的編譯結果僅有 5.69 kb

配置 resolve.alias: { crypto: false }

瀏覽器執行結果:

更好的 TreeShaking

如今有這樣一段代碼:

// inner.js
export const a = 'aaaaaaaaaa';
export const b = 'bbbbbbbbbb';

// module.js
import * as inner from "./inner";
export { inner };

// index.js
import * as module from "./module";
console.log(module.inner.a);
複製代碼

在 v4 中毫無疑問,以上代碼 a、b 變量是被所有打包的:

但咱們只調用了 a 變量,理想狀況應該是 b 被識別爲 unused,不被打包。這一優化在 v5 中實現了。 在 v5 中會分析模塊 exportimport 之間的依賴關係,最終的代碼生成很是簡潔:

重大的變革

若是說以上的變動優化都是常規路數,那麼下面的功能有點出乎意料。

Module Federation

讓 Webpack 達到了線上 runtime 的效果,讓代碼直接在獨立應用間利用 CDN 直接共享,再也不須要本地安裝 NPM 包、構建再發布了!

設計初衷

Webpack 認同多個單獨的構建應可以構成一個應用。 這些獨立的構建不相互依賴,所以能夠單獨開發和部署。 這一般稱爲微型前端,但還不只僅是如此。

在以前咱們但願共享代碼是如何作的?

NPM

維護一個 CommonComponents 的 NPM 包,在不一樣項目中安裝、使用。若是 NPM 包升級,對應項目都須要安裝新版本,本地編譯,打包到 bundle 中。

UMD

UMD 優勢在 runtime。缺點也明顯,體積優化不方便,容易有版本衝突。

微前端

獨立應用間的共享也是問題。通常有兩種打包方式:

  1. 子應用獨立打包,模塊解耦了,但公共的依賴不易維護處理
  2. 總體應用一塊兒打包,能解決公共依賴;但龐大的多個項目又使打包變慢,後續也很差擴展

Webpack 5 實現了全新的解決方案

從圖中能夠看到,這個方案是直接將一個應用的 bundle,應用於另外一個應用。

應用能夠模塊化輸出,就是說它自己能夠自我消費,也能夠動態分發 runtime 子模塊給其餘應用。

理論比較抽象,咱們動手試一下。

實踐測試

如今有兩個應用 app1 (localhost:3001)、app2 (localhost:3002):

入口文件:

// app1 & app2: index.js
import App from "./App";
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<App />, document.getElementById("root")); 複製代碼

app2 生產了 Button 組件:

// app2: Button.js
import React from "react";

const Button = () => <button>App 2 Button</button>;

export default Button;
複製代碼

app2 自身消費 Button 組件:

// app2: App.js
import LocalButton from "./Button";
import React from "react";

const App = () => (
  <div> <h1>Basic Host-Remote</h1> <h2>App 2</h2> <LocalButton /> </div>
);

export default App;
複製代碼

app1 引用 app2Button 組件:

// app1: App.js
import React from "react";
const RemoteButton = React.lazy(() => import("app2/Button"));

const App = () => (
  <div> <h1>Basic Host-Remote</h1> <h2>App 1</h2> <React.Suspense fallback="Loading Button"> <RemoteButton /> </React.Suspense> </div> ); export default App; 複製代碼

先看生產了 Button 組件的 app2,其配置文件:

// app2: webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");

module.exports = {
  entry: "./src/index",
  mode: "development",
  devServer: {
    contentBase: path.join(__dirname, "dist"),
    port: 3002,
  },
  output: {
    publicPath: "http://localhost:3002/",
  },
  module: {
    rules: [
      // ...
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "app2Lib",
      library: { type: "var", name: "app2Lib" },
      filename: "app2-remote-entry.js",
      exposes: {
        Button: "./src/Button",
      },
      shared: ["react", "react-dom"],
    }),
    new HtmlWebpackPlugin({
      template: "./index.html",
    }),
  ],
};
複製代碼

這段配置描述了,須要暴露出 Button 組件、須要依賴 reactreact-dom。管理 exposesshared 的模塊爲 app2Lib,生成入口文件名爲 app-remote-entry.js

app1 的配置文件:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");

module.exports = {
  entry: "./src/index",
  mode: "development",
  devServer: {
    contentBase: path.join(__dirname, "dist"),
    port: 3001,
  },
  output: {
    publicPath: "http://localhost:3001/",
  },
  module: {
    rules: [
      // ...
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "app1",
      library: { type: "var", name: "app1" },
      remotes: {
        app2: "app2Lib",
      },
      shared: ["react", "react-dom"],
    }),
    new HtmlWebpackPlugin({
      template: "./index.html",
    }),
  ],
};
複製代碼

這段配置描述了,使用遠端模塊 app2Lib,依賴 reactreact-dom

最後一步: 在 app1 html 中加載 app2-remote-entry.js

// app1: index.html
<html>
  <head>
    <script src="http://localhost:3002/app2-remote-entry.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
複製代碼

運行結果:

引用的 app2/Button 是如何找到的呢?

經過 app1 的配置文件,知道了 app2 是遠端加載。在生成的 app1 main.js 描述爲:

看這裏的 data 數組:

data[1]webpack/container/reference/app2,這裏是返回 app2Lib 對象:

module.exports = app2Lib;
複製代碼

data[0]webpack/container/remote-overrides/a46c3e,這裏提供了 app2 須要的 reactreact-dom 依賴,並返回 app2Lib

module.exports = (external) => {
  if (external.override) {
    external.override(Object.assign({
      "react": () => {
        return Promise.resolve().then(() => {
          return () => __webpack_require__(/*! react */ "./node_modules/react/index.js")
        })
      },
      "react-dom": () => {
        return Promise.resolve().then(() => {
          return () => __webpack_require__(/*! react-dom */ "./node_modules/react-dom/index.js")
        })
      }
    }, __webpack_require__.O))
  }
  return external;
};
複製代碼

因此最後 promise 的賦值變成了:

var promise = app2Lib.get('Button');
複製代碼

這麼一看,app2Lib 是全局變量呀。

繼續看 app1 加載的 app2-remote-entry.js 內容。果真,生成了一個全局變量 app2Lib

app2Lib 對象擁有兩個方法,具體爲:

var get = (module) => {
  return (
    __webpack_require__.o(moduleMap, module)
      ? moduleMap[module]()
      : Promise.resolve().then(() => {
        throw new Error('Module \"' + module + '\" does not exist in container.');
      })
  );
};

var override = (override) => {
  Object.assign(__webpack_require__.O, override);
};
複製代碼

因此,app2/Button 實際就是 app2Lib.get('Button'),而後根據映射找到模塊,隨後__webpack_require__

var moduleMap = {
  "Button": () => {
    return __webpack_require__.e("src_Button_js").then(() => 
      () => __webpack_require__(/*! ./src/Button */ "./src/Button.js")
    );
  }
};
複製代碼

最後再說 shared: ['react', 'react-dom']

app2 中指明瞭須要依賴 reactreact-dom,並指望消費的應用提供。若是 app1 沒有提供,或沒有提供指定版本,以下把代碼註釋:

plugins: [
  new ModuleFederationPlugin({
    name: "app1",
    library: { type: "var", name: "app1" },
    remotes: {
      'app2': "app2Lib",
    },
    // shared: ["react", "react-dom"],
    // 版本不一致同理
    // shared: {
    // "react-15": "react",
    // "react-dom": "react-dom",
    // },
  }),
  new HtmlWebpackPlugin({
    template: "./index.html",
  }),
]
複製代碼

那麼,剛纔 app1 main.js 中的 data[0]webpack/container/remote-overrides/a46c3e 會變爲:

module.exports = (external) => {
  if (external.override) {
    external.override(__webpack_require__.O);
    // external.override(Object.assign({
    // "react": () => {
    // return Promise.resolve().then(() => {
    // return () => __webpack_require__(/*! react */ "./node_modules/react/index.js")
    // })
    // },
    // "react-dom": () => {
    // return Promise.resolve().then(() => {
    // return () => __webpack_require__(/*! react-dom */ "./node_modules/react-dom/index.js")
    // })
    // }
    // }, __webpack_require__.O))
  }
  return external;
};
複製代碼

app1 則從 app2 加載 react 依賴:

總結,根據 app2 配置的 exposes & shared 內容,產生對應的模塊文件,以及模塊映射關係,經過全局變量 app2Lib 進行訪問;app1 經過全局變量 get 能知道應該去如何加載 button.jsoverride 能知道共享依賴的模塊。

以上,Federation 初看很像 DLL + External,但好處是你無需手動維護、打包依賴,代碼運行時加載。 這種模式下,調試也變得容易,再也不須要複製粘貼代碼或者 npm link,只須要啓動應用便可。 這裏僅以 Button 組件爲例,Button 能夠一個組件,也能夠是一個頁面、一個應用。Module Federation 的落地,結合自動化流程等系列工做,還須要你們在各自場景中實踐。

社區探索實踐

其餘特性

  • Top Level Await
  • SplitChunks 支持更靈活的資源拆分
  • 不包含 JS 代碼的 Chunk 將再也不生成 JS 文件
  • Output 默認生成 ES6 規範代碼,也支持配置爲 5 - 11
  • ......

詳細請閱讀 Changlog

以上 Demo 官方也有給出,供你們參考。筆者也將本身內部項目作了升級測試,過程當中會出現一些 plugins 不兼容的狀況。根據官方 Changelog 說明,均可以找到答案,臨時修改下相關 plugin 代碼。若是你的升級嘗試中也遇到了,能夠自行處理下,同時也反饋回社區,共同推動新版發佈進程。

總的來講,Webpack 5 初步上手體驗後,打包體積、速度都有不錯的提高,多數功能的使用配置也更便捷靈巧,Module Federation 讓人眼前一亮。拋磚引玉,你們感興趣能夠來交流各自的解讀和研究。

若是你對新鮮事物充滿好奇,喜歡專研技術、樂於分享,對 IM 產品、桌面客戶端基礎引擎、基礎平臺建設感興趣,歡迎你的加入!

文章做者:王欣瑜
字節跳動飛書業務,海量 hc,極速響應,快來和我成爲同事吧~職位介紹

相關文章
相關標籤/搜索