普通视图

发现新文章,点击刷新页面。
昨天以前首页

micro-app 微前端项目部署指南

作者 RemHusband
2026年2月7日 15:41

部署指南

本文档介绍如何将 micro-app 微前端项目部署到生产环境。

目录


部署方式

micro-app 微前端项目支持两种部署方式:

  1. 同域部署:主应用和所有子应用部署在同一域名下

    • 优点:无需配置 CORS,部署简单
    • 缺点:所有应用必须部署在同一服务器
  2. 跨域部署:主应用和子应用部署在不同域名

    • 优点:可以独立部署和扩展
    • 缺点:需要配置 CORS,配置相对复杂

同域部署

目录结构

/usr/share/nginx/html/
├── main-app/
│   └── dist/          # 主应用构建产物
├── sub-app-1/
│   └── dist/          # 子应用 1 构建产物
├── sub-app-2/
│   └── dist/          # 子应用 2 构建产物
└── sub-app-3/
    └── dist/          # 子应用 3 构建产物

URL 映射

https://example.com/              → 主应用
https://example.com/sub-app-1/    → 子应用 1
https://example.com/sub-app-2/    → 子应用 2
https://example.com/sub-app-3/    → 子应用 3

Nginx 配置

使用 nginx/main-app.conf 配置文件:

# 复制配置文件
sudo cp nginx/main-app.conf /etc/nginx/sites-available/micro-app

# 创建符号链接
sudo ln -s /etc/nginx/sites-available/micro-app /etc/nginx/sites-enabled/

# 修改配置中的路径和域名
sudo nano /etc/nginx/sites-available/micro-app

# 测试配置
sudo nginx -t

# 重载配置
sudo nginx -s reload

环境变量配置

主应用 .env.production:

# 同域部署不需要配置,自动使用 window.location.origin
# VITE_DEPLOY_MODE=same-origin  # 默认值,可省略

跨域部署

目录结构

# 主应用服务器
/usr/share/nginx/html/main-app/dist/

# 子应用 1 服务器
/usr/share/nginx/html/sub-app-1/dist/

# 子应用 2 服务器
/usr/share/nginx/html/sub-app-2/dist/

# 子应用 3 服务器
/usr/share/nginx/html/sub-app-3/dist/

URL 映射

https://main.example.com/        → 主应用
https://sub1.example.com/         → 子应用 1
https://sub2.example.com/         → 子应用 2
https://sub3.example.com/         → 子应用 3

Nginx 配置

主应用服务器:使用 nginx/main-app.conf(仅配置主应用部分)

子应用服务器:每个子应用使用 nginx/sub-app.conf

# 为每个子应用复制配置文件
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-1
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-2
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-3

# 创建符号链接
sudo ln -s /etc/nginx/sites-available/sub-app-1 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sub-app-2 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sub-app-3 /etc/nginx/sites-enabled/

# 修改每个配置文件中的 server_name 和路径
sudo nano /etc/nginx/sites-available/sub-app-1
sudo nano /etc/nginx/sites-available/sub-app-2
sudo nano /etc/nginx/sites-available/sub-app-3

# 测试配置
sudo nginx -t

# 重载配置
sudo nginx -s reload

重要:子应用必须配置 CORS 头,否则 micro-app 无法加载。

环境变量配置

主应用 .env.production:

# 跨域部署模式
VITE_DEPLOY_MODE=cross-origin

# 子应用入口地址
VITE_SUB_APP_1_ENTRY=https://sub1.example.com
VITE_SUB_APP_2_ENTRY=https://sub2.example.com
VITE_SUB_APP_3_ENTRY=https://sub3.example.com

Nginx 配置

主应用配置

参考 nginx/main-app.conf,主要配置:

  1. 根路径:主应用部署在根路径 /
  2. 子应用路径:每个子应用部署在 /sub-app-X/ 路径下
  3. 路由回退:使用 try_files 确保 SPA 路由正常工作
  4. 静态资源缓存:配置长期缓存策略

子应用配置

参考 nginx/sub-app.conf,主要配置:

  1. CORS 头:必须配置,micro-app 需要跨域支持
  2. OPTIONS 预检:处理跨域预检请求
  3. 路由回退:使用 try_files 确保 SPA 路由正常工作
  4. 静态资源缓存:配置长期缓存策略

关键配置说明

CORS 配置(子应用必须):

add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Requested-With' always;

路由回退(SPA 必需):

location / {
    try_files $uri $uri/ /index.html;
}

静态资源缓存:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

环境变量配置

开发环境

主应用 .env.development:

# 通常不需要修改,使用默认的 localhost 地址

子应用 .env.development:

# 根据实际需求配置
# VITE_API_BASE_URL=http://localhost:8080/api

生产环境

同域部署:

主应用 .env.production:

# 不需要配置,自动使用 window.location.origin

跨域部署:

主应用 .env.production:

VITE_DEPLOY_MODE=cross-origin
VITE_SUB_APP_1_ENTRY=https://sub1.example.com
VITE_SUB_APP_2_ENTRY=https://sub2.example.com
VITE_SUB_APP_3_ENTRY=https://sub3.example.com

子应用 .env.production:

# 根据实际需求配置
# VITE_API_BASE_URL=https://api.example.com

构建和部署步骤

1. 构建所有应用

# 在项目根目录执行
pnpm run build

构建产物会生成在:

  • main-app/dist/
  • sub-app-1/dist/
  • sub-app-2/dist/
  • sub-app-3/dist/

2. 配置环境变量

根据部署方式配置 .env.production 文件(见上文)。

3. 上传构建产物

同域部署:

