Translated by ulivz
Revised by jothy1023

# Webpack 5 Module Federation: JavaScript 架构的变革者

Module Federation [ˌfedəˈreɪʃn] 使 JavaScript 应用得以在客户端或服务器上动态运行另一个 bundle 或者 build 的代码。

原文:Webpack 5 Module Federation: A game-changer in JavaScript architecture


Module Federation 之于 JavaScript Bundler,如同 Apollo 之于 GraphQL。

在独立的应用之间共享代码的可伸缩解决方案从未如此方便,而且几乎不可能大规模实现。此前我们拥有的最成熟方案是 externals 或 DLLPlugin,它们强制把依赖集中于一个外部文件中。共享代码真的很麻烦,因为很多应用看似独立,却不是真正意义上的独立,可共享的依赖非常有限。此外,在单独打包的 Web 应用之间共享实际的特性代码往往并不可行、无效、且毫无益处。

# 那么什么是 Module Federation?

这是我发明并且赋予它最初形态的 JavaScript 架构,在我的联合创作者兼 Webpack 创始人的帮助下,它成为了 Webpack 5 Core 中最令人兴奋的功能之一(Webpack 5 有一堆很酷的东西,新的 API 强大又干净)。

我非常荣幸地向大家介绍 JavaScript 应用架构中的一个期待已久的飞跃,我们对于开源社区的贡献:Module Federation。

Module Federation 使 JavaScript 应用得以从另一个 JavaScript 应用中动态地加载代码 —— 同时共享依赖。如果某应用所消费的 federated module 没有 federated code 中所需的依赖,Webpack 将会从 federated 构建源中下载缺少的依赖项。

代码是可以共享的,但每种情况都有降级方案。federated code 可以总是加载自己的依赖,但在下载前会去尝试使用消费者的依赖。更少的代码冗余,依赖共享就像一个单一的 Webpack 构建。虽然我已经实现了这个最初的系统——他是由我自己(Zack Jackson)和 Marais RossouwTobias Koppers 的悉心帮助和结对编程下完成的。这些开发者们在 Webpack 5 的核心重写和稳定化上起着关键性的作用,谢谢你们一直以来的合作和支持。

# 术语

  • Module federation: 与 Apollo GraphQL federation 的想法相同 —— 但适用于在浏览器或者 Node.js 中运行的 JavaScript 模块。

  • host:在页面加载过程中(当 onLoad 事件被触发)最先被初始化的 Webpack 构建;

  • remote:部分被 “host” 消费的另一个 Webpack 构建;

  • Bidirectional(双向的) hosts:当一个 bundle 或者 webpack build 作为一个 hostremote 运行时,它要么消费其他应用,要么被其他应用消费——均发生在运行时(runtime)。

如果你想更好地理解这篇文章,欢迎阅读Jack Herrington 写的文章!

值得注意的是,该系统的设计使每个完全独立的构建/应用都可以存放在自己的仓库中,独立部署,并作为独立的 SPA 运行。

这些应用都是 bidirectional(双向的) hosts. 所有在你修改路由或者移动应用时率先加载的应用都将成为 host, 它会像你实现 dynamic imports 一样加载 federated modules. 不过,如果你要刷新页面,则任何在之后的 load 中率先启动的应用,都将成为一个 host。

假设一个网站的每个页面都是独立部署和编译的,我想要这种微前端风格的架构,但不希望页面随着我更改路由而重新加载。我还希望在页面之间动态地共享代码和 vendors, 这样它就像支持 code splitting 的大型 Webpack 构建般高效了。

访问应用的 home 也将会使这个 “home” 页成为 “host”,如果你切换到 “about” 页,那么这个 host(home 页的 spa)实际上是从另一个独立应用(about 页的 spa)中动态加载一个模块,它并不会加载应用主入口以及另一个完整的应用,而只会加载几千字节的代码。如果我在 “about” 页刷新浏览器,“about” 页将会成为 “host”,此时我回到 “home” 页,“about” 页(“host”)将会从 “remote” 获取运行时的一些片段——这个 “remote” 就是 “home” 页。在这个系统中,所有的应用都既是 “remote” 又是 “host”,与其它 federated module 互为消费者与被消费者。

