Architecting a Hybrid Micro-Frontend Application with Vue.js, SvelteKit, and a MariaDB Backend


我们面临一个常见的工程困境:一个稳定运行数年的大型单体前端应用,基于 Vue.js 2 和 Options API 构建,承载着公司的核心业务。它很庞大,构建缓慢,技术债累积严重。业务方要求快速上线一个性能要求极高、交互体验现代化的新功能模块——一个实时数据仪表盘。完全重写现有应用风险不可控,周期过长。在现有应用上继续叠加功能,则会进一步恶化其可维护性,且 Vue 2 的性能无法满足新模块的严苛要求。

增量迁移和现代化改造是唯一可行的路径。我们的目标是引入一个更现代、更高性能的技术栈(SvelteKit)来开发新模块,同时保证与现有 Vue.js 应用的无缝集成。这本质上是一个微前端架构的选型与落地问题。

定义复杂技术问题:框架异构下的无缝集成

核心挑战在于:如何在单一产品体验下,协同运行两个完全不同技术栈、不同构建体系、不同运行时范式的前端应用?它们需要共享统一的认证体系、API 数据源,并在必要时进行有限的跨应用通信,同时避免互相污染全局作用域或样式。底层数据持久化依赖于一个集中式的 MariaDB 数据库。

graph TD
    subgraph Browser
        A[User Request] --> B{Routing Shell};
        B -- Path /legacy/* --> C[Vue.js App];
        B -- Path /dashboard/* --> D[SvelteKit App];
    end

    subgraph Backend
        E[Centralized API Server - Node.js];
        F[MariaDB Database];
    end

    C --> |API Calls| E;
    D --> |API Calls| E;
    E <--> F;

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style E fill:#ccf,stroke:#333,stroke-width:2px

上图展示了我们设想的宏观架构。一个轻量级的“路由外壳”负责根据 URL 路径分发流量,将用户引导至对应的技术栈应用。所有应用都与一个统一的后端 API 通信,该 API 负责业务逻辑并与 MariaDB 交互。

方案A:iframe 隔离——简单但粗暴

最先进入考虑范围的是 iframe。它提供了近乎完美的沙箱隔离环境,JS、CSS 甚至 window 对象都完全独立。

优势:

  1. 实现简单: 只需在主应用中嵌入一个 <iframe> 标签,src 指向另一个应用的 URL。
  2. 强隔离: 不存在样式冲突或 JavaScript 全局变量污染的风险。

劣势:

  1. 糟糕的 UX: 页面加载时会出现多个 loading 状态,iframe 的自适应高度难以管理,滚动条体验不一致,URL 状态同步困难。
  2. 通信障碍: 跨域 iframe 间的通信依赖 postMessage,繁琐且延迟高。实现统一的会话管理(Session)和单点登录非常棘手。
  3. 性能开销: 每个 iframe 都意味着一个完整的独立页面生命周期和资源加载,内存和 CPU 开销巨大。

在真实项目中,除非是嵌入完全独立的第三方小部件,否则 iframe 几乎不是一个可接受的主应用架构方案。它牺牲了用户体验和开发效率,换来的只是表面的简单。对于我们需要构建的统一产品体验而言,此方案直接被否决。

方案B:运行时集成(如 Webpack Module Federation)——强大但复杂

Webpack 5 引入的模块联邦(Module Federation)提供了一种在运行时动态加载和共享代码模块的能力,是构建微前端的强大工具。

优势:

  1. 无缝体验: 可以实现组件级别的混用,例如在一个 Vue 组件中直接渲染一个 Svelte 组件。用户体验上是完全一体化的。
  2. 依赖共享: 可以配置共享通用依赖库(如 lodash, axios),减少总体积。

劣势:

  1. 构建复杂性: 配置模块联邦本身就需要深入理解 Webpack。当涉及到两个完全不同的构建工具链(Vue CLI 基于 Webpack,SvelteKit 基于 Vite)时,集成的复杂性呈指数级增长。虽然有社区插件尝试解决 Vite 的模块联邦问题,但在生产环境中的稳定性和成熟度仍需考量。
  2. 技术栈强耦合: 远程模块和宿主应用之间存在隐式的依赖和版本约定。一旦共享库版本不匹配,极易引发难以调试的运行时错误。
  3. 环境问题: 需要确保所有微应用的开发、测试、生产环境配置都能协同工作,这对 CI/CD 流程提出了更高的要求。

对于我们当前的目标——快速、稳定地引入新模块,模块联邦的方案显得“过重”。它带来的配置和维护成本,可能会拖慢新模块的交付速度。一个常见的错误是,为了追求技术上的“完美集成”,而引入了远超业务需求的复杂度。

最终选择:基于路由分发的轻量级外壳与统一 API 网关

我们最终选择了一种更务实的中间道路:页面级集成的微前端。通过一个极其轻量的路由外壳(可以是 Nginx 反向代理,也可以是一个简单的 Node.js 服务)来控制流量分发。

决策理由:

  1. 关注点分离: 前端应用之间保持构建和部署的完全独立。Vue 团队可以继续使用他们的工具链,Svelte 团队也可以享受 Vite 带来的极致开发体验。
  2. 松耦合: 应用间的唯一契约是 URL 路由规则和后端 API 接口。这种耦合程度是最低且最容易管理的。
  3. 稳定性与可预测性: 每个应用都是一个完整的、可独立运行的实体。排查问题时,可以清晰地定位到是哪个应用出了问题,而不是陷入复杂的共享运行时环境中。
  4. 认证统一: 认证状态通过后端 API 签发的 HttpOnlySecure cookie 来维护。由于所有应用都部署在同一个主域名下的不同路径,浏览器会自动携带 cookie,从而天然地实现了单点登录。

这种方案的本质是承认并接受不同技术栈之间的边界,通过架构设计而非构建工具来弥合差异。

核心实现概览

1. MariaDB 数据库结构

一切业务的核心是数据。我们为新的实时仪表盘设计一个简单的数据表结构,用于存储性能指标。

-- file: schema.sql
-- MariaDB Schema for the new dashboard feature

-- 确保数据库存在
CREATE DATABASE IF NOT EXISTS `hybrid_app_db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `hybrid_app_db`;

-- 实时性能指标表
-- 使用 InnoDB 引擎以支持事务
CREATE TABLE IF NOT EXISTS `performance_metrics` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `metric_name` VARCHAR(128) NOT NULL COMMENT '指标名称, e.g., "cpu_usage", "memory_load"',
  `metric_value` DECIMAL(10, 4) NOT NULL COMMENT '指标值',
  `region` VARCHAR(64) NOT NULL COMMENT '数据中心区域',
  `timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间戳',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  INDEX `idx_timestamp_region` (`timestamp`, `region`),
  INDEX `idx_metric_name` (`metric_name`)
) ENGINE=InnoDB COMMENT='实时性能指标数据表';

-- 用户表 (用于演示认证)
CREATE TABLE IF NOT EXISTS `users` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL,
  `password_hash` VARCHAR(255) NOT NULL,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB COMMENT='用户表';

这里的关键是建立了合理的索引(idx_timestamp_region),因为仪表盘应用最常见的查询模式是按时间范围和区域过滤。

2. 统一的 Node.js/Express 后端 API

后端是整个架构的粘合剂。它必须是无状态的、健壮的,并为所有前端提供统一的数据视图。

项目结构:

/api-server
├── src/
│   ├── config/
│   │   └── index.js       # 配置管理 (db, server port)
│   ├── controllers/
│   │   └── metrics.controller.js # 请求处理器
│   ├── middleware/
│   │   └── errorHandler.js   # 全局错误处理
│   │   └── logger.js         # 日志中间件
│   ├── routes/
│   │   └── index.js          # API 路由定义
│   ├── services/
│   │   └── database.js       # 数据库连接池
│   └── app.js             # Express 应用入口
├── .env
└── package.json

数据库连接池 (src/services/database.js):
在生产项目中,绝不能每次请求都创建新的数据库连接。必须使用连接池。

// file: src/services/database.js
import mariadb from 'mariadb';
import logger from '../middleware/logger.js'; // 假设我们有一个pino日志实例

const poolConfig = {
    host: process.env.DB_HOST || '127.0.0.1',
    port: process.env.DB_PORT || 3306,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    connectionLimit: 10, // 关键:根据负载调整
    acquireTimeout: 20000, // 20秒获取不到连接就超时
    idleTimeout: 60000, // 连接空闲60秒后释放
    charset: 'utf8mb4',
    // 生产环境中启用,防止因网络波动导致连接中断
    connectionRetryCount: 3, 
    trace: false
};

// 创建连接池
const pool = mariadb.createPool(poolConfig);

// 优雅关闭处理
process.on('SIGINT', async () => {
    logger.info('Gracefully shutting down database pool...');
    await pool.end();
    logger.info('Database pool closed.');
    process.exit(0);
});

logger.info('Database pool created successfully.');

export default pool;

这里的配置考虑了生产环境的稳定性,包括连接数限制、超时和重试。

API 路由与控制器 (src/routes/index.js, src/controllers/metrics.controller.js):

// file: src/routes/index.js
import { Router } from 'express';
import { getMetrics } from '../controllers/metrics.controller.js';
// import { requireAuth } from '../middleware/auth.js'; // 假设有认证中间件

const router = Router();

// 所有 /api/v1 路由都需要认证
// router.use('/v1', requireAuth); 

router.get('/v1/metrics', getMetrics);

export default router;

// file: src/controllers/metrics.controller.js
import pool from '../services/database.js';
import logger from '../middleware/logger.js';

export const getMetrics = async (req, res, next) => {
    // 从查询参数中获取过滤条件,并提供默认值
    const { start, end, region } = req.query;
    const defaultEnd = new Date();
    const defaultStart = new Date(defaultEnd.getTime() - 60 * 60 * 1000); // 默认过去1小时

    const startTime = start ? new Date(start) : defaultStart;
    const endTime = end ? new Date(end) : defaultEnd;

    let conn;
    try {
        conn = await pool.getConnection();

        let sql = 'SELECT metric_name, metric_value, region, timestamp FROM performance_metrics WHERE timestamp BETWEEN ? AND ?';
        const params = [startTime, endTime];

        if (region) {
            sql += ' AND region = ?';
            params.push(region);
        }

        sql += ' ORDER BY timestamp DESC LIMIT 1000'; // 防止返回过多数据

        const rows = await conn.query(sql, params);
        
        // 关键:返回的数据结构要清晰、一致
        res.status(200).json({
            status: 'success',
            count: rows.length,
            data: rows
        });

    } catch (err) {
        logger.error({ err, context: 'getMetrics' }, 'Failed to fetch metrics from MariaDB');
        // 将错误传递给全局错误处理器
        next(err);
    } finally {
        if (conn) {
            // 确保连接被释放回池中
            conn.release();
        }
    }
};

这段代码展示了一个生产级的控制器:参数校验(虽然这里简化了)、使用连接池、详细的错误处理和日志记录、以及最重要的——finally块中确保连接被释放。

3. 旧应用改造 (Vue.js)

我们需要修改 Vue Router 的配置,使其在 /legacy 基础路径下工作。

// file: vue-legacy-app/src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  // ... other routes
]

const router = new VueRouter({
  // 关键: 设置 base URL
  base: '/legacy/', 
  mode: 'history', // 必须使用 history 模式
  routes
})

export default router

同时,其构建输出需要被部署到服务器的 /legacy 路径下。

4. 新应用开发 (SvelteKit)

SvelteKit 的配置更简单,通常由部署环境决定基础路径。在 svelte.config.js 中可以设置。

// file: svelte-dashboard-app/svelte.config.js
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: vitePreprocess(),

	kit: {
		adapter: adapter(),
        // 关键: 设置基础路径
        paths: {
            base: process.env.NODE_ENV === 'production' ? '/dashboard' : ''
        }
	}
};

export default config;

SvelteKit 的页面组件和 load 函数可以优雅地从我们的 API 获取数据。

<!-- file: svelte-dashboard-app/src/routes/dashboard/+page.svelte -->
<script>
    export let data; // 从 `load` 函数接收数据
</script>

<h1>Real-time Dashboard</h1>

{#if data.error}
    <p class="error">{data.error}</p>
{:else if data.metrics && data.metrics.length > 0}
    <table>
        <thead>
            <tr>
                <th>Timestamp</th>
                <th>Region</th>
                <th>Metric</th>
                <th>Value</th>
            </tr>
        </thead>
        <tbody>
            {#each data.metrics as metric}
                <tr>
                    <td>{new Date(metric.timestamp).toLocaleString()}</td>
                    <td>{metric.region}</td>
                    <td>{metric.metric_name}</td>
                    <td>{metric.metric_value.toFixed(2)}</td>
                </tr>
            {/each}
        </tbody>
    </table>
{:else}
    <p>No data available for the selected range.</p>
{/if}

<style>
/* ... component styles ... */
</style>
// file: svelte-dashboard-app/src/routes/dashboard/+page.server.js
import { error } from '@sveltejs/kit';

