构建基于 Rollup 共享模块的 Nuxt.js 微前端状态管理与 Cypress 端到端测试方案


一个日益复杂的业务平台,被拆分成了多个由不同团队维护的 Nuxt.js 应用,例如“用户中心”、“订单面板”和“营销活动”模块。它们需要被整合进一个统一的宿主(Shell)应用中,呈现给用户的体验必须是无缝的。这意味着,当用户在“用户中心”登录后,“订单面板”需要立即刷新并展示该用户的订单数据。这种跨应用的实时状态同步,是微前端架构中最棘手的挑战之一。

常见的 Module Federation 方案虽然强大,但在我们的场景中显得过于重。它强耦合了构建时环境,并且在 Nuxt.js 这种高度封装的框架中引入会增加不可预知的复杂性。iframe 方案则因其糟糕的性能、孱弱的通信机制和糟糕的 SEO 表现被第一时间排除。

我们需要一个更轻量、更可控的方案,核心目标是:将状态逻辑与 UI 框架解耦,让状态成为一个可被独立构建、版本化和分发的“共享模块”

架构决策:共享状态模块 vs. 联邦状态共享

摆在面前有两个主要的技术路径。

方案A:基于构建工具的联邦式共享 (Federated Sharing)

通过 Webpack 5 的 Module Federation,我们可以将状态管理库(如 Pinia store)从一个微应用(exposer)中暴露出来,由其他微应用(consumer)在运行时动态消费。

  • 优势:
    • 真正的运行时共享,理论上只有一个状态库实例。
    • 依赖项共享可以做得很好,减少总体积。
  • 劣势:
    • 配置极其复杂,尤其是在 Nuxt.js 项目中,需要深入其 Webpack 配置。
    • 强依赖 Webpack,如果未来有团队想尝试 Vite,迁移成本巨大。
    • 版本冲突问题。如果多个微应用依赖了不同版本的共享模块,调试会变成一场噩梦。在真实项目中,这种不一致性几乎是必然的。
    • “魔法”感太强,出了问题很难定位是业务逻辑问题还是联邦配置问题。

方案B:构建独立的、框架无关的状态模块 (Framework-Agnostic Shared Module)

这条路的思路是,将所有跨应用共享的状态、API 请求和业务逻辑,封装在一个纯 JavaScript/TypeScript 项目中。这个项目不依赖任何 UI 框架,使用 Rollup 将其打包成一个标准的 UMD 或 ES Module。然后,各个 Nuxt.js 应用像安装一个普通的 npm 包(如 lodash)一样来安装和使用它。

  • 优势:
    • 技术解耦: 状态模块的开发与 Nuxt.js 应用的开发完全分离。状态模块团队只需要关注核心业务逻辑和数据。
    • 构建独立: Rollup 专注于库的打包,配置简单清晰。Nuxt 应用的构建过程无需任何改动。
    • 清晰的契约: 共享模块的导出就是其公开的 API,这是应用间通信的唯一契约。接口的变更通过版本号来管理,职责分明。
    • 环境无关: 打包后的产物是标准 JavaScript,可以被任何框架(Vue, React, Svelte)甚至原生 JS 使用,为未来技术栈演进留下了空间。

最终选择:方案 B

在真实项目中,可维护性和可预测性远比追求最前沿的技术整合重要。方案 B 提供了一个清晰的边界,降低了团队间的沟通成本和技术耦合。虽然它需要在各个应用中手动管理共享模块的版本,但这本身也是一种明确的“依赖更新”流程,比联邦共享的隐式依赖更新要可控得多。我们选择稳定性和清晰度。

核心实现:三步走

我们的实现将分为三个部分:

  1. 使用 Rollup 构建共享状态模块 (shared-state-module)
  2. 在两个独立的 Nuxt.js 应用(host-appmicro-app-orders)中集成此模块
  3. 使用 Cypress 编写端到端测试,验证跨应用状态同步的正确性
graph TD
    subgraph "Monorepo Project Structure"
        A[packages/shared-state-module]
        B[packages/host-app]
        C[packages/micro-app-orders]
        D[e2e/cypress]
    end

    subgraph "Build & Dependency Flow"
        R[Rollup] -- build --> SM[dist/index.js]
        A --> R
        B -- depends on --> A
        C -- depends on --> A
    end

    subgraph "Integration & Testing"
        B -- imports --> SM
        C -- imports --> SM
        SM -- provides state --> B
        SM -- provides state --> C
        D -- tests --> B
        D -- tests --> C
    end

    style R fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#9cf,stroke:#333,stroke-width:2px

第一步:使用 Rollup 构建共享状态模块

这个模块的核心是提供一个单例的状态存储,我们用 JavaScriptProxySet 来简单实现一个响应式系统,以保持其轻量和框架无关。