你可以在 Github 上找到更多的技术细节:

Merge Proposal: Module federation and code sharing between bundles. Many builds act as one

# 构建一个 federated application

让我们从三个独立的应用入手。

# App One

# 配置

我将使用 App One 中的应用容器 <App>,它将会被另一个应用消费,为此,我把 App expose 为 AppContainer,App One 将会同时消费来自另外两个 federated applications 中的组件,为此,我指定了 remotes scope:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  // other webpack configs...
  plugins: [
    new ModuleFederationPlugin({
      name: "app_one_remote",
      remotes: {
        app_two: "app_two_remote",
        app_three: "app_three_remote"
      },
      exposes: {
        'AppContainer':'./src/App'
      },
      shared: ["react", "react-dom","react-router-dom"]
    }),
    new HtmlWebpackPlugin({
      template: "./public/index.html",
      chunks: ["main"]
    })
  ]
}

# 设定构建编排流程

在应用的头部,我加载了 app_one_remote.js —— 来让当前的应用连接到其他 Webpack runtimes,并在运行时提供一个编排层(orchestration layer),这是一个专门设计的 Webpack runtime 和 entry point,但它不是一个普通的应用 entry point,并且只有几 KB

要注意,这些特殊的 entry points 大小只有几 KB——它们包含一个可以与 host 连接的特殊的 Webpack runtime,它们并非标准的 entry point。

<head>
  <script src="http://localhost:3002/app_one_remote.js"></script>
  <script src="http://localhost:3003/app_two_remote.js"></script>
</head>
<body>
  <div id="root"></div>
</body>

# 从 remote 消费代码

App One 中有个 Page1 页面消费了 App Two 的 dialog 组件:

const Dialog = React.lazy(() => import("app_two_remote/Dialog"));

const Page1 = () => {
    return (
        <div>
            <h1>Page 1</h1>
            <React.Suspense fallback="Loading Material UI Dialog...">
                <Dialog />
            </React.Suspense>
        </div>
    );
}

export default Page1;

路由看起来很标准:

import { Route, Switch } from "react-router-dom";

import Page1 from "./pages/page1";
import Page2 from "./pages/page2";
import React from "react";

const Routes = () => (
  <Switch>
    <Route path="/page1">
      <Page1 />
    </Route>
    <Route path="/page2">
      <Page2 />
    </Route>
  </Switch>
);

export default Routes;

# App Two

# 配置

App Two 将会导出 Dialog,并让 App One 来消费它,App Two 将会同时消费 App One<App> —— 因此我们把 app_one 页设置为 “remote”。演示一下 bi-directional hosts.

const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "app_two_remote",
      library: { type: "var", name: "app_two_remote" },
      filename: "remoteEntry.js",
      exposes: {
        Dialog: "./src/Dialog"
      },
      remotes: {
        app_one: "app_one_remote",
      },
      shared: ["react", "react-dom","react-router-dom"]
    }),
    new HtmlWebpackPlugin({
      template: "./public/index.html",
      chunks: ["main"]
    })
  ]
};

# 消费

这个是根 App

import React from "react";
import Routes from './Routes'
const AppContainer = React.lazy(() => import("app_one_remote/AppContainer"));

const App = () => {
    return (
        <div>
            <React.Suspense fallback="Loading App Container from Host">
                <AppContainer routes={Routes}/>
            </React.Suspense>
        </div>
    );
}

export default App;

这个是使用了 Dialog 的默认页面:

import React from 'react'
import {ThemeProvider} from "@material-ui/core";
import {theme} from "./theme";
import Dialog from "./Dialog";

function MainPage() {
    return (
        <ThemeProvider theme={theme}>
            <div>
                <h1>Material UI App</h1>
                <Dialog />
            </div>
        </ThemeProvider>
    );
}

export default MainPage

# App Three