// fetch 函数由 SvelteKit 提供,它能智能地在服务端和客户端执行
// 在服务端渲染时,它会直接向 API 服务器发起请求
export async function load({ fetch }) {
    try {
        // 在真实项目中,API URL 应该来自环境变量
        const response = await fetch('http://localhost:3000/api/v1/metrics');

        if (!response.ok) {
            // 抛出一个符合 SvelteKit 预期的错误对象
            throw error(response.status, `API request failed with status: ${response.statusText}`);
        }

        const result = await response.json();
        
        return {
            metrics: result.data
        };

    } catch (e) {
        // 处理网络错误或 API 错误
        console.error('Failed to load metrics:', e);
        return {
            metrics: [],
            error: e.message || 'Could not connect to the API server.'
        };
    }
}

SvelteKit 的 +page.server.js 使得在服务端获取数据变得异常简单和安全,这是其相比传统 CSR 框架的一大优势。

5. 跨应用通信

对于简单的通信,比如在一个应用中操作后需要通知另一个应用刷新数据,可以使用 BroadcastChannel API。

// 在 SvelteKit 应用中发送消息
const channel = new BroadcastChannel('app_notifications');
channel.postMessage({ type: 'DATA_UPDATED', payload: { source: 'dashboard' } });

// 在 Vue.js 应用的某个组件中监听
const channel = new BroadcastChannel('app_notifications');
channel.onmessage = (event) => {
    if (event.data.type === 'DATA_UPDATED') {
        console.log('Received update notification from another app:', event.data.payload);
        // this.fetchData(); // 触发数据刷新
    }
};

