基于 Vite 构建由 Python 动态编排并使用 Emotion 实现样式隔离的微前端方案


团队维护的一个大型单体 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 容器只需要:

  1. 确定要加载哪个微应用(例如 profile)。
  2. 找到 profile 应用构建产物中的 manifest.json
  3. 读取 manifest.json 中入口文件(如 src/main.tsx)对应的 file(JS 主文件)和 css 数组(所有关联的 CSS 文件)。
  4. 将这些文件路径动态插入到 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.outDirbase。我们让 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_profileapp_dashboard 都使用了 Emotion,并且定义了相同名称的组件(例如 Button),可能会出现样式冲突或主题不一致的问题。

  1. 样式隔离: Emotion 通过生成唯一的哈希类名(如 css-123xyz)在默认情况下就提供了很好的样式隔离。只要每个微应用是独立构建的,它们的 Emotion 实例就会为各自的组件生成不同的哈希,冲突的概率极低。真正的坑在于全局样式,例如通过 Emotion 的 Global 组件注入的样式。如果两个应用都注入了 body { margin: 0; },虽然不会报错,但这种重复和潜在的覆盖是需要管理的。我们的方案是约定只有容器或者一个特定的“公共”微应用可以注入全局样式。

  2. 主题共享 (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.dispatchEventwindow.addEventListener 实现。

其次,共享依赖。每个微应用都独立构建,如果它们都依赖了 React、Emotion、Lodash 等大型库,会导致这些库被重复打包和加载,增加了最终用户需要下载的资源体积。在性能要求极致的场景下,可以引入 Import Maps 或将通用库通过 CDN 加载并在 Vite 配置中将其标记为 external,但这会增加配置的复杂性。

最后,开发体验。虽然单个微应用的开发体验由 Vite 保证了,但在本地同时运行容器和多个微应用进行联调时,流程还不够顺畅。需要一个统一的启动脚本(例如使用 concurrently)来并行启动所有服务,并可能需要配置代理来解决跨域问题。引入 docker-compose 是一个更稳健的选择。

此方案最适合的场景是那些由不同团队负责、功能上相对独立、页面级集成的业务系统。对于需要在一个页面内进行复杂组件级混排的场景,Module Federation 也许是更合适的工具。


  目录