构建基于 Flutter Web 与 UnoCSS 的异构微前端通信与状态隔离机制


我们面临一个具体的工程挑战:一个核心业务模块由 Flutter 构建,已在移动端稳定运行多年,积累了复杂的业务逻辑和UI组件。现在需要将这个模块无缝集成到我们现有的、基于 Web Components 和 UnoCSS 的主干应用中,形成一个统一的用户体验。直接用 Web 技术重写整个 Flutter 模块成本过高,且短期内无法保证功能对齐。因此,采用微前端架构,将 Flutter Web 应用作为其中一个微前端,是唯一可行的路径。

问题随之而来:如何在一个页面中,让一个由 Dart 编译的 Flutter 应用,和一个由原生 JavaScript/TypeScript 编写、UnoCSS 驱动样式的 Web Component 微应用高效、解耦地共存并通信?状态如何同步?CSS 作用域如何保证不互相污染?

方案权衡:iframe vs. Web Components vs. 事件总线

在微前端的集成方案中,有几个常见的选项,但每一个在我们的异构场景下都有明显的短板。

方案 A: iframe 隔离

这是最简单的方案。将 Flutter Web 应用放置在一个 <iframe> 中。

  • 优势: 提供了完美的运行时隔离。无论是 JavaScript 全局变量、CSS 样式还是 DOM 结构,都完全独立,不会产生冲突。
  • 劣势: 通信机制笨重。所有交互都必须依赖 postMessage API,这会导致大量的样板代码,并且消息的序列化/反序列化存在性能开销。此外,iframe 的 URL 管理、历史堆栈同步、弹窗定位和自适应高度等都是公认的难题,对于追求极致用户体验的应用来说,这是不可接受的。在真实项目中,复杂的交互会让 postMessage 的维护变成一场灾难。

方案 B: 单纯的 Web Components 封装

我们可以尝试将 Flutter 应用和另一个 Web Component 都作为自定义元素(Custom Elements)嵌入主应用。

  • 优势: 相比 iframe,集成度更高,属于 DOM 的一部分,解决了部分体验问题。
  • 劣劣: 这种方案没有解决核心的通信问题。两个独立的 Web Component 之间如何通信?它们仍然需要一个外部机制。更重要的是,Flutter Web 本身并不直接编译成一个独立的 Web Component。它需要一个宿主 DOM 元素来挂载。虽然可以将其封装在一个 Web Component 内部,但这并没有从根本上简化问题。

最终决策:API 网关 + 浏览器自定义事件总线

经过权衡,我们决定采用一种混合架构。数据层面的交互完全分离,而 UI 层面的通信则通过一个轻量级的事件总线。

  1. 数据隔离与后端驱动: 所有微前端都禁止直接通信以同步业务状态。它们各自通过一个统一的 API 网关与后端服务交互。后端使用关系型数据库(如 PostgreSQL)作为唯一可信的数据源。这种模式强制将业务状态的变更和查询收敛到后端,避免了前端复杂的状态同步逻辑。一个微前端的写操作通过 API 更新数据库,另一个微前端通过轮询或 WebSocket 从 API 获取最新状态。
  2. UI 事件解耦: 对于非业务状态的 UI 交互(例如:“打开一个全局抽屉”、“高亮某个项目”),我们使用浏览器原生的 CustomEvent API 构建一个轻量级的全局事件总线。这种方式避免了引入任何重量级的状态管理库,且对所有基于 JavaScript 的框架(包括由 Dart 编译为 JS 的 Flutter)都通用。

这个架构的清晰边界是它最大的优点:API 网关负责业务状态,事件总线负责 UI 通知