这是一个轻量级、无依赖的解决方案,适用于不需要强一致性的场景。

架构的扩展性与局限性

这个架构模式为我们提供了一个清晰的演进路径。未来可以继续将 Vue.js 单体中的其他模块,逐一用 SvelteKit 或其他现代框架重写,并以新的路径(如 /reports, /settings)挂载到系统中。后端 API 作为稳定的基石,保证了业务逻辑的一致性。

但这个方案并非没有局限性。

  1. 非组件级集成: 它无法实现将一个 Svelte 组件嵌入到 Vue 页面中的需求。应用之间的边界是页面级别的。如果未来有强烈的组件级复用需求,那么迁移到模块联邦方案是必要的下一步。
  2. UI一致性: 维护两套技术栈的 UI 组件库(例如 Element UI for Vue 和 Carbon Components for Svelte)会带来额外的工作量。一个长远之计是投资于一套独立的、基于 Web Components 的设计系统,它可以被任何框架使用。
  3. 导航性能:/legacy/page-a 导航到 /dashboard/page-b 会导致浏览器进行一次完整的页面加载,而不是像单页面应用(SPA)那样的平滑路由切换。这对于某些对切换体验要求极高的应用可能是一个问题,但对于大部分后台管理系统或内容型网站来说,这种牺牲是完全可以接受的。

该方案的本质是一种务实的权衡,它用可接受的、微小的用户体验妥协,换取了巨大的开发效率提升、技术栈现代化和长期的系统可维护性。对于大多数处于类似困境的团队而言,这通常是正确的第一步。


  目录