阅读视图

发现新文章,点击刷新页面。

用 React 手搓一个 3D 翻页书籍组件,呼吸海浪式翻页,交互体验带感!

用 React 手搓一个 3D 翻页书籍组件,页角还能卷起来!从零到踩坑全记录

前端开发中,你是否也想过把枯燥的内容展示做得像翻书一样?本文记录了我从零开发一个 3D 交互式书籍组件 的完整过程——包括 CSS 3D 翻页、拖拽手势、页角海浪卷起效果,以及中间踩过的坑和最终的解决方案。

一、为什么要做这个组件?

在做一个 AI 知识库产品时,产品经理提了一个需求:

「能不能把教程做成一本可以翻页的书?用户点击或拖拽就能翻页,体验要像真书。」

市面上的轮播图、Tab 切换都太「平」了,我希望做一个有纵深感的 3D 翻书交互。翻遍了 npm,要么功能太简陋,要么依赖 Canvas 体积太大,最终决定——自己写一个

目标很明确:

  • 🎨 CSS 3D 实现真实翻页效果,不用 Canvas
  • ✋ 支持拖拽翻页、点击翻页、键盘翻页
  • 🌊 鼠标悬停页角时有「海浪卷起」的视觉提示
  • 📱 移动端触摸支持
  • 🧱 纯 React 组件,零外部翻书依赖

二、架构设计:一本书的 DOM 结构

先想清楚一本书的物理结构:

┌─────────────────────────────────┐
│           Container             │  ← perspective: 2000px 提供 3D 视角
│  ┌───────────────────────────┐  │
│  │       BookWrapper         │  │  ← 打开时 translateX(50%) 居中
│  │  ┌─────────────────────┐  │  │
│  │  │      Cover          │  │  │  ← rotateY(-180deg) 翻开
│  │  │  ┌ front ┐┌ back ─┐ │  │  │
│  │  │  │封面图片││内封页  │ │  │  │
│  │  │  └───────┘└───────┘ │  │  │
│  │  ├─────────────────────┤  │  │
│  │  │      Pages          │  │  │  ← 所有页面叠在一起
│  │  │  ┌ Page 1 ────────┐ │  │  │
│  │  │  │ front │ back   │ │  │  │  ← 每页双面
│  │  │  └────────────────┘ │  │  │
│  │  │  ┌ Page 2 ────────┐ │  │  │
│  │  │  │ front │ back   │ │  │  │
│  │  │  └────────────────┘ │  │  │
│  │  │  ┌ BackCover ─────┐ │  │  │
│  │  │  │   The End      │ │  │  │
│  │  │  └────────────────┘ │  │  │
│  │  └─────────────────────┘  │  │
│  └───────────────────────────┘  │
│        Navigation Bar           │
└─────────────────────────────────┘

核心思路:

  • 每一页都是绝对定位叠在一起,transform-origin: left center,翻页就是绕左边缘旋转 -180°
  • backface-visibility: hidden + 前后两个 div 模拟正反面
  • 通过 zIndex 控制翻过的页和未翻的页的层叠关系

三、核心实现

3.1 CSS 3D 翻页

关键 CSS:

.container {
  perspective: 2000px;  // 3D 视角距离
}

.page {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: left;  // 绕左边轴翻转
}

.pageFront, .pageBack {
  backface-visibility: hidden;  // 只显示朝向用户的面
}

.pageBack {
  transform: rotateY(180deg) translateZ(0.5px);  // 背面翻转 180°
}

用 Framer Motion 的 variants 控制翻转动画:

const variants = {
  flipped: {
    rotateY: -180,
    zIndex: isBuriedLeft ? index + 1 : pages.length + 10,
    transition: {
      rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
      zIndex: { delay: 0.6 },
    },
  },
  unflipped: {
    rotateY: 0,
    zIndex: pages.length - index,
    transition: {
      rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
      zIndex: { delay: 0.6 },
    },
  },
}

这里的贝塞尔曲线 [0.645, 0.045, 0.355, 1] 是精心调的,模拟纸张翻页时先快后慢的物理感。

3.2 拖拽翻页

参考电子书阅读器的拖拽逻辑:

// mousedown → 记录起点
// mousemove → 计算偏移,用 rAF 优化性能
// mouseup → 偏移超过阈值(80px)则触发翻页

const handleMouseMove = useCallback((e: MouseEvent) => {
  if (!isDragging) return
  currentDragXRef.current = e.clientX
  if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
  rafIdRef.current = requestAnimationFrame(() => {
    setDragOffset(currentDragXRef.current - dragStartXRef.current)
  })
}, [isDragging])

拖拽过程中,当前页面会有一个「弓起」效果:

const curlAngle = isActiveDragPage
  ? Math.min(Math.abs(dragOffset) * 0.25, 45) * (dragOffset < 0 ? -1 : 1)
  : 0
const curlZ = isActiveDragPage
  ? Math.min(Math.abs(dragOffset) * 0.15, 30)
  : 0

根据拖拽偏移量,页面最多弓起 45°,同时沿 Z 轴抬升 30px,配合 box-shadow 产生投影,效果非常逼真。

3.3 页角海浪卷起效果 🌊

这是整个组件最有趣的交互细节:鼠标悬停在页角时,纸张会像海浪一样卷起来,提示用户「这里可以翻页」。

实现原理:在页面的右下角/左下角放置 80×80 的热区,hover 时用 border-radius: 100% + 渐变背景模拟卷角,配合 CSS @keyframes 实现呼吸式波浪动画。

.cornerZone {
  position: absolute;
  width: 80px;
  height: 80px;
  cursor: pointer;
}

.curlEffect {
  width: 0;
  height: 0;
  transition: width 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
              height 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}

// hover 时展开卷角
.cornerActive .curlEffect {
  width: 55px;
  height: 55px;
}

卷角的渐变模拟了纸张翻起时的明暗变化:

.cornerBottomRight .curlEffect {
  background: linear-gradient(
    225deg,
    rgba(253, 251, 247, 0.95) 0%,    // 翻起的纸面(亮)      
    rgba(253, 251, 247, 0.9) 35%,
    rgba(230, 225, 215, 0.85) 50%,   // 折痕处(暗)
    rgba(200, 195, 185, 0.4) 70%,
    transparent 100%                  // 渐隐到背景
  );
  border-top-left-radius: 100%;      // 关键!圆弧形卷角
}

海浪动画通过 @keyframes 让卷角大小在 50px - 70px 之间波动:

@keyframes curlWaveRight {
  0%   { width: 55px; height: 55px; }
  30%  { width: 70px; height: 70px; }  // 浪涌
  60%  { width: 50px; height: 50px; }  // 回落
  100% { width: 55px; height: 55px; }  // 归位
}

弹性过渡的贝塞尔曲线 cubic-bezier(0.34, 1.56, 0.64, 1) 让展开有一个「弹一下」的效果,像纸张被风吹起。

四、踩坑实录:那些让我抓狂的 Bug

坑 1:页角点击不触发翻页

现象:鼠标在页角卷起后点击,但页面没有翻动。

原因mousedown 事件冒泡到了父容器 .pages,触发了拖拽逻辑(isDragging = true)。由于 React 的条件渲染逻辑写了 !isDragging,页角区域立刻被卸载,onClick 根本来不及触发。

解决:在页角热区上阻止 mousedown 冒泡:

<div
  className={styles.cornerZone}
  onMouseDown={(e) => e.stopPropagation()}  // 关键!
  onTouchStart={(e) => e.stopPropagation()}
  onClick={(e) => {
    e.stopPropagation()
    setCornerHover('none')
    nextPage(e)
  }}
>

坑 2:翻到下一页时左侧短暂闪烁

现象:翻页时左侧会短暂显示封面内容,然后才变成当前页的背面。

第一次尝试(失败):用 Framer Motion 的 opacity 动画延迟隐藏已翻过的页面。设置了 delay: 0.65s,等翻转动画完成后再隐藏。

结果:时序不可靠。opacity 依赖 Framer Motion 的 variant 重算,isBuriedLeft 变化时 variant 值立刻更新,无论 delay 多少都可能出现竞态。

最终方案:彻底放弃 opacity 动画,改用 CSS visibility 隐藏深层页面:

// 只隐藏 "深层" 掩埋的页面(index < currentPageIndex - 1)
// 保留紧邻的前一页可见,确保左侧始终有背面内容
const isDeeplyBuried = isFlipped && index < currentPageIndex - 1

<motion.div style={{
  visibility: isDeeplyBuried ? 'hidden' : 'visible',
}}>

visibility: hidden即时的、无动画的、确定性的——完美解决闪烁问题。

坑 3:翻回上一页时又闪了

现象:修好了向后翻页,但翻回上一页时又出现闪烁。

原因unflipped variant 的 zIndex transition 的 delay 设为了 0,导致页面还在翻转动画过程中,zIndex 就提前降低了,被其他页面遮挡。

解决:双向翻页的 zIndex 都延迟到动画结束后再更新:

unflipped: {
  rotateY: 0,
  zIndex: pages.length - index,
  transition: {
    rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
    zIndex: { delay: 0.6 },  // 和翻页动画时长一致!
  },
},