# 上传所有构建产物到同一服务器
scp -r main-app/dist/* user@server:/usr/share/nginx/html/main-app/dist/
scp -r sub-app-1/dist/* user@server:/usr/share/nginx/html/sub-app-1/dist/
scp -r sub-app-2/dist/* user@server:/usr/share/nginx/html/sub-app-2/dist/
scp -r sub-app-3/dist/* user@server:/usr/share/nginx/html/sub-app-3/dist/

跨域部署:

# 主应用
scp -r main-app/dist/* user@main-server:/usr/share/nginx/html/main-app/dist/

# 子应用 1
scp -r sub-app-1/dist/* user@sub1-server:/usr/share/nginx/html/sub-app-1/dist/

# 子应用 2
scp -r sub-app-2/dist/* user@sub2-server:/usr/share/nginx/html/sub-app-2/dist/

# 子应用 3
scp -r sub-app-3/dist/* user@sub3-server:/usr/share/nginx/html/sub-app-3/dist/

4. 配置 Nginx

参考 Nginx 配置 部分。

5. 测试部署

  1. 访问主应用:https://example.com
  2. 检查子应用加载是否正常
  3. 检查路由跳转是否正常
  4. 检查数据通信是否正常
  5. 检查浏览器控制台是否有错误

常见问题

Q1: 子应用加载失败,显示 CORS 错误?

A: 检查子应用的 Nginx 配置是否包含 CORS 头:

add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;

Q2: 路由跳转后显示 404?

A: 检查 Nginx 配置是否包含路由回退:

location / {
    try_files $uri $uri/ /index.html;
}

Q3: 静态资源加载失败?

A: 检查:

  1. 资源路径是否正确
  2. Nginx 配置中的 alias 路径是否正确
  3. 文件权限是否正确

Q4: 生产环境子应用地址不正确?

A: 检查主应用的 .env.production 配置:

# 跨域部署
VITE_DEPLOY_MODE=cross-origin
VITE_SUB_APP_1_ENTRY=https://sub1.example.com

Q5: 如何启用 HTTPS?

A: 参考 Nginx 配置文件中的 HTTPS 配置示例:

  1. 获取 SSL 证书
  2. 配置证书路径
  3. 启用 HTTPS server 块
  4. 配置 HTTP 重定向到 HTTPS

Q6: 如何配置 CDN?

A: 修改环境变量中的入口地址为 CDN 地址:

VITE_SUB_APP_1_ENTRY=https://cdn.example.com/sub-app-1

从零到一:基于 micro-app 的企业级微前端模板完整实现指南

作者 RemHusband
2026年2月7日 15:39

本文是一篇完整的技术实践文章,记录了如何从零开始构建一个企业级 micro-app 微前端模板项目。文章包含完整的技术选型、架构设计、核心代码实现、踩坑经验以及最佳实践,适合有一定前端基础的开发者深入学习。

📋 文章摘要

本文详细记录了基于 micro-app 框架构建企业级微前端模板的完整实现过程。项目采用 Vue 3 + TypeScript + Vite 技术栈,实现了完整的主子应用通信、路由同步、独立运行等核心功能。文章不仅包含技术选型分析、架构设计思路,还提供了大量可直接使用的代码示例和实战经验,帮助读者快速掌握微前端开发的核心技能。

🎯 你将学到什么

  • ✅ micro-app 框架的核心特性和使用技巧
  • ✅ 微前端架构设计思路和最佳实践
  • ✅ 主子应用双向通信的完整实现方案
  • ✅ 路由同步和跨应用导航的实现细节
  • ✅ TypeScript 类型安全的微前端开发实践
  • ✅ 事件总线解耦和代码组织技巧
  • ✅ 开发/生产环境配置管理方案
  • ✅ 常见问题的解决方案和踩坑经验

💎 项目亮点

  • 🚀 开箱即用:完整的项目模板,可直接用于生产环境
  • 🔒 类型安全:完整的 TypeScript 类型定义,零 @ts-ignore,零 any
  • 🎨 企业级实践:可支撑真实企业项目
  • 📦 独立运行:子应用支持独立开发和调试
  • 🔄 智能通信:策略模式处理不同类型事件,代码清晰易维护
  • 🛠️ 一键启动:并行启动所有应用,提升开发效率

🎉 开源地址

micro-app-front-end


📑 目录


一、项目背景与需求分析

1.1 为什么选择微前端?

随着前端应用规模的不断增长,传统的单体应用架构面临诸多挑战:

  • 团队协作困难:多个团队维护同一个代码库,容易产生冲突
  • 技术栈限制:难以引入新技术,升级成本高
  • 部署效率低:任何小改动都需要整体发布
  • 性能问题:应用体积过大,首屏加载慢

微前端架构通过将大型应用拆分为多个独立的小应用,每个应用可以独立开发、测试、部署,有效解决了上述问题。

1.2 项目需求

基于企业级微前端项目实践,我们需要构建一个开箱即用的 micro-app 微前端模板,具备以下核心特性:

  1. 完整的通信机制:主子应用之间的双向数据通信,支持多种事件类型
  2. 路由同步:自动处理路由同步,支持浏览器前进后退,用户体验流畅
  3. 独立运行:子应用支持独立开发和调试,提升开发效率
  4. 类型安全:完整的 TypeScript 类型定义,避免运行时错误
  5. 环境适配:支持开发/生产环境,同域/跨域部署
  6. 错误处理:完善的错误处理和降级方案,提高系统稳定性

1.3 项目目标

  • ✅ 提供可直接用于生产环境的完整模板
  • ✅ 代码结构清晰,易于维护和扩展
  • ✅ 完整的文档和最佳实践指南
  • ✅ 解决常见问题,避免重复踩坑

技术选型

微前端框架: micro-app

选择理由:

  1. 基于 WebComponent: 天然实现样式隔离
  2. 原生路由模式: 子应用使用 createWebHistory,框架自动劫持路由
  3. 内置通信机制: 无需额外配置,开箱即用
  4. 轻量级: 相比 qiankun 更轻量,性能更好

版本: @micro-zoe/micro-app@1.0.0-rc.28

前端框架: Vue 3 + TypeScript

选择理由:

  1. 组合式 API: 更好的逻辑复用和类型推导
  2. TypeScript 支持: 完整的类型安全
  3. 生态成熟: 丰富的插件和工具链

构建工具: Vite

选择理由:

  1. 极速开发体验: HMR 速度快
  2. 原生 ES 模块: 更好的开发体验
  3. 配置简单: 开箱即用

注意: Vite 作为子应用时,必须使用 iframe 沙箱模式


三、架构设计详解

3.1 项目结构

micro-app/
├── main-app/              # 主应用(基座应用)
│   ├── src/
│   │   ├── components/    # 组件目录
│   │   │   └── MicroAppContainer.vue  # 子应用容器组件
│   │   ├── config/        # 配置文件
│   │   │   └── microApps.ts  # 子应用配置管理
│   │   ├── router/        # 路由配置
│   │   ├── types/         # TypeScript 类型定义
│   │   │   └── micro-app.ts  # 微前端相关类型
│   │   ├── utils/         # 工具函数
│   │   │   ├── microAppCommunication.ts  # 通信工具
│   │   │   └── microAppEventBus.ts  # 事件总线
│   │   ├── views/         # 页面组件
│   │   ├── App.vue        # 根组件
│   │   └── main.ts        # 入口文件
│   ├── vite.config.ts     # Vite 配置
│   └── package.json
│
├── sub-app-1/             # 子应用 1
│   ├── src/
│   │   ├── plugins/       # 插件目录
│   │   │   └── micro-app.ts  # MicroAppService 通信服务
│   │   ├── router/        # 路由配置
│   │   ├── utils/         # 工具函数
│   │   │   ├── env.ts     # 环境检测
│   │   │   └── navigation.ts  # 导航工具
│   │   ├── types/         # 类型定义
│   │   ├── views/         # 页面组件
│   │   ├── App.vue        # 根组件
│   │   └── main.ts        # 入口文件(支持独立运行)
│   ├── vite.config.ts     # Vite 配置
│   └── package.json
│
├── sub-app-2/             # 子应用 2(结构同 sub-app-1)
├── sub-app-3/             # 子应用 3(结构同 sub-app-1)
├── docs/                  # 文档目录
│   ├── TROUBLESHOOTING.md  # 踩坑记录
│   ├── IMPLEMENTATION_LOG.md  # 实现过程记录
│   └── FAQ.md             # 常见问题
├── package.json           # 根目录配置(一键启动脚本)
└── README.md              # 项目说明文档

3.2 核心模块设计

3.2.1 主应用通信模块 (microAppCommunication.ts)

设计思路

主应用通信模块是整个微前端架构的核心,负责主子应用之间的数据通信。我们采用策略模式处理不同类型的事件,使代码结构清晰、易于扩展。

核心功能

  1. 向子应用发送数据microAppSetData()

    • 自动添加时间戳,确保数据变化被检测
    • 自动添加来源标识,便于调试
  2. 跨应用路由跳转microAppTarget()

    • 智能区分同应用内跳转和跨应用跳转
    • 同应用内:通过通信让子应用自己跳转
    • 跨应用:通过主应用路由跳转
  3. 统一的数据监听处理器microAppDataListener()

    • 使用策略模式处理不同类型的事件
    • 支持扩展新的事件类型

代码示例

/**
 * 向指定子应用发送数据
 * 自动添加时间戳,确保数据变化被检测到
 */
