Vite 也可以模塊聯邦

前言

之前寫過一篇文章,《將 React 應用遷移至 Vite》[1] 介紹了 Vite 的優勢,並且和 webpack 做對比,但 webpack5 有個很重要的功能,就是模塊聯邦,那麼什麼是模塊聯邦?Vite 中也可以實現嗎?我們一起來探究下。

什麼是模塊聯邦?

Module Federation 中文直譯爲 “模塊聯邦”,而在 webpack 官方文檔中,其實並未給出其真正含義,但給出了使用該功能的 motivation, 即動機,翻譯成中文

多個獨立的構建可以形成一個應用程序。這些獨立的構建不會相互依賴,因此可以單獨開發和部署它們。這通常被稱爲微前端,但並不僅限於此。

原文在這裏:module-federation[2], 並且給出了 stackblitz 在線運行鏈接 [3]

這個是一個基於 lernamonorepo 倉庫, app1app2 是並行啓動的, 分別運行在 30013002 端口上。

但在 app1 中卻可以直接引用 app2 的組件

現在,直接修改 app2 中組件的代碼,在 app1 中就可以同步更新。

此處需要點擊下刷新按鈕,因爲 2 個應用啓動在 2 個端口上,所以不會熱更新。

結合以上,不難看出,MF 實際想要做的事,便是把多個無相互依賴、單獨部署的應用合併爲一個。通俗點講,即 MF 提供了能在當前應用中加載遠程服務器上應用模塊的能力,這就是模塊聯邦(Module Federation)。

模塊聯邦解決了什麼問題

我們要在多個應用直接實現模塊共享,我們原來是怎麼做的?

  1. 發佈 npm 組件

npm 是前端的優勢,也是前端之痛,一個項目只依賴了 1 個 npm 包,而在 node_modules 卻有無數個包,若是純粹的基礎組件發佈 npm 包還可以,因爲不常改動,若一個模塊涉及業務,發佈 npm 包就會變得很麻煩,比如一個常見的需求,需要給每個應用加上客服聊天窗口。這個聊天窗口會隨着 chat services的改動而變化,當 chat 這個組件改變時,我們就會陷入 npm 發佈 ——> app 升級 npm 包 -> app 上線 這樣的輪迴之中,而在現實場景中,我們會採用另一種方式。

  1. Iframe

Iframe 是另一種方案,可以將 chat 做一個 iframe 嵌入到各個應用中,這樣只需要升級 chat 一個應用,其他應用都不用改動。但 iframe 也有缺點,首先使用 iframe 每次打開組件,DOM 樹都會重建,所以打開速度較慢。其次 iframe 跨應用通信使用 window.postMessage 的方式,若應用部署在不同的域名下,使用 postMessage 需要控制好 originsource 屬性驗證發件人的身份,不然可能會存在跨站點腳本漏洞。

而 MF 很好地解決了多應用模塊複用的問題,相比上面的這 2 中解決方案,它的解決方式更加優雅和靈活。

如何配置模塊聯邦

MF 引出下面兩個概念:

在 webpack 中配置

無論是當前應用還是遠程應用都依賴 webpack5 中的 ModuleFederationPlugin plugin

作爲組件提供方,需要在 plugins 中配置如下代碼

const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  entry: './src/index',
  mode: 'development',
  devServer: {
    static: path.join(__dirname, 'dist'),
    port: 3002,
  },
  output: {
    publicPath: 'auto',
  },
  plugins: [
    new ModuleFederationPlugin({
        // 遠程組件的應用名稱
      name: 'app2',
      // 遠程組件的入口文件
      filename: 'remoteEntry.js',
      // 定義需要導出的組件列表
      exposes: {
        './App': './src/App',
        './Component': './src/component',
      },
      // 可以被共享的模塊
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

在當前應用中,也就是作爲組件的使用方,需要在 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: {
    static: path.join(__dirname, "dist"),
    port: 3001,
  },
  output: {
    publicPath: "auto",
  },
  plugins: [
    new ModuleFederationPlugin({
        // 當前應用名稱
      name: "app1",
      // 遠程應用加載js入口列表
      remotes: {
        app2: "app2@http://localhost:3002/remoteEntry.js",
      },
      //共享的模塊
      shared: {react: {singleton: true}, "react-dom": {singleton: true}},
    })
  ],
};

本地模塊需配置所有使用到的遠端模塊的依賴;遠端模塊需要配置對外提供的組件的依賴。

最後一步需要將入口文件改爲異步加載

比如原先的入口文件 index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

index.js要修改爲異步加載

+ import('./bootstrap');

- import React from 'react';

- import ReactDOM from 'react-dom';

- import App from './App';

- ReactDOM.render(<App />, document.getElementById('root'));

重命名原先的 index.jsbootstrap.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
const RemoteApp = React.lazy(() => import("app2/App"));

這樣在 app1 中就可以只有引用 app2 中的組件了。

在 vite 中配置

MF 提供的是一種加載方式,並不是 webpack 獨有的,所以社區中已經提供了一個的 Vite 模塊聯邦方案: vite-plugin-federation[4],這個方案基於 Vite(Rollup) 也實現了完整的模塊聯邦能力。

Vite 模塊聯邦 stackblitz 在線運行鏈接 [5] 打開這個示例,請按 readme 命令依次運行,由於 Vite 是按需編譯,所以 app2 必須先打包啓動, 2 個 App 無法同時是開發模式。

配置步驟

首先需要安裝 @originjs/vite-plugin-federation

app1 當前應用:vite.config.js配置

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "app1",
      remotes: {
        app2: "http://localhost:3002/assets/remoteEntry.js",
      },
      shared: ["react"],
    }),
  ],
});