坑 4:最后一页拖不动但光标还是「抓手」

现象:翻到最后一页(The End),虽然结束页已经阻止了事件冒泡,但在页面空白区域鼠标仍然显示 grab 光标。

解决:检测最后一页状态,同时禁用拖拽逻辑和光标样式:

const isLastPage = currentPageIndex >= pages.length - 1

// 禁用 mousedown
const handleMouseDown = useCallback((e) => {
  if (!isOpen || isLastPage) return  // 最后一页不触发拖拽
  // ...
}, [isOpen, isLastPage])

// 光标
cursor: isOpen
  ? (isLastPage ? 'default' : isDragging ? 'grabbing' : 'grab')
  : 'default'

五、最终效果

组件支持的交互方式一览:

交互方式 说明
🖱️ 拖拽翻页 按住页面左右拖拽,超过 80px 阈值松手翻页
🌊 页角点击 悬停右下角/左下角出现卷起效果,点击翻页
🔘 导航栏 底部导航栏前后翻页按钮
⌨️ 键盘 ← → 翻页 / Escape 关闭 / Home End 跳转
📱 触摸 移动端触摸滑动翻页
📕 封面 点击或向左拖拽打开书籍

使用方式非常简单:

import InteractiveBook from '@stateless/InteractiveBook'

<InteractiveBook
  coverImage="/cover.jpg"
  bookTitle="AI Agent 完全指南"
  bookAuthor="AI 专家"
  pages={[
    {
      pageNumber: 1,
      title: '第一章',
      content: <div>正面内容</div>,
      backContent: <div>背面内容</div>,
    },
    // ...
  ]}
  onPageChange={(index) => console.log('当前页:', index)}
  enableKeyboard
/>

六、技术栈总结

技术 用途
React + TypeScript 组件逻辑
Framer Motion 翻页动画、封面动画、导航栏动画
CSS 3D Transform perspectiverotateYpreserve-3dbackface-visibility
CSS Modules (Less) 样式隔离
requestAnimationFrame 拖拽性能优化
lucide-react 图标

七、写在最后

一个看似简单的翻书组件,涉及了 CSS 3D 变换、事件冒泡机制、Framer Motion variant 生命周期、zIndex 时序控制 等多个知识点。最大的教训是:

不要用动画属性(opacity/transform)去做「显示/隐藏」这种二元状态控制。visibility 或条件渲染——确定性比优雅更重要。

完整代码已开源,欢迎 Star ⭐


GitHub: Pro React Admin

预览地址: Interactive Book

image.png

image.png

如果这篇文章对你有帮助,别忘了点个赞 👍 收藏一下 📌

Nginx 路径映射深度解析:从本地开发到生产交付的底层哲学

Nginx 静态资源映射:从原理到生产环境的最佳实践

摘要:在现代前后端分离架构中,Nginx 不仅是高性能的静态资源服务器,更是不可或缺的反向代理枢纽。然而,由于对资源映射(root/alias)及请求转发(proxy_pass)逻辑的理解偏差,往往会导致从 Windows 开发环境迁移至 Linux 生产环境时出现 404 或转发异常。本文将从 HTTP 协议视角出发,深度剖析“路径映射三剑客”的底层逻辑,并提供一套可落地的工程化配置规范与避坑指南。


1. 业务场景与工程痛点

在实际的工程链路中,我们经常遇到这样的场景: 前端同学在 Windows 本地使用 Nginx 调试 SPA(单页应用)或静态站点,一切运行正常。但当 CI/CD 流水线将代码部署到 Linux 生产服务器后,访问特定资源(如图片、次级路由)却频频出现 404 错误。

这并非玄学,而是由于对 Nginx 路径解析机制操作系统文件系统差异 理解不足导致的。要解决这个问题,我们需要先建立正确的路径映射心智模型。

2. 核心模型解析:URL 与文件系统的映射

Nginx 的核心职责之一,就是将抽象的 HTTP URI 映射到具体的 服务器文件系统路径

2.1 URI 的语义差异

在配置之前,必须明确 URL 尾部斜杠的协议语义:

  • /images:客户端请求名为 images资源实体(可能是文件,也可能是目录)。
  • /images/:客户端明确请求名为 images目录容器

工程细节: 当用户访问 /images(不带斜杠)且服务器上存在同名目录时,Nginx 默认会返回 301 Moved Permanently,自动重定向到 /images/。这是为了确保相对路径资源(如 ./logo.png)能基于正确的 Base URL 加载。


3. 资源映射三剑客:Root、Alias 与 Proxy_Pass

rootaliasproxy_pass 是 Nginx 流量分发的核心指令。前两者解决的是如何将 URI 映射到 本地文件系统,而后者解决的是如何将请求转发到 网络服务接口

3.1 Root:追加逻辑 (Append)

root 指令采用追加策略。它将请求的 URI 完整拼接到 root 指定的路径之后。

  • 计算公式最终物理路径 = root路径 + 完整URI
  • 配置示例
    location /static/ {
        root /var/www/app;
    }
    
  • 解析过程:请求 GET /static/css/style.css -> 物理路径:/var/www/app/static/css/style.css

3.2 Alias:替换逻辑 (Replace)

alias 指令采用替换策略。它用 alias 指定的路径替换掉 location 匹配到的部分。

  • 计算公式最终物理路径 = alias路径 + (完整URI - location匹配部分)
  • 配置示例
    location /static/ {
        alias /var/www/app/public/;
    }
    
  • 解析过程:请求 GET /static/css/style.css -> 匹配 /static/ -> 剩余 css/style.css -> 最终访问:/var/www/app/public/css/style.css

3.3 Proxy_Pass:请求转发逻辑 (Forward)

与处理本地文件的指令不同,proxy_pass 处理的是网络协议栈的转发。其路径处理逻辑遵循相似的“追加”与“替换”哲学,由目标 URL 结尾是否有 / 决定。

场景 A:不带斜杠(透明转发,对应 Root 逻辑)

proxy_pass 的目标 URL 不带路径(即没有结尾的 /)时,Nginx 会将原始请求的 URI 完整地传递给后端服务。

  • 配置示例
    location /api/ {
        proxy_pass http://127.0.0.1:3000; 
    }
    
  • 路径解析:请求 GET /api/user -> 转发到 http://127.0.0.1:3000/api/user
  • 工程特征location 匹配路径被完整保留。适用于后端服务本身就包含 /api 前缀的场景。
场景 B:带斜杠(路径重写,对应 Alias 逻辑)

proxy_pass 的目标 URL 包含路径(即使只有一个结尾的 /)时,Nginx 会将 URI 中匹配 location 的部分替换为该路径。

  • 配置示例
    location /api/ {
        proxy_pass http://127.0.0.1:3000/; 
    }
    
  • 路径解析:请求 GET /api/user -> 转发到 http://127.0.0.1:3000/user
  • 工程特征location 匹配路径被“剥离”。适用于后端服务是纯净接口,仅通过 Nginx 统一前缀入口的场景。

3.4 资源映射三剑客对比表

假设统一配置 location /api/,观察不同指令下的映射结果:

指令 映射目标 URI 处理方式 示例配置 实际请求 -> 结果映射 典型场景
Root 本地磁盘 追加 (Append) root /data; /api/user -> /data/api/user 静态站点默认部署
Alias 本地磁盘 替换 (Replace) alias /data/v1/; /api/user -> /data/v1/user 虚拟路径、资源别名
Proxy_Pass (无/) 远程服务 透明转发 proxy_pass http://node:3000; /api/user -> node:3000/api/user 后端服务自带前缀
Proxy_Pass (带/) 远程服务 路径重写 proxy_pass http://node:3000/; /api/user -> node:3000/user 统一入口,后端无前缀

4. 工程化落地:跨平台环境差异处理

在团队协作中,统一开发环境(Windows/Mac)与生产环境(Linux)的配置规范至关重要。

4.1 Windows 开发环境的陷阱

Windows 文件系统有“盘符”概念,且对路径分隔符不敏感。

  • 绝对路径问题: 在 Windows 下配置 root /html;,Nginx 会将其解析为当前盘符的根目录(如 D:\html),而非 Nginx 安装目录。
  • 最佳实践使用相对路径
    # 推荐:相对于 Nginx 安装目录 (prefix)
    location / {
        root html; 
        index index.html;
    }
    

4.2 Linux 生产环境的规范

Linux 环境强调权限控制与路径的确定性。

  • 绝对路径强制: 生产配置必须使用绝对路径,避免因启动方式不同导致的工作目录漂移。

    root /usr/share/nginx/html;
    
  • 权限隔离 (Permission): 常见的 403 Forbidden 错误通常并非配置错误,而是权限问题。

    • 要求:Nginx 运行用户(通常是 nginxwww-data)必须拥有从根目录到目标文件全路径的 x (执行/搜索) 权限,以及目标文件的 r (读取) 权限。
    • 排查命令
      namei -om /var/www/project/static/image.png
      
  • Alias 的斜杠对称性: 这是一个容易被忽视的 Bug 源。在 Linux 下使用 alias 时,如果 location 只有尾部斜杠,建议 alias 也加上尾部斜杠,保持对称,避免路径拼接错位。

    # Good
    location /img/ {
        alias /var/www/images/;
    }
    