packages/shared-state-module/src/index.ts

// src/index.ts

// 日志记录器,用于调试状态变更
const logger = {
    log: (message: string, ...args: any[]) => console.log(`[SharedState] ${message}`, ...args),
    error: (message: string, ...args: any[]) => console.error(`[SharedState] ${message}`, ...args),
};

// 存储订阅者的集合
const subscribers: Set<(state: State) => void> = new Set();

interface User {
    id: string | null;
    name: string | null;
    token: string | null;
}

interface State {
    user: User;
    isAuthenticated: boolean;
    lastLoginTime: number | null;
}

// 初始状态
const initialState: State = {
    user: {
        id: null,
        name: null,
        token: null,
    },
    isAuthenticated: false,
    lastLoginTime: null,
};

// 内部状态存储
let internalState: State = { ...initialState };

// 通知所有订阅者状态已变更
function notify() {
    logger.log('Notifying subscribers of state change...');
    // 使用深拷贝传递状态,防止外部直接修改
    const stateSnapshot = JSON.parse(JSON.stringify(internalState));
    subscribers.forEach(callback => {
        try {
            callback(stateSnapshot);
        } catch (e) {
            logger.error('Error in subscriber callback:', e);
        }
    });
}

// 使用 Proxy 监听状态变更
const stateProxy = new Proxy(internalState, {
    set(target, property, value) {
        // @ts-ignore
        if (target[property] !== value) {
            logger.log(`State changed: ${String(property)} from`, target[property], 'to', value);
            // @ts-ignore
            target[property] = value;
            notify();
        }
        return true;
    },
});

// --- 公开的 API ---

/**
 * 获取当前状态的只读快照
 * @returns {State} 当前状态的深拷贝
 */
export function getState(): State {
    return JSON.parse(JSON.stringify(internalState));
}

/**
 * 订阅状态变更
 * @param {(state: State) => void} callback - 状态变更时执行的回调函数
 * @returns {() => void} - 用于取消订阅的函数
 */
export function subscribe(callback: (state: State) => void): () => void {
    if (typeof callback !== 'function') {
        logger.error('Subscriber must be a function.');
        return () => {};
    }
    subscribers.add(callback);
    logger.log('New subscriber added. Total subscribers:', subscribers.size);
    // 立即用当前状态调用一次,确保新订阅者能获取初始状态
    callback(getState());
    return () => {
        subscribers.delete(callback);
        logger.log('Subscriber removed. Total subscribers:', subscribers.size);
    };
}

/**
 * 登录操作
 * @param {string} name - 用户名
 * @param {string} token - 模拟的认证令牌
 */
export function login(name: string, token: string) {
    if (!name || !token) {
        logger.error('Login failed: name and token are required.');
        return;
    }
    logger.log(`Performing login for user: ${name}`);
    stateProxy.user = { id: `user-${Date.now()}`, name, token };
    stateProxy.isAuthenticated = true;
    stateProxy.lastLoginTime = Date.now();
}

/**
 * 登出操作
 */
export function logout() {
    logger.log('Performing logout.');
    stateProxy.user = { ...initialState.user };
    stateProxy.isAuthenticated = false;
    stateProxy.lastLoginTime = null;
}

// 确保模块只被实例化一次
logger.log('Shared state module initialized.');

packages/shared-state-module/rollup.config.js

import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser';
import dts from 'rollup-plugin-dts';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require('./package.json');

export default [
    {
        input: 'src/index.ts',
        output: [
            {
                file: pkg.main, // CommonJS for Node environment (if needed)
                format: 'cjs',
                sourcemap: true,
            },
            {
                file: pkg.module, // ES Module for modern bundlers
                format: 'esm',
                sourcemap: true,
            },
        ],
        plugins: [
            typescript({
                tsconfig: './tsconfig.json',
                // 确保声明文件被正确处理
                declaration: true,
                declarationDir: 'dist/types',
            }),
            terser(), // 压缩代码
        ],
        // 标记外部依赖,避免打包进最终文件
        external: [],
    },
    // 单独打包类型声明文件
    {
        input: 'dist/types/index.d.ts',
        output: [{ file: 'dist/index.d.ts', format: 'esm' }],
        plugins: [dts()],
    }
];

运行 rollup -c 后,我们会在 dist 目录下得到一个干净、可移植的 JavaScript 模块。

第二步:在 Nuxt.js 应用中集成

我们将创建两个 Nuxt 应用,host-app 作为主应用,micro-app-orders 模拟一个独立的订单模块。它们都依赖本地的 shared-state-module

host-appmicro-app-orderspackage.json 中添加依赖:

"dependencies": {
  "shared-state-module": "file:../shared-state-module"
}

