深入 ‘React Micro-frontends’:基于 Module Federation 实现不同 React 版本的运行时共存
各位技术同仁,大家好!
今天,我们将深入探讨一个在现代前端开发中日益重要且充满挑战的主题:如何利用 Webpack 5 的 Module Federation 特性,在微前端架构下实现不同 React 版本的运行时共存。随着业务的快速发展和团队规模的扩大,微前端已经成为解决大型前端项目痛点的重要方案。然而,技术栈的演进是不可避免的,同一个产品中可能存在由不同团队、不同时间开发,使用不同 React 版本的子应用。如何让这些子应用和谐共处,是微前端实践中的一个核心难题。
引言:微前端的崛起与版本共存的挑战
微前端架构的核心思想是将一个庞大的前端应用拆解成多个独立开发、独立部署的小型应用,这些应用可以由不同的团队负责,使用不同的技术栈,最终在浏览器中组合成一个完整的用户体验。这种模式带来了诸多好处:
- 技术栈独立性:团队可以选择最适合其业务的技术。
- 独立部署与发布:降低了部署风险,加快了发布周期。
- 团队自治性:提高了开发效率和团队满意度。
- 可伸缩性:易于扩展和维护。
然而,随之而来的挑战也显而易见,其中最棘手的问题之一就是不同前端框架或同一框架不同版本的运行时共存。特别是对于 React 这样的状态管理和生命周期高度依赖单一实例的库,当页面中同时存在 React 17 和 React 18 的组件时,通常会导致以下问题:
- “Invalid hook call” 错误:React 的 Hooks 机制依赖于当前渲染树中的单一 React 实例。如果存在多个实例,可能会导致 Hooks 无法正确查找其内部状态,从而抛出错误。
- Context 不兼容:React Context 也是基于特定的 React 实例创建和消费的。不同 React 实例创建的 Context 无法互相识别。
- 性能问题:加载多个 React 实例会增加应用的包体积,并可能导致内存占用增加。
- 状态混乱:如果组件依赖于全局的 React 状态或事件机制,多个实例可能导致预期外的行为。
传统的解决方案,如 IFrames 和 Web Components,虽然能实现隔离,但它们在通信、路由、共享状态和用户体验方面存在诸多限制,难以满足复杂业务需求。直到 Webpack 5 引入了 Module Federation,我们才真正拥有了一个强大而灵活的工具,能够优雅地解决这些运行时共存问题。
本次讲座的目标,便是深入剖析 Module Federation 的工作原理,并在此基础上,提供一套行之有效的策略和代码示例,帮助大家在实际项目中实现不同 React 版本的微前端应用在运行时和谐共存。
微前端架构的演进与 Module Federation 的崛起
在 Module Federation 出现之前,微前端的实现方式大致经历了几个阶段:
-
IFrame 方案:
- 优点:天然的隔离性,不同应用之间完全独立,技术栈互不影响。
- 缺点:通信困难,路由同步复杂,样式隔离过度导致共享 UI 不便,SEO 差,用户体验不连贯(如页面刷新)。
-
Web Components 方案:
- 优点:基于浏览器原生标准,封装性好,可跨框架使用。
- 缺点:开发复杂,生态系统不如主流框架成熟,样式隔离(Shadow DOM)在某些场景下不便。
-
Single-SPA 或 Qiankun 类库:
- 优点:提供了更完善的微前端生命周期管理、路由和通信机制,实现了应用级别的隔离。
- 缺点:通常需要对子应用进行打包适配,且在共享依赖方面仍有局限性,可能会导致重复加载。
Module Federation 的革命性特点
Webpack 5 的 Module Federation 插件是微前端领域的一项重大突破。它允许一个 Webpack 构建的应用程序(Host)在运行时动态地从另一个 Webpack 构建的应用程序(Remote)加载代码。其核心优势在于:
- 运行时集成:模块在运行时通过网络加载,而非构建时合并。
- 真正的代码共享:不同应用可以共享同一个依赖(如 React),避免重复打包和加载。
- 去中心化:没有一个中心化的微前端框架来协调,每个应用都是独立的 Webpack 构建。
- 双向共享:一个应用既可以是 Host 也可以是 Remote,甚至同时是两者的混合。
Module Federation 解决了以往微前端方案中“依赖重复”和“运行时隔离不足”的痛点,为构建高性能、可维护的微前端系统提供了坚实的基础。
Module Federation 基础配置与共享机制
要理解如何处理 React 版本共存,首先需要掌握 Module Federation 的基本配置和其强大的 shared 选项。
每个参与 Module Federation 的应用都需要在 webpack.config.js 中配置 ModuleFederationPlugin。
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
publicPath: 'http://localhost:3000/', // 宿主应用或远程应用的访问地址
},
mode: 'development',
devServer: {
port: 3000,
historyApiFallback: true,
},
module: {
rules: [
{
test: /.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
// ... 其他 loader
],
},
plugins: [
new ModuleFederationPlugin({
name: 'hostApp', // 当前应用的名称,必须唯一
filename: 'remoteEntry.js', // 远程入口文件的名称,用于被其他应用引用
remotes: {
// host 应用消费 remote 应用时,在这里声明 remote 的名称和入口地址
// 'remoteApp': 'remoteApp@http://localhost:3001/remoteEntry.js'
},
exposes: {
// 当前应用暴露给其他应用消费的模块
// './Button': './src/components/Button',
},
shared: {
// 共享依赖配置
// 'react': { singleton: true, requiredVersion: '^18.0.0' },
// 'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
// ... 其他共享库
},
}),
],
};
ModuleFederationPlugin 的核心属性:
name:当前应用的唯一名称,用于在 Module Federation 运行时识别。filename:远程入口文件的名称。当其他应用引用当前应用时,会通过这个文件加载暴露的模块。exposes:一个对象,定义了当前应用要暴露给其他应用消费的模块。键是外部引用的名称,值是本地模块的路径。remotes:一个对象,定义了当前应用要消费的远程应用。键是本地引用的名称,值是远程应用的name@url。shared:这是处理依赖共享和版本共存的关键。 它是一个对象或数组,定义了应用之间可以共享的模块。
shared 选项的深度解析:
shared 配置项可以是一个数组,也可以是一个对象,通常推荐使用对象形式,以便更精细地控制每个共享模块。
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
singleton |
boolean |
false |
如果设置为 true,则确保在整个应用程序中只加载和使用该模块的一个实例。这对于 React、ReactDOM 这样的框架至关重要,因为它们不允许多个实例同时存在。 |
requiredVersion |
string |
null |
指定共享模块所需的版本范围(遵循 semver 规范,如 ^17.0.0 或 18.x.x)。Module Federation 会尝试寻找兼容的版本。 |
eager |
boolean |
false |
如果设置为 true,则共享模块会立即加载,而不是按需加载。这在某些情况下可以避免运行时加载延迟,但会增加初始包体积。对于像 React 这样的大型核心库,通常不建议 eager,除非你明确知道其影响。 |
strictVersion |
boolean |
false |
如果设置为 true,Module Federation 将严格匹配 requiredVersion。如果找不到完全匹配的版本,则会发出警告并加载自己的版本(如果配置了 import),或者直接报错。 |
import |
string |
null |
当共享模块不存在或版本不兼容时,指定一个回退模块路径。例如,如果 react 共享失败,可以指定一个本地的 react 模块作为备用。 |
shareKey |
string |
null |
允许为共享模块指定一个不同的 key,这在某些高级场景下用于区分不同共享策略的同一个模块。 |
挑战核心:不同 React 版本的运行时共存
现在,我们聚焦于本次讲座的核心挑战:如何让 React 17 和 React 18 这样的不同版本在同一个微前端应用中运行时共存。
为什么不同 React 版本会出问题?
React 的设计哲学是假设页面上只有一个 React 实例。无论是 Context API、Hooks、事件系统,还是调度器(Scheduler),它们都依赖于内部的全局状态或特定的 React 实例。当存在两个或更多个 React 实例时,这些内部机制会变得混乱:
- Hooks:
useState、useEffect等 Hook 依赖于当前渲染树中的 React 实例。如果一个组件的 JSX 最终被编译为调用了错误的 React 实例的 Hook 方法,就会出现Invalid hook call错误。 - Context:
React.createContext创建的 Context 是特定于创建它的 React 实例的。由 React 17 实例创建的 Context 无法被 React 18 实例正确消费,反之亦然。 - 事件系统:React 18 对事件系统进行了重大改进(例如自动批处理),这与 React 17 的行为不同。
Module Federation 对 React 共享的处理机制:
当 Host 和 Remote 都尝试共享 React 时,Module Federation 会根据 shared 配置中的 singleton 和 requiredVersion 属性进行协调:
- 版本匹配且
singleton: true:如果 Host 和 Remote 都声明共享 React,并且它们的requiredVersion兼容(例如,Host 需要^18.0.0,Remote 需要^18.2.0),且都设置了singleton: true,那么 Module Federation 会确保只加载一个兼容的 React 实例,通常是宿主提供的版本或一个更高版本。 - 版本不匹配但
singleton: true:如果 Host 和 Remote 都设置了singleton: true,但它们的requiredVersion冲突(例如,Host 需要^18.0.0,Remote 需要^17.0.0),Module Federation 会尝试发出警告。在这种情况下,它通常会加载两个不同的 React 实例。宿主会使用自己的 React 实例,而远程应用则会加载并使用自己的 React 实例。 singleton: false或未设置singleton:如果未设置singleton或设置为false,Module Federation 不会强制只有一个实例。如果 Host 和 Remote 都依赖 React,即使版本兼容,也可能会加载两个独立的 React 实例。
核心问题在于:当加载了两个不同的 React 实例时,我们如何确保每个组件都使用正确的 React 实例进行渲染,并且避免冲突?
策略一:通过 shared 配置强制共享单一 React 版本 (最简单但最严格)
这种策略的目标是确保整个微前端应用只加载一个特定版本的 React。
配置示例:
假设我们决定统一使用 React 18。
Host (webpack.config.js):
// host/webpack.config.js
new ModuleFederationPlugin({
name: 'host',
filename: 'remoteEntry.js',
remotes: {
// 假设 remoteApp 是一个 React 17 的应用,但我们强制它使用 React 18
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0', // 强制宿主和所有远程应用使用 React 18
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
// ... 其他共享库
},
}),
Remote (webpack.config.js):
// remote/webpack.config.js (即使它内部使用 React 17)
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0', // 强制它也使用 React 18
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
// ... 其他共享库
},
}),
优缺点分析:
- 优点:
- 简单直接,避免了多个 React 实例带来的所有问题。
- 减少了包体积,提高了性能。
- 缺点:
- 要求所有子应用都兼容指定的 React 版本。 如果某个子应用确实无法升级(例如,使用了旧版本 React 特有的生命周期方法或第三方库不兼容新版 React),这种方案就行不通。
- 缺乏灵活性。
这种策略适用于所有子应用都使用相同或兼容 React 版本的场景。如果存在不兼容的版本,Module Federation 会发出警告,并且可能会加载多个 React 实例,从而回到我们最初的问题。
策略二:隔离的 React 环境,允许多版本加载 (推荐且深入讲解)
当不同 React 版本之间存在不兼容性,无法强制统一版本时,我们需要一种机制,让每个子应用在自己的 React 运行时环境中独立运行,同时宿主又能成功渲染这些子应用提供的组件。
核心思想:
- 宿主和远程应用都声明共享 React 和 ReactDOM,但对于不兼容的版本,Module Federation 会在运行时加载多个实例。
- 关键在于:当远程应用暴露一个组件时,该组件内部对
import React from 'react'的解析,会指向远程应用自身的 React 实例,而不是宿主的 React 实例。 - Module Federation 如何实现这一点?
当宿主应用加载远程模块时,Webpack 会为远程模块创建一个独立的模块作用域。在这个作用域内,远程模块的import语句会首先尝试从 Module Federation 的共享作用域中解析依赖。如果共享作用域中存在一个兼容的 React 版本,并且配置允许共享,它就会使用共享的。
如果共享作用域中没有兼容的 React 版本(因为版本冲突),或者远程应用明确配置不共享,那么远程应用会加载并使用它自己的 React 实例。
最终,宿主只是接收并渲染一个由远程应用导出的组件。这个组件的渲染逻辑和内部状态管理都将依赖于远程应用自己的 React 实例。
实现步骤与代码示例: Host (React 18) 与 Remote (React 17)
我们将创建一个 Host 应用 (使用 React 18) 和一个 Remote 应用 (使用 React 17)。Remote 应用会暴露一个简单的 React 组件,供 Host 应用消费。
项目结构:
micro-frontend-react-versions/
├── host/
│ ├── package.json
│ ├── webpack.config.js
│ ├── src/
│ │ ├── index.js
│ │ ├── bootstrap.js
│ │ └── App.js
├── remote/
│ ├── package.json
│ ├── webpack.config.js
│ ├── src/
│ │ ├── index.js
│ │ ├── bootstrap.js
│ │ ├── App.js
│ │ └── components/
│ │ └── RemoteButton.js
1. Remote 应用 (React 17)
Remote 应用将使用 React 17,并暴露一个 RemoteButton 组件。
remote/package.json:
{
"name": "remote",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "webpack serve --port 3001",
"build": "webpack --mode production"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"babel-loader": "^8.2.3",
"html-webpack-plugin": "^5.5.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.7.2"
}
}
remote/webpack.config.js:
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
publicPath: 'http://localhost:3001/', // Remote 应用的访问地址
},
mode: 'development',
devServer: {
port: 3001,
historyApiFallback: true,
headers: {
'Access-Control-Allow-Origin': '*', // 允许跨域访问
},
},
module: {
rules: [
{
test: /.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'remoteApp', // 远程应用名称
filename: 'remoteEntry.js', // 远程入口文件
exposes: {
'./RemoteButton': './src/components/RemoteButton', // 暴露 RemoteButton 组件
},
shared: {
// 对于 React 17 应用,我们仍希望它能共享 React,但我们不强制宿主使用它的版本。
// 如果宿主提供了兼容版本,就用宿主的;否则,它会加载自己的。
// singleton: true 配合 requiredVersion 会让 webpack 尝试协商,
// 如果版本不兼容,则会加载自己的副本。
react: {
singleton: true,
requiredVersion: '^17.0.2',
},
'react-dom': {
singleton: true,
requiredVersion: '^17.0.2',
},
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
remote/src/components/RemoteButton.js:
这是一个简单的 React 17 组件,它会使用 useState。
import React, { useState } from 'react';
const RemoteButton = () => {
const [count, setCount] = useState(0);
return (
<button
onClick={() => setCount(count + 1)}
style={{
padding: '10px 20px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '16px',
margin: '10px'
}}
>
Remote React 17 Button: Clicked {count} times
</button>
);
};
export default RemoteButton;
remote/src/App.js:
import React from 'react';
import RemoteButton from './components/RemoteButton';
const App = () => {
return (
<div style={{ padding: '20px', border: '2px solid green', margin: '10px' }}>
<h1>Remote App (React 17)</h1>
<RemoteButton />
<p>This app uses React version: {React.version}</p>
</div>
);
};
export default App;
remote/src/bootstrap.js:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
remote/src/index.js:
// This file is typically just for development mode, to load the app directly.
// In production, the remoteEntry.js is loaded by the host.
import('./bootstrap');
2. Host 应用 (React 18)
Host 应用将使用 React 18,并动态加载 Remote 应用提供的 RemoteButton 组件。
host/package.json:
{
"name": "host",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "webpack serve --port 3000",
"build": "webpack --mode production"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"babel-loader": "^8.2.3",
"html-webpack-plugin": "^5.5.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.7.2"
}
}
host/webpack.config.js:
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
publicPath: 'http://localhost:3000/', // Host 应用的访问地址
},
mode: 'development',
devServer: {
port: 3000,
historyApiFallback: true,
},
module: {
rules: [
{
test: /.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'host', // 宿主应用名称
filename: 'remoteEntry.js', // 宿主也可能暴露模块,但这里主要是消费
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js', // 消费远程应用
},
shared: {
// 宿主应用共享 React 18
react: {
singleton: true,
requiredVersion: '^18.2.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
},
// 注意:react-dom/client 是 React 18 新增的,React 17 没有。
// 如果宿主需要,可以共享,但远程应用不需要。
// 理论上,如果远程应用不需要,最好不要在其 shared 中声明。
'react-dom/client': {
singleton: true,
requiredVersion: '^18.2.0',
},
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
host/src/App.js:
这里我们将使用 React.lazy 和 Suspense 来异步加载远程组件。
import React, { Suspense } from 'react';
// 动态加载远程组件
const RemoteButton = React.lazy(() => import('remoteApp/RemoteButton'));
const App = () => {
return (
<div style={{ padding: '20px', border: '2px solid blue', margin: '10px' }}>
<h1>Host App (React 18)</h1>
<p>This app uses React version: {React.version}</p>
<h2>Remote Component from React 17 App:</h2>
<Suspense fallback={<div>Loading RemoteButton...</div>}>
<RemoteButton />
</Suspense>
<p>Host app can have its own React 18 components here too.</p>
</div>
);
};
export default App;
host/src/bootstrap.js:
注意,React 18 引入了新的客户端渲染 API (createRoot)。
import React from 'react';
import ReactDOM from 'react-dom/client'; // 注意这里是 React 18 的新 API
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
host/src/index.js:
import('./bootstrap');
运行和验证:
- 在
remote目录下执行npm install然后npm start。 - 在
host目录下执行npm install然后npm start。 - 访问
http://localhost:3000(Host 应用)。
你将看到 Host 应用 (React 18) 成功渲染了一个来自 Remote 应用 (React 17) 的 RemoteButton。点击 RemoteButton,它的计数器会正常工作,并且不会出现 Invalid hook call 错误。
解释为什么它能工作:
- Host (React 18) 和 Remote (React 17) 都通过
shared配置声明了react和react-dom,并设置了singleton: true和各自的requiredVersion。 - 当 Host 启动时,它会加载自己的 React 18 实例。
- 当 Host 尝试加载
remoteApp/RemoteButton时,Module Federation 会去remoteApp的remoteEntry.js中查找RemoteButton。 RemoteButton内部import React from 'react'。Module Federation 会检查 Host 的共享作用域中是否有兼容 Remote (React 17) 的 React 实例。- 由于 Host 提供了 React 18 (
^18.2.0),而 Remote 需要 React 17 (^17.0.2),这两个版本是不兼容的。 - 在这种情况下,Module Federation 会加载 Remote 自己的 React 17 实例。
- 最终,页面上将存在两个 React 实例:Host 的 React 18 和 Remote 的 React 17。
- 由于
RemoteButton组件在编译时和运行时都解析到 Remote 的 React 17 实例,它的 Hooks 调用、Context 等都将作用于它自己的 React 17 环境,因此不会与 Host 的 React 18 产生冲突。
此策略的优缺点:
- 优点:
- 实现了不同 React 版本的运行时共存,解决了版本不兼容的难题。
- 每个子应用可以独立升级或降级其 React 版本,互不影响。
- 保持了微前端的独立开发和部署优势。
- 缺点:
- 包体积可能增大:如果多个子应用使用了不同版本的 React,每个子应用都会加载自己的 React 副本。
- 潜在的内存占用增加:多个 React 实例可能占用更多内存。
- 跨 React 实例的 Context 或状态共享变得复杂:直接使用 React Context 无法在不同 React 实例之间共享状态。需要通过其他机制(如事件总线、共享存储库或 props 传递)进行通信。
进一步优化:shared 配置的细节考量
在上述示例中,我们让 Host 和 Remote 都声明了 singleton: true 和各自的 requiredVersion。这是 Module Federation 协商共享依赖的推荐方式。
| 应用类型 | 共享模块配置 (React/React-DOM) |
| Host | ‘react’: { singleton: true, requiredVersion: ‘^18.2.0’ }, ‘react-dom’: { singleton: true, requiredVersion: ‘^18.2.0’ } | Host 应用在 shared 中指定 React 18 版本,并设 singleton: true。当它加载 remoteApp 时,Module Federation 会在 Host 的共享作用域中查找 react。如果 remoteApp 声明的 requiredVersion 与 Host 的不兼容,Module Federation 知道无法共享同一个实例,于是会允许 remoteApp 加载自己的 React 17 实例。
| 应用类型 | 共享模块配置 (React/React-DOM) | 确保 React 和 ReactDOM 的版本兼容性。Host 应用提供 React 18,如果 Remote 应用的 requiredVersion 与其不兼容,则 Remote 会加载自己的 React 实例。这是最灵活的策略,允许不兼容版本的共存。 |
| Remote | ‘react’: { singleton: true, requiredVersion: ‘^17.0.2’ }, ‘react-dom’: { singleton: true, requiredVersion: ‘^17.0.2’ }