graph TD
    subgraph Browser
        A[Host Shell - index.html]
        B[Flutter Web Micro-App]
        C[Dashboard Widget - Web Component]
        EB((Event Bus - CustomEvent))

        B -- Dispatches 'user:profile:open' --> EB
        C -- Listens for 'user:profile:open' --> EB
        C -- Dispatches 'dashboard:widget:clicked' --> EB
        A -- Listens for global events --> EB
    end

    subgraph Backend
        GW[API Gateway - Node.js/Express]
        DB[(SQL Database - PostgreSQL)]
    end

    B -- HTTP Request (fetch user data) --> GW
    C -- HTTP Request (fetch stats) --> GW
    GW -- SQL Query --> DB

核心实现概览

我们将逐步构建这个系统的关键部分:后端 API、主应用 Shell、Flutter 微应用、Web Component 微应用,以及针对性的单元测试。

1. 后端 API 与数据库 schema

后端是整个系统的基石。一个简洁的 Express 应用负责提供数据。

数据库 Schema (PostgreSQL):

-- a simple schema to represent users and their activity
CREATE TABLE users (
    user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE user_dashboard_stats (
    stat_id SERIAL PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(user_id),
    metric_key VARCHAR(100) NOT NULL,
    metric_value BIGINT NOT NULL,
    last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(user_id, metric_key)
);

-- Seed some data for demonstration
INSERT INTO users (username, email) VALUES ('demo_user', '[email protected]');

INSERT INTO user_dashboard_stats (user_id, metric_key, metric_value)
SELECT user_id, 'profile_views', 1024 FROM users WHERE username = 'demo_user';

INSERT INTO user_dashboard_stats (user_id, metric_key, metric_value)
SELECT user_id, 'items_created', 256 FROM users WHERE username = 'demo_user';

Node.js / Express API Gateway:

这个服务器必须健壮,包含错误处理和日志。

// server.js
import express from 'express';
import pg from 'pg';
import cors from 'cors';

const app = express();
const PORT = 3000;

// 在生产环境中,配置应来自环境变量
const pool = new pg.Pool({
    user: 'your_user',
    host: 'localhost',
    database: 'your_db',
    password: 'your_password',
    port: 5432,
});

// 中间件
app.use(cors()); // 生产环境应配置更严格的 CORS 策略
app.use(express.json());

// 日志中间件
app.use((req, res, next) => {
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
    next();
});

// API 端点
app.get('/api/users/:username', async (req, res) => {
    const { username } = req.params;
    try {
        const result = await pool.query('SELECT user_id, username, email, created_at FROM users WHERE username = $1', [username]);
        if (result.rows.length === 0) {
            return res.status(404).json({ error: 'User not found' });
        }
        res.json(result.rows[0]);
    } catch (err) {
        console.error('Database query error:', err);
        res.status(500).json({ error: 'Internal Server Error' });
    }
});

app.get('/api/users/:userId/stats', async (req, res) => {
    const { userId } = req.params;
    try {
        const result = await pool.query('SELECT metric_key, metric_value, last_updated FROM user_dashboard_stats WHERE user_id = $1', [userId]);
        res.json(result.rows);
    } catch (err) {
        console.error('Database query error:', err);
        res.status(500).json({ error: 'Internal Server Error' });
    }
});

// 统一错误处理
app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).send('Something broke!');
});

app.listen(PORT, () => {
    console.log(`API Gateway listening on port ${PORT}`);
});

2. 主应用 Shell 与 UnoCSS

主应用非常轻量,它的主要职责是提供布局和加载微应用。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hybrid Micro-frontend</title>
    <!-- UnoCSS CDN for rapid prototyping. In production, use a build step. -->
    <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime"></script>
    <style>
        /* A simple global reset */
        body { margin: 0; font-family: sans-serif; }
    </style>