app2 遠程應用:vite.config.js配置

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "app2",
      filename: "remoteEntry.js",
      library: { type: "module" },
      exposes: {
        "./App": "./src/App.jsx",
      },
      shared: ["react"],
    }),
  ],
});

我們看到這些配置同 webpack 如出一轍,稍微不同的是

remotes 對象中不需要寫, app2@這個全局變量名稱,並且 vite 打包的 remoteEntry.js 默認在 assets 文件夾下

remotes: {
    app2: "http://localhost:3002/assets/remoteEntry.js",
},

vite-plugin-federation 還可以和 webpack 配合使用

remotes: {
    app2: {
        external: 'http://localhost:5011/remoteEntry.js',
        format: 'var',
        from: 'webpack'
    }
}

官方還提供了 systemjs\ esmvar 等不同的加載方式

gOsm7q

官方還提供了 2 點 warning

模塊聯邦的原理

‍Host 端消費 Remote 模塊

整體邏輯

比如 Host 端有如下代碼

import Section from './components/Section.vue'

const script = {
  name: 'App',
  components: {
    Section,
    Button: () => import('./components/Button.vue'),
    RemoteButton: () => import('remote-simple/remote-simple-button'),
  }
}

編譯後悔轉換爲如下代碼

import __federation__ from '__federation__';
import Section from './components/Section.vue'

const script = {
  name: 'App',
  components: {
    Section,
    Button: () => import('./components/Button.vue'),
    RemoteButton: () => __federation__.ensure("remote-simple").then((remote) => remote.get("./remote-simple-button")),
  }
}

__federation__ 是一個虛擬文件,用於維護 remotesMap對象

const remotesMap = {
  'remote-simple': () => import('http://localhost:5011/remoteEntry.js')
}

const shareScope = {
  vue: { get: () => import('__rf_shareScope__${vue}') }
}

const initMap = {}

export default {
  ensure: async (remoteId) => {
    const remote = await remotesMap[remoteId]()

    if (!initMap[remoteId]) {
      remote.init(shareScope)
      initMap[remoteId] = true
    }

    return remote
  }
}

Remote 端暴露模塊

比如 Remote 暴露了 Button 模塊

exposes: {
  './Button': './src/components/Button.js',
},

則會生成如下 remoteEntry.js

let moduleMap = {
"./Button":()=>{return import('./button.js')},
};

const get =(module, getScope) => {
    return moduleMap[module]();
};
const init =(shareScope, initScope) => {
    let global = window || node;
    global.__rf_var__shared= shareScope;
};

export { get, init };

moduleMap 維護了所有導出的 remote 模塊對象, init()做一些shareScope初始化的工作。get()會根據傳入的模塊名動態加載模塊。

此時 remote./button.js 是不存在的,需要根據 exposes 配置信息將模塊單獨打包爲 chunk,供 Host 端調用時加載。所以需要將 remote 端改成多入口的打包方式,Rollup 插件在 options()鉤子,根據 exposes 改寫 Rollup 的 input 配置,例如示例的 exposes 會生成:

input: {
    Button: './src/components/Button.js'
}

以上便是模塊聯邦的基本邏輯。

模塊聯邦存在問題

小結

鑑於 MF 的能力,我們可以完全實現一個去中心化的應用:每個應用是單獨部署在各自的服務器,每個應用都可以引用其他應用,也能被其他應用所引用,即每個應用可以充當 Host 的角色,亦可以作爲 Remote 出現,無中心應用的概念。

本文介紹了什麼是模塊聯邦,在模塊聯邦之前,前端模塊共享存在着各種痛點,並且通過在線例子演示了模塊聯邦的配置,也介紹了vite-plugin-federation 插件的使用及原理,它讓我們可以在 Vite 項目中也可以實現模塊共享。總體而言模塊聯邦配置相對簡單,但模塊聯邦想要真正落地可能需要全員推動,因爲在現實開發中,存在着跨部門協作,開發人員不可能瞭解每個項目的 vite.config.js 配置,這就需要我們將所有的 remote 模塊維護成文檔,供跨團隊調用。

參考

[1] 將 react 應用遷移至 Vite: https://juejin.cn/post/7110535158863757319

[2]module-federation: https://webpack.js.org/concepts/module-federation/

[3]stackblitz 在線運行鏈接: https://stackblitz.com/github/webpack/webpack.js.org/tree/master/examples/module-federation?file=README.md&terminal=start&terminal=

[4]vite-plugin-federation: https://github.com/originjs/vite-plugin-federation

[5]vite 模塊聯邦 stackblitz 在線運行鏈接: https://stackblitz.com/edit/github-kyokdx?file=readme.md

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/H8emiJzYxN06Syrmn5ShOg