webpack 5 模块联邦入门教程

584 阅读5分钟

原文地址:Getting Started With Federated Modules, by Jacob Ebey

译注:本文将带大家快速地学会在 webpack 5 进行模块联邦功能的配置。需要说明的是,原文代码有点古旧,翻译的过程中笔者将代码统一转换成最新代码了。

我们要构建的项目

我们将构建两个单独的单页应用程序(SPA),并使用模块联邦在运行期间共享组件。

应用程序A将包含一个 SayHelloFromA 组件,该组件将被 Application B 消费,而 Application B 将包含一个 SayHelloFromB 组件,该组件将被 Application A 消费。具体如下:

这种架构将允许每个单页应用程序独立开发和部署,并即时接收来自其他联合应用程序(federated applications)的更新,而无需进行任何部署。

简而言之

你可以在此处找到这个示例的完整源代码:github.com/baooab/fede…

配置环境

首先,让我们配置环境。为了简单起见,我们将使用 yarn mono-repo 结构,但 Module Federation 的理念是允许团队自主操作的,在现实世界中,你的 SPA 很可能是在独立的仓库中,而不是我们这里 mono-repo 结构。

译注:没有安装 yarn 包管理器的同学,可以通过 npm install -g yarn 完成安装。

创建一个新项目文件夹,并使用以下 package.json 文件来同时运行两个 SPA:

package.json

{
  "name": "federation-example",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "dev": "wsrun --parallel dev",
    "build": "yarn workspaces run build"
  },
  "devDependencies": {
    "wsrun": "^5.2.4"
  }
}

现在我们将创建两个文件夹,用于存放我们的单页应用程序,这些文件夹位于名为 application-aapplication-b 的目录下,分别包含以下 package.json 文件:

packages/application-a/package.json

{
  "name": "application-a",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "webpack serve",
    "build": "webpack --mode=production"
  },
  "license": "ISC",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.21.8",
    "@babel/preset-react": "^7.18.6",
    "babel-loader": "^9.1.2",
    "html-webpack-plugin": "^5.5.1",
    "webpack": "^5.83.1",
    "webpack-cli": "^5.1.1",
    "webpack-dev-server": "^4.15.0"
  }
}

packages/application-b/package.json

{
  "name": "application-b",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "webpack serve",
    "build": "webpack --mode=production"
  },
  "license": "ISC",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.21.8",
    "@babel/preset-react": "^7.18.6",
    "babel-loader": "^9.1.2",
    "html-webpack-plugin": "^5.5.1",
    "webpack": "^5.83.1",
    "webpack-cli": "^5.1.1",
    "webpack-dev-server": "^4.15.0"
  }
}

两个 package.json 文件只有 name 属性不同,其余全一样。

项目根目录下安装依赖:

# 也会安装 mono-repo 中的依赖
> yarn

编写单页应用程序

接下来是编写我们的 SPA React 应用程序。我们需要在每个项目中创建一个 src 目录,并包含以下文件:

packages/application-{a,b}/src/index.js

import('./bootstrap')

packages/application-{a,b}/src/bootstrap.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'

import App from './app'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(<App />)

我们还需要在每个包中添加一个 public 目录,包含以下 HTML 模板,承载 SPA 项目,稍后我们还会做一些修改:

packages/application-{a,b}/public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0"> 
</head>
<body>
  <div id="root"></div>
</body>
</html>

现在我们可以为每个应用程序实现两个 app.jsx 文件,以容纳我们的共享组件:

packages/application-a/src/app.jsx

import React from 'react'

export default function SayHelloFromA() {
  return <h1>Hello From Application A!</h1>
}

packages/application-b/src/app.jsx

import React from 'react'

export default function SayHelloFromB() {
  return <h1>Hello From Application B!</h1>
}

最后,我们为每个应用程序添加配置文件 webpack.config.js

packages/application-{a,b}/webpack.config.js

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

module.exports = (env, argv) => {
  const mode = argv.mode || 'development'

  return {
    mode,
    entry: './src/index',
    devServer: {
      static: path.join(__dirname, 'dist'),
      port: 3001,
    },
    devtool: 'source-map',
    resolve: {
      extensions: ['.jsx', '.js', '.json']
    },
    module: {
      rules: [
        {
          test: /\.jsx?$/,
          loader: 'babel-loader',
          exclude: /node_modules/,
          options: {
            presets: ['@babel/preset-react']
          }
        }
      ]
    },

    plugins: [
      new HtmlWebpackPlugin({
        template: './public/index.html',
      }),
    ],
  }
}

在根目录,现在通过运行以下命令,就可以在 http://localhost:3001http://localhost:3002 访问您的两个单页应用:

> yarn dev # 如果报错,执行 npm run dev

配置模块联邦

现在我们有两个独立的单页应用程序正在运行,让我们继续将每个单页应用程序作为联合容器(Federated Container)和消费者(Consumer)。我们通过利用 webpack 5 核心中的新 ModuleFederationPlugin 来实现这一点。

首先,我们将向 Application A 添加 ModuleFederationPlugin,代码如下:

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