正如所料,App Three 看起来(和App Two)很相似。但是,它不消费来自 App one 的独立自运行的组件 <App> (没有导航栏或侧边栏),因此,它并未指定任何 “remote”:

new ModuleFederationPlugin({
  name: "app_three_remote",
  library: { type: "var", name: "app_three_remote" },
  filename: "remoteEntry.js",
  exposes: {
    Button: "./src/Button"
  },
  shared: ["react", "react-dom"]
}),

# 浏览器中的最终结果(不同于第一个视频)

请重点关注 network 标签页,来自三个不同服务器的代码正在被整合(federated),三个不同的 bundles,我们一般建议不要 federate 整个应用的容器,除非你正享受 SSR 或者渐进式加载带来的好处。不管怎么说,这个概念简直太强大了。

# 代码冗余

这里几乎没有任何依赖冗余,通过 shared 选项 —— remotes 将会首先依赖来自 host 的依赖,如果 host 没有依赖,它将会下载自己的依赖。没有代码层面的冗余,而只有内置的冗余。

然而手动地增加 vendors 或其他用于共享的模块并不利于拓展,你可以自定义一个函数或者相应的 Webpack 插件来实现自动化。我们的确计划发布 AutomaticModuleFederationPlugin,并且在 Webpack Core 之外去维护它。既然我们在 Webpack 中已经有了相当好的 code federation 支持,拓展它的功能就不值一提了。

那么大问题来了… 上面说的这些支持 SSR 吗??

# 服务端渲染

我们将其设计成了可以在任何环境下工作的 “通用模块 Federation”,服务端渲染 federated code 是完全可行的,只要让服务器构建使用一个 commonjs 的 library target。实现 federated SSR 有很多种办法,S3 Streaming, ESI, 自动化一个 npm 发布去消费服务器的变化内容,我计划用一个常用的共享文件的 volume 或者移步的 S3 streaming 来跨文件系统传输文件。让服务器能够像在浏览器中一样去 require federated code,使用 fs 而不是 http 来加载 federated code。

module.exports = {
 plugins: [
  new ModuleFederationPlugin({
   name: "container",
   library: { type: "commonjs-module" },
   filename: "container.js",
   remotes: {
    containerB: "../1-container-full/container.js"
   },
   shared: ["react"]
  })
 ]
};

“Module Federation 当然也适用于 target: "node",这里使用指向其他微前端应用的文件路径,而不是 URLs。这样的话你就可以用同样的代码,外加不一样的 Webpack 配置来在 Node.js 中实现 SSR. Module Federation 的特性在 Node.js 中保持不变,如独立构建、独立部署。—— Tobias Koppers

# Federated Next.js on Webpack 5

Federation 依赖 Webpack 5 —— Next 并未官方支持。不过我已经 fork Next.js,并设法升级它以支持 Webpack 5 了!这项工作仍在进行中,还有一些开发模式下的中间件需要完成。生产模式已经搞定了,但某些附加的 loaders 仍然需要重新测试。

feat: Upgrade Next.js to Webpack 5

# Talks, podcasts, or feedback

我非常想有和大家分享这项技术的机会,如果你想要使用 Module Federation 或者 Federated architecture,我们想要听到你的使用经历以及对现有的架构的调整。我们也非常期望能够有机会在 Podcasts、meetups 或者 corporations 中讨论它。你可以通过 Twitter 联系到我。

你还可以联系我的联合创作者,来了解 Module Federation 的最新变化、FOSA (Federation of Standalone Applications) 架构,以及我们正在做的其他工具 —— 用于和 Federated Applications 共用的工具。

# 使用 Module Federation 的例子

社区对 Module Federation 的反馈非常热烈!我和我的创作者们的大部分时间,都集中在将这项特性写到 Webpack 5 中,当我们忙于完成剩余特性,以及书写文档时,希望这些代码示例能对你有帮助。

因为我们有带宽,所以我们将创建一些 SSR 示例和更全面的 demo,如果有人想建设可以作为 demo 使用的东西 —— 我们很乐意接受提交给 webpack-external-import 的 pull requests。

感谢 Tobias Koppers.