export const microAppSetData = (name: string, data: Partial<MicroData>): void => {
  const targetName = name || getServiceName();

  if (!targetName) {
    devWarn("无法发送数据:未指定子应用名称");
    return;
  }

  // 自动添加时间戳,确保数据变化
  const dataWithTimestamp: MicroData = {
    ...data,
    t: Date.now(),
    source: getServiceName(),
  };

  try {
    microApp.setData(targetName, dataWithTimestamp);
    devLog(`向 ${targetName} 发送数据`, dataWithTimestamp);
  } catch (error) {
    console.error(`[主应用通信] 发送数据失败:`, error);
  }
};

💡 完整代码:代码仓库中包含完整的通信模块实现,包含所有事件类型的处理逻辑。

3.2.2 事件总线模块 (microAppEventBus.ts)

设计思路

事件总线模块用于解耦生命周期钩子和业务逻辑。当子应用生命周期发生变化时,通过事件总线通知业务代码,而不是直接在生命周期钩子中处理业务逻辑。

核心特性

  • ✅ 支持一次性监听 (once)
  • ✅ 支持静默模式(避免无监听器警告)
  • ✅ 完整的 TypeScript 类型定义
  • ✅ 支持移除监听器

代码示例

/**
 * 事件总线类
 * 用于解耦生命周期钩子和业务逻辑
 */
class EventBus {
  private listeners: Map<string, EventListener[]> = new Map();
  private silent: boolean = true; // 默认静默模式

  /**
   * 监听事件
   */
  on<T = any>(event: string, callback: EventCallback<T>, once = false): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }

    this.listeners.get(event)!.push({ callback, once });
  }

  /**
   * 触发事件
   */
  emit<T = any>(event: string, data: T): void {
    const listeners = this.listeners.get(event);

    if (!listeners || listeners.length === 0) {
      if (!this.silent) {
        devWarn(`事件 ${event} 没有监听器`);
      }
      return;
    }

    // 执行监听器,并移除一次性监听器
    listeners.forEach((listener, index) => {
      listener.callback(data);
      if (listener.once) {
        listeners.splice(index, 1);
      }
    });
  }
}

💡 完整实现:代码仓库中包含完整的事件总线实现,包含所有方法和类型定义。

3.2.3 子应用通信服务 (MicroAppService)

设计思路

子应用通信服务是一个类,负责初始化数据监听器、处理主应用发送的数据、向主应用发送数据以及清理资源。采用策略模式处理不同类型的事件,使代码结构清晰。

核心方法

  1. init(): 初始化数据监听器
  2. handleData(): 处理主应用数据(策略模式)
  3. sendData(): 向主应用发送数据
  4. destroy(): 清理监听器

