团队维护的一个大型单体 React 应用终于走到了十字路口。不同业务线的需求迭代相互阻塞,一次简单的样式调整都可能引发全局性的回归问题,CI/CD 流水线运行时间也越来越长。微前端化是唯一的出路,但技术选型却让我们陷入了困境。社区主流的 Module Federation 或 single-spa 方案功能强大,但对于我们这种后端驱动、页面间隔离度高的业务场景来说,显得过于复杂。我们需要的不是一个前端“应用路由器”,而是一个能根据 URL 动态加载完全独立的前端“应用包”的轻量级容器。
我们的后端技术栈是 Python (FastAPI),它负责了所有的路由、认证和数据服务。一个想法逐渐清晰:为什么不能让 Python 容器直接负责微前端的编排?它完全有能力根据请求路径,查询到该路径对应的微应用,然后将该应用构建后的 HTML、CSS 和 JS 资源注入到一个主模板中返回给浏览器。这样一来,前端的复杂度被大大降低,每个微应用可以独立开发、独立构建、独立部署。
Vite 成为了前端构建工具的不二之选。它的开发体验极佳,更重要的是,它能在构建时生成一个 manifest.json
文件,精确记录了每个入口文件及其依赖的所有资源路径。这正是我们 Python 编排层所需要的核心信息。
最大的挑战在于样式管理。团队统一使用 Emotion (CSS-in-JS),但在微前端架构下,如何保证各个应用间的样式隔离,同时又能实现全局主题的动态切换,成了一个棘手的问题。
初始架构构想与核心流程
我们的目标是建立一个 Python 服务作为主容器,它能动态地挂载由 Vite 构建的多个 React 微应用。
sequenceDiagram participant Browser participant PythonContainer as Python 容器 (FastAPI) participant MFE_Assets as 微应用静态资源 (Nginx/S3) Browser->>+PythonContainer: GET /apps/profile/settings PythonContainer->>PythonContainer: 解析路由: app='profile', path='/settings' Note right of PythonContainer: 校验权限、获取用户信息等 PythonContainer->>PythonContainer: 读取 'profile' 应用的 manifest.json PythonContainer->>PythonContainer: 根据 manifest 生成 CSS/JS 资源路径 PythonContainer->>PythonContainer: 渲染主 HTML 模板,注入资源路径 PythonContainer-->>-Browser: 返回编排后的 HTML Browser->>+MFE_Assets: 并行请求 HTML 中注入的 CSS 和 JS MFE_Assets-->>-Browser: 返回静态资源 Browser->>Browser: 执行 JS,渲染 React 微应用
这个流程的核心在于 Python 容器如何精准地知道要为每个微应用注入哪些资源。Vite 的 manifest
功能是关键。当 Vite 项目构建时,如果开启了 build.manifest: true
,它会生成一个如下结构的 manifest.json
文件:
{
"src/main.tsx": {
"file": "assets/index-D8asbF2a.js",
"src": "src/main.tsx",
"isEntry": true,
"imports": [
"_vendor-B2sSDFg3.js"
],
"css": [
"assets/index-B4sFgH1j.css"
]
},
"_vendor-B2sSDFg3.js": {
"file": "assets/vendor-B2sSDFg3.js"
}
}
Python 容器只需要:
- 确定要加载哪个微应用(例如
profile
)。 - 找到
profile
应用构建产物中的manifest.json
。 - 读取
manifest.json
中入口文件(如src/main.tsx
)对应的file
(JS 主文件)和css
数组(所有关联的 CSS 文件)。 - 将这些文件路径动态插入到 HTML 模板的
<head>
和<body>
中。
项目结构搭建
我们采用一个类 monorepo 的目录结构来管理所有代码,便于统一维护。
/microfrontend-python-vite/
├── container_app/ # Python 容器应用
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI 主逻辑
│ │ └── services/
│ │ └── manifest_service.py # manifest 解析服务
│ ├── static/ # 这是一个占位,实际资源由微应用提供
│ └── templates/
│ └── index.html # 主 HTML 模板
├── micro_apps/ # 所有微应用
│ ├── app_profile/ # “个人中心”微应用
│ │ ├── public/
│ │ ├── src/
│ │ ├── package.json
│ │ └── vite.config.ts
│ └── app_dashboard/ # “仪表盘”微应用
│ ├── ...
├── scripts/
│ └── build_all.sh # 一键构建所有微应用脚本
└── docker-compose.yml # (可选) 用于本地开发
微应用的 Vite 配置
每个微应用的 vite.config.ts
都需要进行特殊配置,以确保构建产物能被 Python 容器正确消费。
micro_apps/app_profile/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 关键配置: build
build: {
// 生成 manifest.json, 供后端读取资源映射
manifest: true,
// rollup 配置,用于处理文件名和输出路径
rollupOptions: {
output: {
// 确保 chunk 文件名是可预测的,避免随机 hash 带来的问题
// 在真实项目中,为了缓存,hash 是必须的。这里只是为了简化演示
// 生产环境应使用 [name]-[hash].js
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
},
// 构建输出目录,相对于项目根目录
outDir: '../../container_app/static/profile',
// 清空输出目录
emptyOutDir: true,
},
// 基础路径,非常重要!
// 所有资源请求都会带上这个前缀,例如 /static/profile/assets/index.js
// 这样 Python 容器才能正确地通过 /static/... 路由提供服务
base: '/static/profile/',
});
这里的核心是 build.outDir
和 base
。我们让 Vite 直接将构建产物输出到 Python 容器的静态文件目录下,并按应用名分子目录(static/profile
)。同时,base
路径必须与静态文件服务的 URL 前缀完全匹配。
Python 容器的实现
我们使用 FastAPI 作为 Web 框架,Jinja2 作为模板引擎。
container_app/app/services/manifest_service.py
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 定义静态文件和 manifest 的基础路径
STATIC_DIR = Path(__file__).parent.parent.parent / "static"
class ManifestService:
"""
负责解析和管理 Vite 生成的 manifest.json 文件。
"""
_manifests: Dict[str, Dict] = {}
def _load_manifest(self, app_name: str) -> Optional[Dict]:
"""
加载指定微应用的 manifest.json 文件。
包含缓存和错误处理。
"""
if app_name in self._manifests:
return self._manifests[app_name]
manifest_path = STATIC_DIR / app_name / "manifest.json"
if not manifest_path.exists():
logger.error(f"Manifest file not found for app '{app_name}' at {manifest_path}")
return None
try:
with open(manifest_path, "r") as f:
manifest_data = json.load(f)
self._manifests[app_name] = manifest_data
logger.info(f"Successfully loaded manifest for app '{app_name}'")
return manifest_data
except (json.JSONDecodeError, IOError) as e:
logger.error(f"Failed to load or parse manifest for app '{app_name}': {e}")
return None
def get_app_assets(self, app_name: str) -> Optional[Tuple[List[str], List[str]]]:
"""
从 manifest 中获取应用的 JS 和 CSS 资源路径。
返回:
一个元组 (js_files, css_files),如果找不到则返回 None。
"""
manifest = self._load_manifest(app_name)
if not manifest:
return None
# 找到入口文件。这里假设入口文件名包含 'main' 或 'index'。
# 生产级代码需要更健壮的入口查找逻辑。
entry_key = next((key for key in manifest if manifest[key].get("isEntry")), None)
if not entry_key:
logger.error(f"No entry point found in manifest for app '{app_name}'")
return None
entry_point = manifest[entry_key]
# 从 base 路径获取 URL 前缀
base_url = f"/static/{app_name}/"
js_files = [f"{base_url}{manifest[key]['file']}" for key in entry_point.get("imports", [])]
js_files.append(f"{base_url}{entry_point['file']}")
css_files = [f"{base_url}{css_file}" for css_file in entry_point.get("css", [])]
return js_files, css_files
# 创建单例
manifest_service = ManifestService()
container_app/app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import logging
from .services.manifest_service import manifest_service, STATIC_DIR
app = FastAPI()
# 挂载静态文件目录
# 所有 /static 的请求都会映射到 ./static/ 文件夹
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# 配置 Jinja2 模板
templates = Jinja2Templates(directory="templates")
logger = logging.getLogger(__name__)
@app.get("/apps/{app_name}/{rest_of_path:path}", response_class=HTMLResponse)
async def serve_micro_app(request: Request, app_name: str, rest_of_path: str):
"""
主路由,负责编排和提供微前端应用。
"""
logger.info(f"Serving app '{app_name}' for path '/{rest_of_path}'")
assets = manifest_service.get_app_assets(app_name)
if assets is None:
# 在真实项目中,这里应该返回一个更友好的 404 页面
return HTMLResponse(content="<h1>Application not found</h1>", status_code=404)
js_files, css_files = assets
# 这里的 initial_data 可以是从后端获取的、需要传递给前端的数据
# 例如用户信息、全局配置等
initial_data = {
"userInfo": {"name": "Admin", "role": "admin"},
"theme": {
"primaryColor": "#1677ff",
"borderRadius": "6px"
}
}
context = {
"request": request,
"js_files": js_files,
"css_files": css_files,
"initial_data": initial_data
}
return templates.TemplateResponse("index.html", context)
最后的拼图是 HTML 模板。
container_app/templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-g">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Micro Frontend Container</title>
{% for css_file in css_files %}
<link rel="stylesheet" href="{{ css_file }}">
{% endfor %}
</head>
<body>
<div id="root"></div>
<script>
// 将后端数据注入到全局 window 对象,供微应用消费
window.__INITIAL_DATA__ = {{ initial_data | tojson }};
</script>
{% for js_file in js_files %}
<script type="module" src="{{ js_file }}"></script>
{% endfor %}
</body>
</html>
解决 Emotion 的样式隔离与主题共享
现在,当访问 /apps/profile/settings
,Python 容器会正确地加载 app_profile
的资源。但如果 app_profile
和 app_dashboard
都使用了 Emotion,并且定义了相同名称的组件(例如 Button
),可能会出现样式冲突或主题不一致的问题。
样式隔离: Emotion 通过生成唯一的哈希类名(如
css-123xyz
)在默认情况下就提供了很好的样式隔离。只要每个微应用是独立构建的,它们的 Emotion 实例就会为各自的组件生成不同的哈希,冲突的概率极低。真正的坑在于全局样式,例如通过 Emotion 的Global
组件注入的样式。如果两个应用都注入了body { margin: 0; }
,虽然不会报错,但这种重复和潜在的覆盖是需要管理的。我们的方案是约定只有容器或者一个特定的“公共”微应用可以注入全局样式。主题共享 (The Core Challenge): 这是更复杂的问题。我们希望后端能下发一个统一的主题对象,所有微应用都遵循这个主题,同时允许某个微应用在局部覆盖主题。
直接的想法是在容器层提供一个 ThemeProvider
,然后微应用挂载到其内部。但这在我们的架构中行不通,因为容器是 Python,微应用是独立的 JS 包,它们之间没有 React 组件树的父子关系。
最终我们采取了基于 window
对象的事件和数据注入方案。
修改 index.html
<!-- ... head ... -->
<body>
<div id="root"></div>
<script>
// 将后端数据注入到全局 window 对象
window.__INITIAL_DATA__ = {{ initial_data | tojson }};
</script>
{% for js_file in js_files %}
<script type="module" src="{{ js_file }}"></script>
{% endfor %}
</body>
Python 后端将主题配置和其他全局信息放在 initial_data
中。
在微应用中消费全局主题
每个微应用都需要一个入口文件来读取这个全局配置,并用它来初始化自己的 ThemeProvider
。
micro_apps/app_profile/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ThemeProvider } from '@emotion/react';
// 定义全局数据接口,提供类型安全
interface InitialData {
userInfo: { name: string; role: string };
theme: {
primaryColor: string;
borderRadius: string;
};
}
// 扩展 Window 接口
declare global {
interface Window {
__INITIAL_DATA__?: InitialData;
}
}
const initialData = window.__INITIAL_DATA__;
// 提供一个默认主题,以防全局数据加载失败
const defaultTheme = {
colors: {
primary: '#007bff',
},
spacing: {
unit: 8,
},
};
const appTheme = initialData
? {
colors: {
primary: initialData.theme.primaryColor,
},
spacing: {
unit: parseInt(initialData.theme.borderRadius) || 8,
},
}
: defaultTheme;
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={appTheme}>
<App userInfo={initialData?.userInfo} />
</ThemeProvider>
</React.StrictMode>
);
通过这种方式,所有微应用都从同一个数据源 (window.__INITIAL_DATA__.theme
) 获取主题配置,从而确保了视觉上的一致性。如果某个微应用需要局部定制,它可以在自己的组件树内部再包裹一个 ThemeProvider
并传入覆盖后的主题。
这种方法的关键在于约定。约定 window.__INITIAL_DATA__
的数据结构,并做好微应用在数据不存在时的降级处理。
遗留问题与未来迭代方向
这个架构解决了我们当前的核心痛点:实现了后端驱动的、轻量级的微前端编排,并解决了 Emotion 的主题共享问题。但它并非银弹,仍然存在一些局限和可以优化的方向。
首先,微应用间的通信。目前的方案中,应用之间是完全隔离的,没有直接通信机制。如果需要跨应用通信(例如,在一个应用中操作后需要更新另一个应用的状态),需要引入一个全局事件总线,例如通过 window.dispatchEvent
和 window.addEventListener
实现。
其次,共享依赖。每个微应用都独立构建,如果它们都依赖了 React、Emotion、Lodash 等大型库,会导致这些库被重复打包和加载,增加了最终用户需要下载的资源体积。在性能要求极致的场景下,可以引入 Import Maps 或将通用库通过 CDN 加载并在 Vite 配置中将其标记为 external
,但这会增加配置的复杂性。
最后,开发体验。虽然单个微应用的开发体验由 Vite 保证了,但在本地同时运行容器和多个微应用进行联调时,流程还不够顺畅。需要一个统一的启动脚本(例如使用 concurrently
)来并行启动所有服务,并可能需要配置代理来解决跨域问题。引入 docker-compose
是一个更稳健的选择。
此方案最适合的场景是那些由不同团队负责、功能上相对独立、页面级集成的业务系统。对于需要在一个页面内进行复杂组件级混排的场景,Module Federation 也许是更合适的工具。