</head>
<body class="bg-gray-100">
    <header class="p-4 bg-white shadow-md text-xl font-bold">
        My Application Shell
    </header>
    <main class="grid grid-cols-3 gap-4 p-4">
        <!-- Flutter micro-app will be mounted here -->
        <div id="flutter-container" class="col-span-2 p-4 bg-white rounded-lg shadow">
            <h2 class="text-lg font-semibold mb-2 text-gray-700">User Profile (Flutter)</h2>
            <!-- This div is the target for the Flutter app -->
            <div id="flutter_target"></div>
        </div>
        
        <!-- Web Component micro-app -->
        <div id="web-component-container" class="col-span-1 p-4 bg-white rounded-lg shadow">
            <h2 class="text-lg font-semibold mb-2 text-gray-700">Dashboard (Web Component)</h2>
            <dashboard-widget user-id="e6a0c5c6-c958-4f7f-8c4c-35e6c1e3a9c7"></dashboard-widget>
        </div>
    </main>

    <!-- Load Micro-apps -->
    <script src="./dashboard-widget.js" type="module"></script>
    <!-- Flutter Loader -->
    <script src="flutter/flutter.js" defer></script>
    <script src="main.js"></script>
</body>
</html>

main.js (负责加载 Flutter)

// main.js
window.addEventListener("load", function(ev) {
  _flutter.loader.loadEntrypoint({
    onEntrypointLoaded: async function(engineInitializer) {
      const appRunner = await engineInitializer.initializeEngine({
        // Flutter Web 会自动寻找 ID 为 flutter_target 的元素
        // 这里的配置是为了更明确地指定宿主元素
        hostElement: document.querySelector("#flutter_target")
      });
      appRunner.runApp();
    }
  });
});

3. Flutter 微应用与 JavaScript 互操作

这是技术挑战的核心。Flutter (Dart) 需要与浏览器环境的 JavaScript 通信。我们使用 dart:js_interop (在 Flutter 3.22+ 中推荐) 来实现。

lib/main.dart

// main.dart
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:flutter/material.dart';

// 定义一个 JS 事件分发函数
// 这里的 'dispatchGlobalEvent' 必须在 JS 端实现
('dispatchGlobalEvent')
external void dispatchGlobalEvent(JSString eventName, JSObject detail);

void main() {
  runApp(const UserProfileApp());
}

class UserProfileApp extends StatelessWidget {
  const UserProfileApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: UserProfileScreen(),
    );
  }
}

class UserProfileScreen extends StatefulWidget {
  const UserProfileScreen({super.key});

  
  State<UserProfileScreen> createState() => _UserProfileScreenState();
}

class _UserProfileScreenState extends State<UserProfileScreen> {
  String _userName = 'Loading...';
  String _userEmail = '...';

  
  void initState() {
    super.initState();
    _fetchUserData();
  }

  Future<void> _fetchUserData() async {
    // 实际项目中, API URL 应通过配置传入
    // 此处硬编码用于演示
    final response = await JSApi.fetch('http://localhost:3000/api/users/demo_user'.toJS).toDart;
    final data = await response.json().toDart;
    
    setState(() {
      _userName = (data.getProperty('username'.toJS) as JSString).toDart;
      _userEmail = (data.getProperty('email'.toJS) as JSString).toDart;
    });
  }

  void _handleEditProfile() {
    // 准备要发送的数据
    final detail = {'userId': 'e6a0c5c6-c958-4f7f-8c4c-35e6c1e3a9c7'}.jsify();
    
    // 调用全局 JS 函数来分发事件
    dispatchGlobalEvent('user:profile:open-edit-modal'.toJS, detail as JSObject);
    
    // 这是一个关键点:Flutter 本身不负责实现弹窗,
    // 它只通知 Shell 或其他微应用来处理。
    // 这就是解耦。
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.transparent,
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Username: $_userName', style: const TextStyle(fontSize: 18)),
            const SizedBox(height: 8),
            Text('Email: $_userEmail', style: const TextStyle(fontSize: 18)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _handleEditProfile,
              child: const Text('Edit Profile'),
            ),
          ],
        ),
      ),
    );
  }
}

// 封装 JS 的 fetch API 调用
()
class JSApi {
  ('fetch')
  external static JSPromise<JSResponse> fetch(JSAny url);
}

()

class JSResponse {
  external JSPromise<JSObject> json();
}