为了在 Nuxt 中优雅地使用这个状态模块,我们创建一个插件,将其 API 注入到 Nuxt 上下文中,并利用 Vue 的响应式系统。

packages/host-app/plugins/shared-state.client.ts

import { defineNuxtPlugin } from '#app';
import { ref } from 'vue';
import * as sharedState from 'shared-state-module';

export default defineNuxtPlugin(() => {
    // 创建一个 Vue 的 ref 来持有共享状态,这样模板就能自动更新
    const state = ref(sharedState.getState());

    // 订阅共享模块的状态变更,并在变更时更新 Vue 的 ref
    const unsubscribe = sharedState.subscribe((newState) => {
        console.log('[HostApp] Received state update from shared module:', newState);
        state.value = newState;
    });

    // 当 Vue 应用卸载时,取消订阅,防止内存泄漏
    // 这是生产级代码必须考虑的细节
    const app = useNuxtApp();
    app.hook('vue:beforeUnmount', () => {
        console.log('[HostApp] Unsubscribing from shared state.');
        unsubscribe();
    });

    // 将响应式状态和操作函数提供给整个应用
    return {
        provide: {
            sharedState: {
                // 提供响应式的 state
                state,
                // 直接暴露原始的操作函数
                login: sharedState.login,
                logout: sharedState.logout,
            }
        }
    };
});

micro-app-orders 也使用完全相同的插件。

现在,我们可以在两个应用的组件中消费和操作这个共享状态。

packages/host-app/components/UserProfile.vue

<template>
  <div class="card" data-cy="user-profile-card">
    <h3>Host App - User Profile</h3>
    <div v-if="$sharedState.state.value.isAuthenticated">
      <p>Welcome, <strong data-cy="user-name">{{ $sharedState.state.value.user.name }}</strong>!</p>
      <p>Token: <span class="token" data-cy="user-token">{{ $sharedState.state.value.user.token }}</span></p>
      <button @click="handleLogout" data-cy="logout-button">Logout</button>
    </div>
    <div v-else>
      <p data-cy="guest-message">You are not logged in.</p>
      <button @click="handleLogin" data-cy="login-button">Login as Admin</button>
    </div>
  </div>
</template>

<script setup>
const { $sharedState } = useNuxtApp();

const handleLogin = () => {
  // 调用共享模块的 action
  $sharedState.login('Admin User', `fake-token-${Math.random().toString(36).substring(7)}`);
};

const handleLogout = () => {
  $sharedState.logout();
};
</script>

<style scoped>
.card { border: 2px solid #00DC82; padding: 1rem; margin: 1rem; border-radius: 8px; }
.token { font-family: monospace; font-size: 0.8rem; background: #eee; padding: 2px 4px; border-radius: 4px; word-break: break-all; }
</style>

packages/micro-app-orders/components/OrderDashboard.vue

<template>
  <div class="card" data-cy="order-dashboard-card">
    <h3>Micro App - Order Dashboard</h3>
    <div v-if="$sharedState.state.value.isAuthenticated">
      <p data-cy="dashboard-welcome">
        Fetching orders for user: <strong>{{ $sharedState.state.value.user.name }}</strong>...
      </p>
      <p>
        Authentication Status: <span class="status-ok">Authenticated</span>
      </p>
    </div>
    <div v-else>
      <p data-cy="dashboard-login-prompt">
        Please log in from the Host App to see your orders.
      </p>
       <p>
        Authentication Status: <span class="status-err">Not Authenticated</span>
      </p>
    </div>
  </div>
</template>

<script setup>
const { $sharedState } = useNuxtApp();
</script>

<style scoped>
.card { border: 2px solid #3B82F6; padding: 1rem; margin: 1rem; border-radius: 8px; }
.status-ok { color: green; font-weight: bold; }
.status-err { color: red; font-weight: bold; }
</style>

最后,我们在 host-app 的页面中同时渲染这两个组件,模拟微前端的集成。

packages/host-app/app.vue

<template>
  <div>
    <h1>Hybrid Micro-Frontend State Management Demo</h1>
    <UserProfile />
    <ClientOnly>
      <MicroAppOrdersOrderDashboard />
    </ClientOnly>
  </div>
</template>

<script setup>
// 在 nuxt.config.ts 中通过 components:dirs 配置跨项目组件引用
// components: {
//   dirs: [
//     '~/components',
//     { path: '~/../micro-app-orders/components', prefix: 'MicroAppOrders' }
//   ]
// }
</script>

现在,当我们在 UserProfile 组件中点击“Login”,OrderDashboard 组件应该会立刻响应状态变化,反之亦然。

第三步:使用 Cypress 进行端到端验证

这个方案中最脆弱的部分就是“同步”。手动测试可以验证,但在 CI/CD 流程中,我们需要自动化的端到端测试来保证每次代码提交都不会破坏这种跨应用通信。Cypress 是完成此任务的完美工具。

e2e/cypress.config.ts

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    // 假设 host-app 运行在 3000 端口
    baseUrl: 'http://localhost:3000',
    // 增加命令超时时间,以应对 Nuxt 的水合作用
    defaultCommandTimeout: 5000,
    // 禁用视频录制以加快本地测试速度
    video: false,
    setupNodeEvents(on, config) {
      // implement node event listeners here
      on('task', {
        log(message) {
          console.log(`[Cypress Task Log] ${message}`);
          return null;
        },
      });
    },
  },
});