5. 调试与排错指南

当出现 404 或 403 时,不要盲目猜测,请遵循以下排查路径:

  1. Check Error Log: 这是最直接的证据。Nginx 的 error.log 会明确打印出它试图访问的完整物理路径。

    open() "/var/www/app/static/css/style.css" failed (2: No such file or directory)
    

    对比日志中的路径与你预期的路径,通常能立刻发现 rootalias 的误用。

  2. 验证文件存在性: 直接复制日志中的路径,在服务器上执行 ls -l <path>,确认文件是否存在以及权限是否正确。


总结: Nginx 的路径映射与转发逻辑虽然细碎,但其背后遵循着高度一致的“追加”与“替换”哲学。掌握 rootaliasproxy_pass 的底层差异,不仅能解决 404/403 等表象问题,更能帮助开发者构建出优雅、可维护的配置体系。在工程实践中,建议通过规范化路径命名(如统一使用 /api/ 前缀)与环境感知配置(如 Linux 绝对路径强制化)来降低运维复杂度,确保从本地开发到生产交付的丝滑顺畅。

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

本文是一篇完整的技术实践文章,记录了如何从零开始构建一个企业级 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 项目价值

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

📚 参考资源

官方文档

前端架构治理演进规划

一、背景与目标

1.1 现状分析

经过 Phase 1~4 的微前端治理(详见 doc/wujie集成.md),已完成:

已完成项 成果
微前端框架 iframe → wujie,7 个子应用统一接入
通信协议 postMessage → wujie bus,类型化事件
子应用预加载 preloadChildApps 高/低优先级分级
CSS 统一 UnoCSS → Tailwind CSS 4
Monorepo pnpm workspace + Turborepo

但微前端只是架构治理的第一步。 当前 7 个子应用之间存在大量重复建设:

重复领域 现状 影响
UI 组件 每个子应用独立封装 Table/Form/Dialog 7 份重复代码,风格不统一
业务组件 客户选择器、产品选择器等各自实现 逻辑不一致,Bug 修一处漏六处
Utils 工具函数 日期格式化、金额计算、权限判断各写一套 维护成本 ×7
Hooks/Composables useTable、useForm、useDict 各子应用独立 无法共享最佳实践
API 层 接口定义、拦截器、错误处理各自维护 后端改一个字段,前端改 7 处
类型定义 业务实体 TS 类型各子应用独立定义 类型不同步,联调困难

1.2 治理目标

                    ┌─────────────────────────────────┐
                    │        业务应用层(7 个子应用)     │
                    │   mkt / doc / ibs-manage / ...   │
                    └──────────────┬──────────────────┘
                                   │ 消费
                    ┌──────────────┴──────────────────┐
                    │        公共资源层(packages)       │
                    │                                   │
                    │  ┌───────────┐  ┌─────────────┐  │
                    │  │ UI 组件库  │  │ 业务组件库   │  │
                    │  │@cmclink/ui│  │@cmclink/biz  │  │
                    │  └───────────┘  └─────────────┘  │
                    │  ┌───────────┐  ┌─────────────┐  │
                    │  │ Hooks 库  │  │  Utils 库    │  │
                    │  │@cmclink/  │  │@cmclink/     │  │
                    │  │ hooks     │  │ utils        │  │
                    │  └───────────┘  └─────────────┘  │
                    │  ┌───────────┐  ┌─────────────┐  │
                    │  │ API SDK   │  │  类型定义    │  │
                    │  │@cmclink/  │  │@cmclink/     │  │
                    │  │ api       │  │ types        │  │
                    │  └───────────┘  └─────────────┘  │
                    └──────────────┬──────────────────┘
                                   │ 支撑
                    ┌──────────────┴──────────────────┐
                    │        基础设施层                  │
                    │  micro-bridge / vite-config /     │
                    │  tsconfig / eslint-config         │
                    └──────────────────────────────────┘

核心原则

  1. 资源化 — 可复用的代码提取为独立 package
  2. 公共化 — 跨子应用共享,单点维护
  3. 文档化 — 每个公共包配套使用文档和示例
  4. AI 友好 — 沉淀为 AI Agent 可消费的 Skills/MCP 资源

二、公共资源沉淀规划

2.1 @cmclink/ui — 基础 UI 组件库

定位:基于 Element Plus 二次封装的业务通用 UI 组件。

组件 说明 来源
CmcTable 统一表格(分页、排序、列配置、导出) 各子应用 useTable + 模板代码
CmcForm 统一表单(校验、布局、动态字段) 各子应用表单封装
CmcDialog 统一弹窗(确认、表单弹窗、详情弹窗) 各子应用 Dialog 封装
CmcSearch 搜索栏(条件组合、折叠展开、快捷搜索) 各子应用搜索区域
CmcUpload 文件上传(拖拽、预览、进度、断点续传) 各子应用上传组件
CmcEditor 富文本编辑器(统一配置) 各子应用编辑器封装
CmcDescription 详情描述列表 各子应用详情页

实施策略

packages/
└── ui/
    ├── package.json          # @cmclink/ui
    ├── src/
    │   ├── components/       # 组件源码
    │   │   ├── CmcTable/
    │   │   ├── CmcForm/
    │   │   └── ...
    │   ├── composables/      # 组件内部 hooks
    │   └── index.ts          # 统一导出
    └── docs/                 # 组件文档(可选 VitePress)

2.2 @cmclink/biz — 业务组件库

定位:与业务强相关的可复用组件,跨产品线共享。

组件 说明 使用方
CustomerSelector 客户选择器(搜索、分页、多选) mkt / doc / commerce-finance
ProductSelector 产品选择器 mkt / operation
PortSelector 港口选择器 doc / operation
VesselSelector 船名航次选择器 doc / operation
DictSelect 字典下拉(统一字典管理) 全部子应用
UserSelector 用户/员工选择器 全部子应用
ApprovalFlow 审批流程组件 多个子应用

2.3 @cmclink/hooks — 通用 Composables

定位:跨子应用复用的 Vue 3 组合式函数。

Hook 说明 当前状态
useTable 表格数据管理(分页、排序、筛选、刷新) 各子应用独立实现
useForm 表单状态管理(校验、提交、重置) 各子应用独立实现
useDict 字典数据获取与缓存 各子应用独立实现
usePermission 权限判断(按钮级、菜单级) 各子应用独立实现
useExport 数据导出(Excel/CSV/PDF) 各子应用独立实现
useWebSocket WebSocket 连接管理 部分子应用实现
useI18n 国际化增强(业务术语统一翻译) 各子应用独立实现
useCrud CRUD 操作封装(增删改查一体) 各子应用独立实现

2.4 @cmclink/utils — 工具函数库

定位:纯函数工具集,零依赖或仅依赖 lodash-es

模块 函数示例 说明
date formatDate, diffDays, toUTC 日期处理(统一格式)
money formatMoney, toFixed, currencyConvert 金额计算(精度安全)
validator isPhone, isEmail, isTaxNo 业务校验规则
formatter formatFileSize, formatDuration 格式化工具
tree flatToTree, treeToFlat, findNode 树结构操作
auth getToken, setToken, removeToken 认证工具
storage getCache, setCache, removeCache 存储封装

2.5 @cmclink/api — API SDK

定位:统一的后端接口定义层,前后端类型对齐。

// packages/api/src/modules/customer.ts
import type { Customer, CustomerQuery } from '@cmclink/types'
import { request } from '../request'

/** 客户列表 */
export const getCustomerList = (params: CustomerQuery) =>
  request.get<PageResult<Customer>>('/admin-api/customer/page', { params })

/** 客户详情 */
export const getCustomerDetail = (id: number) =>
  request.get<Customer>(`/admin-api/customer/get?id=${id}`)

价值

  • 后端改接口 → 只改 @cmclink/api 一处 → 所有子应用自动同步
  • TypeScript 类型约束 → 编译期发现接口不匹配
  • 可自动生成 → 结合 Swagger/OpenAPI 自动生成 SDK

2.6 @cmclink/types — 共享类型定义

定位:业务实体的 TypeScript 类型定义,前后端对齐。

// packages/types/src/customer.ts
export interface Customer {
  id: number
  name: string
  code: string
  contactPerson: string
  phone: string
  email: string
  status: CustomerStatus
  createdAt: string
}

export type CustomerStatus = 'active' | 'inactive' | 'pending'

export interface CustomerQuery {
  name?: string
  code?: string
  status?: CustomerStatus
  pageNo: number
  pageSize: number
}

三、前后端职能对齐

3.1 基础架构团队职责矩阵

职责领域 前端基础架构 后端基础架构 协同点
框架治理 微前端(wujie)、Monorepo 微服务、网关 子应用 ↔ 微服务 1:1 映射
通信协议 wujie bus 事件定义 API 接口规范 事件名 / 接口路径统一命名
类型系统 @cmclink/types Swagger/OpenAPI 自动生成 TS 类型
API 层 @cmclink/api SDK RESTful API 实现 SDK 自动生成
权限体系 前端按钮/菜单权限 后端接口权限 权限码统一定义
国际化 前端翻译资源 后端错误码翻译 翻译 Key 统一管理
监控告警 前端性能/错误上报 后端 APM 全链路 TraceID 打通
CI/CD 前端构建部署 后端构建部署 统一流水线、环境管理