代码示例

/**
 * 子应用通信服务类
 */
export class MicroAppService {
  private serviceName: string;
  private dataListener: ((data: MicroData) => void) | null = null;

  constructor() {
    this.serviceName = getServiceName();
    this.init();
  }

  /**
   * 初始化数据监听器
   */
  public init(): void {
    if (!isMicroAppEnvironment()) {
      return;
    }

    const microApp = (window as any).microApp;
    if (!microApp) {
      return;
    }

    // 创建数据监听器
    this.dataListener = (data: MicroData) => {
      this.handleData(data);
    };

    // 添加数据监听器
    microApp.addDataListener(this.dataListener);
  }

  /**
   * 处理主应用发送的数据(策略模式)
   */
  private handleData(data: MicroData): void {
    switch (data.type) {
      case "target":
        // 处理路由跳转
        break;
      case "menuCollapse":
        // 处理菜单折叠
        break;
      // ... 其他事件类型
    }
  }
}

💡 完整实现:代码仓库中包含完整的 MicroAppService 实现,包含所有事件类型的处理逻辑。


四、核心功能实现

4.1 主应用通信模块

4.1.1 数据发送功能

主应用向子应用发送数据时,需要自动添加时间戳,确保 micro-app 能检测到数据变化:

/**
 * 向指定子应用发送数据
 */
export const microAppSetData = (name: string, data: Partial<MicroData>): void => {
  const dataWithTimestamp: MicroData = {
    ...data,
    t: Date.now(),  // 自动添加时间戳
    source: getServiceName(),  // 自动添加来源
  };

  try {
    microApp.setData(name, dataWithTimestamp);
  } catch (error) {
    console.error(`[主应用通信] 发送数据失败:`, error);
  }
};

4.1.2 跨应用路由跳转

智能区分同应用内跳转和跨应用跳转,提供更好的用户体验:

/**
 * 跨应用路由跳转
 */
export const microAppTarget = (
  service: string,
  url: string
): void => {
  const currentService = getServiceName();

  // 同应用内:通过通信让子应用自己跳转
  if (currentService === service) {
    microAppSetData(service, { type: "target", path: url });
    return;
  }

  // 跨应用:通过主应用路由跳转
  const routeMapping = routeMappings.find((m) => m.appName === service);
  if (routeMapping) {
    const fullPath = `${routeMapping.basePath}${url}`;
    router.push(fullPath).catch((error) => {
      console.error(`[主应用通信] 路由跳转失败:`, error);
    });
  }
};

4.1.3 统一的数据监听处理器

使用策略模式处理不同类型的事件,代码结构清晰、易于扩展:

/**
 * 统一的数据监听处理器(策略模式)
 */
export const microAppDataListener = (params: DataListenerParams): void => {
  const { service, data } = params;

  if (!data || !data.type) {
    return;
  }

  // 使用策略模式处理不同类型的事件
  const eventHandlers: Record<MicroAppEventType, (data: MicroData) => void> = {
    target: (eventData) => {
      // 处理路由跳转
      const targetService = eventData.service || eventData.data?.service;
      const targetUrl = eventData.url || eventData.path || "";
      if (targetService && targetUrl) {
        microAppTarget(targetService, targetUrl);
      }
    },
    navigate: (eventData) => {
      // 处理跨应用导航
      // ...
    },
    logout: () => {
      // 处理退出登录
      // ...
    },
    // ... 其他事件类型
  };

  const handler = eventHandlers[data.type];
  if (handler) {
    handler(data);
  }
};

4.2 事件总线模块

事件总线用于解耦生命周期钩子和业务逻辑,使代码更易维护:

/**
 * 监听生命周期事件
 */
export const onLifecycle = (
  event: LifecycleEventType,
  callback: (data: LifecycleEventData) => void,
  once = false
): void => {
  eventBus.on(event, callback, once);
};

/**
 * 触发生命周期事件
 */
export const emitLifecycle = (
  event: LifecycleEventType,
  data: LifecycleEventData
): void => {
  eventBus.emit(event, data);
};

4.3 子应用通信服务

子应用通过 MicroAppService 类管理通信逻辑:

/**
 * 子应用通信服务类
 */
export class MicroAppService {
  private serviceName: string;
  private dataListener: ((data: MicroData) => void) | null = null;

  constructor() {
    this.serviceName = getServiceName();
    this.init();
  }

  /**
   * 处理主应用发送的数据(策略模式)
   */
  private handleData(data: MicroData): void {
    switch (data.type) {
      case "target": {
        // 路由跳转
        const path = data.path || data.data?.path || "";
        if (path && path !== router.currentRoute.value.path) {
          router.push(path);
        }
        break;
      }
      case "menuCollapse": {
        // 菜单折叠
        const collapse = data.data?.collapse ?? false;
        // 处理菜单折叠逻辑
        break;
      }
      // ... 其他事件类型
    }
  }

  /**
   * 向主应用发送数据
   */
  public sendData(data: Partial<MicroData>): void {
    if (!isMicroAppEnvironment()) {
      return;
    }

    const microApp = (window as any).microApp;
    if (!microApp || typeof microApp.dispatch !== "function") {
      return;
    }

    const dataWithTimestamp: MicroData = {
      ...data,
      t: Date.now(),
      source: this.serviceName,
    };

    microApp.dispatch(dataWithTimestamp);
  }

  /**
   * 清理监听器
   */
  public destroy(): void {
    if (this.dataListener) {
      const microApp = (window as any).microApp;
      if (microApp && typeof microApp.removeDataListener === "function") {
        microApp.removeDataListener(this.dataListener);
      }
      this.dataListener = null;
    }
  }
}

💡 完整代码:代码仓库中包含所有核心模块的完整实现,包含详细的注释和类型定义。


五、关键实现细节

5.1 类型安全优先

决策背景