web/index.html 中注入全局 JS 函数:

在 Flutter 生成的 web/index.html<head> 部分添加这个脚本,它为 Dart 代码提供了事件分发的桥梁。

<script>
    // 这个函数暴露给 Flutter (Dart) 调用
    function dispatchGlobalEvent(eventName, detail) {
        console.log(`Event from Flutter: ${eventName}`, detail);
        const event = new CustomEvent(eventName, { 
            bubbles: true, // 允许事件冒泡
            composed: true, // 允许事件跨越 Shadow DOM 边界
            detail: detail
        });
        window.dispatchEvent(event);
    }
</script>

4. Web Component 微应用与 Vitest 测试

这个组件使用原生 Web Components API,并利用 UnoCSS 的 runtime 按需生成样式。

dashboard-widget.js

// dashboard-widget.js
const template = document.createElement('template');
template.innerHTML = `
  <style>
    /* 这里的样式被 Shadow DOM 隔离 */
    :host {
      display: block;
      font-size: 14px;
    }
    .loading {
      color: #999;
    }
    .error {
      color: #ef4444; /* text-red-500 */
    }
  </style>
  <div id="container" class="p-2 border border-gray-200 rounded">
    <p id="status" class="loading">Loading stats...</p>
    <ul id="stats-list"></ul>
  </div>
`;

class DashboardWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    this._userId = null;
  }

  static get observedAttributes() {
    return ['user-id'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'user-id' && oldValue !== newValue) {
      this._userId = newValue;
      this.fetchStats();
    }
  }

  connectedCallback() {
    // 监听来自其他微应用的事件
    window.addEventListener('user:profile:open-edit-modal', this._handleHighlight);
  }

  disconnectedCallback() {
    window.removeEventListener('user:profile:open-edit-modal', this._handleHighlight);
  }

  _handleHighlight = (event) => {
    // 这是一个 UI 响应的例子
    console.log('DashboardWidget received event:', event.detail);
    const container = this.shadowRoot.querySelector('#container');
    container.classList.add('border-blue-500', 'shadow-lg');
    setTimeout(() => {
      container.classList.remove('border-blue-500', 'shadow-lg');
    }, 2000);
  }

  async fetchStats() {
    if (!this._userId) return;

    const statusEl = this.shadowRoot.querySelector('#status');
    const listEl = this.shadowRoot.querySelector('#stats-list');
    
    try {
      statusEl.textContent = 'Loading stats...';
      const response = await fetch(`http://localhost:3000/api/users/${this._userId}/stats`);
      
      if (!response.ok) {
        throw new Error(`API Error: ${response.statusText}`);
      }

      const stats = await response.json();
      statusEl.style.display = 'none';
      listEl.innerHTML = stats.map(stat => 
        // 使用 UnoCSS 的 class
        `<li class="flex justify-between items-center py-1">
          <span class="text-gray-600">${stat.metric_key.replace('_', ' ')}</span>
          <span class="font-bold text-lg">${stat.metric_value}</span>
        </li>`
      ).join('');

    } catch (error) {
      console.error('Failed to fetch stats:', error);
      statusEl.textContent = 'Failed to load stats.';
      statusEl.className = 'error';
    }
  }
}

window.customElements.define('dashboard-widget', DashboardWidget);

使用 Vitest 进行单元测试:

在真实项目中,为微前端编写独立的、可信的测试至关重要。Vitest 以其与 Vite 的集成和快速的性能,成为一个优秀的选择。

dashboard-widget.test.js

// dashboard-widget.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JSDOM } from 'jsdom';
import './dashboard-widget.js'; // 注册 custom element

// Vitest 需要一个 DOM 环境来测试 Web Components
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
global.window = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.customElements = dom.window.customElements;
global.CustomEvent = dom.window.CustomEvent;

// Mock fetch API
global.fetch = vi.fn();