3.2 前后端类型自动同步方案

后端 Swagger/OpenAPI 定义
         │
         ▼
    openapi-typescript / swagger-typescript-api
         │
         ▼
  @cmclink/types(自动生成 TS 类型)
         │
         ▼
  @cmclink/api(自动生成 API SDK)
         │
         ▼
    各子应用直接消费

工具选型

  • openapi-typescript:从 OpenAPI 3.0 生成 TypeScript 类型
  • swagger-typescript-api:从 Swagger 生成完整的 API Client

四、AI 编程能力沉淀

4.1 为什么基础架构要考虑 AI

AI 编程(Copilot、Cursor、Windsurf 等)已成为开发者日常工具。公共资源的质量直接决定 AI 生成代码的质量

AI 编程痛点 根因 基础架构解法
AI 生成的代码风格不统一 缺乏项目级规范上下文 .windsurf/rules/ 规范文件
AI 不了解业务组件 API 组件文档缺失或分散 组件库 + JSDoc + 示例
AI 重复造轮子 不知道已有公共函数 @cmclink/utils + @cmclink/hooks
AI 生成的接口调用不对 不了解后端 API 结构 @cmclink/api 类型化 SDK
AI 无法理解项目架构 架构文档不完善 架构决策记录(ADR)

4.2 Agent Skills 沉淀

将项目规范和最佳实践沉淀为 AI Agent 可消费的 Skills:

.windsurf/
├── rules/                    # 已有:27 个专项规范
│   ├── core.mdc
│   ├── vue3-component-standards.mdc
│   ├── typescript-standards.mdc
│   └── ...
├── workflows/                # 工作流定义
│   ├── create-component.md   # 新建组件工作流
│   ├── create-api-module.md  # 新建 API 模块工作流
│   ├── create-page.md        # 新建页面工作流
│   └── migrate-child-app.md  # 子应用迁入工作流
└── skills/                   # AI Skills 定义(规划中)
    ├── cmclink-ui.md         # UI 组件库使用指南
    ├── cmclink-api.md        # API SDK 使用指南
    └── cmclink-patterns.md   # 业务模式最佳实践

Skills 示例 — 新建 CRUD 页面

---
description: 创建标准 CRUD 页面(列表 + 新增 + 编辑 + 删除)
---

1.`@cmclink/types` 中定义实体类型
2.`@cmclink/api` 中定义接口
3. 使用 `CmcTable` + `CmcSearch` + `CmcForm` 组合
4. 使用 `useCrud` hook 管理状态
5. 使用 `usePermission` 控制按钮权限

4.3 MCP Server 能力规划

MCP(Model Context Protocol) 让 AI Agent 能够直接访问项目资源:

MCP 能力 说明 价值
组件文档查询 AI 查询 @cmclink/ui 组件 Props/Slots/Events 生成代码直接使用正确的组件 API
API 接口查询 AI 查询后端接口定义和参数 生成的接口调用代码类型正确
字典数据查询 AI 查询业务字典(状态码、类型码) 生成代码使用正确的枚举值
权限码查询 AI 查询按钮/菜单权限码 生成的权限判断代码准确
代码模板生成 AI 基于模板生成标准化页面 新页面开发效率 ×3

MCP Server 架构

┌──────────────────────────────────────────────┐
│              AI Agent (Windsurf/Cursor)        │
│                                                │
│  "帮我创建一个客户管理的 CRUD 页面"              │
└──────────────────┬─────────────────────────────┘
                   │ MCP Protocol
┌──────────────────┴─────────────────────────────┐
│            @cmclink/mcp-server                  │
│                                                 │
│  ┌─────────────┐  ┌──────────────────────────┐ │
│  │ 组件文档资源 │  │ API 接口资源              │ │
│  │ (Resources) │  │ (Resources)              │ │
│  └─────────────┘  └──────────────────────────┘ │
│  ┌─────────────┐  ┌──────────────────────────┐ │
│  │ 代码生成工具 │  │ 字典/权限查询工具         │ │
│  │ (Tools)     │  │ (Tools)                  │ │
│  └─────────────┘  └──────────────────────────┘ │
└─────────────────────────────────────────────────┘

五、实施路线图

5.1 短期(1~2 个月)— 基础沉淀

优先级 任务 产出 负责
P0 提取 @cmclink/utils 工具函数包 基础架构
P0 提取 @cmclink/hooks 通用 Composables 包 基础架构
P0 提取 @cmclink/types 共享类型定义包 基础架构 + 后端
P1 完善 .windsurf/rules/ AI 规范文件 基础架构
P1 创建 .windsurf/workflows/ 标准工作流 基础架构

5.2 中期(3~4 个月)— 组件化

优先级 任务 产出 负责
P0 搭建 @cmclink/ui 组件库 CmcTable / CmcForm / CmcSearch 基础架构
P0 搭建 @cmclink/api SDK 统一 API 调用层 基础架构 + 后端
P1 搭建 @cmclink/biz 业务组件库 客户选择器等业务组件 基础架构 + 业务
P1 组件文档站(VitePress) 在线文档 + 示例 基础架构
P2 OpenAPI → TypeScript 自动生成 类型自动同步流水线 基础架构 + 后端

5.3 长期(5~6 个月)— AI 赋能

优先级 任务 产出 负责
P1 @cmclink/mcp-server AI Agent 资源服务 基础架构
P1 AI Skills 沉淀 组件/API/模式使用指南 基础架构
P2 代码模板生成器 标准化页面脚手架 基础架构
P2 全链路 TraceID 打通 前后端监控联动 基础架构 + 后端

六、预期收益

6.1 效率提升

场景 当前耗时 治理后耗时 提升
新建 CRUD 页面 4~8 小时 1~2 小时 4x
修复跨子应用 Bug 改 7 处 改 1 处 7x
新子应用接入 2~3 天 半天 5x
后端接口变更适配 改 7 个子应用 改 1 个 SDK 7x
AI 生成代码可用率 ~30% ~80% 2.7x

6.2 质量提升

  • 一致性:所有子应用使用相同的组件和交互模式
  • 可维护性:公共代码单点维护,变更自动传播
  • 类型安全:前后端类型自动同步,编译期发现问题
  • AI 友好:规范化的代码库让 AI 生成更准确的代码

6.3 团队赋能

  • 新人上手:标准化组件 + 文档 + AI Skills → 快速产出
  • 跨团队协作:公共组件库是团队间的共同语言
  • 技术影响力:沉淀的基础设施可对外输出

七、风险与缓解

风险 影响 缓解措施
公共包变更影响所有子应用 回归范围大 Changesets 版本管理 + 自动化测试
业务组件抽象不当 过度抽象或不够通用 先在 2 个子应用验证,再推广
AI Skills 维护成本 文档过时 与代码同仓库,CI 检查文档同步
团队推广阻力 业务团队不愿迁移 渐进式迁移,新页面优先使用

附录:packages 目录规划

packages/
├── micro-bridge/       # ✅ 已有 — 微前端通信 SDK
├── micro-bootstrap/    # ✅ 已有 — 子应用启动器
├── vite-config/        # ✅ 已有 — 统一 Vite 配置
├── tsconfig/           # ✅ 已有 — 统一 TS 配置
├── ui/                 # 📋 规划 — 基础 UI 组件库
├── biz/                # 📋 规划 — 业务组件库
├── hooks/              # 📋 规划 — 通用 Composables
├── utils/              # 📋 规划 — 工具函数库
├── api/                # 📋 规划 — API SDK
├── types/              # 📋 规划 — 共享类型定义
├── eslint-config/      # 📋 规划 — 统一 ESLint 配置
└── mcp-server/         # 📋 规划 — AI Agent MCP 服务

微前端 — wujie(无界)集成设计文档

一、背景与选型

1.1 现状问题

主应用 cmclink-web-micro-main 采用 iframe 过渡方案 加载 7 个子应用,存在以下痛点:

问题 影响
7 个 <iframe> 硬编码在 App.vue 新增子应用需改 App.vue + router + tabs.ts 三处
每个 iframe 独立加载完整 Vue + Element Plus 内存和带宽 ×7,首屏慢
postMessage 通信无类型约束 调试困难,事件名拼写错误无感知
弹窗无法突破 iframe 边界 Element Plus 的 Dialog/MessageBox 被裁切
reloadIframe() 暴力刷新 子应用状态全部丢失
URL 不同步 刷新页面后子应用路由丢失

1.2 方案对比

维度 qiankun micro-app wujie iframe(当前)
Vue 3 + Vite 兼容 ⚠️ 需插件
JS 沙箱强度 Proxy(有逃逸风险) iframe 沙箱 iframe 沙箱(最强) 天然隔离
CSS 隔离 动态样式表 样式隔离 iframe 级别 天然隔离
keep-alive ❌ 不支持 原生 alive 模式
弹窗突破容器
子应用改造量 大(导出生命周期) 最小
从 iframe 迁移成本 最低
维护活跃度 ⚠️ 停滞 ✅ 京东 ✅ 腾讯