在微前端项目中,类型安全尤为重要。主子应用之间的通信如果没有类型约束,很容易出现运行时错误。

实现方案

1. 全局类型声明

window.microApp 添加全局类型声明:

// types/micro-app.d.ts
declare global {
  interface Window {
    microApp?: {
      setData: (name: string, data: any) => void;
      getData: () => any;
      addDataListener: (listener: (data: any) => void) => void;
      removeDataListener: (listener: (data: any) => void) => void;
      dispatch: (data: any) => void;
    };
    __MICRO_APP_ENVIRONMENT__?: boolean;
    __MICRO_APP_BASE_ROUTE__?: string;
    __MICRO_APP_NAME__?: string;
  }
}

2. 完整的类型定义

定义所有通信数据的类型:

/**
 * 微前端通信数据类型
 */
export interface MicroData {
  /** 事件类型(必填) */
  type: MicroAppEventType;
  /** 事件数据 */
  data?: Record<string, any>;
  /** 时间戳(确保数据变化) */
  t?: number;
  /** 来源应用 */
  source?: string;
  /** 路径(用于路由跳转) */
  path?: string;
  /** 目标服务(用于跨应用跳转) */
  service?: string;
  /** 目标URL(用于跨应用跳转) */
  url?: string;
}

3. 类型守卫

使用类型守卫确保类型安全:

function isMicroAppEnvironment(): boolean {
  return !!window.__MICRO_APP_ENVIRONMENT__;
}

收益

  • ✅ 更好的 IDE 提示和自动补全
  • ✅ 编译时错误检查,避免运行时错误
  • ✅ 代码可维护性显著提升
  • ✅ 重构更安全,类型系统会提示所有需要修改的地方

5.2 事件总线解耦

决策背景

在微前端项目中,子应用的生命周期钩子需要触发各种业务逻辑。如果直接在生命周期钩子中处理业务逻辑,会导致代码耦合度高,难以维护。

实现方案

1. 事件总线设计

class EventBus {
  private listeners: Map<string, EventListener[]> = new Map();
  private silent: boolean = true;

  on<T = any>(event: string, callback: EventCallback<T>, once = false): void {
    // 添加监听器
  }

  emit<T = any>(event: string, data: T): void {
    // 触发事件
  }

  off(event: string, callback?: EventCallback): void {
    // 移除监听器
  }
}

2. 生命周期钩子触发事件

// 生命周期钩子中触发事件
const onMounted = () => {
  emitLifecycle("mounted", { name: props.name });
};

3. 业务代码监听事件

// 业务代码中监听事件
onLifecycle("mounted", (data) => {
  // 处理业务逻辑
  console.log(`子应用 ${data.name} 已挂载`);
});

收益

  • ✅ 代码解耦,生命周期钩子和业务逻辑分离
  • ✅ 业务逻辑可以独立测试
  • ✅ 支持多个监听器,扩展性强
  • ✅ 代码结构清晰,易于维护

5.3 日志系统优化

决策背景

在开发环境中,详细的日志有助于调试。但在生产环境中,过多的日志会影响性能,还可能泄露敏感信息。

实现方案

const isDev = import.meta.env.DEV;

/**
 * 开发环境日志输出
 */
const devLog = (message: string, ...args: any[]) => {
  if (isDev) {
    console.log(`%c[标签] ${message}`, "color: #1890ff", ...args);
  }
};

/**
 * 开发环境警告输出
 */
const devWarn = (message: string, ...args: any[]) => {
  if (isDev) {
    console.warn(`%c[标签] ${message}`, "color: #faad14", ...args);
  }
};

/**
 * 错误日志(始终输出)
 */
const errorLog = (message: string, ...args: any[]) => {
  console.error(`[标签] ${message}`, ...args);
};

收益

  • ✅ 生产环境性能更好,无日志开销
  • ✅ 开发环境调试更方便,彩色日志易于识别
  • ✅ 避免敏感信息泄露
  • ✅ 错误日志始终输出,便于问题排查

5.4 Vite 子应用 iframe 沙箱

决策背景

根据 micro-app 官方文档,Vite 作为子应用时,必须使用 iframe 沙箱模式,否则会出现脚本执行错误。

实现方案

<micro-app
  :name="name"
  :url="url"
  router-mode="native"
  iframe  <!-- 必须添加此属性 -->
/>

收益

  • ✅ 解决 Vite 开发脚本执行错误
  • ✅ 更好的隔离性,样式和脚本完全隔离
  • ✅ 符合官方最佳实践

六、最佳实践与优化

6.1 统一配置管理

使用配置文件统一管理所有子应用地址,支持环境感知:

// config/microApps.ts
const envConfigs: Record<string, EnvConfig> = {
  "sub-app-1": {
    dev: "http://localhost:3000",
    prod: "//your-domain.com/sub-app-1",
    envKey: "VITE_SUB_APP_1_ENTRY",
  },
  // ...
};

export function getEntry(appName: string): string {
  const config = envConfigs[appName];

  // 优先级 1: 环境变量覆盖
  if (import.meta.env[config.envKey]) {
    return import.meta.env[config.envKey];
  }

  // 优先级 2: 根据环境选择配置
  if (import.meta.env.DEV) {
    return config.dev;
  }

  // 生产环境根据部署模式选择
  const deployMode = import.meta.env.VITE_DEPLOY_MODE || "same-origin";
  return deployMode === "same-origin"
    ? `${window.location.origin}/${appName}`
    : config.prod;
}

优势

  • ✅ 配置集中管理,易于维护
  • ✅ 自动适配开发/生产环境
  • ✅ 支持环境变量覆盖
  • ✅ 支持同域/跨域部署

6.2 自动添加时间戳

发送数据时自动添加时间戳,确保 micro-app 能检测到数据变化:

const dataWithTimestamp: MicroData = {
  ...data,
  t: Date.now(),  // 自动添加时间戳
  source: getServiceName(),  // 自动添加来源
};