describe('DashboardWidget', () => {
    
    beforeEach(() => {
        // 清理 body 和 mock
        document.body.innerHTML = '';
        fetch.mockClear();
    });

    it('renders a loading state initially', () => {
        const element = document.createElement('dashboard-widget');
        document.body.appendChild(element);
        
        const statusEl = element.shadowRoot.querySelector('#status');
        expect(statusEl.textContent).toBe('Loading stats...');
    });

    it('fetches stats and renders them when user-id attribute is set', async () => {
        const mockStats = [
            { metric_key: 'profile_views', metric_value: 123 },
            { metric_key: 'items_created', metric_value: 45 },
        ];
        fetch.mockResolvedValue({
            ok: true,
            json: () => Promise.resolve(mockStats),
        });

        const element = document.createElement('dashboard-widget');
        document.body.appendChild(element);
        
        // 动态设置 attribute,触发 fetch
        element.setAttribute('user-id', 'test-user-123');
        
        // 等待异步操作完成
        await vi.waitFor(() => {
            const listItems = element.shadowRoot.querySelectorAll('li');
            expect(listItems.length).toBe(2);
        });

        const firstItem = element.shadowRoot.querySelector('li');
        expect(firstItem.textContent).toContain('profile views');
        expect(firstItem.textContent).toContain('123');
        expect(fetch).toHaveBeenCalledWith('http://localhost:3000/api/users/test-user-123/stats');
    });
    
    it('handles API errors gracefully', async () => {
        fetch.mockResolvedValue({
            ok: false,
            statusText: 'Not Found',
        });
        
        const element = document.createElement('dashboard-widget');
        document.body.appendChild(element);
        element.setAttribute('user-id', 'error-user');

        await vi.waitFor(() => {
            const statusEl = element.shadowRoot.querySelector('#status');
            expect(statusEl.textContent).toBe('Failed to load stats.');
            expect(statusEl.classList.contains('error')).toBe(true);
        });
    });

    it('reacts to global custom events', () => {
        const element = document.createElement('dashboard-widget');
        document.body.appendChild(element);
        const container = element.shadowRoot.querySelector('#container');

        // 模拟一个从 Flutter 发出的事件
        const event = new CustomEvent('user:profile:open-edit-modal', {
            detail: { message: 'hello from test' }
        });
        window.dispatchEvent(event);

        expect(container.classList.contains('border-blue-500')).toBe(true);
    });
});

架构的局限性与未来展望

这套架构并非银弹。它的成功建立在清晰的边界划分和团队纪律之上。

局限性:

  1. 事件总线复杂性: 当微前端数量增多,UI 事件的种类和流向可能会变得混乱。需要制定严格的事件命名规范(如 source:subject:action),并有文档记录。滥用事件总线会导致系统重新退化为难以追踪的紧耦合状态。
  2. 性能考量: Flutter Web 的初始加载体积仍然比原生 Web 框架大。对于性能敏感的首页或公共页面,应避免使用 Flutter 微前端。它更适合用于功能复杂、已经存在的内部工具或专业应用模块的集成。
  3. 构建与部署: 异构微前端的 CI/CD 流程更复杂。需要为每个微前端建立独立的构建管道,并使用类似 Module Federation 的技术或简单的脚本注入来在主 Shell 中组合它们。版本管理和依赖同步是需要持续关注的工程问题。
  4. 共享UI库: 在这个模型中,共享纯UI组件(如一个设计系统)变得非常困难。UnoCSS 可以通过共享配置来达到一定程度的样式统一,但无法共享一个用 Flutter 编写的按钮给 Web Component 使用,反之亦然。

未来迭代方向:

可以引入一个更健壮的服务来管理事件总线,例如实现一个轻量级的代理,对事件进行校验、记录和转发,增强系统的可观测性。在数据层面,对于实时性要求高的场景,可以从 API 网关轮询升级为 WebSocket 推送,将后端 SQL 数据库的变更通过 CDC (Change Data Capture) 实时推送到前端,进一步提升用户体验。


  目录