1.3 选型结论

选择 wujie(无界),核心理由:

  1. 从 iframe 迁移成本最低<iframe> 标签 1:1 替换为 <WujieVue>
  2. 隔离性最强 — iframe 沙箱是浏览器原生级别,零逃逸风险
  3. 原生 alive 模式 — 子应用切换时状态完整保留(当前 7 个 iframe 全挂载就是为了保活)
  4. 子应用几乎零改造 — 不要求导出 bootstrap/mount/unmount 生命周期函数

二、整体架构

2.1 系统架构图

┌─────────────────────────────────────────────────────────────┐
│                    主应用 @cmclink/main                      │
│                  (cmclink-web-micro-main)                     │
│                                                              │
│  ┌──────────────┐  ┌──────────────────────────────────────┐ │
│  │ LayoutHeader │  │         wujie bus (事件总线)           │ │
│  └──────────────┘  └──────────┬───────────────────────────┘ │
│  ┌──────────────┐             │                              │
│  │  SiderMenu   │             │ $on / $emit                  │
│  └──────────────┘             │                              │
│  ┌────────────────────────────┼──────────────────────────┐  │
│  │              App.vue 子应用容器                         │  │
│  │                            │                           │  │
│  │  ┌─────────┐ ┌─────────┐ ┌┴────────┐ ┌─────────┐    │  │
│  │  │WujieVue │ │WujieVue │ │WujieVue │ │  ...    │    │  │
│  │  │  mkt    │ │  doc    │ │commerce │ │ (x7)    │    │  │
│  │  │:alive   │ │:alive   │ │-finance │ │         │    │  │
│  │  └─────────┘ └─────────┘ └─────────┘ └─────────┘    │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

2.2 子应用列表

子应用 name 路由前缀 开发端口 说明
营销 mkt /mkt 3001 营销产品线
业财 commerce-finance /commerce-finance 3002 业财产品线
单证 doc /doc 3003 单证产品线
操作线 operation /operation 3004 操作产品线
通用 general /general 3005 公共产品线
公共 common /common 3006 基础数据
运营后台 ibs-manage /ibs-manage 3007 运营管理

子应用注册表统一维护在 packages/micro-bridge/src/registry.ts

2.3 Monorepo 目录结构

微前端/
├── apps/
│   ├── cmclink-web-micro-main/    # @cmclink/main — 真正的主应用(基座)
│   ├── doc/                        # @cmclink/doc — 单证子应用
│   ├── ibs-manage/                 # @cmclink/ibs-manage — 运营后台子应用
│   └── main/                       # ⚠️ 旧主应用(待手动删除,见第八章)
├── packages/
│   ├── micro-bridge/               # @cmclink/micro-bridge — 通信 SDK + 注册表
│   ├── micro-bootstrap/            # @cmclink/micro-bootstrap — 子应用启动器
│   ├── vite-config/                # @cmclink/vite-config — 统一 Vite 配置
│   └── tsconfig/                   # @cmclink/tsconfig — 统一 TS 配置
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

三、主应用集成详解

3.1 依赖安装

// apps/cmclink-web-micro-main/package.json
{
  "dependencies": {
    "wujie-vue3": "^1.0.22",
    "@cmclink/micro-bridge": "workspace:*"
  }
}

3.2 插件注册

// src/plugins/wujie.ts
import WujieVue from 'wujie-vue3'
import type { App } from 'vue'

export function setupWujie(app: App) {
  app.use(WujieVue)
}
// src/main.ts(关键行)
import { setupWujie } from "@/plugins/wujie"
// ...
setupWujie(app)
app.mount("#app")

3.3 App.vue — 子应用容器

改造前(7 个硬编码 iframe):

<iframe :src="'/mkt/?_t=' + now" name="mkt" v-show="route.path === '/mkt'" />
<iframe :src="'/doc/?_t=' + now" name="doc" v-show="route.path === '/doc'" />
<!-- ... 重复 7 次 -->

改造后(基于注册表动态渲染):

<WujieVue
  v-for="app in microAppRegistry"
  :key="app.name"
  v-show="route.path === app.activeRule"
  :name="app.name"
  :url="getAppUrl(app)"
  :alive="true"
  :props="{ token: userStore.token, userInfo: userStore.userInfo }"
  width="100%"
  height="100%"
/>

关键属性说明

属性 说明
:name app.name 子应用唯一标识,wujie 内部用于实例管理
:url getAppUrl(app) 子应用入口 URL,优先读环境变量
:alive true alive 模式:子应用切换时不销毁,保留完整状态
:props { token, userInfo } 直接传递数据给子应用(替代 postMessage)
v-show route.path === app.activeRule 控制显示/隐藏,配合 alive 实现 keep-alive

URL 解析逻辑

const getAppUrl = (app: MicroAppConfig): string => {
  // 环境变量命名规则:VITE_{APP_NAME}_APP_URL(大写,连字符转下划线)
  // 例如:VITE_MKT_APP_URL, VITE_COMMERCE_FINANCE_APP_URL
  const envKey = `VITE_${app.name.toUpperCase().replace(/-/g, '_')}_APP_URL`
  const envUrl = (import.meta.env as Record<string, string>)[envKey]
  return envUrl || app.entry  // 兜底使用注册表中的 entry
}

3.4 AuthenticatedLayout.vue — 事件监听

改造前(iframe postMessage):

import { listenFromSubApp, MESSAGE_TYPE } from "@/utils/iframe-bridge"
onMounted(() => {
  listenFromSubApp((data: any) => {
    if (data.type === MESSAGE_TYPE.TO_ROUTE) { ... }
    if (data.type === MESSAGE_TYPE.ROUTE_CHANGE) { ... }
  })
})

改造后(wujie bus):

import { bus } from "wujie"
onMounted(() => {
  bus.$on("TO_ROUTE", (data: any) => {
    tabsStore.menuClick({ appName: data.appName, path: data.path, ... })
  })
  bus.$on("ROUTE_CHANGE", (data: any) => {
    tabsStore.updateQuery(data)
  })
  bus.$on("ASSETS_404", (data: any) => {
    ElMessageBox.confirm(...)
  })
  bus.$on("CLOSE_ALL_TABS", (data: any) => {
    tabsStore.removeTab(data?.appName)
  })
})

3.5 tabs.ts — 主应用向子应用通信

改造前

import { sendToSubApp, reloadIframe } from "@/utils/iframe-bridge"
sendToSubApp(tab.appName, { type: NOTICE_TYPE.ROUTER_CHANGE, payload: {...} })
reloadIframe(tab.appName)

改造后

import { bus } from "wujie"
bus.$emit("ROUTER_CHANGE_TO_CHILD", { appName, route, query })
bus.$emit("REFRESH_CHILD", { appName: tab.appName })

四、通信协议设计

4.1 事件总线(wujie bus)

wujie 内置了一个全局事件总线 bus,主应用和子应用共享同一个 bus 实例。

主应用                          子应用
  │                               │
  │  bus.$emit("事件名", data)  ──→│  bus.$on("事件名", handler)
  │                               │
  │  bus.$on("事件名", handler) ←──│  bus.$emit("事件名", data)
  │                               │

4.2 事件清单

子应用 → 主应用

事件名 触发场景 payload 结构 主应用处理
TO_ROUTE 子应用请求跳转到某个路由 { appName, path, query, name } tabsStore.menuClick()
ROUTE_CHANGE 子应用内部路由变更 { appName, path } tabsStore.updateQuery() 同步 URL
ASSETS_404 子应用静态资源加载失败 { appName } 弹窗提示用户刷新
CLOSE_ALL_TABS 子应用请求关闭自己的 tab { appName } tabsStore.removeTab()

主应用 → 子应用

事件名 触发场景 payload 结构 子应用处理
ROUTER_CHANGE_TO_CHILD 主应用 tab 切换/菜单点击 { appName, route, query } 子应用内部路由跳转
CLOSE_ALL_TAB_TO_CHILD 主应用关闭子应用 tab { appName } 子应用清理状态
REFRESH_CHILD 用户点击刷新按钮 { appName } 子应用重新加载当前页

4.3 props 直传(补充通道)

除了 bus 事件,wujie 还支持通过 :props 直接向子应用传递数据:

<!-- 主应用 -->
<WujieVue :props="{ token: userStore.token, userInfo: userStore.userInfo }" />
// 子应用中获取
const props = (window as any).__WUJIE?.props
const token = props?.token

适用场景:token、用户信息等初始化数据,不需要事件驱动的静态数据。


五、子应用侧适配方案

5.1 改造范围

子应用需要将 iframe-bridge.ts 中的 postMessage 通信替换为 wujie bus.$emit

涉及文件(以 doc 子应用为例):

文件 当前用法 改造方案
src/utils/iframe-bridge.ts notifyMainApp()postMessage 新建 wujie-bridge.ts 替代
src/router/index.ts notifyMainApp(MESSAGE_TYPE.ROUTE_CHANGE, ...) 改 import 路径即可
src/App.vue getPathFromParent() 读父窗口 URL 改为 bus 监听 ROUTER_CHANGE_TO_CHILD
src/main.ts errorCheck()postMessage 报告 404 setupErrorCheck() 用 bus