优势

  • ✅ 确保 micro-app 能检测到数据变化
  • ✅ 避免数据未更新的问题
  • ✅ 便于调试,可以看到数据来源

6.3 智能路由跳转

区分同应用内跳转和跨应用跳转,提供更好的用户体验:

// 同应用内:通过通信让子应用自己跳转
// 跨应用:通过主应用路由跳转
if (currentService === service) {
  microAppSetData(service, { type: "target", path: url });
} else {
  router.push(fullPath);
}

优势

  • ✅ 避免路由记录混乱
  • ✅ 更好的用户体验
  • ✅ 支持浏览器前进后退

6.4 完善的错误处理

所有关键操作都使用 try-catch,提供降级方案:

try {
  microApp.setData(targetName, dataWithTimestamp);
} catch (error) {
  console.error(`[主应用通信] 发送数据失败:`, error);
  // 降级处理
}

优势

  • ✅ 提高系统稳定性
  • ✅ 更好的错误提示
  • ✅ 便于问题排查

6.5 子应用独立运行

子应用支持独立运行,便于开发调试:

// main.ts
if (!isMicroAppEnvironment()) {
  // 独立运行时直接挂载
  render();
} else {
  // 微前端环境导出生命周期函数
  window.mount = () => render();
  window.unmount = () => app.unmount();
}

优势

  • ✅ 提升开发效率
  • ✅ 便于独立调试
  • ✅ 支持独立部署

七、踩坑经验总结

在实现过程中,我们遇到了许多问题,以下是主要问题和解决方案:

7.1 Vue 无法识别 micro-app 自定义元素

问题:Vue 3 默认会将所有标签当作 Vue 组件处理,但 micro-app 是 WebComponent 自定义元素。

解决方案:在 vite.config.ts 中配置 isCustomElement

vue({
  template: {
    compilerOptions: {
      isCustomElement: (tag) => tag === "micro-app",
    },
  },
})

7.2 Vite 子应用必须使用 iframe 沙箱

问题:Vite 作为子应用时,如果不使用 iframe 沙箱,会出现脚本执行错误。

解决方案:在 MicroAppContainer 组件中添加 iframe 属性:

<micro-app
  :name="name"
  :url="url"
  router-mode="native"
  iframe  <!-- 必须添加 -->
/>

7.3 通信数据未接收

问题:发送数据后,子应用未接收到数据。

解决方案

  1. 确保添加了时间戳,确保数据变化被检测
  2. 使用 forceDispatch 强制发送数据
  3. 检查监听器是否正确注册

7.4 路由不同步

问题:子应用路由变化时,浏览器地址栏未更新。

解决方案

  1. 确保主应用使用 router-mode="native"
  2. 确保子应用使用 createWebHistory
  3. 检查基础路由配置是否正确

💡 更多踩坑记录:代码仓库中包含完整的踩坑记录文档(docs/TROUBLESHOOTING.md),包含所有遇到的问题和解决方案。


八、项目总结与展望

8.1 已完成功能

完整的通信机制:主子应用双向通信,支持多种事件类型 ✅ 路由同步:自动处理路由同步,支持浏览器前进后退 ✅ 独立运行:子应用支持独立开发和调试 ✅ 类型安全:完整的 TypeScript 类型定义,零 @ts-ignore,零 any事件总线:解耦生命周期和业务逻辑 ✅ 一键启动:并行启动所有应用,提升开发效率 ✅ 错误处理:完善的错误处理和降级方案 ✅ 日志优化:开发/生产环境区分,性能优化 ✅ 环境适配:支持开发/生产环境,同域/跨域部署

8.2 技术亮点

  1. 类型安全优先:完整的 TypeScript 类型定义,避免运行时错误
  2. 企业级实践:参考真实企业项目,但改进其不足
  3. 开箱即用:完整的配置和文档,快速上手
  4. 最佳实践:遵循 micro-app 官方推荐实践
  5. 代码质量:清晰的代码结构,完善的注释
  6. 可维护性:模块化设计,易于扩展

8.3 项目价值

  • 🎯 学习价值:完整的微前端实现示例,适合深入学习
  • 🚀 实用价值:可直接用于生产环境,节省开发时间
  • 📚 参考价值:最佳实践和踩坑经验,避免重复踩坑

📚 参考资源

官方文档

踩坑记录:iOS Safari 软键盘下的"幽灵弹窗"问题

作者 RemHusband
2026年2月4日 12:59

最近在做移动端 H5 登录页面时,遇到了一个诡异的 bug:在 iOS Safari 中,弹窗明明显示在屏幕中央,点击按钮却毫无反应。检查元素后发现,DOM 的实际位置和视觉位置完全对不上。折腾了一番后终于搞清楚了原因,记录一下。

问题现象

我们的登录模块有两个页面:账号密码登录和验证码登录,可以互相切换。两个页面都有一个协议确认弹窗,使用 position: fixed 定位。

诡异的事情发生了:

  1. 在账号密码页面输入手机号(软键盘弹出),触发弹窗,点击取消 —— 正常
  2. 切换到验证码页面,触发弹窗,点击取消 —— 正常
  3. 再切换回账号密码页面,触发弹窗,点击取消 —— 没反应!

打开 Safari 调试器一看,弹窗的 DOM 位置和屏幕上显示的位置差了好几百像素。点击事件实际触发在了空白区域。

这就是传说中的"幽灵弹窗"。


前置知识:理解移动端视口

要搞清楚这个问题,得先理解移动端浏览器的视口概念。这部分内容稍微有点绕,但理解了之后很多移动端的坑就都能解释了。

三种视口

移动端浏览器有三种视口:

1. 布局视口(Layout Viewport)

布局视口是 CSS 布局的基准。当你写 width: 100% 时,这个 100% 就是相对于布局视口的宽度。