e2e/cypress/e2e/state-sync.cy.ts

describe('Cross-Application State Synchronization', () => {
  beforeEach(() => {
    // 每次测试前访问根页面
    cy.visit('/');
    // 确保两个关键组件都已渲染
    cy.get('[data-cy="user-profile-card"]').should('be.visible');
    cy.get('[data-cy="order-dashboard-card"]').should('be.visible');
  });

  it('should correctly reflect initial logged-out state in both apps', () => {
    cy.task('log', 'Verifying initial state is logged out.');
    
    // 验证 Host App 的状态
    cy.get('[data-cy="guest-message"]').should('contain.text', 'You are not logged in.');
    cy.get('[data-cy="login-button"]').should('be.visible');
    cy.get('[data-cy="logout-button"]').should('not.exist');

    // 验证 Micro App 的状态
    cy.get('[data-cy="dashboard-login-prompt"]').should('contain.text', 'Please log in');
  });

  it('should sync state from Host App to Micro App after login', () => {
    cy.task('log', 'Testing login action from Host App.');

    // 在 Host App 中执行登录操作
    cy.get('[data-cy="login-button"]').click();

    // 验证 Host App UI 更新
    cy.get('[data-cy="user-name"]')
      .should('be.visible')
      .and('contain.text', 'Admin User');
    
    // 这里的断言是关键:我们不仅要检查文本,还要检查 token 是否存在,
    // 以确保整个 state 对象都已正确同步。
    cy.get('[data-cy="user-token"]').should('not.be.empty');

    // **核心验证点**:验证 Micro App 的 UI 是否已同步更新
    cy.get('[data-cy="dashboard-welcome"]')
      .should('be.visible')
      .and('contain.text', 'Fetching orders for user: Admin User');
    
    // 确保提示语消失
    cy.get('[data-cy="dashboard-login-prompt"]').should('not.exist');
  });

  it('should sync state from Host App to Micro App after logout', () => {
    cy.task('log', 'Testing logout action from Host App.');
    
    // 先登录,为登出操作准备环境
    cy.get('[data-cy="login-button"]').click();
    cy.get('[data-cy="user-name"]').should('be.visible');
    cy.get('[data-cy="dashboard-welcome"]').should('be.visible');

    // 在 Host App 中执行登出操作
    cy.get('[data-cy="logout-button"]').click();

    // 验证 Host App UI 恢复到初始状态
    cy.get('[data-cy="guest-message"]').should('contain.text', 'You are not logged in.');

    // **核心验证点**:验证 Micro App 的 UI 是否也已同步恢复到初始状态
    cy.get('[data-cy="dashboard-login-prompt"]').should('contain.text', 'Please log in');
    cy.get('[data-cy="dashboard-welcome"]').should('not.exist');
  });
});

这些 Cypress 测试用例,从用户的角度出发,模拟了完整的交互流程。它不关心内部实现是 Proxy 还是 Pinia,只关心最终的 UI 表现是否符合预期。这为我们这套微前端状态管理方案提供了一道坚实的质量防线。

方案的局限性与未来展望

这套基于 Rollup 共享模块的方案并非银弹。它的主要局限在于:

  1. 版本管理: 共享模块的任何破坏性更新,都需要所有消费方应用协调升级。这需要严格的 semver 规范和良好的团队沟通,否则很容易导致生产事故。
  2. 热更新 (HMR): 在开发环境下,修改共享模块代码无法在 Nuxt 应用中实现热更新,需要重新构建模块并重启 Nuxt 服务,这会影响开发效率。
  3. 适用范围: 该方案完美解决了逻辑和状态共享,但对于跨应用的 UI 组件共享则无能为力。UI 共享需要另一套独立的方案,例如打包成 Web Components 或专门的 Vue 组件库。

尽管存在这些局限,但该方案在“逻辑优先”的微前端场景中,提供了一个清晰、可控且技术栈无关的优秀实践。未来的迭代方向可以聚焦于自动化:通过 CI/CD 流水线自动化共享模块的构建、发布、版本检测和消费方应用的升级提醒,从而将版本管理的人为错误降到最低。


  目录