5.2 改造步骤

Step 1:创建 wujie-bridge.ts(替代 iframe-bridge.ts)

// src/utils/wujie-bridge.ts
/**
 * @description wujie 子应用通信桥接器
 * @author yaowb
 * @date 2026-02-06
 */

// wujie 子应用环境下,bus 挂载在 window.__WUJIE 上
function getWujieBus() {
  return (window as any).__WUJIE?.bus
}

/** 是否在 wujie 子应用环境中 */
export function isInWujie(): boolean {
  return !!(window as any).__WUJIE
}

/** 获取主应用传递的 props */
export function getWujieProps(): Record<string, any> {
  return (window as any).__WUJIE?.props || {}
}

/** 向主应用发送事件(保持与 iframe-bridge 相同的函数签名) */
export function notifyMainApp(type: string, payload: any) {
  const bus = getWujieBus()
  if (bus) {
    bus.$emit(type, payload)
  } else {
    console.warn('[wujie-bridge] Not in wujie environment, skip emit:', type)
  }
}

/** 监听主应用发来的事件 */
export function onMainAppEvent(type: string, handler: (data: any) => void) {
  const bus = getWujieBus()
  if (bus) {
    bus.$on(type, handler)
  }
}

/** 移除事件监听 */
export function offMainAppEvent(type: string, handler: (data: any) => void) {
  const bus = getWujieBus()
  if (bus) {
    bus.$off(type, handler)
  }
}

/** 资源 404 错误检测 */
export function setupErrorCheck(appName: string) {
  if (!isInWujie()) return
  window.addEventListener('error', (event) => {
    if (event.target instanceof Element) {
      const tagName = event.target.tagName.toUpperCase()
      if (tagName === 'SCRIPT' || tagName === 'LINK') {
        notifyMainApp('ASSETS_404', { appName })
      }
    }
  }, true)
}

export const MESSAGE_TYPE = {
  TO_ROUTE: 'TO_ROUTE',
  ROUTE_CHANGE: 'ROUTE_CHANGE',
  ASSETS_404: 'ASSETS_404',
  CLOSE_ALL_TABS: 'CLOSE_ALL_TABS',
}

Step 2:改造 router/index.ts

// 改造前
import { notifyMainApp, MESSAGE_TYPE } from '@/utils/iframe-bridge'

// 改造后(函数签名不变,只换 import 路径)
import { notifyMainApp, MESSAGE_TYPE } from '@/utils/wujie-bridge'

// 业务代码完全不用改
router.afterEach((to) => {
  notifyMainApp(MESSAGE_TYPE.ROUTE_CHANGE, {
    appName: import.meta.env.VITE_APP_NAME,
    path: to.fullPath
  })
})

关键设计wujie-bridge.ts 保持与 iframe-bridge.ts 相同的 notifyMainApp() 函数签名和 MESSAGE_TYPE 常量,子应用只需替换 import 路径,业务代码零改动

Step 3:改造 App.vue(路由同步)

// 改造前:从父窗口 URL 读取 childPath
import { getPathFromParent } from '@/utils/iframe-bridge'
const childPath = getPathFromParent()

// 改造后:监听主应用的路由指令
import { onMainAppEvent } from '@/utils/wujie-bridge'
onMainAppEvent('ROUTER_CHANGE_TO_CHILD', (data) => {
  if (data.appName === import.meta.env.VITE_APP_NAME) {
    router.push({ path: data.route, query: data.query })
  }
})

Step 4:改造 main.ts(错误检测)

// 改造前
import { errorCheck } from '@/utils/iframe-bridge'
errorCheck()

// 改造后
import { setupErrorCheck } from '@/utils/wujie-bridge'
setupErrorCheck(import.meta.env.VITE_APP_NAME)

5.3 兼容性策略

子应用需要同时支持 wujie 模式独立运行模式(开发调试时直接访问子应用端口)。wujie-bridge.ts 已内置兼容:

export function notifyMainApp(type: string, payload: any) {
  const bus = getWujieBus()
  if (bus) {
    bus.$emit(type, payload)  // wujie 环境
  } else {
    console.warn('[wujie-bridge] Not in wujie, skip:', type)  // 独立运行,静默跳过
  }
}

5.4 改造检查清单(每个子应用)

  • 创建 src/utils/wujie-bridge.ts
  • src/router/index.tsimport 路径改为 wujie-bridge
  • src/App.vuegetPathFromParent()onMainAppEvent('ROUTER_CHANGE_TO_CHILD')
  • src/main.tserrorCheck()setupErrorCheck()
  • 搜索所有 iframe-bridge 引用,确认全部替换
  • 独立运行验证(直接访问子应用端口)
  • wujie 模式验证(通过主应用加载)
  • 删除旧的 src/utils/iframe-bridge.ts

六、子应用预加载策略

6.1 wujie preloadApp API

wujie 提供 preloadApp() 方法,可以在用户访问前预热子应用,减少首次加载白屏时间。

import { preloadApp } from 'wujie'

// 预加载指定子应用(只加载 HTML/JS/CSS,不渲染)
preloadApp({ name: 'doc', url: '/doc/' })

6.2 推荐策略

// src/plugins/wujie.ts(增强版)
import WujieVue from 'wujie-vue3'
import { preloadApp } from 'wujie'
import type { App } from 'vue'
import { microAppRegistry } from '@cmclink/micro-bridge'

export function setupWujie(app: App) {
  app.use(WujieVue)
}

/**
 * 预加载子应用(登录成功后调用)
 * 策略:
 *   - 高频子应用(doc、mkt):立即预加载
 *   - 其他子应用:延迟 3 秒后预加载,避免抢占主应用资源
 */
export function preloadChildApps() {
  const highPriority = ['doc', 'mkt']
  const lowPriority = microAppRegistry
    .filter(app => !highPriority.includes(app.name))

  // 高优先级:立即预加载
  highPriority.forEach(name => {
    const app = microAppRegistry.find(a => a.name === name)
    if (app) {
      preloadApp({ name: app.name, url: app.entry })
    }
  })

  // 低优先级:延迟预加载
  setTimeout(() => {
    lowPriority.forEach(app => {
      preloadApp({ name: app.name, url: app.entry })
    })
  }, 3000)
}

6.3 调用时机

// AuthenticatedLayout.vue — 用户登录成功后
import { preloadChildApps } from '@/plugins/wujie'

onMounted(() => {
  preloadChildApps()
  // ... 其他初始化
})

6.4 预加载效果预估

指标 无预加载 有预加载
子应用首次切换白屏 1-3 秒 < 500ms
主应用首屏影响 高优先级 +200ms,低优先级无感
内存占用 按需加载 预热后常驻(alive 模式本身就常驻)

七、路由同步设计

7.1 当前方案

主应用路由与子应用路由的映射关系:

主应用 URL: /micro-main/doc?childPath=/order/list
                          |
子应用内部路由: /order/list

7.2 路由同步流程

用户点击菜单
    │
    ▼
主应用 router.push('/doc')
    │
    ▼
App.vue v-show 切换显示 doc 子应用
    │
    ▼
tabs.ts bus.$emit('ROUTER_CHANGE_TO_CHILD', { appName: 'doc', route: '/order/list' })
    │
    ▼
子应用 bus.$on('ROUTER_CHANGE_TO_CHILD') → router.push('/order/list')
    │
    ▼
子应用 router.afterEach → bus.$emit('ROUTE_CHANGE', { appName: 'doc', path: '/order/list' })
    │
    ▼
主应用 bus.$on('ROUTE_CHANGE') → router.replace({ query: { childPath: '/order/list' } })

7.3 wujie sync 路由同步模式(深度优化方向)

wujie 内置了路由同步能力,可以通过 sync 属性开启:

<WujieVue
  :name="app.name"
  :url="getAppUrl(app)"
  :alive="true"
  :sync="true"   <!-- 开启路由同步 -->
/>

开启后,子应用的路由变更会自动同步到主应用 URL 的 query 参数中:

主应用 URL: /micro-main/doc?doc=/order/list&doc-query=xxx

注意sync 模式与当前手动 childPath 方案有冲突,建议在 Phase 3 中评估后再开启。当前阶段保持手动同步方案,确保平稳过渡。


八、清理旧 apps/main

8.1 背景

apps/main 是之前基于 @micro-zoe/micro-app 框架搭建的主应用原型,不是真正的生产主应用。真正的主应用是 apps/cmclink-web-micro-main

8.2 差异对比

维度 apps/main(旧) apps/cmclink-web-micro-main(真)
package name main-app @cmclink/main
微前端方案 @micro-zoe/micro-app iframe → wujie
子应用数量 3(marketing/doc/ibs-manage) 7(完整业务线)
业务代码 简化版 完整生产代码
状态 ⚠️ 待清理 ✅ 正式使用

8.3 清理步骤

# 1. 确认 cmclink-web-micro-main 正常运行
pnpm --filter @cmclink/main dev

# 2. 删除旧主应用
rm -rf apps/main

# 3. 更新 turbo.json(如有 filter 引用 main-app 的地方)