在没有 <meta name="viewport"> 标签的情况下,移动端浏览器的布局视口默认是 980px(不同浏览器可能略有差异)。这就是为什么早期的桌面网页在手机上看起来特别小 —— 浏览器把 980px 的内容硬塞进了 320px 的屏幕。

<!-- 这行代码让布局视口等于设备宽度 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

2. 视觉视口(Visual Viewport)

视觉视口是用户当前能看到的区域。当用户双指缩放页面时,布局视口不变,但视觉视口会变小(放大时)或变大(缩小时)。

可以这样理解:布局视口是一张大地图,视觉视口是你手里的放大镜。放大镜移动或缩放,地图本身不会变。

3. 理想视口(Ideal Viewport)

理想视口是设备屏幕的实际尺寸。width=device-width 就是让布局视口等于理想视口。

用代码获取视口信息

// 布局视口
const layoutWidth = document.documentElement.clientWidth;
const layoutHeight = document.documentElement.clientHeight;

// 视觉视口(需要 Visual Viewport API)
const visualWidth = window.visualViewport?.width;
const visualHeight = window.visualViewport?.height;
const offsetTop = window.visualViewport?.offsetTop; // 视觉视口相对于布局视口的偏移

// 屏幕尺寸
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;

视口与 position: fixed 的关系

这里是关键点:position: fixed 的元素是相对于视觉视口定位的,但它的点击区域是根据布局视口计算的

正常情况下,视觉视口和布局视口是重合的,所以没问题。但当软键盘弹出时,iOS Safari 会改变视觉视口而不改变布局视口,这就导致了视觉位置和点击区域的错位。


浏览器渲染原理:为什么会"错位"

要理解"幽灵弹窗",还需要了解浏览器是怎么渲染页面的。

渲染流水线

浏览器渲染一个页面大致经过以下步骤:

HTML → DOM Tree
              ↘
                → Render Tree → Layout → Paint → Composite
              ↗
CSS  → CSSOM
  1. 解析 HTML:构建 DOM 树
  2. 解析 CSS:构建 CSSOM(CSS Object Model)
  3. 合并:DOM + CSSOM = Render Tree(渲染树)
  4. 布局(Layout/Reflow):计算每个元素的位置和大小
  5. 绘制(Paint):把元素画到屏幕上
  6. 合成(Composite):把不同图层合并

重排(Reflow)与重绘(Repaint)

重排(Reflow)

当元素的几何属性(位置、大小)发生变化时,浏览器需要重新计算布局,这就是重排。

触发重排的操作:

  • 改变窗口大小
  • 改变字体大小
  • 添加/删除 DOM 元素
  • 改变元素的 widthheightmarginpadding
  • 读取某些属性:offsetTopscrollTopclientTopgetComputedStyle()

重绘(Repaint)

当元素的外观(颜色、背景、阴影等)发生变化,但几何属性不变时,只需要重绘,不需要重排。

触发重绘的操作:

  • 改变 colorbackgroundvisibility

性能影响

重排的代价比重绘大得多,因为重排会导致整个渲染树的重新计算。这就是为什么我们要尽量避免频繁触发重排。

图层(Layer)与合成

现代浏览器会把页面分成多个图层,每个图层独立渲染,最后合成到一起。某些 CSS 属性会触发元素提升为独立图层:

  • transform
  • opacity
  • will-change
  • position: fixed(在某些浏览器中)

独立图层的好处是:当这个元素变化时,不会影响其他图层,只需要重新合成,不需要重排整个页面。

这就是为什么我们在解决方案中使用 transform: translate3d(0, 0, 0) —— 它强制创建一个独立的合成层,让弹窗的渲染与页面其他部分隔离。


问题根因深度分析

现在我们有了足够的背景知识,来深入分析"幽灵弹窗"的成因。

iOS Safari 的"独特"处理方式

当软键盘弹出时,不同平台的处理方式不同:

Android Chrome 的做法:

软键盘弹出前:
┌─────────────────────┐
│                     │
│   Layout Viewport   │ = Visual Viewport
│   (100vh)           │
│                     │
└─────────────────────┘

软键盘弹出后:
┌─────────────────────┐
│   Layout Viewport   │ = Visual Viewport(缩小了)
│   (变小了)           │
├─────────────────────┤
│      软键盘          │
└─────────────────────┘

Android 的做法是缩小布局视口,position: fixed 的元素会相对于新的视口重新定位,一切正常。

iOS Safari 的做法:

软键盘弹出前:
┌─────────────────────┐
│                     │
│   Layout Viewport   │ = Visual Viewport
│   (100vh)           │
│                     │
└─────────────────────┘

软键盘弹出后:
┌─────────────────────┐ ← Layout Viewport(不变)
│   ↑                 │
│   │ 页面被推上去     │
│   ↓                 │
├─────────────────────┤ ← Visual Viewport(变小了)
│      软键盘          │
└─────────────────────┘

iOS 保持布局视口不变,只是把页面内容往上"推"。这时候:

  • position: fixed 的元素根据布局视口计算位置
  • 但用户看到的是视觉视口
  • 两者不一致,就出现了"幽灵"现象

为什么多次切换后才出问题?

每次软键盘弹出,window.scrollY 都会产生变化。当用户在多个页面之间切换时:

  1. 页面 A:软键盘弹出,scrollY = 200
  2. 切换到页面 B:scrollY 可能没有完全重置
  3. 页面 B:软键盘弹出,scrollY 累加
  4. 切换回页面 A:累积的偏移量导致计算错误

Vue Router 的 router.replace() 不会触发完整的页面刷新,滚动状态会残留。

为什么 iOS Chrome 也有问题?

这里要提一个很多人不知道的事实:iOS 上所有浏览器都是 Safari 套壳

Apple 的 App Store 政策要求:iOS 上的所有浏览器必须使用 WebKit 引擎。所以:

浏览器 Android 引擎 iOS 引擎
Chrome Blink WebKit
Firefox Gecko WebKit
Edge Blink WebKit
Safari - WebKit

