我们面临一个具体的工程挑战:一个核心业务模块由 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 层面的通信则通过一个轻量级的事件总线。
- 数据隔离与后端驱动: 所有微前端都禁止直接通信以同步业务状态。它们各自通过一个统一的 API 网关与后端服务交互。后端使用关系型数据库(如 PostgreSQL)作为唯一可信的数据源。这种模式强制将业务状态的变更和查询收敛到后端,避免了前端复杂的状态同步逻辑。一个微前端的写操作通过 API 更新数据库,另一个微前端通过轮询或 WebSocket 从 API 获取最新状态。
- 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);
});
});
架构的局限性与未来展望
这套架构并非银弹。它的成功建立在清晰的边界划分和团队纪律之上。
局限性:
- 事件总线复杂性: 当微前端数量增多,UI 事件的种类和流向可能会变得混乱。需要制定严格的事件命名规范(如
source:subject:action
),并有文档记录。滥用事件总线会导致系统重新退化为难以追踪的紧耦合状态。 - 性能考量: Flutter Web 的初始加载体积仍然比原生 Web 框架大。对于性能敏感的首页或公共页面,应避免使用 Flutter 微前端。它更适合用于功能复杂、已经存在的内部工具或专业应用模块的集成。
- 构建与部署: 异构微前端的 CI/CD 流程更复杂。需要为每个微前端建立独立的构建管道,并使用类似 Module Federation 的技术或简单的脚本注入来在主 Shell 中组合它们。版本管理和依赖同步是需要持续关注的工程问题。
- 共享UI库: 在这个模型中,共享纯UI组件(如一个设计系统)变得非常困难。UnoCSS 可以通过共享配置来达到一定程度的样式统一,但无法共享一个用 Flutter 编写的按钮给 Web Component 使用,反之亦然。
未来迭代方向:
可以引入一个更健壮的服务来管理事件总线,例如实现一个轻量级的代理,对事件进行校验、记录和转发,增强系统的可观测性。在数据层面,对于实时性要求高的场景,可以从 API 网关轮询升级为 WebSocket 推送,将后端 SQL 数据库的变更通过 CDC (Change Data Capture) 实时推送到前端,进一步提升用户体验。