# 4. pnpm install 重新解析 workspace
pnpm install

⚠️ 注意:删除前请确认 apps/main 中的 MicroAppContainer.vue@cmclink/micro-bridge 集成代码等有价值的内容已迁移到 cmclink-web-micro-main


九、CSS 方案统一路线

9.1 现状

应用 CSS 方案 版本 问题
主应用 Tailwind CSS 4 ^4.1.14 ✅ 无问题
doc 子应用 UnoCSS 0.56.5 ❌ 不兼容 Vite 7
ibs-manage 子应用 UnoCSS 0.56.5 ❌ 不兼容 Vite 7

9.2 推荐方案

统一迁移到 Tailwind CSS 4,理由:

  • 主应用已使用 Tailwind CSS 4,统一后减少认知负担
  • Tailwind CSS 4 原生支持 Vite 7
  • UnoCSS 0.56.5 的 Vite 插件不兼容 Vite 7(peer dependency 冲突)

9.3 迁移步骤(每个子应用)

# 1. 卸载 UnoCSS
pnpm --filter @cmclink/doc remove unocss @unocss/vite @unocss/preset-uno

# 2. 安装 Tailwind CSS 4
pnpm --filter @cmclink/doc add tailwindcss @tailwindcss/vite

# 3. 替换 vite.config.ts 中的插件
#    UnoCSS() → tailwindcss()

# 4. 创建 CSS 入口文件
#    @import "tailwindcss";

# 5. 逐步替换 UnoCSS 专有语法(如 attributify 模式)

9.4 风险评估

风险 影响 缓解措施
UnoCSS attributify 语法无对应 需手动改为 class 写法 全局搜索 un- 前缀
UnoCSS 自定义 rules 需转为 Tailwind 插件 逐个评估,大部分有等价写法
迁移期间样式回归 页面样式可能错乱 逐页面验证,保留 UnoCSS 作为过渡

十、实施路线图

Phase 1 ✅ 已完成:主应用 wujie 集成
├── ✅ cmclink-web-micro-main 纳入 monorepo
├── ✅ App.vue iframe → WujieVue 动态渲染
├── ✅ AuthenticatedLayout.vue 通信改造
├── ✅ tabs.ts 通信改造
├── ✅ 子应用注册表更新(7 个子应用)
└── ✅ pnpm install + 启动验证

Phase 2 ✅ 已完成:子应用侧适配(2026-02-06)
├── ✅ 创建 wujie-bridge.ts 替代 iframe-bridge.ts(doc + ibs-manage)
├── ✅ 改造 router/index.ts — import 路径替换,业务代码零改动
├── ✅ 改造 App.vue — getPathFromParent 来源替换
├── ✅ 改造 main.ts / service.ts / linkCpf.vue — 所有引用替换
└── ✅ pnpm install 验证通过

Phase 3 ✅ 已完成:深度优化(2026-02-06)
├── ✅ 子应用预加载策略(preloadChildApps 高/低优先级分级)
├── ✅ wujie sync 路由同步评估(当前手动方案已满足,sync 留待后续)
├── 通信层类型安全增强(后续迭代)
└── 性能监控与错误上报(后续迭代)

Phase 4 ✅ 已完成:CSS 统一(2026-02-06)
├── ✅ doc 子应用 UnoCSS → Tailwind CSS 4
├── ✅ ibs-manage 子应用 UnoCSS → Tailwind CSS 4
├── ✅ package.json 依赖替换(+2 -60 packages)
├── ✅ stylelintrc.json 规则更新
└── 其他子应用迁入时直接使用 Tailwind CSS 4

Phase 5 📋 规划中:公共资源沉淀与 AI 编程能力
├── 详见 doc/前端架构治理演进规划.md

附录 A:wujie 核心概念速查

概念 说明
alive 模式 子应用实例常驻内存,切换时不销毁。适合多 tab 场景
bus 全局事件总线,主子应用共享。bus.$on / bus.$emit
props 主应用通过 :props 向子应用传递数据,子应用通过 window.__WUJIE.props 读取
preloadApp 预加载子应用资源(HTML/JS/CSS),不渲染 DOM
sync 路由同步模式,子应用路由自动映射到主应用 URL query
degrade 降级模式,当浏览器不支持 Proxy 时自动降级为 iframe

附录 B:常用命令

# 启动主应用
pnpm --filter @cmclink/main dev

# 启动主应用 + 单证子应用
pnpm --filter @cmclink/main --filter @cmclink/doc dev

# 全量启动
pnpm dev

# 构建
pnpm build

# 增量构建(只构建变更的应用)
pnpm build:affected

附录 C:新增子应用接入指南

新增一个子应用只需 3 步:

1. 注册表添加配置packages/micro-bridge/src/registry.ts):

{
  name: 'new-app',
  entry: '/new-app/',
  activeRule: '/new-app',
  port: 3008,
}

2. 主应用路由添加占位src/router/index.ts):

{
  path: '/new-app',
  name: 'NewApp',
  component: { render: () => null },
  meta: { name: '新应用', appName: 'new-app' },
}

3. tabs.ts 添加子应用路径和 tab 信息

// childPathList 添加
'/new-app'

// appList 添加
{ name: '新应用', nameEn: 'New App', appName: 'new-app', route: '/new-app', show: false }

App.vue 中的 <WujieVue> 会自动基于注册表渲染,无需修改

服务拆分之旅:测试过程全揭秘|得物技术

一、引言

代码越写越多怎么办?在线等挺急的! Bidding-interface服务代码库代码量已经达到100w行!!

Bidding-interface应用是出价域核心应用之一,主要面向B端商家。跟商家后台有关的出价功能都围绕其展开。是目前出价域代码量最多的服务。

随着出价业务最近几年来的快速发展,出价服务承接的流量虽然都是围绕卖家出价,但是已远远超过卖家出价功能范围。业务的快速迭代而频繁变更给出价核心链路高可用、高性能都带来了巨大的风险。

经总结有如下几个痛点:

  • 核心出价链路未隔离:

    出价链路各子业务模块间代码有不同程度的耦合,迭代开发可扩展性差,往往会侵入到出价主流程代码的改动。每个子模块缺乏独立的封装,而且存在大量重复的代码,每次业务规则调整,需要改动多处,容易出现漏改漏测的问题。

  • 大单体&功能模块定义混乱:

    历史原因上层业务层代码缺乏抽象,代码无法实现复用,需求开发代码量大,导致需求估时偏高,经常出现20+人日的大需求,需求开发中又写出大量重复代码,导致出价服务代码库快速膨胀,应用启动耗时过长,恶性循环。

  • B/C端链路未隔离:

    B端卖家出价链路流量与C端价格业务场景链路流量没有完全隔离,由于历史原因,有些B端出价链路接口代码还存在于price应用中,偶尔B端需求开发会对C端应用做代码变更。存在一定的代码管控和应用权限管控成本。

  • 发布效率影响:

    代码量庞大,导致编译速度缓慢。代码过多,类的依赖关系更为复杂,持续迭代逐步加大编译成本,随着持续迭代,新的代码逻辑 ,引入更多jar 依赖,间接导致项目部署时长变长蓝绿发布和紧急问题处理时长显著增加;同时由于编译与部署时间长,直接影响开发人员在日常迭代中的效率(自测,debug,部署)。

  • 业务抽象&分层不合理:

    历史原因出价基础能力领域不明确,出价底层和业务层分层模糊,业务层代码和出价底层代码耦合严重,出价底层能力缺乏抽象,上层业务扩展需求频繁改动出价底层能力代码。给出价核心链路代码质量把控带来较高的成本, 每次上线变更也带来一定的风险。

以上,对于Bidding服务的拆分和治理,已经箭在弦上不得不发。否则,持续的迭代会继续恶化服务的上述问题。

经过前期慎重的筹备,设计,排期,拆分,和测试。目前Bidding应用经过四期的拆分节奏,已经马上要接近尾声了。服务被拆分成三个全新的应用,目前在小流量灰度放量中。

本次拆分涉及:1000+Dubbo接口,300+个HTTP接口,200+ MQ消息,100+个TOC任务,10+个 DJob任务。

本人是出价域测试一枚,参与了一期-四期的拆分测试工作。

项目在全组研发+测试的ALL IN投入下,已接近尾声。值此之际输出一篇文章,从测试视角复盘下,Bidding服务的拆分与治理,也全过程揭秘下出价域内的拆分测试过程。

二、服务拆分的原则

首先,在细节性介绍Bidding拆分之前。先过大概过一下服务拆分原则:

  • 单一职责原则 (SRP):  每个服务应该只负责一项特定的业务功能,避免功能混杂。

  • 高内聚、低耦合:  服务内部高度内聚,服务之间松耦合,尽量减少服务之间的依赖关系。

  • 业务能力导向:  根据业务领域和功能边界进行服务拆分,确保每个服务都代表一个完整的业务能力。

拆分原则之下,还有不同的策略可以采纳:基于业务能力拆分、基于领域驱动设计 (DDD) 拆分、基于数据拆分等等。同时,拆分时应该注意:避免过度拆分、考虑服务之间的通信成本、设计合理的 API 接口。