iOS 上的 Chrome 只是换了个 UI 的 Safari,底层渲染引擎完全一样。这个问题在 iOS 上是无差别攻击。


解决方案

核心思路

弹窗显示时,把页面"冻住",让布局视口和视觉视口强制保持一致。

let savedScrollY = 0;
let savedBodyStyle = '';

// 锁定
const lockBodyScroll = () => {
  // 兼容性写法,确保能获取到滚动位置
  savedScrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;
  savedBodyStyle = document.body.style.cssText;

  document.body.style.cssText = `
    ${savedBodyStyle};
    position: fixed;
    top: -${savedScrollY}px;
    left: 0;
    right: 0;
    width: 100%;
    overflow: hidden;
  `;
};

// 解锁
const unlockBodyScroll = () => {
  document.body.style.cssText = savedBodyStyle;
  window.scrollTo(0, savedScrollY);
};

原理解析:

  1. position: fixed 让 body 脱离文档流,不再滚动
  2. top: -${savedScrollY}px 用负值补偿当前滚动位置,保持视觉一致
  3. overflow: hidden 禁止滚动
  4. 关闭弹窗时恢复原始样式,并用 scrollTo 恢复滚动位置

Vue 组件中的使用

import { watch, toRefs, nextTick } from 'vue';

const props = defineProps<{ show: boolean }>();
const { show } = toRefs(props);

watch(show, (val) => {
  if (val) {
    nextTick(() => {
      lockBodyScroll();
    });
  } else {
    unlockBodyScroll();
  }
}, { immediate: true });

// 组件卸载时确保解锁,防止状态残留
onBeforeUnmount(() => {
  if (show.value) {
    unlockBodyScroll();
  }
});

CSS 优化

.dialog-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;

  // 动态视口高度,iOS 15+ 支持
  // 会随软键盘弹出自动调整
  min-height: 100vh;
  min-height: 100dvh;

  // 强制创建独立合成层
  // 让弹窗的渲染与页面隔离
  transform: translate3d(0, 0, 0);
  -webkit-transform: translate3d(0, 0, 0);

  // 禁用触摸滚动手势
  touch-action: none;
  -webkit-touch-callout: none;

  // 防止滚动穿透到父元素
  overscroll-behavior: contain;
}

CSS 属性解析:

属性 作用
100dvh 动态视口高度,会随软键盘变化
transform: translate3d(0,0,0) 触发 GPU 加速,创建独立图层
touch-action: none 禁用所有触摸手势
overscroll-behavior: contain 滚动到边界时不会触发父元素滚动

踩过的坑

坑 1:忘记在组件卸载时解锁

如果用户在弹窗打开状态下切换页面(比如点击了弹窗里的链接),bodyfixed 样式会残留,导致新页面无法滚动。

解决: 一定要在 onBeforeUnmount 里清理。

坑 2:滚动位置恢复不准确

window.scrollY 在某些老旧浏览器或特殊情况下可能是 undefined

解决: 使用兼容性写法:

const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;

坑 3:100vh 在 iOS 上不靠谱

iOS Safari 的 100vh 是一个"理想值",包含了地址栏的高度。当地址栏收起或软键盘弹出时,实际可视区域会变化,但 100vh 不变。

解决: 使用 100dvh(动态视口高度),但要注意兼容性:

  • iOS 15.4+
  • Chrome 108+
  • Firefox 101+

对于不支持的浏览器,可以用 JS 动态计算:

const setVh = () => {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
};
window.addEventListener('resize', setVh);

坑 4:多个弹窗嵌套

如果页面上有多个弹窗组件,每个都调用 lockBodyScroll,会互相覆盖 savedScrollY

解决: 使用引用计数:

let lockCount = 0;
let savedScrollY = 0;
let savedBodyStyle = '';

const lockBodyScroll = () => {
  if (lockCount === 0) {
    savedScrollY = window.scrollY || 0;
    savedBodyStyle = document.body.style.cssText;
    document.body.style.cssText = `...`;
  }
  lockCount++;
};

const unlockBodyScroll = () => {
  lockCount--;
  if (lockCount === 0) {
    document.body.style.cssText = savedBodyStyle;
    window.scrollTo(0, savedScrollY);
  }
};

延伸:其他解决方案

方案二:使用 Visual Viewport API

const adjustDialogPosition = () => {
  if (window.visualViewport) {
    const offsetY = window.innerHeight - window.visualViewport.height;
    dialogRef.value.style.transform = `translateY(-${offsetY / 2}px)`;
  }
};

onMounted(() => {
  window.visualViewport?.addEventListener('resize', adjustDialogPosition);
});

onBeforeUnmount(() => {
  window.visualViewport?.removeEventListener('resize', adjustDialogPosition);
});

优点: 不需要锁定 body,更"优雅" 缺点: 需要持续监听,有性能开销;弹窗位置会跳动

方案三:软键盘收起后再显示弹窗

const showDialog = () => {
  // 先让输入框失焦,收起软键盘
  (document.activeElement as HTMLElement)?.blur();

  // 等待软键盘收起动画完成
  setTimeout(() => {
    dialogVisible.value = true;
  }, 300);
};

优点: 简单粗暴,绕过问题 缺点: 用户体验不好,有明显延迟


总结

这个问题本质上是 iOS WebKit 的 viewport 处理机制position: fixed 定位 的冲突。

关键点:

  1. iOS Safari 软键盘弹出时只改变 Visual Viewport,不改变 Layout Viewport
  2. position: fixed 根据 Layout Viewport 计算位置,但渲染在 Visual Viewport
  3. 页面切换时滚动状态累积,加剧了偏移

解决方案的核心就一句话:弹窗显示时锁定 body 滚动,关闭时恢复

这不是 bug,是 Apple 的"设计选择"。作为开发者,我们只能见招拆招。

希望这篇文章能帮到遇到同样问题的朋友,少走点弯路。


参考资料:

❌
❌