module.exports = (env, argv) => {
  const mode = argv.mode || 'development'

  return {
    mode,
    entry: './src/index',
+    output: {
+      publicPath: 'auto',
+    },
    devServer: {
      static: path.join(__dirname, 'dist'),
      port: 3001,
    },
    devtool: 'source-map',
    resolve: {
      extensions: ['.jsx', '.js', '.json']
    },
    module: {
      rules: [
        {
          test: /\.jsx?$/,
          loader: 'babel-loader',
          exclude: /node_modules/,
          options: {
            presets: ['@babel/preset-react']
          }
        }
      ]
    },

    plugins: [
+      new ModuleFederationPlugin({
+        name: 'application_a',
+        filename: 'remoteEntry.js',
+        library: { type: 'var', name: 'application_a' },
+        exposes: {
+          './SayHelloFromA': './src/app'
+        },
+        remotes: {
+          'application_b': 'application_b'
+        },
+        shared: {
+          react: { singleton: true },
+          'react-dom': { singleton: true }
+        }
+      }),
      new HtmlWebpackPlugin({
        template: './public/index.html',
      }),
    ],
  }
}

项目运行期间,指定了 Application A 将其 app 组件作为名为 SayHelloFromA 的联合模块公开给外部;同时,从 application_b 导入时,模块代码都来源于 Application B。

我们对 Application B 执行相同的操作,指定其 app 组件作为名为 SayHelloFromB 的联合模块公开给外部;同时,从 application_a 导入时,模块代码都来源于 Application A。

packages/application-b/webpack.config.js

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

module.exports = (env, argv) => {
  const mode = argv.mode || 'development'

  return {
    mode,
    entry: './src/index',
+    output: {
+      publicPath: 'auto',
+    },
    devServer: {
      static: path.join(__dirname, 'dist'),
      port: 3002,
    },
    devtool: 'source-map',
    resolve: {
      extensions: ['.jsx', '.js', '.json']
    },
    module: {
      rules: [
        {
          test: /\.jsx?$/,
          loader: 'babel-loader',
          exclude: /node_modules/,
          options: {
            presets: ['@babel/preset-react']
          }
        }
      ]
    },

    plugins: [
+      new ModuleFederationPlugin({
+        name: 'application_b',
+        filename: 'remoteEntry.js',
+        library: { type: 'var', name: 'application_b' },
+        exposes: {
+          './SayHelloFromB': './src/app'
+        },
+        remotes: {
+          'application_a': 'application_a'
+        },
+        shared: {
+          react: { singleton: true },
+          'react-dom': { singleton: true }
+        }
+      }),
      new HtmlWebpackPlugin({
        template: './public/index.html',
      }),
    ],
  }
}

在我们开始使用暴露的组件之前,最后一步是在运行期间,指定期望消费的容器远程入口(Remote Entries for the Containers)。我们只需向希望消费的 HTML 模板中添加一个脚本标签即可。

packages/application-a/public/index.html

<head>
  <!-- 加载应用程序 B 的远程入口 -->
  <script src="http://localhost:3002/remoteEntry.js"></script>    
</head>

packages/application-b/public/index.html

<head>
  <!-- 加载应用程序 A 的远程入口 -->
  <script src="http://localhost:3001/remoteEntry.js"></script>    
</head>

远程入口文件中是给 webpack 解析单独导入的模块使用的,很小,能避免传输不必要的信息;还负责启用项目间使用的共享库,在这种情况下,当 Application A 请求 Application B 的 SayHelloFromB 组件时,不需要额外加载 Application B 中的 React、ReactDOM 资源了,因为 Application A 中已经有了一份副本。

使用联合组件

现在我们的两个 SPA 应用程序,既是宿主容器(Container Hosts)也是消费者(Consumers),就可以开始使用共享的组件了。在 webpack 配置中,我们已经指定了容器名称为 application_aapplication_b,所以我们会从这些容器中导入组件。

从 Application A 开始,在 bootstrap.jsx 文件中可以渲染 SayHelloFromB 组件了:

import React from 'react'
import ReactDOM from 'react-dom/client'

+ import SayHelloFromB from 'application_b/SayHelloFromB'

import App from './app'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
+  <>
    <App />
+    <SayHelloFromB />
+  </>
)

Application B 类似,从 application_a 导入组件:

import React from 'react'
import ReactDOM from 'react-dom/client'

+ import SayHelloFromA from 'application_a/SayHelloFromA'

import App from './app'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
+  <>
    <App />
+    <SayHelloFromA />
+  </>
)

一些注意事项

查看 Application A 的网络日志,你会发现我们从 Application A 加载了两个文件:remoteEntry.js 文件和包含 SayHelloFromB 组件的 977.js

译注:大家本地测试时,跟原文作者这里截图的可能稍有不同。因为本文写作时,作者当时使用的 beta 版本,这块后面有过变动,但不影响大家对此概念的理解,特此说明。

image.png

第一次 Application B 时,你会注意到我们已经缓存了 Applicatino B 和 Application A 的 remoteEntrie.js 文件 。

image.png