服务拆分是微服务架构设计的关键步骤,需要根据具体的业务场景和团队情况进行综合考虑。合理的服务拆分可以提高系统的灵活性、可扩展性和可维护性,而不合理的服务拆分则会带来一系列问题。

三、Bidding服务拆分的设计

如引言介绍过。Bidding服务被拆分出三个新的应用,同时保留bidding应用本身。目前共拆分成四个应用:Bidding-foundtion,Bidding-interface,Bidding-operation和Bidding-biz。详情如下:

  • 出价基础服务-Bidding-foundation:

出价基础服务,对出价基础能力抽象,出价领域能力封装,基础能力沉淀。

  • 出价服务-Bidding-interfaces:

商家端出价,提供出价基础能力和出价工具,提供商家在各端出价链路能力,重点保障商家出价基础功能和出价体验。

  • 出价运营服务-Bidding-operation:

出价运营,重点支撑运营对出价业务相关规则的维护以及平台其他域业务变更对出价域数据变更的业务处理:

  1. 出价管理相关配置:出价规则配置、指定卖家规则管理、出价应急隐藏/下线管理工具等;
  2. 业务大任务:包括控价生效/失效,商研鉴别能力变更,商家直发资质变更,品牌方出价资质变更等大任务执行。
  • 业务扩展服务-Bidding-biz:

更多业务场景扩展,侧重业务场景的灵活扩展,可拆出的现有业务范围:国补采购单出价,空中成单业务,活动出价,直播出价,现订现采业务,预约抢购,新品上线预出价,入仓预出价。

应用拆分前后流量分布情况:

图片

四、Bidding拆分的节奏和目标收益

服务拆分是项大工程,对目前的线上质量存在极大的挑战。合理的排期和拆分计划是重点,可预期的收益目标是灵魂。

经过前期充分调研和规划。Bidding拆分被分成了四期,每期推进一个新应用。并按如下六大步进行:

图片

Bidding拆分目标

  • 解决Bidding大单体问题: 对Bidding应用进行合理规划,完成代码和应用拆分,解决一直以来Bidding大单体提供的服务多而混乱,维护成本高,应用编译部署慢,发布效率低等等问题。
  • 核心链路隔离&提升稳定性: 明确出价基础能力,对出价基础能力下沉,出价基础能力代码拆分出独立的代码库,并且部署在独立的新应用中,实现出价核心链路隔离,提升出价核心链路稳定性。
  • 提升迭代需求开发效率: 完成业务层代码抽象,业务层做组件化配置化,实现业务层抽象复用,降低版本迭代需求开发成本。
  • 实现出价业务应用合理规划: 各服务定位、职能明确,分层抽象合理,更好服务于企/个商家、不同业务线运营等不同角色业务推进。

预期的拆分收益

  • 出价服务应用结构优化:

    完成对Bidding大单体应用合理规划拆分,向下沉淀出出价基础服务应用层,降低出价基础能力维护成功;向上抽离出业务扩展应用层,能够实现上层业务的灵活扩展;同时把面向平台运营和面向卖家出价的能力独立维护;在代码库和应用层面隔离,有效减少版本迭代业务需求开发变更对应用的影响面,降低应用和代码库的维护成本。

  • 完成业务层整体设计,业务层抽象复用,业务层做组件化配置化,提升版本迭代需求开发效率,降低版本迭代需求开发成本:

    按业务类型对业务代码进行分类,统一设计方案,提高代码复用性,支持业务场景变化时快速扩展,以引导降价为例,当有类似降价换流量/降价换销量新的降价场景需求时,可以快速上线,类似情况每个需求可以减少10-20人日开发工作量。

  • 代码质量提升 :

    通过拆分出价基础服务和对出价流程代码做重构,将出价基础底层能力代码与上层业务层代码解耦,降低代码复杂度,降低代码冲突和维护难度,从而提高整体代码质量和可维护性。

  • 开发效率提升 :

    1. 缩短应用部署时间: 治理后的出价服务将加快编译和部署速度,缩短Bidding-interfaces应用发布(编译+部署)时间 由12分钟降低到6分钟,从而显著提升开发人员的工作效率,减少自测、调试和部署所需的时间。以Bidding服务T1环境目前一个月编译部署至少1500次计算,每个月可以节约150h应用发布时间。
    2. 提升问题定位效率: 出价基础服务层与上层业务逻辑层代码库&应用分开后,排查定位开发过程中遇到的问题和线上问题时可以有效缩小代码范围,快速定位问题代码位置。

五、测试计划设计

服务拆分的前期,研发团队投入了大量的心血。现在代码终于提测了,进入我们的测试环节:

为了能收获更好的质量效果,同时也为了不同研发、测试同学的分工。我们需要细化到最细粒度,即接口维度整理出一份详细的文档。基于此文档的基础,我们确定工作量和人员排期:

如本迭代,我们投入4位研发同学,2位测试同学。完成该200个Dubbo接口和100个HTTP接口,以及20个Topic迁移。对应的提测接口,标记上负责的研发、测试、测试进度、接口详细信息等内容。

基于该文档的基础上,我们的工作清晰而明确。一个大型的服务拆分,也变成了一步一步的里程碑任务。

接下来给大家看一下,关于Bidding拆分。我们团队整体的测试计划,我们一共设计了五道流程。

  • 第一关:自测接口对比:

    每批次拆分接口提测前,研发同学必须完成接口自测。基于新旧接口返回结果对比验证。验证通过后标记在文档中,再进入测试流程。

    对于拆分项目,自测卡的相对更加严格。由于仅做接口迁移,逻辑无变更,自测也更加容易开展。由研发同学做好接口自测,可以避免提测后新接口不通的低级问题。提高项目进度。

    在这个环节中。偶尔遇见自测不充分、新接口参数传丢、新Topic未配置等问题。(三期、四期测试中,我们加强了对研发自测的要求)。

  • 第二关:测试功能回归

    这一步骤基本属于测试的人工验证,同时重点需关注写接口数据验证。

    回归时要测的细致。每个接口,测试同学进行合理评估。尽量针对接口主流程,进行细致功能回归。由于迁移的接口数量多,历史逻辑重。一方面在接口测试任务分配时,要尽量选择对该业务熟悉的同学。另一方面,承接的同学也有做好历史逻辑梳理。尽量不要产生漏测造成的问题。

    该步骤测出的问题五花八门。另外由于Bidding拆分成多个新服务。两个新服务经常彼此间调用会出现问题。比如二期Bidding-foundation迁移完成后,Bidding-operation的接口在迁移时,依赖接口需要从Bidding替换成foundation的接口。

    灰度打开情况下,调用新接口报错仍然走老逻辑。(测试时,需要关注trace中是否走了新应用)。

  • 第三关:自动化用例

    出价域内沉淀了比较完善的接口自动化用例。在人工测试时,测试同学可以借助自动化能力,完成对迁移接口的回归功能验证。

    同时在发布前天,组内会特地多跑一轮全量自动化。一次是迁移接口开关全部打开,一次是迁移接口开关全部关闭即正常的自动化回归。然后全员进行排错。

    全量的自动化用例执行,对迁移接口问题拦截,有比较好的效果。因为会有一些功能点,人工测试时关联功能未考虑到,但在接口自动化覆盖下无所遁形。

  • 第四关:流量回放

    在拆分接口开关打开的情况下,在预发环境进行流量回放。

    线上录制流量的数据往往更加复杂,经常会测出一些意料之外的问题。

    迭代过程中,我们组内仍然会在沿用两次回放。迁移接口开关打开后回放一次,开关关闭后回放一次。(跟发布配置保持一致)。

  • 第五关:灰度过程中,关闭接口开关,功能回滚

    为保证线上生产质量,在迁移接口小流量灰度过程中。我们持续监测线上问题告警群。

    以上,就是出价域测试团队,针对服务拆分的测试流程。同时遵循可回滚的发布标准,拆分接口做了非常完善的灰度功能。下一段落进行介绍。

六、各流量类型灰度切量方案

出价流程切新应用灰度控制从几个维度控制:总开关,出价类型范围,channel范围,source范围,bidSource范围,uid白名单&uid百分比(0-10000):

  • 灰度策略
  • 支持 接口维度 ,按照百分比进行灰度切流;

  • 支持一键回切;

Dubbo接口、HTTP接口、TOC任务迁移、DMQ消息迁移分别配有不同的灰度策略。

七、结语

拆分的过程中,伴随着很多迭代需求的开发。为了提高迁移效率,我们会在需求排期后,并行处理迭代功能相关的接口,把服务拆分和迭代需求一起完成掉。

目前,我们的拆分已经进入尾声。迭代发布后,整体的技术项目就结束了。灰度节奏在按预期节奏进行~

值得一提的是,目前我们的流量迁移仍处于第一阶段,即拆分应用出价域内灰度迁移,上游不感知。目前所有的流量仍然通过bidding服务接口进行转发。后续第二阶段,灰度验证完成后,需要进行上游接口替换,流量直接请求拆分后的应用。

往期回顾

1.大模型网关:大模型时代的智能交通枢纽|得物技术

2.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

3.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

4.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

5.Galaxy比数平台功能介绍及实现原理|得物技术

文 /寇森

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