阅读视图

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

《前端架构设计》:除了写代码,我们还得管点啥

《前端架构设计》:除了写代码,我们还得管点啥

很多人对“前端架构”这四个字有误解,觉得就是选个 React 还是 Vue,或者是折腾一下 Webpack 和 Vite。

但 Micah Godbolt 在《前端架构设计》里泼了一盆冷水:选工具只是最简单的一步。真正的架构,是让团队在业务疯狂迭代的时候,代码还能写得顺手,系统还能跑得稳当。

这本书的核心其实就是一句话:架构不是结果,而是为了让开发更爽、系统更强而设计的一整套制度。


一、 别把架构师当成“高级打工人”

在很多项目里,前端总是在“下游”接活:UI 给图,后端给口,前端就在中间拼命赶进度。但作者认为,前端架构师应该是项目里的“一等公民”,甚至是“城市规划师”。

1. 搭体系 (System Design)

你得像规划城市一样去规划代码。

  • 组件库的颗粒度:到底是一个按钮算一个组件,还是一个带图标的搜索框算一个组件?这直接决定了复用效率。
  • 样式冲突预防:不同业务线共用一套组件时,怎么保证 A 团队改的样式不会让 B 团队的页面崩掉?
  • 技术选型:不能光看 GitHub 上哪个库星星多,得看它能不能管个三五年。架构师要考虑的是“可持续性”,而不是“时髦值”。

2. 理流程 (Workflow Planning)

大家开发得顺不顺手,全看流程顺不顺。架构师得操心:

  • 环境一致性:是不是每个人的 Node 版本、依赖版本都一样?
  • 构建自动化:代码怎么自动化编译、压缩、分包?
  • 新人上手成本:能不能让新人进来半天就能配好环境开始写业务? 很多时候,一个好的构建工具(比如现在的 Vite 或者以前的 Webpack)配上一套好流程,比写出神仙代码更能救命。

3. 盯着质量 (Oversight)

业务跑得快,脏代码肯定多。架构师得心里有数:

  • 技术债管理:什么时候该去清理那些“为了上线先凑合”的代码?
  • 代码审查 (Code Review):不是为了找茬,而是为了同步思路。
  • 风险预判:业务下个月要搞大促,现在的系统架构能不能扛住高并发和频繁的样式变更? 别等项目成了“屎山”才去想重构,那时候可能连下脚的地方都没了。

二、 架构的四个核心支柱

作者把架构分成了四个板块:代码、流程、测试、文档。这四块拼图凑齐了,项目才算有了魂。

1. 代码 (Code):拒绝“意大利面条”

代码架构的核心就是“解耦”。别让 HTML、CSS 和 JS 粘得太死,要像乐高积木一样可以随时拆卸。

HTML:结构的纯粹性
  • 拒绝过度生成:要在自动化和控制力之间找平衡。别让后端逻辑直接吐一堆乱七八糟的标签出来,那样前端根本没法改。
  • 组件化思维:HTML 只负责描述“这是什么”(比如这是一个导航栏),而不负责“它长什么样”。
  • 语义化:保持 HTML 的简洁和语义化,是架构长青的基石。
CSS:架构的重灾区

这是全书聊得最细的地方,因为 CSS 最容易写乱,也最难维护。

  • OOCSS (面向对象 CSS)
    • 核心是“结构与皮肤分离”。
    • 比如一个按钮的形状、边距是“结构”,它的背景色、投影是“皮肤”。
  • SMACSS
    • 给样式分分类,就像衣柜收纳:
      1. Base:基础样式,比如 body, a 标签的默认外观。
      2. Layout:布局样式,负责页面大框架。
      3. Module:模块样式,这是重头戏,比如导航条、轮播图。
      4. State:状态样式,比如 is-active, is-hidden
      5. Theme:主题样式,换肤全靠它。
  • BEM 命名法
    • 虽然 block__element--modifier 名字长,但它能解决权重冲突。
    • 坚持用单类名(Single Class Selection),重构的时候底气才足,不用担心改个按钮全站崩。
  • 单一来源 (Single Source of Truth)
    • 变量(Variables)是神。颜色、字号、间距,全用变量管起来,改一个地方全站生效。
JavaScript:逻辑的独立性
  • 框架无关性:选框架要冷静。业务逻辑要尽量从 UI 库里抽出来。
  • 纯函数:尽量写不依赖外部环境的函数,这样不开启浏览器也能跑单元测试。
  • 状态管理:清晰的数据流向是大型项目不崩溃的前提。

2. 流程 (Process):别把时间花在重复劳动上

流程决定了代码从你的键盘到用户屏幕的距离。

  • 原型驱动 (Prototyping)

    • 别光看设计稿,先写个 HTML 原型。
    • 为什么?因为原型能让你早点体验到真实的交互,早点发现问题。
    • 改原型永远比改正式代码便宜。
  • 任务自动化

    • 凡是能让机器干的活(编译 Sass、压图、跑 Lint),都别让人干。
    • 现在的 npm scripts 或者是各种 Task Runner 就是为了解放生产力的。
  • 持续集成 (CI)

    • 代码合并前,必须经过一顿“毒打”——编译、Lint 检查、自动化测试。
    • 只有通过了,才能合并。这能保证主干代码永远是健康的。
  • 文档化工作流:让每个步骤都有迹可循,减少沟通成本。


3. 测试 (Testing):你的后悔药

没有测试的重构就是裸奔。

  • 视觉回归测试 (Visual Regression)

    • 这是作者最推崇的黑科技。
    • 重构 CSS 之后,用工具(比如 BackstopJS)自动截图和以前对比,像素级的差异都能找出来。
    • 这是重构老旧系统的“保命符”,有了它,你才敢动那些几年前写的样式。
  • 性能预算 (Performance Budget)

    • 性能不是后期优化的,是前期定好的。
    • 首屏时间不能超过几秒?JS 包体积不能超过多大?
    • 定死这些指标,加第三方库的时候你才会心疼,才会去想有没有更好的替代方案。
  • 自动化单元测试:保证核心逻辑的稳定性,改代码不再提心吊胆。


4. 文档 (Documentation):它是活的吗?

文档不是写给老板交差的,是给团队对齐思路的。

  • 动态样式指南 (Living Style Guides)

    • 静态文档写完就过期。
    • 理想的状态是:文档就是代码的一部分。代码改了,文档自动更新。
  • 模式库 (Pattern Lab)

    • 按照“原子设计”的思路管理组件:
      • 原子 (Atoms):标签、按钮、输入框。
      • 分子 (Molecules):带标签的搜索栏。
      • 有机体 (Organisms):整个导航头部。
      • 模板 (Templates):页面骨架。
      • 页面 (Pages):最终呈现。
    • 这种层级关系理清楚了,组件复用才不会乱成一团。
  • 开发者文档:记录“为什么要这么设计”,比“怎么用”更重要。


三、 Red Hat 的实战经验:怎么干重构?

作者在 Red Hat 负责过一次大规模重构,这部分的实战干货非常多,值得细品:

1. 解决命名空间冲突

在大型公司,不同团队可能都在写 CSS。以前样式到处打架,改个按钮全公司网站都变色。

  • 方案:重构时,他们给所有核心组件加了 rh- 这种命名前缀。

  • 感悟:技术虽然简单,但它建立了“地盘感”,彻底实现了样式隔离。

2. 语义化网格系统 (Semantic Grids)

  • 痛点:别在 HTML 里写死 .col-6 这种类名。如果你想把 6 列改成 4 列,你得改几百个 HTML 文件。

  • 绝招:在 Sass 里用 Mixins 定义布局。

    • HTML 只要保持语义(比如 .main-content)。
    • CSS 里写 @include make-column(8);
  • 结果:改布局只要动一个 CSS 配置文件,HTML 完全不用变。

3. 文档系统的四阶进化

Red Hat 的文档不是一步到位的,他们经历了四个阶段:

  1. 第一阶段:静态页面。写完就没人看了,很快就和代码脱节。
  2. 第二阶段:自动化 Pattern Lab。让文档和代码同步,实现了“活文档”。
  3. 第三阶段:独立组件库。把组件从业务项目里剥离出来,像发 npm 包一样管理,跨项目复用变得极其简单。
  4. 第四阶段:统一渲染引擎
    • 核心:用 JSON Schema 定义数据格式。
    • 效果:不管后端是 PHP 还是 Java,只要按这个 JSON 格式给数据,前端组件就能准确渲染。这彻底解决了前后端对接时的“扯皮”问题,前端成了真正的“界面引擎”。

四、 架构师的心法:BB 鸟与歪心狼

书中提到了两个非常有意思的隐喻,这才是架构设计的最高境界:

1. BB 鸟规则 (Roadrunner Rule)

看过动画片的都知道,BB 鸟跑得飞快,而歪心狼(Coyote)总是背着一堆高科技装备,结果最后都被装备给坑了。

  • 道理:架构要轻量,要解决的是“现在”和“可预见的未来”的问题。
  • 戒律:别为了解决那种万分之一才会出现的特殊情况,把系统搞得无比复杂。别把自己变成那个被装备压垮的歪心狼。

2. 解决“最后一英里”问题

代码写完、测试通过、合并进主干,这就算完了吗?架构师说:还没。

  • 全路径关注
    • 静态资源在 CDN 上刷了吗?
    • 用户的浏览器缓存策略配对了吗?
    • 第三方广告脚本会不会把页面卡死?
    • 在偏远地区、慢网环境下,用户看到的是白屏还是有意义的内容? 架构师得关注从代码仓库到用户浏览器的“每一寸路程”。

五、 写在最后

前端架构不是一个静态的目标,而是一直在变的过程。一个好的架构师得会沟通,在技术理想和业务现实之间找平衡。

说到底,架构就是为了把这四块拼图理顺:

  1. 代码 得够模块化,重构不心慌;
  2. 流程 得够自动化,开发不心累;
  3. 测试 得够全面,上线不背锅;
  4. 文档 得够实时,沟通不扯皮。

如果你能把这几件事干好了,你写的就不只是代码,而是一个能长久活下去、有生命力的系统。这,才是前端架构设计的真谛。

iframe → wujie 迁移收益分析与子应用集成方案

一、背景

当前 CMCLink 平台存在两种微前端集成方案并行:

  • 旧方案:原生 iframe 嵌入 + postMessage 通信(mkt、doc、ibs-manage 等子应用在用)
  • 新方案:wujie iframe 沙箱 + bus 通信(template 子应用模板已验证,ibs-manage 已完成迁移试点)

本文档从决策层面工程层面两个维度分析:

  1. 为什么要从 iframe 迁移到 wujie?投入产出比如何?
  2. 如何将子应用集成复杂度降到最低,让不同部门愿意迁移?

二、旧 iframe 方案的真实痛点

以下痛点均来自 ibs-manage 子应用迁移前的实际代码,非理论推演。

2.1 通信机制:postMessage 的脆弱性

旧方案代码(子应用 App.vue):

// 子应用监听主应用消息
window.addEventListener('message', (event: MessageEvent) => {
  if (typeof event.data?.type === 'string') {
    if (event.data.type === 'router-change') {
      router.push({ path: event.data.payload.route })
    } else if (event.data.type === 'close-all-tab') {
      tagsViewStore.delAllViews()
    }
  }
})

// 主应用通知子应用
window.parent.postMessage({ type: 'router-change', payload: { route } }, origin)

问题

问题 影响
消息类型是字符串魔法值,无 TypeScript 类型约束 拼写错误不会编译报错,只能运行时排查
数据需要序列化(不支持函数、循环引用) 复杂数据传递受限
跨域时 origin 校验容易出错 安全隐患或消息丢失
每个子应用独立实现消息协议 协议不统一,新增事件需要双端同步修改
无法追踪消息链路 调试困难,console.log 满天飞

wujie 方案

// 统一的 EventEmitter 模式,有类型约束
bus.$emit('CHILD_ROUTE_CHANGE', { appName, path, name, query })
bus.$on('ROUTE_CHANGE_TO_CHILD', (data) => { router.push(data.path) })

2.2 路由同步:hack 堆叠

旧方案代码(子应用 App.vue):

// 路由恢复:3 层 fallback
const restoreRoute = () => {
  let targetPath = ''
  // 1. 尝试从父页面 URL 参数获取
  try {
    if (window.parent !== window && window.parent.location.hostname === window.location.hostname) {
      targetPath = new URLSearchParams(window.parent.location.search).get('childPath') || ''
    }
  } catch { /* 跨域失败 */ }
  // 2. 回退到 localStorage
  if (!targetPath) {
    targetPath = localStorage.getItem('ibs-manage-latest-path') || ''
  }
  // 3. 兜底空路径
  router.replace(targetPath || '')
}

问题

问题 影响
跨域部署时 parent.location 不可访问 路由恢复完全失效
localStorage 在多 Tab 场景下互相覆盖 Tab A 刷新可能恢复到 Tab B 的路由
主应用 URL 不反映子应用当前路由 无法通过 URL 分享/收藏具体页面
每个子应用独立实现恢复逻辑 代码重复,bug 各异

wujie 方案

# 主应用 URL 自动同步子应用路由(sync 模式)
http://localhost:3000/ibs-manage/operation/xxx?ibs-manage={~}/operation/xxx

# F5 刷新时 wujie 自动从 URL query 恢复子应用路由,零代码

2.3 状态共享:Token 传递的安全隐患

旧方案

  • Token 通过 URL 参数传递给 iframe → 明文暴露在浏览器历史记录和服务器日志中
  • 或依赖同域 Cookie → 跨域部署时失效
  • 或通过 postMessage 传递 → 需要手动管理刷新/过期同步

wujie 方案

// 主应用通过 props 注入,子应用通过 __WUJIE.props 读取
// 内存传递,不经过 URL/Cookie,不序列化
const sharedAuth = (window as any).__WUJIE?.props?.$shared?.auth
// token、userInfo、permissions、menus 一次性获取

2.4 性能:首屏加载体验

指标 iframe 方案 wujie 方案
首次打开子应用 2-5 秒白屏(iframe 从零加载 HTML + JS + CSS) <500ms(preloadApp 预加载 + alive 保活)
切换已访问子应用 1-3 秒(iframe 重新加载或从 bfcache 恢复) 瞬切(alive 模式保持 Vue 实例不销毁)
子应用内部路由切换 正常 正常
keep-alive 页面缓存 ❌ 不支持(iframe 销毁即丢失) ✅ 支持(alive 模式 + 自动缓存同步)

2.5 开发体验

维度 iframe 方案 wujie 方案
DevTools 调试 需切换 iframe context 同一 DevTools 窗口
HMR 热更新 正常 正常
独立运行 ✅ 支持 ✅ 支持
联调成本 高(需同时启动主应用 + 子应用,调试 postMessage 链路) 低(bus 事件可在 DevTools 中直接观察)

三、迁移投入成本

3.1 一次性投入(已完成)

项目 工时 状态
@cmclink/micro-bootstrap 子应用启动器 2 人天 ✅ 已完成
@cmclink/micro-bridge 通信桥接 + 注册表 2 人天 ✅ 已完成
@cmclink/vite-config 统一构建配置 1 人天 ✅ 已完成
主应用 AuthenticatedLayout WujieVue 容器 1 人天 ✅ 已完成
主应用 shared-provider 状态广播 1 人天 ✅ 已完成
template 子应用模板验证 0.5 人天 ✅ 已完成
ibs-manage 迁移试点 1 人天 ✅ 已完成
合计 ~8.5 人天 已完成

3.2 单个子应用迁移成本

基于 ibs-manage 实际迁移数据:

步骤 工时 说明
改 vite.config.ts 15 分钟 替换为 createChildAppConfig
改 src/main.ts 30 分钟 替换为 bootstrapMicroApp
改 src/App.vue 2-4 小时 wujie 适配(共享数据注入 + 路由恢复)
改 src/router/index.ts 15 分钟 删除旧通知逻辑
改 env + package.json 15 分钟 端口 + 依赖
主应用配置 15 分钟 env + 路由 + 注册表
联调验证 2-4 小时 路由 + 状态 + Tab + 刷新
合计 0.5-1.5 人天 视子应用复杂度而定

3.3 长期维护成本对比

场景 iframe 方案 wujie 方案
新增通信事件 双端各加 postMessage 处理(~30 行/事件) 注册表加类型 + bus.$on(~5 行/事件)
新增子应用 复制粘贴 ~150 行通信代码 + 调试适配 bootstrapMicroApp 一行启动
修复路由同步 bug 每个子应用独立排查 统一在 micro-bridge 修复,所有子应用受益
升级 Vue/Router 版本 每个子应用独立处理 micro-bootstrap 统一兼容

四、风险评估

4.1 迁移风险

风险 概率 影响 缓解措施
wujie 框架停止维护 低(GitHub 活跃,腾讯开源) wujie 核心代码量小(~3000 行),可 fork 自维护
子应用版本不兼容 micro-bootstrap 已放宽类型约束,支持 vue@3.4/3.5 共存
样式隔离不完美 WebComponent shadowDOM 隔离 + teleported=false 弹窗隔离
迁移期间两套方案并存 确定 App.vue 中 isWujie / isInIframe 分支兼容,可渐进迁移

4.2 不迁移的风险

风险 概率 影响
postMessage 协议碎片化加剧 高 — 新子应用接入成本持续增加
路由同步 bug 反复出现 中 — 用户体验差,开发疲于修补
无法实现 keep-alive 缓存 确定 中 — 表单填写中途切换 Tab 数据丢失
首屏性能无法优化 确定 中 — 每次切换子应用白屏 2-5 秒

五、子应用集成简化方案(v2 提案)

5.1 问题:当前集成仍然太重

ibs-manage 迁移后,App.vue 中仍有 ~80 行 wujie 适配代码

injectSharedDataFromWujie()     — 25 行(从 props.$shared 注入 token/user/menus)
wujie 路由恢复                   — 15 行(await generateRoutes + router.replace)
isWujie 环境检测                 — 3document.title / localStorage 分支 — 10 行
旧 iframe 兼容逻辑               — 30

这些代码对每个子应用都是几乎相同的模板代码。如果其他部门(mkt、doc、finance 等)迁移时都要手动写这些,集成意愿度会很低。

5.2 目标:子应用 App.vue 零 wujie 感知

理想状态:子应用开发者完全不需要知道 wujie 的存在。App.vue 只写业务逻辑,所有微前端适配在 bootstrapMicroApp 中自动完成。

5.3 方案:micro-bootstrap 新增 sharedData 配置

BootstrapOptions 中新增一个配置项,让 micro-bootstrap 自动完成共享数据注入:

// ===== 子应用 main.ts(简化后)=====
bootstrapMicroApp({
  app: App,
  router,
  pinia: store,
  appId: '#ibs-manage',
  appName: 'ibs-manage',
  tagsViewStore: () => useTagsViewStore(store),
  plugins: [setupI18n, setupElementPlus, setupGlobCom],

  // 🆕 共享数据注入配置(micro-bootstrap 自动处理 wujie props → 本地缓存)
  sharedData: {
    // 缓存适配器:告诉 bootstrap 如何读写子应用的本地缓存
    cache: {
      get: (key: string) => wsCache.get(key),
      set: (key: string, value: any) => wsCache.set(key, value),
    },
    // 缓存 key 映射
    keys: {
      accessToken: 'ACCESS_TOKEN',
      refreshToken: 'REFRESH_TOKEN',
      user: 'USER',  // 对应 CACHE_KEY.USER
    },
  },

  // 🆕 动态路由注册回调(可选,有动态路由的子应用才需要)
  onBeforeMount: async ({ router, cache }) => {
    const userInfo = cache.get('USER')
    if (userInfo?.menus) {
      userStore.menus = userInfo.menus
      await generateRoutes()
    }
  },
})

子应用 App.vue 变化

  // ========== 应用初始化 ==========
  const init = async () => {
-   // wujie 环境:从主应用共享数据注入 token 和用户信息
-   if (isWujie) {
-     injectSharedDataFromWujie()
-   }
-
-   // 从缓存加载用户信息并生成动态路由
-   const userInfo = wsCache.get(CACHE_KEY.USER)
-   if (userInfo) {
-     if (userInfo.menus) {
-       userStore.menus = userInfo.menus
-       await generateRoutes()
-     }
-     if (userInfo.user) {
-       userInfoRef.value = userInfo.user
-     }
-   }
+   // 共享数据注入 + 动态路由注册已由 micro-bootstrap 自动处理
+   // 此处只需读取缓存中的用户信息用于 UI 显示
+   const userInfo = wsCache.get(CACHE_KEY.USER)
+   if (userInfo?.user) {
+     userInfoRef.value = userInfo.user
+   }

    if (getAccessToken()) {
      userStore.setUserInfoAction()
    }

-   // wujie 环境:动态路由注册后重新匹配当前路径
-   if (isWujie) {
-     const currentPath = router.currentRoute.value.fullPath
-     if (currentPath && currentPath !== '/') {
-       await router.replace(currentPath)
-     }
-   }
-
-   // 非 wujie 环境:从 localStorage / 父页面恢复路由
-   restoreRouteForLegacyMode()
+   // 路由恢复已由 micro-bootstrap 自动处理(wujie sync / localStorage fallback)
  }

减少 ~50 行 wujie 适配代码,App.vue 只剩纯业务逻辑。

5.4 micro-bootstrap 内部实现要点

// micro-bootstrap 内部(伪代码)
async function bootstrapMicroApp(options: BootstrapOptions) {
  const isWujie = !!(window as any).__WUJIE

  // ... 创建 app、安装插件 ...

  // 🆕 自动注入共享数据(mount 之前)
  if (isWujie && options.sharedData) {
    injectSharedData(options.sharedData)
  }

  // 🆕 执行用户自定义的 mount 前回调(动态路由注册等)
  if (options.onBeforeMount) {
    await options.onBeforeMount({
      router,
      cache: options.sharedData?.cache,
    })
  }

  // 挂载应用
  mount()

  // 🆕 wujie 环境:动态路由注册后自动恢复路由
  if (isWujie) {
    const currentPath = router.currentRoute.value.fullPath
    if (currentPath && currentPath !== '/') {
      await router.replace(currentPath)
    }
  }
}

function injectSharedData(config: SharedDataConfig) {
  const sharedAuth = (window as any).__WUJIE?.props?.$shared?.auth
  if (!sharedAuth) return

  const { cache, keys } = config
  if (sharedAuth.token) cache.set(keys.accessToken, sharedAuth.token)
  if (sharedAuth.refreshToken) cache.set(keys.refreshToken, sharedAuth.refreshToken)
  if (sharedAuth.userInfo) {
    const cachedUser = cache.get(keys.user) || {}
    cachedUser.user = sharedAuth.userInfo
    cachedUser.permissions = sharedAuth.permissions || []
    cachedUser.roles = sharedAuth.roles || []
    if (sharedAuth.menus) cachedUser.menus = sharedAuth.menus
    cache.set(keys.user, cachedUser)
  }
}

5.5 集成复杂度对比

维度 旧 iframe 方案 wujie 当前(v1) wujie 简化后(v2 提案)
main.ts ~185 行(手动生命周期) ~53 行(bootstrapMicroApp) ~53 行(不变)
App.vue wujie 代码 0(但有 ~150 行 postMessage 代码) ~80 行 ~10 行(仅读缓存用于 UI)
router/index.ts ~71 行(含旧通知) ~52 行 ~52 行(不变)
子应用需要理解的概念 postMessage 协议、origin 校验、序列化 wujie props、bus、sync、prefix 只需知道 bootstrapMicroApp 的配置项
新增子应用工时 1-2 人天 0.5-1.5 人天 2-4 小时

5.6 对不同子应用类型的适配

子应用类型 sharedData onBeforeMount 说明
新子应用(无动态路由) ✅ 配置 不需要 最简单,2 小时搞定
存量子应用(有动态路由) ✅ 配置 ✅ 提供回调 需要在回调中注册动态路由
存量子应用(有旧 iframe 兼容) ✅ 配置 ✅ 提供回调 App.vue 保留 isInIframe 分支,渐进清理

六、推荐迁移策略

6.1 渐进式迁移路线

阶段一(已完成):基础设施 + ibs-manage 试点
    ↓
阶段二(当前):实现 v2 简化方案 + 迁移 doc 子应用验证
    ↓
阶段三:推广到 mkt、finance 等子应用(各部门自行迁移,提供文档 + 模板)
    ↓
阶段四:清理旧 iframe 兼容代码 + 统一 vue/vue-router 版本

6.2 并行兼容期

迁移期间,子应用 App.vue 通过 isWujie / isInIframe 分支同时支持两种模式:

const isWujie = !!(window as any).__WUJIE
const isInIframe = !isWujie && window.parent !== window

// wujie 环境:由 micro-bootstrap 自动处理
// 旧 iframe 环境:保留原有 postMessage 逻辑
// 独立运行:正常启动

旧 iframe 方案不需要立即下线,可以在所有子应用迁移完成后统一清理。

6.3 各部门迁移支持

支持项 内容
迁移文档 docs/migration/ibs-manage-wujie-集成迁移指南.md(已产出)
子应用模板 apps/template/(可直接 copy 作为新子应用骨架)
排错指南 迁移文档第 7 章(8 个常见问题 + 排查步骤)
培训文档 docs/training/(5 章,覆盖 L1-L3 三个梯队)
Code Review 首个迁移子应用由架构组 review,后续自行迁移

七、决策建议

迁移的核心论点

基础设施投入(8.5 人天)已完成且沉没。单个子应用迁移成本仅 0.5-1.5 人天(v2 简化后降至 2-4 小时),但能获得:

  1. 首屏性能提升 5-10 倍(预加载 + alive 保活)
  2. 消除 ~150 行/子应用的重复通信代码
  3. F5 刷新可靠恢复(wujie sync 机制,零代码)
  4. keep-alive 页面缓存(iframe 方案无法实现)
  5. 统一的通信协议(有类型约束,bug 减少)

不迁移的隐性成本(每次新功能都要在 postMessage 协议上打补丁、路由同步 bug 反复出现、无法实现缓存)远大于一次性迁移成本。

建议行动项

  1. 评审本方案,确认 v2 简化方案的 API 设计
  2. 实现 v2 简化,在 micro-bootstrap 中落地 sharedData + onBeforeMount
  3. 用 doc 子应用验证 v2 简化方案的实际效果
  4. 发布迁移通知,各部门按优先级排期迁移
  5. 设定清理时间线,在所有子应用迁移完成后统一清理旧 iframe 代码

附录:术语表

术语 说明
wujie 腾讯开源的微前端框架,基于 iframe 沙箱 + WebComponent 容器
alive 模式 wujie 保活模式,切换子应用时不销毁 Vue 实例
sync 模式 wujie 路由同步模式,子应用路由写入主应用 URL query
prefix wujie sync 短路径映射,压缩 URL query 长度
micro-bootstrap CMCLink 子应用统一启动器,封装 wujie 生命周期
micro-bridge CMCLink 通信桥接层,封装 wujie bus + 子应用注册表
shared-provider 主应用状态广播器,将 Pinia store 数据广播给子应用

【前后端联调】接口代码生成 - hono + typescript + openapi 最佳实践

背景:在团队协作开发,前后端开发接口对齐永远是一道难题。在TS的世界里要保证类型安全往往浪费不必要的时间在定义类型上了。

实践方案:遵循 design-first,先设计接口,确定openapi文档。然后生成服务端API模板和前端请求SDK,保证类型安全,同时节省繁琐重复的代码编写时间。

约定 openAPI文档(通过apifox等方式)

这里展示使用apifox导出 openAPI描述文件。

image.png 一份简单的openAPI文档的json格式描述如下(默认模版.openapi.json):

{
  "openapi": "3.0.1",
  "info": {
    "title": "默认模块",
    "description": "",
    "version": "1.0.0"
  },
  "tags": [],
  "paths": {
"/sessions": {
      "post": {
        "summary": "登录",
        "deprecated": false,
        "description": "",
        "tags": [],
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                },
                "required": [
                  "email",
                  "password"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {}
                }
              }
            }
          }
        },
        "security": []
      }
    },
  },
  "components": {
    "schemas": {},
    "responses": {},
    "securitySchemes": {}
  },
  "servers": [],
  "security": []
}  
  

paths下定义了你的路由(包括路径、方法、入参、响应等等)。
openAPI一般有两个导出形式json和yaml。这里简单起见,只放了1个登录接口(/sessions)的定义。

后端

后端框架,有很多选项,比如express/koa,hono,nest等等。我选择了hono,主要因为能支持bun/node多运行时和性能不错。

生成 hono 代码有两种比较推荐的方式:

下面主要介绍使用 hono-takibi 。如果是需要生成其他TS服务端框架的模板代码,可以选择使用Kubb 。如果是针对Java Springboot 则使用openapi-generator

hono-takibi生成模板

首先介绍下@hono/zod-openapi,这个是在hono框架的基础上,提供了http入参校验(基于zod)和文档生成(代码即文档)。

使用hono-takibi生成的代码是基于@hono/zod-openapi。基于json/yaml文件生成命令如下:

npx hono-takibi [path/openapi.json] -o [path/routes.ts]

比如我在项目根目录下,生成模板代码。

image.png

以登录注册为例,下面是生成的服务端代码routes.ts

import { createRoute, z } from '@hono/zod-openapi'
export const postSessionsRoute = createRoute({
  method: 'post',
  path: '/sessions',
  tags: [],
  summary: '登录',
  request: {
    body: {
      content: {
        'application/json': {
          schema: z
            .object({ email: z.string(), password: z.string() })
            .openapi({ required: ['email', 'password'] }),
        },
      },
    },
  },
  responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
  security: [],
})


export const postUsersRoute = createRoute({
  method: 'post',
  path: '/users',
  tags: [],
  summary: '注册',
  request: {
    body: {
      content: {
        'application/json': {
          schema: z
            .object({
              email: z.string().openapi({ title: '邮箱' }),
              password: z.string().openapi({ title: '密码' }),
              emailCode: z.string().openapi({ title: '邮箱验证码' }),
              inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
            })
            .openapi({ required: ['email', 'password', 'emailCode'] }),
        },
      },
    },
  },
  responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
  security: [],
})

存在这么几个问题:

  1. 对于请求体的内容request.body.content 可以发现schema 是内联的,这不利于复用zod schema(不利于类型复用)。同理 还有request.queryresponses等。
  2. responses这里的响应体结构,没有做到复用。你可以看到{ 200: { content: {...}}}这种重复,这种不利于统一维护。

那么如何解决?

  1. 针对内联schema,我们这样解决:在apifox设计阶段,建立数据模型(这个是对应到服务端的DTO对象),最好是符合命名规范,对于query/body中的入参命名为XxxDto,对于响应结果命名为XxxResponseDto
  2. 针对响应体结构,定义统一的响应组件:在apifox设计阶段,建立响应组件(针对不同状态码200/201/400/401等)。同时针对固定响应结构需要设计一个数据模型ApiResponseDto 来填充。

数据模型和一些特定状态码的响应结构:

image.png

登录接口示例:

image.png

最终 再使用 hono-takibi 生成一下服务端 代码,如下:

import { createRoute, z } from '@hono/zod-openapi'

const UserResponseDtoSchema = z
  .object({ id: z.string(), email: z.string(), username: z.string(), avatar: z.string() })
  .openapi({ required: ['id', 'email', 'username', 'avatar'] })
  .openapi('UserResponseDto')

const CreateUserDtoSchema = z
  .object({
    email: z.string().openapi({ title: '邮箱' }),
    password: z.string().openapi({ title: '密码' }),
    emailCode: z.string().openapi({ title: '邮箱验证码' }),
    inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
  })
  .openapi({ required: ['email', 'password', 'emailCode'] })
  .openapi('CreateUserDto')

const LoginDtoSchema = z
  .object({ email: z.string(), password: z.string() })
  .openapi({ required: ['email', 'password'] })
  .openapi('LoginDto')

const ApiResponseDtoSchema = z
  .object({
    code: z.int().openapi({ description: '业务号码' }),
    data: z.object({}).nullable().openapi({ description: '业务数据' }),
    message: z.string().exactOptional().openapi({ description: '消息' }),
  })
  .openapi({ required: ['code', 'data'] })
  .openapi('ApiResponseDto')

const LoginResponseDtoSchema = z
  .object({
    accessToken: z.string().openapi({ description: '身份token' }),
    user: UserResponseDtoSchema,
  })
  .openapi({ required: ['accessToken', 'user'] })
  .openapi('LoginResponseDto')

const SuccessNullResponse = {
  description: '无内容的成功响应',
  content: { 'application/json': { schema: ApiResponseDtoSchema } },
}

const UnprocessableResponse = {
  description: '无法处理请求,失败响应',
  content: { 'application/json': { schema: ApiResponseDtoSchema } },
}

export const postSessionsRoute = createRoute({
  method: 'post',
  path: '/sessions',
  tags: [],
  summary: '登录',
  request: { body: { content: { 'application/json': { schema: LoginDtoSchema, examples: {} } } } },
  responses: {
    200: {
      description: '成功',
      headers: z.object({}),
      content: {
        'application/json': {
          schema: z
            .object({
              code: z.int().openapi({ description: '业务号码' }),
              data: LoginResponseDtoSchema.nullable().openapi({ description: '业务数据' }),
              message: z.string().exactOptional().openapi({ description: '消息' }),
            })
            .openapi({ required: ['code', 'data'] }),
        },
      },
    },
    400: UnprocessableResponse,
  },
  security: [],
})

export const postUsersRoute = createRoute({
  method: 'post',
  path: '/users',
  tags: [],
  summary: '注册',
  request: {
    body: { content: { 'application/json': { schema: CreateUserDtoSchema, examples: {} } } },
  },
  responses: {
    200: {
      description: '成功',
      headers: z.object({}),
      content: {
        'application/json': {
          schema: z
            .object({
              code: z.int().openapi({ description: '业务号码' }),
              data: z.object({}).nullable().openapi({ description: '业务数据' }),
              message: z.string().exactOptional().openapi({ description: '消息' }),
            })
            .openapi({ required: ['code', 'data'] }),
        },
      },
    },
    400: UnprocessableResponse,
  },
  security: [],
})


P.S.有个小问题,就是你定义 响应组件/响应时,一定要定义描述(description),否则生成代码会出现TS问题。

image.png

定义了description的效果:

image.png

集成到hono提供API服务

需要注意:前面提到的routes.ts中生成的xxxRoute是对参数校验和文档描述。实际上的路由逻辑是下面这样的:

import { OpenAPIHono } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import { postSessionsRoute, postUsersRoute } from './routes'

const app = new OpenAPIHono()

app.use(
  '/*',
  cors({
    origin: '*',
  })
)

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

app.openapi(postSessionsRoute, async (c) => {
  const { password } = c.req.valid('json')
  if (password.length < 6) {
    return c.json(
      {
        code: 400,
        data: null,
        message: '密码长度小于6',
      },
      400
    )
  }
  // TODO: 实现登录逻辑
  return c.json({
    code: 0,
    data: {
      accessToken: 'token',
      user: {
        id: '1',
        email: 'user@example.com',
        username: 'user',
        avatar: '',
      },
    },
  })
})

app.openapi(postUsersRoute, async (c) => {
  const { password } = c.req.valid('json')
  if (password.length < 6) {
    return c.json(
      {
        code: 400,
        data: null,
        message: '密码长度小于6',
      },
      400
    )
  }
  // TODO: 实现注册逻辑
  return c.json({
    code: 0,
    data: null,
  })
})

export default app

使用 bun dev 运行(这是一个honojs项目),然后在apifox中测试如下:

image.png

前端

生成TS客户端代码,选择就很多了

  • 最轻量:openapi-typescript + openapi-fetch。见openapi-ts.dev
  • 框架党首选:Orval (配合 TanStack Query/React Query),见orval.dev
  • 体系一致性方案:Kubb,前后端都采用Kubb。见kubb.dev
  • @hey-api/openapi-ts FastAPI官方就推荐 这个。见heyapi.dev

这里没有推荐 openapi-generator 了,因为确实存在一些局限性,生成的前端SDK并不好用,在TS安全类型上不如其他选择(运行这个工具还要折腾Java环境)。

下面重点介绍下 openapi-typescript + openapi-fetchhey-api 2种方式。

openapi-typescript + openapi-fetch

使用

1.安装 两个依赖

pnpm add openapi-typescript -D
pnpm add openapi-fetch

2.运行openapi-typescript 生成ts类型

npx openapi-typescript "../默认模块.openapi.json" -o "app/utils/openapi/schema.d.ts"

3.编写客户端代码

import createClient from "openapi-fetch";
import type { paths } from "./schema";

export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });

// Export type for use in components
export type Client = typeof client;

4.一个登录例子:

const { 
data,  // only present if 2XX response
error  // only present if 4XX or 5XX response
} = await client.POST("/sessions", {
    body: {
        email,
        password,
    },
});

if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
} else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
}

一个好的 fetch 包装器绝对不应该使用泛型。 泛型需要更多的输入,而且可能会隐藏错误!

可以看出client 提供了GETPOST等方法,熟悉的写法,传入url和body参数。返回的结果包括data和error,data就是我们前面定义的ApiResponseDto,如下:

const data: {
    code: number;
    data: {
        accessToken: string;
        user: {
            id: string;
            email: string;
            username: string;
            avatar: string;
        };
    };
    message?: string | undefined;
} | undefined

而 error,就是当状态码不是2xx时,不空,类型是UnprocessableResponse | XxxErrorResponse类型,如下:

const error: {
    code: number;
    data: Record<string, never> | null;
    message?: string;
} | undefined
特性

1.支持的请求库如下:

Library Size (min)
openapi-fetch 6 kB
openapi-typescript-fetch 3 kB
feature-fetch 15 kB
openapi-axios 32 kB

2.支持「中间件」。

使用axios的同学,肯定对请求拦截和响应拦截不陌生,而openapi-fetch 提供中间件完成同样的功能:

import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./schema";



const myMiddleware: Middleware = {
  async onRequest({ request, options }) {
    // set "Authorization" header,认证
    request.headers.set("Authorization", "Bearer " + "your_access_token"); 
    return request;
  },
  async onResponse({ request, response, options }) {
    const { body, ...resOptions } = response;
    console.log('body', body); // ReadableStream
    console.log('response', response);
    if (response.status === 401) {
      const error = new Error("Unauthorized");
      (error as Error & { status?: number }).status = 401;
      
      window.location.href = "/login";
      return
    }

    return response;
    // 或者 return new Response(body, { ...resOptions});
  },
  async onError({ error }) {
    // wrap errors thrown by fetch
    console.log('error', error);
    if (error instanceof Error) {
      return error;
    }
    return new Error("Oops, fetch failed", { cause: error });
  },
};


export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });


// register middleware
client.use(myMiddleware);

// Export type for use in components
export type Client = typeof client;

需要注意,openapi-fetch 一般不会抛出错误,比如401/403之类错误状态码(除非你在onResponse中手动抛出错误)。onError 回调函数允许你处理 fetch 抛出的错误。常见的错误包括 TypeError (当出现网络或 CORS 错误时可能发生)和 DOMException (当使用 AbortController 中止请求时可能发生)。

3.支持使用 DTO类型

之前生成的schema.d.ts中定义了interface components:

export interface components {
    schemas: {
        UserResponseDto: {
            id: string;
            email: string;
            username: string;
            avatar: string;
        };
        CreateUserDto: {
            /** 邮箱 */
            email: string;
            /** 密码 */
            password: string;
            /** 邮箱验证码 */
            emailCode: string;
            /** 邀请码 */
            inviteCode?: string;
        };
        LoginDto: {
            email: string;
            password: string;
        };
        ...

可以这样使用:

import { client , type components } from "~/utils/openapi";

const body: components["schemas"]["LoginDto"] = {
      email,
      password,
    }

4.对框架的支持

通过openapi-react-query库,也能支持结合tanstack query使用。use-query

hey-api

基础使用和特性

1.hey-api生成代码时,会创建一个文件夹(默认是"client")存放内容,和openapi-typescript相比,是它生成了一个默认的client,并且每个API都提供了方法直接调用(无需路径)

2.安装

pnpm add @hey-api/openapi-ts -D

3.配置openapi-ts命令

"scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "openapi-ts": "openapi-ts --input ../默认模块.openapi.json --output ./app/utils/heyapi"
  }

参数复杂了,也可以放配置文件/openapi-ts.config.ts中,比如:

import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: 'http://127.0.0.1:8800/openapi.json', //支持远程url和本地openapi文件
  output: './app/APIs',
  plugins: [{
      name: '@hey-api/client-fetch',
      runtimeConfigPath: '@/hey-api',  // 控制client.gen.ts生成 
    },
  ], 
});

4.执行pnpm openapi-ts. 生成的sdk代码都在heyapi目录下,比较复杂。请求方法的代码都生成在sdk.gen.ts,而DTO类型都生成在types.gen.ts中。

app/
├── utils/
│ ├── heyapi/
│ │ ├── client/
│ │ ├── core/
│ │ ├── client.gen.ts
│ │ ├── index.ts
│ │ ├── sdk.gen.ts
│ │ └── types.gen.ts
│ └── index.ts

5.看看是如何使用的吧:

import { postSessions, type LoginDto } from "~/utils/heyapi";

import { client } from "~/utils/heyapi/client.gen";

// 需要先做一些基础的client配置 (也支持自己重新创建一个新 client)
client.setConfig({
  baseUrl: "http://127.0.0.1:3000",
});


const body: LoginDto = {
      email,
      password,
    }

const { data, error } = await postSessions({
body: body,
  });

  if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
  } else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
  }

其中请求结果:data 和 error ,默认情况下 和 openapi-fetch的处理是一致的(都是不抛出错误,而是将错误通过error暴露)。

image.png

6.网络请求库方面也适配了fetch和axios,也支持tanstack query。并且计划未来对TS服务端框架支持,但还有很多没完成的(处在soon状态)。

结合 tanstack query

参考:plugin tanstack-query. 准备配置文件/openapi-ts.config.ts

import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: '../默认模块.openapi.json',
  output: './app/utils/heyapi',
  plugins: ['@tanstack/react-query'], 
});

执行 pnpm openapi-ts,此时生成的目录下多出一个文件 ./@tanstack/react-query.gen.ts

// This file is auto-generated by @hey-api/openapi-ts

import type { UseMutationOptions } from '@tanstack/react-query';

import { type Options, postSessions, postUsers } from '../sdk.gen';
import type { PostSessionsData, PostSessionsError, PostSessionsResponse, PostUsersData, PostUsersError, PostUsersResponse } from '../types.gen';

/**
 * 登录
 */
export const postSessionsMutation = (options?: Partial<Options<PostSessionsData>>): UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> => {
    const mutationOptions: UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> = {
        mutationFn: async (fnOptions) => {
            const { data } = await postSessions({
                ...options,
                ...fnOptions,
                throwOnError: true
            });
            return data;
        }
    };
    return mutationOptions;
};

/**
 * 注册
 */
export const postUsersMutation = (options?: Partial<Options<PostUsersData>>): UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> => {
    const mutationOptions: UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> = {
        mutationFn: async (fnOptions) => {
            const { data } = await postUsers({
                ...options,
                ...fnOptions,
                throwOnError: true
            });
            return data;
        }
    };
    return mutationOptions;
};

整个登录页面的代码如下:

import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {postSessionsMutation }  from "~/utils/heyapi/@tanstack/react-query.gen"
import { postSessions, type LoginDto } from "~/utils/heyapi";
import { client } from "~/utils/heyapi/client.gen";
import type { Route } from "./+types/login3";

// Configure the client
client.setConfig({
  baseUrl: "http://127.0.0.1:3000",
});

export function meta({}: Route.MetaArgs) {
  return [
    { title: "登录" },
    { name: "description", content: "用户登录" },
  ];
}

export default function Login() {
  const [email, setEmail] = useState("abc@example.com");
  const [password, setPassword] = useState("123456");

  const { mutate, isPending, isSuccess, error } = useMutation({
    // mutationFn: async (body: LoginDto) => {
    //   const { data, error } = await postSessions({
    //     body: body,
    //   });
    //   if (error) {
    //     throw error;
    //   }
    //   return data;
    // },
    ...postSessionsMutation(),
    onSuccess: (data) => {
      console.log("Login successful:", data);
      // 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
      // const token = data.data?.accessToken;
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutate({
      body: {
        email,
        password,
      }
    });
  };

  const status = isPending ? "loading" : isSuccess ? "success" : error ? "error" : "idle";
  const message = isSuccess
    ? "登录成功!"
    : error
    ? (error as any).message || "登录失败"
    : "";

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
      <div className="w-full max-w-md space-y-8 bg-white p-8 shadow rounded-lg">
        <div>
          <h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
            登录您的账户3
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          <div className="-space-y-px rounded-md shadow-sm">
            <div>
              <label htmlFor="email-address" className="sr-only">
                邮箱地址
              </label>
              <input
                id="email-address"
                name="email"
                type="email"
                autoComplete="email"
                required
                className="relative block w-full rounded-t-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
                placeholder="邮箱地址"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                密码
              </label>
              <input
                id="password"
                name="password"
                type="password"
                autoComplete="current-password"
                required
                className="relative block w-full rounded-b-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
                placeholder="密码"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
              />
            </div>
          </div>

          {message && (
            <div
              className={`text-sm text-center ${
                status === "success" ? "text-green-600" : "text-red-600"
              }`}
            >
              {message}
            </div>
          )}

          <div>
            <button
              type="submit"
              disabled={status === "loading"}
              className="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
            >
              {status === "loading" ? "登录中..." : "登录"}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

技术选型

技术方案比较

在选择 TypeScript OpenAPI 客户端生成方案时,核心的权衡点在于:“运行时开销 vs. 开发体验” 以及 “灵活性 vs. 自动化程度”

维度 openapi-typescript (+ fetch) Orval Kubb @hey-api/openapi-ts
定位 极简/轻量主义 前端框架深度集成 工业级流水线/全栈体系 官方首选/生产级SDK
生成内容 仅生成 TS 类型定义 类型 + API 请求函数 + Hooks + Mocks 类型 + Hooks + 验证器 + 路由 + 模版 类型 + SDK + Hooks + 验证器
核心优势 零运行时开销。直接利用 TS 类型收窄,包体积增加几乎为 0。 TanStack Query 亲儿子。一键生成全套 React/Vue Query 钩子。 高度可插件化。支持用 JSX 写代码生成模版,前后端契约高度一致。 FastAPI 官方推荐。由原作者维护的升级版,支持 20+ 插件。
状态管理集成 无 (需手动配合 TanStack Query) 内置支持 TanStack Query, SWR 内置支持 TanStack Query 插件支持 TanStack Query, Pinia
校验支持 无 (仅编译期) 支持 Zod 支持 Zod, Faker 支持 Zod, Valibot
网络库 原生 fetch (通过 openapi-fetch) Axios, Fetch, Hook Axios, Fetch Fetch, Axios, Angular, Nuxt
Mock 支持 内置支持 MSW 内置支持 Faker 计划支持 (Chance)
适用场景 极度关注包体积、喜欢原生 API、对封装有“洁癖”的项目。 典型的中后台管理系统,深度使用 React/Vue Query 的项目。 复杂项目,需要自定义生成逻辑(如自动生成后端路由、Schema)的团队。 需要高度成熟稳定、符合 FastAPI 体系或大厂规范的 SDK。
学习曲线 极低 中 (需配置 orval.config.js) 高 (需理解插件系统/模版)

  1. 如果你追求 “极致轻量”
  • 选择: openapi-typescript + openapi-fetch
  • 理由: 它是目前最符合 TypeScript 原生思维的方案。它不生成成千上万行的 JS 代码,只生成类型。你的 API 调用看起来就像原生的 fetch,但带有完美的自动补全。
  1. 如果你是 “TanStack Query (React/Vue Query) 用户”
  • 选择: Orval
  • 理由: Orval 是目前生成 React Query Hooks 最成熟的工具。它能自动生成 queryKey、处理缓存逻辑、甚至自动生成 MSW 的 Mock 数据,极大提升开发效率。
  1. 如果你想要 “全栈体系一致性”
  • 选择: Kubb
  • 理由: Kubb 的野心更大,它不仅是为了前端。通过它的插件系统,你可以把一套 OpenAPI 定义同时转化为前端的 Hooks 和后端的路由定义(如 Hono/Elysia),确保前后端代码在结构上是“镜像”的。
  1. 如果你追求 “官方规范与工程化”
  • 选择: @hey-api/openapi-ts
  • 理由: 这是 openapi-typescript-codegen 的正统继任者。如果你的后端是 FastAPI,或者你希望生成的代码像一个正式的 SDK(有完整的类、方法封装),它是最稳妥的选择。它的插件系统(Plugin)也让它在功能上非常全能。

总结一句话:

  • 想简单:openapi-typescript
  • 想省事(前端):Orval
  • 想折腾/全栈:Kubb
  • 想标准/大而全:Hey API

我个人的话,就主要从hey-apiopenapi-typescript/openapi-fetch中选了:

  • 对于管理端、ToB的应用,使用hey-api 或者 Orval
  • 对于比较轻量化的h5页面使用openapi-typescript/openapi-fetch

模块联邦实践

早期 微信图片_20250922174323_104_83.jpg

早期业务,线进行开发的时候,我复杂的业务,和在一个主应用中,发布,测试部署,严重耦合,多个业务线复杂人,迭代,一旦有bug 就会立刻回滚,最终导致上线失败率很低 并且只是改变其中的子页面,模块,导致整个应用,全部都进行拆分 而且业务形态,非常集中以报表,图表为主

所有结合当时的业务形态,我们这里,主要是用模块联邦来处理,进行拆分

首先,划分主子应用边界 左侧栏,以及顶部栏,以及图表,下拉框,表格,都是划到主应用 作为子应用,主要去负责

最后结合具体实践,和坑的整理

模块联邦

  1. 作为webpack 承担的功能,解决的是单体复杂应用,如难以单独部署,协作问题,以及

拆分一个巨型应用

先去配置一个最基本的模块联邦微应用: 以下以一些

// 主应用
plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
    new ModuleFederationPlugin({
      name: 'hostApp',
      filename: 'remoteEntry.js',
      exposes: {
        './RouteGuard': './src/components/RouteGuard',
        './GlobalConfigContext': './src/context/GlobalConfigContext',
        './ThemeContext': './src/context/ThemeContext',
        './ActionToolbar': './src/components/ActionToolbar',
        './SmartTable': './src/components/SmartTable/SmartTable',
        './BaseChart': './src/components/BaseChart/BaseChart',
        './DateProcessor': './src/components/DateProcessor',
        './useBusinessData': './src/hooks/useBusinessData',
        './biz-utils': './src/utils/biz-utils',
        ...
      },
      remotes: {
        remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: false, eager: true },
        'react-dom': { singleton: true, requiredVersion: false, eager: true },
        antd: { singleton: true, eager: true },
        axios: { singleton: true },
        'react-router-dom': { singleton: true },
        'react-redux': { singleton: true },
      },
    }),
    //

//子应用
plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
    new ModuleFederationPlugin({
      name: 'remoteApp',
      // 这里的也必须用这个remoteEntr.js吗
      filename: 'remoteEntry.js',
      // 这里暴露的是业务模块,不是共用的组件
      exposes: {
        './ReportModule': './src/modules/ReportModule/ReportModule',
        './OptionChainModule': './src/modules/OptionChainModule/OptionChainModule'
      },
      remotes: {
        hostApp: 'hostApp@http://localhost:3000/remoteEntry.js',
      }
    })

实践中常见坑

  1. 初期的useTheme context 上下文,共享缺失的问题
  2. 主应用,有context的访问,子应用也有类似的访问
  3. 主子应用和主应用,最好是共用一套,全局状态管理

css 隔离

非常常见的css 污染,对于模块联邦,没有内置的隔离

微前端实践1.png

一种方案为,我自己通过建立xxx.module.css 文件,来进行模块化处理


<div className="report-module-container>
    <div className={styles.reportModuleContainer}

由于本公司技术栈,以及金融报表业务,不允许,有太多的自定义样式, 因此没有采用更灵活的 css-in-js模式.


const Button = styled.button`
  background: blue;
  color: white;
`;

通过这种模式来进行处理,那么其他模块,主应用自然样式是不会干扰的.

模块联邦为何不自己提供呢?

Wujie 微前端架构下的跨应用状态管理实践:Props + Bus 双通道方案

在基于 Wujie 的微前端架构中,主应用与子应用之间的状态同步是一个绑不开的难题。本文分享我们在生产项目中的实践:如何用最简洁的方式,实现系统级数据的单一可信源和实时同步,同时避免过度设计的陷阱。

背景

我们的项目是一个典型的企业级 B 端系统,采用 Vue 3 + Pinia + Wujie 的微前端架构:

  • 1 个主应用:负责登录、菜单、布局、系统级数据管理
  • 6+ 个子应用:各自独立开发部署,通过 Wujie 嵌入主应用运行

技术栈:Vue 3.5 / Pinia 3 / Vite 7 / TypeScript 5 / Wujie 1.0

问题:6 个子应用,6 份重复数据

随着子应用数量增长,我们遇到了一系列状态管理问题:

1. 字典数据 N+1 次请求

主应用启动时请求一次全量字典,但每个子应用启动后又各自请求一次。6 个子应用 = 7 次相同的接口调用。

2. 语言切换不同步

用户在主应用切换语言后,已加载的子应用仍然显示旧语言,必须刷新页面才能生效。

3. Token 刷新断裂

主应用静默刷新 Token 后,子应用持有的旧 Token 继续发请求,触发 401 错误。

4. 用户信息传递不完整

主应用通过 Wujie props 只传了 tokenuserInfo,但子应用还需要 permissions(权限列表)和 roles(角色树),只能自己再请求一次。

5. 数据源不唯一

同一份用户数据,在主应用的 Pinia Store 里有一份,在每个子应用的 Pinia Store 里又各有一份。数据不一致的风险始终存在。

归结为一句话:缺少一个统一的跨应用状态分发机制

方案选型:我们踩过的坑

尝试一:封装 npm 包(过度设计)

最初我们的方案是创建一个 @cmclink/shared-stores 基础包:

packages/shared-stores/
├── src/
│   ├── auth.ts        # useSharedAuth() composable
│   ├── dict.ts        # useSharedDict() composable
│   ├── locale.ts      # useSharedLocale() composable
│   ├── provider.ts    # createSharedStoreProvider()
│   ├── utils.ts       # isInWujie() / getWujieBus()
│   ├── types.ts       # 12 个类型定义
│   └── constants.ts   # 事件常量

看起来很"工程化",但实际落地后发现几个问题:

  1. 增加了复杂度却没带来多少收益。系统级数据本就由主应用维护,子应用只需要只读消费,多一个包多一层抽象,反而增加了理解成本和维护负担。
  2. Provider 的 watch 机制失效。我们设计了 createSharedStoreProvider(bus, source) 来监听 Store 变化,但 source.auth() 每次返回新的普通对象,Vue 的 watch 根本追踪不到响应式变化——整个广播机制是失效的。
  3. 子应用被迫依赖这个包。本来子应用只需要读 props 和监听 bus,现在还要安装一个额外的 npm 包。

最终方案:回归简洁

反思后我们确立了核心原则:

系统级数据由主应用独占维护,通过 Wujie 原生机制(props + bus)只读分发给子应用。不引入额外的包,不搞抽象层。

架构设计

Store 三层分类

┌─────────────────────────────────────────────────┐
│  Layer 0 — 系统级(主应用独占维护,子应用只读)     │
│  userStore / dictStore / localeStore             │
├─────────────────────────────────────────────────┤
│  Layer 1 — 应用级(主应用独有)                    │
│  tabsStore / messageStore / historyStore         │
├─────────────────────────────────────────────────┤
│  Layer 2 — 业务级(各子应用独有)                   │
│  orderStore / routeStore / blStore ...           │
└─────────────────────────────────────────────────┘

关键区分:Layer 0 的数据需要跨应用共享,Layer 1 和 Layer 2 不需要。只对 Layer 0 做状态分发,保持最小化。

双通道通信协议

利用 Wujie 自带的两个通信机制:

通道 机制 用途 特点
Channel 1 wujie props 冷启动初始快照 同步、可靠、子应用启动即可用
Channel 2 wujie bus 运行时增量同步 异步、实时、事件驱动

props 结构:

{
  $shared: {
    auth: { token, refreshToken, userId, permissions, roles, userInfo },
    dict: { dictMap },
    locale: { lang }
  },
  // 向后兼容旧字段
  token: '...',
  userInfo: { ... }
}

bus 事件:

事件 载荷 触发时机
SHARED:AUTH_UPDATED { token, permissions, roles, userInfo } 权限/角色/用户信息变更
SHARED:TOKEN_REFRESHED { token, refreshToken } Token 静默刷新后
SHARED:DICT_UPDATED { dictMap, version } 字典数据加载完成
SHARED:LOCALE_CHANGED { lang } 语言切换
SHARED:LOGOUT void 用户登出

两者互补:props 解决冷启动,bus 解决热更新

核心实现

整个方案的核心就一个文件:主应用的 shared-provider.ts

主应用侧:Provider

// apps/main/src/stores/shared-provider.ts
import { watch } from 'vue'
import { bus } from 'wujie'

// 事件常量就地定义,不引入额外包
const SHARED_EVENTS = {
  AUTH_UPDATED: 'SHARED:AUTH_UPDATED',
  TOKEN_REFRESHED: 'SHARED:TOKEN_REFRESHED',
  DICT_UPDATED: 'SHARED:DICT_UPDATED',
  LOCALE_CHANGED: 'SHARED:LOCALE_CHANGED',
  LOGOUT: 'SHARED:LOGOUT',
} as const

export function setupSharedStoreProvider(): void {
  const userStore = useUserStoreWithOut()
  const dictStore = useDictStoreWithOut()
  const localeStore = useLocaleStoreWithOut()

  // 直接 watch Pinia store 的响应式属性
  watch(
    () => ({
      permissions: userStore.permissions,
      roles: userStore.roles,
      roleId: userStore.roleId,
      userInfo: userStore.user,
    }),
    (newVal) => {
      bus.$emit(SHARED_EVENTS.AUTH_UPDATED, {
        token: getAccessToken(),
        ...newVal,
      })
    },
    { deep: true },
  )

  // 字典加载完成后广播
  watch(
    () => dictStore.isSetDict,
    (isSet) => {
      if (isSet) {
        bus.$emit(SHARED_EVENTS.DICT_UPDATED, {
          dictMap: dictMapToRecord(dictStore.dictMap),
          version: Date.now(),
        })
      }
    },
  )

  // 语言切换
  watch(
    () => localeStore.currentLocale.lang,
    (lang) => bus.$emit(SHARED_EVENTS.LOCALE_CHANGED, { lang }),
  )

  // $subscribe 兜底检测登出
  userStore.$subscribe(() => {
    if (!userStore.isSetUser && userStore.permissions.length === 0) {
      bus.$emit(SHARED_EVENTS.LOGOUT)
    }
  })
}

关键细节:直接 watch Pinia store 的响应式属性,而不是通过 getter 函数间接访问。这是我们踩过的坑——如果 watch(() => source.auth().token, ...) 中的 source.auth() 每次返回新对象,Vue 的响应式追踪会完全失效。

非响应式数据的处理

Token 存储在 sessionStorage(通过 wsCache),不是 Pinia 的响应式状态,无法用 watch 监听。我们的做法是在写入点主动广播

// apps/main/src/utils/auth.ts
export const setToken = (token: TokenType) => {
  wsCache.set(CACHE_KEY.REFRESH_TOKEN, token.refreshToken)
  wsCache.set(CACHE_KEY.ACCESS_TOKEN, token.accessToken)
  // Token 写入后主动广播(动态 import 避免循环依赖)
  import('@/stores/shared-provider').then(({ emitTokenRefreshed }) => {
    emitTokenRefreshed()
  })
}

Logout 同理,在 user.tslogout() action 中主动调用:

async logout() {
  await logout()
  removeToken()
  emitSharedLogout()  // 主动广播,确保子应用收到
  this.resetState()
}

初始快照注入

主应用的 App.vue 通过 computed 构建 props,每次 Store 变化自动更新:

<WujieVue
  v-for="app in loadedApps"
  :key="app.name"
  :props="sharedProps"
  :alive="true"
/>

<script setup>
const sharedProps = computed(() => ({
  $shared: {
    auth: { token, permissions, roles, userInfo, ... },
    dict: { dictMap },
    locale: { lang },
  },
  // 向后兼容旧子应用
  token: getAccessToken(),
  userInfo: userStore.user,
}))
</script>

子应用侧:只读消费

子应用不需要安装任何额外依赖,直接用 Wujie 原生 API:

// 冷启动:从 props 获取初始数据
const wujie = (window as any).__WUJIE
const shared = wujie?.props?.$shared

if (shared) {
  // 微前端环境:使用主应用的数据
  authStore.setToken(shared.auth.token)
  authStore.setPermissions(shared.auth.permissions)
  dictStore.setDictMap(shared.dict.dictMap)
  i18n.global.locale.value = shared.locale.lang
} else {
  // 独立运行:走本地 API
  await authStore.fetchUserInfo()
  await dictStore.fetchDictData()
}

// 热更新:监听 bus 事件
wujie?.bus?.$on('SHARED:TOKEN_REFRESHED', (data) => {
  authStore.setToken(data.token)
})
wujie?.bus?.$on('SHARED:LOCALE_CHANGED', (data) => {
  i18n.global.locale.value = data.lang
})
wujie?.bus?.$on('SHARED:LOGOUT', () => {
  authStore.clearLocal()
  router.push('/login')
})

通过 __WUJIE 是否存在来判断运行环境,微前端环境走 props/bus,独立运行走本地 API,子应用始终保持独立可运行。

数据流全景

┌──────────────────────────────────────────────────────────┐
│                        主应用                              │
│                                                          │
│  API 请求 → userStore / dictStore / localeStore          │
│                        │                                  │
│              shared-provider.ts                           │
│              (watch 响应式属性 → bus.$emit)                │
│                        │                                  │
│            ┌───────────┼───────────┐                      │
│            ▼           ▼           ▼                      │
│       wujie props   wujie bus   tabsStore 等              │
│       ($shared)     (SHARED:*)                            │
│            │           │                                  │
└────────────┼───────────┼──────────────────────────────────┘
             │           │
   ┌─────────┼───────────┼─────────┐
   ▼         ▼           ▼         ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ doc  │ │ ibs  │ │ mkt  │ │ ...  │
│      │ │      │ │      │ │      │
│ 只读 │ │ 只读 │ │ 只读 │ │ 只读 │
│ 消费 │ │ 消费 │ │ 消费 │ │ 消费 │
└──────┘ └──────┘ └──────┘ └──────┘

踩坑记录

坑 1:watch getter 返回新对象导致响应式失效

// ❌ 错误:每次调用 source.auth() 返回新对象,watch 追踪不到变化
watch(() => source.auth().token, (token) => { ... })

// ✅ 正确:直接 watch Pinia store 的响应式属性
watch(() => userStore.permissions, (permissions) => { ... })

这是 Vue 响应式系统的基本原理,但在抽象层过多时很容易忽略。getter 函数如果每次返回新的普通对象,Vue 无法建立依赖追踪

坑 2:dictStore.dictMap 是 Map 不是 Object

Pinia store 中字典数据用 Map<string, any> 存储,但跨 iframe context 传输时 Map 无法正确序列化。需要转换为普通 Record:

function dictMapToRecord(dictMap: any): Record<string, any[]> {
  const result: Record<string, any[]> = {}
  if (dictMap instanceof Map) {
    dictMap.forEach((value, key) => { result[key] = value })
  } else {
    Object.assign(result, dictMap)
  }
  return result
}

坑 3:Token 存在 sessionStorage 中,不是响应式的

Token 通过 wsCache(封装的 web-storage-cache)存储在 sessionStorage 中,不在 Pinia state 里,watch 监听不到。

解决方案:在写入点主动广播,而不是试图监听存储变化。用动态 import() 避免循环依赖。

坑 4:过度抽象的代价

最初我们设计了完整的 composable 层(useSharedAuthuseSharedDictuseSharedLocale),每个都有 fallback 机制、readonly 包装、onScopeDispose 清理。

看起来很优雅,但实际上:

  • 子应用只需要读 props + 监听 bus,10 行代码的事
  • 多了一个 npm 包依赖,子应用的 package.json 要加,CI 要装
  • composable 内部的 wujie 环境检测逻辑和子应用自己写没区别
  • 维护成本远大于收益

最终我们删掉了整个包,回归最简方案。

设计原则总结

原则 说明
主应用独占维护 系统级数据只在主应用写入,子应用只读
不过度设计 不搞 composable 抽象层、不搞额外 npm 包
用平台能力 Wujie 自带 props + bus,够用就不造轮子
在写入点广播 非响应式数据(Token)在写入时主动 emit
向后兼容 新增 $shared 字段,保留旧的 token/userInfo
独立可运行 子应用通过 __WUJIE__ 环境检测,非微前端环境走本地 API

效果

  • 接口调用:字典请求从 N+1 次降为 1 次
  • 语言切换:实时同步,无需刷新
  • Token 刷新:主应用刷新后 50ms 内所有子应用同步
  • 代码量:主应用新增 1 个文件(~180 行),子应用各减少 3 个冗余 Store
  • 依赖:零新增 npm 包

适用场景

这个方案适用于:

  • 基于 Wujie(或类似 iframe 沙箱方案)的微前端架构
  • 主应用是唯一的系统级数据管理者
  • 子应用数量 > 2,且共享用户/权限/字典/语言等全局数据
  • 团队希望保持架构简洁,避免过度工程化

不适用于:

  • 子应用之间需要双向通信的场景(本方案是单向只读分发)
  • 子应用需要修改系统级数据的场景(应该通过 bus 事件请求主应用修改)

本文基于 Wujie 1.0 + Vue 3.5 + Pinia 3 的生产实践,如有问题欢迎交流。

微前端(无界)样式架构重构方案

日期: 2025-02-10
影响范围: 主应用 apps/main、共享包 packages/ui、所有子应用


一、现状诊断

1.1 样式文件分布(主应用)

当前主应用存在 3 个互不关联的样式目录,没有统一入口:

apps/main/src/
├── assets/
│   ├── main.css              ← Vue 脚手架残留 + Tailwind 入口
│   ├── base.css              ← Vue 脚手架残留 CSS Reset + 变量
│   └── styles/
│       └── micro-app.css     ← 微前端"隔离"样式(!important 硬覆盖)
├── styles/
│   ├── index.scss            ← 样式入口(⚠️ 从未被任何文件引用!)
│   ├── var.css               ← 布局 + 主题 CSS 变量
│   ├── dark.css              ← 暗色主题变量
│   ├── resizeStyle.scss      ← 632 行巨型文件(EP 覆盖 + 布局 + 业务混杂)
│   ├── dialog.scss           ← 弹窗尺寸
│   ├── theme.scss            ← 空文件(全注释)
│   ├── variables.scss        ← 仅 2 个 SCSS 变量
│   └── global.module.scss    ← 导出命名空间给 JS

1.2 main.ts 样式导入链

main.ts
  ├── assets/main.css           → @import base.css + @import tailwindcss
  ├── assets/styles/micro-app.css
  ├── element-plus 组件样式 ×4(message-box / message / notification / loading)
  └── @cmclink/ui/styles        → variables + element-override + base + utilities + animations + tailwindcss

关键发现styles/index.scss(包含 var.cssresizeStyle.scssdialog.scss在整个项目中没有被任何文件 import。这意味着 632 行的 Element Plus 覆盖样式、布局变量、暗色主题等可能完全没有生效,或者曾经生效但在某次重构中被遗漏。

1.3 核心问题清单

# 问题 严重程度 说明
1 Tailwind CSS 重复引入 🔴 高 assets/main.css@cmclink/ui/styles 各引入一次,产生重复样式
2 CSS Reset 重复执行 🔴 高 assets/base.css@cmclink/ui/base.scss 各做一次全局 reset
3 主色定义冲突 🔴 高 var.css#005aae@cmclink/ui#004889resizeStyle.scss→硬编码 #005aae,三处不一致
4 styles/index.scss 未被引用 🔴 高 632 行 EP 覆盖 + 布局变量 + 暗色主题可能完全未生效
5 body 样式双重定义 🟡 中 base.css@cmclink/ui/base.scss 都设置了 body 样式
6 微前端样式隔离 = !important 战争 🔴 高 micro-app.css!important 硬覆盖,没有利用 wujie CSS 沙箱
7 脚手架残留代码 🟡 中 base.css--vt-c-* 变量、main.css.green
8 废文件堆积 🟡 中 theme.scss(空)、variables.scss(2 行)、global.module.scss(价值存疑)
9 !important 泛滥 🟡 中 resizeStyle.scssmicro-app.css 大量使用
10 样式职责不清 🔴 高 EP 覆盖、页面布局、业务样式、主题变量全混在一个文件

1.4 @cmclink/ui 样式包分析

packages/ui/src/styles/ 已经有一套相对完整的设计体系:

  • variables.scss — 完整的设计令牌(颜色、字体、间距、圆角、阴影等)+ CSS 变量映射
  • mixins.scss — 响应式断点、文本截断、布局、按钮变体等混合器
  • base.scss — 全局 reset、标题、段落、表格、滚动条、打印、暗色、无障碍
  • element-override.scss — Element Plus 样式覆盖
  • utilities.scss — CMC 专用工具类(cmc-* 前缀)
  • animations.scss — 动画库(1250 行,含大量与 Tailwind 重复的工具类)

问题@cmclink/uianimations.scss 中有大量 .translate-*.scale-*.rotate-*.duration-* 等类名,与 Tailwind CSS 完全重复。


二、目标架构设计

2.1 设计原则

  1. 单一真相源(Single Source of Truth):设计令牌只在 @cmclink/ui 中定义一次
  2. 分层隔离:全局样式、主题变量、EP 覆盖、布局样式、业务样式严格分层
  3. 微前端天然隔离:依赖 wujie 的 CSS 沙箱(WebComponent + iframe),而非 !important
  4. Tailwind 唯一入口:整个主应用只在一个地方引入 Tailwind CSS
  5. 可维护性:每个文件职责单一,命名语义化,新人 5 分钟能理解结构

2.2 目标目录结构

apps/main/src/
├── styles/                          ← 主应用样式唯一目录
│   ├── index.scss                   ← ⭐ 唯一入口文件(main.ts 只 import 这一个)
│   ├── tailwind.css                 ← Tailwind CSS 唯一引入点
│   │
│   ├── tokens/                      ← 主应用级设计令牌(覆盖/扩展 @cmclink/ui)
│   │   ├── _variables.scss          ← SCSS 变量(供 SCSS 文件内部使用)
│   │   └── _css-variables.scss      ← CSS 自定义属性(布局变量、主应用专属变量)
│   │
│   ├── themes/                      ← 主题系统
│   │   ├── _light.scss              ← 亮色主题变量
│   │   └── _dark.scss               ← 暗色主题变量
│   │
│   ├── overrides/                   ← Element Plus 样式覆盖(主应用级)
│   │   ├── _button.scss             ← 按钮覆盖
│   │   ├── _table.scss              ← 表格覆盖
│   │   ├── _form.scss               ← 表单覆盖(filterBox 等)
│   │   ├── _dialog.scss             ← 弹窗覆盖
│   │   ├── _message-box.scss        ← 消息框覆盖
│   │   ├── _select.scss             ← 下拉框覆盖
│   │   ├── _tag.scss                ← 标签覆盖
│   │   ├── _tabs.scss               ← 标签页覆盖
│   │   └── _index.scss              ← EP 覆盖汇总入口
│   │
│   ├── layout/                      ← 布局样式
│   │   ├── _app-view.scss           ← AppViewContent / AppViewScroll
│   │   ├── _login.scss              ← 登录页样式
│   │   └── _index.scss              ← 布局汇总入口
│   │
│   ├── vendors/                     ← 第三方库样式适配
│   │   └── _nprogress.scss          ← NProgress 主题色适配
│   │
│   └── legacy/                      ← 遗留业务样式(逐步迁移到组件 scoped 中)
│       ├── _si-detail.scss          ← 出口单证详情
│       ├── _marketing.scss          ← 营销模块
│       └── _index.scss              ← 遗留样式汇总入口
│
├── assets/                          ← 仅保留静态资源
│   └── images/                      ← 图片资源
│       ├── avatar.gif
│       ├── cmc-logo.png
│       └── ...

2.3 入口文件设计

styles/index.scss(唯一入口):

// =============================================================================
// CMCLink 主应用样式入口
// 加载顺序严格按照优先级排列,请勿随意调整
// =============================================================================

// 1️⃣ Tailwind CSS(最先加载,作为基础原子类层)
@use './tailwind.css';

// 2️⃣ 主应用级设计令牌(覆盖/扩展 @cmclink/ui 的变量)
@use './tokens/css-variables';

// 3️⃣ 主题系统
@use './themes/light';
@use './themes/dark';

// 4️⃣ Element Plus 样式覆盖
@use './overrides/index';

// 5️⃣ 布局样式
@use './layout/index';

// 6️⃣ 第三方库适配
@use './vendors/nprogress';

// 7️⃣ 遗留业务样式(逐步清理)
@use './legacy/index';

main.ts(重构后):

// 样式:唯一入口
import './styles/index.scss'

// Element Plus 反馈组件样式(非按需导入的全局组件)
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/loading/style/css'

// 组件库样式(@cmclink/ui 提供设计令牌 + 基础样式 + EP 覆盖)
import '@cmclink/ui/styles'

// 其余保持不变...

注意@cmclink/ui/styles 中的 Tailwind CSS 引入需要移除,改为只在主应用的 styles/tailwind.css 中引入一次。

2.4 样式加载顺序

┌─────────────────────────────────────────────────────────┐
│ 1. Tailwind CSS(原子类基础层)                           │
├─────────────────────────────────────────────────────────┤
│ 2. @cmclink/ui/styles                                   │
│    ├── variables.scss  → 设计令牌 + CSS 变量映射          │
│    ├── base.scss       → 全局 reset + 排版               │
│    ├── element-override.scss → 组件库级 EP 覆盖           │
│    ├── utilities.scss  → CMC 工具类                       │
│    └── animations.scss → 动画(需清理与 Tailwind 重复部分)│
├─────────────────────────────────────────────────────────┤
│ 3. Element Plus 反馈组件样式(message/notification 等)    │
├─────────────────────────────────────────────────────────┤
│ 4. 主应用 styles/index.scss                              │
│    ├── tokens/    → 主应用级变量(布局尺寸、主题色扩展)    │
│    ├── themes/    → 亮色/暗色主题                         │
│    ├── overrides/ → 主应用级 EP 覆盖                      │
│    ├── layout/    → 页面布局                              │
│    ├── vendors/   → 第三方库适配                          │
│    └── legacy/    → 遗留业务样式                          │
├─────────────────────────────────────────────────────────┤
│ 5. 组件 <style scoped>(组件级样式,天然隔离)             │
└─────────────────────────────────────────────────────────┘

三、微前端样式隔离策略

3.1 wujie CSS 沙箱机制

wujie 使用 WebComponent(shadowDOM)+ iframe 双重沙箱:

  • JS 沙箱:子应用 JS 运行在 iframe 中,天然隔离
  • CSS 沙箱:子应用 DOM 渲染在 WebComponent 的 shadowDOM 中,样式天然隔离

这意味着:

  • ✅ 子应用的样式不会泄漏到主应用
  • ✅ 主应用的样式不会侵入子应用(shadowDOM 边界)
  • ⚠️ 但是:Element Plus 的弹窗(Dialog/Drawer/MessageBox)默认挂载到 document.body,会逃逸出 shadowDOM

3.2 弹窗逃逸问题的正确解决方案

当前做法(错误):在主应用 micro-app.css 中用 !important 覆盖子应用弹窗样式。

正确做法:在子应用中配置 Element Plus 的 teleportedappend-to 属性,让弹窗挂载到子应用自身的 DOM 容器内,而非 document.body

方案 A:子应用全局配置(推荐)

在子应用的入口文件中配置 Element Plus 的全局属性:

// 子应用 main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'

const app = createApp(App)
app.use(ElementPlus, {
  // 弹窗不使用 teleport,保持在子应用 DOM 树内
  // 这样弹窗样式就在 shadowDOM 内,天然隔离
})

同时在子应用的弹窗组件中统一设置:

<!-- 子应用中使用 Dialog -->
<el-dialog :teleported="false">
  <!-- 内容 -->
</el-dialog>

<!-- 或者指定挂载到子应用容器 -->
<el-dialog append-to=".child-app-root">
  <!-- 内容 -->
</el-dialog>

方案 B:wujie 插件拦截(自动化)

通过 wujie 的插件系统,自动为子应用的弹窗组件注入配置:

// plugins/wujie.ts 中增加插件配置
import { setupApp } from 'wujie'

setupApp({
  name: 'child-app',
  // ... 其他配置
  plugins: [
    {
      // CSS loader:可以在子应用 CSS 加载时做处理
      cssLoader: (code: string) => code,
      // JS loader:可以在子应用 JS 加载时做处理
      jsLoader: (code: string) => code,
    }
  ]
})

方案 C:共享样式包(兜底方案)

如果确实需要主应用和子应用共享某些样式(如统一的弹窗规范),应该通过 @cmclink/ui 包来分发,而非在主应用中硬写覆盖:

packages/ui/src/styles/
├── shared/                    ← 可被子应用独立引入的共享样式
│   ├── dialog-standard.scss   ← 弹窗标准样式
│   └── form-standard.scss     ← 表单标准样式

子应用按需引入:

// 子应用 main.ts
import '@cmclink/ui/styles/shared/dialog-standard.scss'

3.3 样式隔离总结

场景 隔离机制 是否需要额外处理
子应用普通样式 wujie shadowDOM 天然隔离 ❌ 无需处理
子应用弹窗/抽屉 配置 :teleported="false" ✅ 子应用侧配置
主应用与子应用共享样式 通过 @cmclink/ui 包分发 ✅ 包级别管理
主应用全局样式 不会侵入子应用(shadowDOM) ❌ 无需处理

3.4 删除 micro-app.css

重构完成后,assets/styles/micro-app.css 应该被完全删除。其中的内容处理方式:

原内容 处理方式
.micro-app .el-dialog 覆盖 子应用配置 :teleported="false" 后不再需要
.micro-app-test.marketing 测试代码,直接删除
.test-paragraph / .test-box 测试代码,直接删除

四、主色统一方案

4.1 现状:三处主色定义

位置 主色值 说明
styles/var.css #005aae 主应用布局变量
@cmclink/ui/variables.scss #004889 组件库设计令牌
resizeStyle.scss #005aae(硬编码) 登录页等处直接写死

4.2 统一方案

@cmclink/ui 的设计令牌为唯一真相源

  1. 确认最终主色值(与设计团队对齐):

    • 如果设计稿是 #004889 → 主应用的 var.css 需要同步修改
    • 如果设计稿是 #005aae@cmclink/uivariables.scss 需要同步修改
  2. 主应用中所有硬编码的颜色值,改为引用 CSS 变量:

    // ❌ 错误
    background-color: #005aae;
    
    // ✅ 正确
    background-color: var(--el-color-primary);
    
  3. 主应用的 tokens/_css-variables.scss 只定义布局相关的变量,颜色变量全部继承 @cmclink/ui


五、文件迁移映射

5.1 需要删除的文件

文件 原因
assets/main.css 脚手架残留,Tailwind 迁移到 styles/tailwind.css
assets/base.css 脚手架残留,与 @cmclink/ui/base.scss 重复
assets/styles/micro-app.css !important 硬覆盖,改用 wujie 沙箱
styles/theme.scss 空文件
styles/variables.scss 仅 2 行,合并到 tokens/_variables.scss
styles/global.module.scss 价值存疑,如需保留可合并

5.2 需要拆分的文件

styles/var.css(154 行)→ 拆分为:

目标文件 内容
tokens/_css-variables.scss 布局变量(--left-menu-*--top-header-*--tags-view-*--app-content-* 等)
删除 颜色变量(--el-color-primary 等),改为继承 @cmclink/ui

styles/resizeStyle.scss(632 行)→ 拆分为:

目标文件 内容 行数(约)
overrides/_button.scss 按钮样式覆盖(L1-20) ~20
overrides/_tag.scss 标签样式(L29-33) ~5
overrides/_select.scss 下拉框样式(L36-38) ~3
overrides/_table.scss 表格样式(L41-80, L374-417) ~80
overrides/_message-box.scss 消息框样式(L83-98, L399-406) ~20
overrides/_form.scss filterBox 表单样式(L484-618) ~135
layout/_login.scss 登录页样式(L100-117) ~18
layout/_app-view.scss AppViewContent / AppViewScroll / tabPage(L119-306) ~190
legacy/_si-detail.scss 出口单证详情(index.scss L50-105) ~55
legacy/_marketing.scss 营销模块(L308-373, L418-438) ~70
删除 .container-selected-row(箱管选中行)→ 迁移到对应组件 scoped ~8

styles/index.scss(126 行)→ 拆分为:

目标文件 内容
vendors/_nprogress.scss NProgress 主题色适配(L22-38)
overrides/_form.scss append-click-input(L39-49)
legacy/_si-detail.scss si-detail-dialog / si-detail-tabs(L50-105)
删除 .reset-margin.el-popup-parent--hidden.el-scrollbar__bar → 评估是否仍需要

styles/dark.css(85 行)→ 迁移到:

目标文件 说明
themes/_dark.scss 完整保留暗色主题变量,清理注释掉的废代码

styles/dialog.scss(17 行)→ 迁移到:

目标文件 说明
overrides/_dialog.scss 弹窗尺寸规范

5.3 @cmclink/ui 需要调整的部分

文件 调整内容
styles/index.scss 移除 @use 'tailwindcss',Tailwind 只在应用层引入
styles/animations.scss 清理与 Tailwind 重复的工具类(.translate-*.scale-*.rotate-*.duration-* 等约 800 行)
styles/utilities.scss 保留 cmc-* 前缀的工具类,删除与 Tailwind 重复的部分

六、实施步骤

Phase 1:基础清理(低风险,可立即执行)

预计耗时:1-2 小时

  1. 创建新目录结构

    • 创建 styles/tokens/styles/themes/styles/overrides/styles/layout/styles/vendors/styles/legacy/
  2. 创建 styles/tailwind.css

    @import "tailwindcss";
    
  3. 删除废文件

    • styles/theme.scss(空文件)
  4. 统一主色

    • 与设计团队确认最终主色值
    • 更新所有硬编码颜色为 CSS 变量引用

Phase 2:文件拆分迁移(中风险,需逐步验证)

预计耗时:3-4 小时

  1. 拆分 resizeStyle.scss(632 行 → 8 个文件)
  2. 拆分 var.css(布局变量 → tokens/_css-variables.scss
  3. 迁移 dark.cssthemes/_dark.scss
  4. 迁移 dialog.scssoverrides/_dialog.scss
  5. 拆分 index.scss(NProgress → vendors,业务 → legacy)
  6. 创建新的 styles/index.scss 入口文件

Phase 3:入口重构(高风险,需完整回归测试)

预计耗时:1-2 小时

  1. 修改 main.ts

    • 移除 import './assets/main.css'
    • 移除 import './assets/styles/micro-app.css'
    • 添加 import './styles/index.scss'
    • 调整 @cmclink/ui/styles 的导入顺序
  2. 删除旧文件

    • assets/main.css
    • assets/base.css
    • assets/styles/micro-app.css
    • assets/styles/ 目录
  3. 完整回归测试

    • 登录页样式
    • 主布局(侧边栏、顶部导航、标签页)
    • 表格页面(筛选框、表格、分页)
    • 弹窗/抽屉
    • 暗色主题切换
    • 子应用加载和样式隔离

Phase 4:微前端隔离优化(需子应用配合)

预计耗时:2-3 小时(每个子应用)

  1. 子应用配置弹窗不逃逸

    • 全局配置 :teleported="false"append-to
    • @cmclink/micro-bootstrap 中提供统一配置能力
  2. 删除 micro-app.css

  3. 验证子应用样式隔离

    • 主应用样式不侵入子应用
    • 子应用样式不泄漏到主应用
    • 弹窗/抽屉样式正确

Phase 5:@cmclink/ui 优化(独立进行)

预计耗时:2-3 小时

  1. 移除 Tailwind CSS 引入(从 @cmclink/ui/styles/index.scss
  2. 清理 animations.scss 中与 Tailwind 重复的工具类(约 800 行)
  3. 审查 utilities.scss 中与 Tailwind 重复的部分

七、风险评估与回退方案

7.1 风险点

风险 概率 影响 缓解措施
styles/index.scss 未被引用但样式实际生效 先在浏览器 DevTools 确认哪些样式实际生效
拆分后样式加载顺序变化导致覆盖失效 严格按照原有顺序组织 @use
子应用弹窗 :teleported="false" 导致层级问题 逐个子应用测试,必要时用 z-index 调整
删除 base.css 后某些页面样式异常 @cmclink/ui/base.scss 已覆盖所有 reset

7.2 回退方案

每个 Phase 独立提交 Git,如果出现问题可以精确回退到任意阶段:

feat(styles): phase-1 基础清理和目录结构
feat(styles): phase-2 文件拆分迁移
feat(styles): phase-3 入口重构
feat(styles): phase-4 微前端隔离优化
feat(styles): phase-5 @cmclink/ui 优化

八、验证清单

8.1 样式正确性

  • 登录页样式正常(背景、表单、按钮)
  • 主布局样式正常(侧边栏展开/收起、顶部导航、面包屑)
  • 标签页样式正常(激活态、hover、关闭按钮)
  • 表格页面样式正常(筛选框、表头、行、分页)
  • 弹窗/抽屉样式正常(大/中/小尺寸)
  • 表单样式正常(输入框、下拉框、日期选择器)
  • 按钮样式正常(主要、链接、危险、禁用)
  • NProgress 进度条颜色正确
  • 暗色主题切换正常

8.2 微前端隔离

  • 子应用加载后样式正常
  • 主应用样式未侵入子应用
  • 子应用样式未泄漏到主应用
  • 子应用弹窗样式正确(不逃逸到主应用 body)
  • 多个子应用切换时样式无残留

8.3 构建产物

  • 构建无报错
  • CSS 产物体积不增长(预期减少 30%+)
  • 无重复的 Tailwind CSS 输出
  • 无重复的 CSS Reset

8.4 开发体验

  • HMR 样式热更新正常
  • 新增样式时知道该放在哪个文件
  • SCSS 变量和 CSS 变量可正常引用

九、预期收益

维度 现状 重构后
CSS 产物体积 Tailwind ×2 + Reset ×2 + 大量重复 减少约 30-40%
样式文件数 散落 3 个目录,8+ 个文件 1 个目录,清晰分层
入口文件 main.ts 导入 2 个样式文件 + 隐式依赖 main.ts 导入 1 个入口
新人上手 不知道样式该写在哪 5 分钟理解结构
微前端隔离 !important 硬覆盖 wujie 沙箱天然隔离
主色一致性 3 处定义,2 个不同值 1 处定义,全局统一
!important 使用 泛滥 仅在必要的 EP 覆盖中使用

微前端图标治理方案


一、背景与问题

在微前端架构下,主应用长期积累了 5 套图标方案并存 的混乱局面:

# 方案 位置 使用方式 核心问题
1 iconfont JS assets/icon.min.js(115KB) <SvgIcon name="xxx">#icon-xxx 全量加载无 Tree-shaking,iconfont 平台维护成本高
2 本地 SVG assets/svgs/(30 个文件) vite-plugin-svg-icons → SVG Sprite 仅主应用可用,子应用无法共享
3 @purge-icons + Iconify Icon.vue <Icon icon="ep:edit"> 运行时渲染,依赖 @purge-icons/generated
4 IconJson 硬编码 Icon/src/data.ts(1962 行) IconSelect 组件消费 手动维护 EP / FA 图标名列表,极易过时
5 CmcIcon @cmclink/ui <CmcIcon name="xxx"> 已有基础但只支持 SVG Sprite,未与其他方案打通

核心痛点

  • 子应用无法共享主应用图标,每个应用各自维护
  • 同一个图标可能通过 3 种不同方式引用
  • iconfont JS 全量加载 115KB,无法按需
  • 1962 行硬编码图标列表,维护成本极高
  • 中后台系统 90% 以上使用通用图标,不需要每个应用单独管理

二、治理目标

统一入口 + 集中管理 + 零配置共享
  • 一个组件<CmcIcon> 统一消费所有图标
  • 一个图标包@cmclink/icons 集中管理 SVG 资源
  • 零配置:子应用迁入 Monorepo 后自动获得所有共享图标
  • 按需加载:Element Plus 图标异步 import,不影响首屏

三、方案架构

┌──────────────────────────────────────────────────────┐
│                    使用层(所有子应用)                  │
│                                                      │
│  <CmcIcon name="Home" />           — SVG Sprite 图标  │
│  <CmcIcon name="ep:Edit" />        — Element Plus 图标 │
│  <CmcIcon name="Star" size="lg" color="primary" />   │
├──────────────────────────────────────────────────────┤
│               @cmclink/ui — CmcIcon 组件              │
│                                                      │
│  ┌─────────────┐    ┌──────────────────┐             │
│  │ SVG Sprite  │    │ Element Plus     │             │
│  │ <svg><use>  │    │ 动态 import      │             │
│  │ 无前缀      │    │ ep: 前缀         │             │
│  └─────────────┘    └──────────────────┘             │
├──────────────────────────────────────────────────────┤
│            @cmclink/icons — 共享图标资源包              │
│                                                      │
│  packages/icons/src/svg/                             │
│  ├── Home.svg                                        │
│  ├── Star.svg                                        │
│  ├── Logo.svg                                        │
│  └── ... (30+ 通用图标)                               │
├──────────────────────────────────────────────────────┤
│           @cmclink/vite-config — 构建自动集成           │
│                                                      │
│  vite-plugin-svg-icons 自动扫描:                      │
│  1. packages/icons/src/svg/  (共享图标,优先)          │
│  2. apps/{app}/src/assets/svgs/ (本地图标,可覆盖)     │
└──────────────────────────────────────────────────────┘

四、CmcIcon 组件设计

4.1 Props 接口

interface CmcIconProps {
  /**
   * 图标名称
   * - 无前缀: SVG Sprite 图标(如 "Home"、"Star")
   * - "ep:" 前缀: Element Plus 图标(如 "ep:Edit"、"ep:Delete")
   */
  name: string
  /** 尺寸:数字(px) | 预设('xs'|'sm'|'md'|'lg'|'xl') | CSS 字符串 */
  size?: number | string | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  /** 颜色:CSS 值 | 主题色('primary'|'success'|'warning'|'danger'|'info') */
  color?: string
  /** 旋转角度 */
  rotate?: number
  /** 旋转动画 */
  spin?: boolean
  /** 禁用状态 */
  disabled?: boolean
  /** 可点击 */
  clickable?: boolean
}

4.2 预设尺寸

尺寸 像素 场景
xs 12px 辅助文字旁小图标
sm 14px 表单项内图标
md 16px 默认,正文行内图标
lg 20px 按钮内图标
xl 24px 标题旁图标

4.3 主题色

使用 CSS 变量自动跟随 Element Plus 主题:

const colorMap = {
  primary: 'var(--el-color-primary, #004889)',
  success: 'var(--el-color-success, #10b981)',
  warning: 'var(--el-color-warning, #f59e0b)',
  danger:  'var(--el-color-danger, #ef4444)',
  info:    'var(--el-color-info, #3b82f6)',
}

4.4 Element Plus 图标异步加载

// ep: 前缀触发异步加载,不影响首屏 bundle
watch(
  () => props.name,
  async (name) => {
    if (!name.startsWith('ep:')) return
    const iconName = name.slice(3) // "ep:Edit" → "Edit"
    const icons = await import('@element-plus/icons-vue')
    elIconComponent.value = icons[iconName] ?? null
  },
  { immediate: true }
)

五、Vite 构建集成

5.1 主应用配置(main-app.ts)

createSvgIconsPlugin({
  iconDirs: [
    // 共享图标库(@cmclink/icons)— 所有子应用共享
    resolve(root, '../../packages/icons/src/svg'),
    // 应用本地图标(可覆盖共享图标,或放置业务特有图标)
    resolve(root, 'src/assets/svgs'),
  ],
  symbolId: 'icon-[dir]-[name]',
  svgoOptions: true,
})

5.2 子应用配置(child-app.ts)

// svgIcons 选项默认 true,子应用零配置即可共享图标
export interface ChildAppOptions {
  svgIcons?: boolean  // 默认 true
  // ...
}

关键设计iconDirs 数组中共享图标在前、本地图标在后,本地同名 SVG 可覆盖共享图标,实现灵活的图标定制能力。

六、迁移实施

6.1 迁移映射表

旧用法 新用法 说明
<SvgIcon name="Home" :size="20" /> <CmcIcon name="Home" :size="20" /> 仅改标签名
<Icon icon="ep:edit" /> <CmcIcon name="ep:Edit" /> iconname,PascalCase
<Icon icon="ep:user-filled" /> <CmcIcon name="ep:UserFilled" /> kebab → PascalCase
<Icon icon="fontisto:email" /> <CmcIcon name="ep:Message" /> 替换为 EP 等效图标
<svg><use href="#icon-xxx" /></svg> <CmcIcon name="xxx" /> 直接使用组件

6.2 实施清单

已完成 ✅

步骤 变更 影响文件数
创建 @cmclink/icons 共享图标包 packages/icons/ 新建
迁移 SVG 到共享包 assets/svgs/packages/icons/src/svg/ 30 个 SVG
重写 CmcIcon 组件 支持 SVG Sprite + ep: 前缀 1 个文件
main-app.ts 配置共享图标扫描 iconDirs 新增共享目录 1 个文件
child-app.ts 同步配置 新增 svgIcons 选项 1 个文件
替换 <SvgIcon><CmcIcon> 删除 import + 替换标签 10 个文件
替换 <Icon><CmcIcon> iconname,PascalCase 9 个文件
删除 icon.min.js 移除 iconfont 全量加载 -115KB
删除 Icon/ 目录 Icon.vue + IconSelect.vue + data.ts -1962 行
删除 SvgIcon.vue 旧 SVG 图标组件 1 个文件
清理 setupGlobCom 移除旧 Icon 全局注册 1 个文件
清理 Form.vue <Icon><CmcIcon> (JSX) 1 个文件

6.3 收益量化

指标 治理前 治理后 收益
图标方案数量 5 套 1 套 维护成本降低 80%
首屏资源 +115KB (iconfont JS) 0KB (按需加载) -115KB
硬编码图标列表 1962 行 0 行 消除过时风险
子应用图标配置 每个应用单独维护 零配置 开发效率提升
图标使用入口 3 个组件 1 个组件 心智负担降低

七、使用指南

7.1 SVG Sprite 图标(推荐)

<!-- 基础用法 -->
<CmcIcon name="Home" />

<!-- 预设尺寸 -->
<CmcIcon name="Star" size="lg" />

<!-- 自定义像素 -->
<CmcIcon name="Document" :size="32" />

<!-- 主题色 -->
<CmcIcon name="Warning" color="danger" />

<!-- 旋转动画 -->
<CmcIcon name="Loading" spin />

<!-- 可点击 -->
<CmcIcon name="Close" clickable @click="handleClose" />

7.2 Element Plus 图标

<!-- ep: 前缀,异步加载 -->
<CmcIcon name="ep:Edit" />
<CmcIcon name="ep:Delete" color="danger" />
<CmcIcon name="ep:Search" :size="18" />
<CmcIcon name="ep:Loading" spin />

7.3 添加新图标

  1. 将 SVG 文件放入 packages/icons/src/svg/
  2. 文件名即图标名(如 MyIcon.svg<CmcIcon name="MyIcon" />
  3. 无需任何额外配置,Vite HMR 自动生效
  4. 所有子应用自动可用

7.4 应用级图标覆盖

如果某个子应用需要定制某个图标的样式:

  1. apps/{app}/src/assets/svgs/ 放入同名 SVG
  2. 本地版本自动覆盖共享版本
  3. 其他子应用不受影响

八、目录结构

packages/
├── icons/                          # 共享图标包
│   ├── package.json                # @cmclink/icons
│   ├── README.md                   # 使用文档
│   └── src/
│       ├── index.ts                # 导出图标目录路径常量
│       └── svg/                    # 所有共享 SVG 图标
│           ├── Home.svg
│           ├── Star.svg
│           ├── UnStar.svg
│           ├── Logo.svg
│           ├── TopMenu.svg
│           └── ...
├── ui/
│   └── src/base/CmcIcon/
│       ├── index.ts
│       └── src/CmcIcon.vue         # 统一图标组件
└── vite-config/
    └── src/
        ├── main-app.ts             # iconDirs: [共享, 本地]
        └── child-app.ts            # svgIcons 选项

九、FAQ

Q: IconSelect 组件删除后,图标选择功能怎么办?

A: IconSelect 依赖已删除的 data.ts(1962 行硬编码列表)。如果业务确实需要图标选择器,建议基于 @element-plus/icons-vue 的导出列表动态生成,而非硬编码。后续可在 @cmclink/ui 中实现新版 CmcIconPicker

Q: 子应用还在外部独立仓库,如何使用共享图标?

A: 当前 child-app.tsiconDirs 使用相对路径 ../../packages/icons/src/svg,仅适用于 Monorepo 内的子应用。外部子应用迁入 Monorepo 后自动生效。迁入前可通过 extraPlugins 自行配置 vite-plugin-svg-icons

Q: 第三方图标库(如 Font Awesome)怎么处理?

A: 当前 CmcIcon 支持 SVG Sprite 和 Element Plus 两种源。如需扩展第三方图标库,可在 CmcIcon 中增加新的前缀识别(如 fa: → Font Awesome),通过异步 import 按需加载。但中后台系统建议优先使用 Element Plus 图标,保持设计一致性。

微前端路由设计方案 & 子应用管理保活

版本: v2.0(sync 模式重构)
日期: 2026-02-09
框架: wujie(无界)— alive + sync 模式
适用范围: CMCLink 微前端主应用 + 7 个子应用


一、架构总览

1.1 系统拓扑

┌─────────────────────────────────────────────────────────────────┐
│                    CMCLink 微前端架构                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              主应用 @cmclink/main (:3000)                  │  │
│  │  ┌─────────┐  ┌──────────────┐  ┌─────────────────────┐  │  │
│  │  │ Router  │  │ AuthLayout   │  │     App.vue         │  │  │
│  │  │ (Vue)   │  │ (Header/Tab) │  │ (WujieVue 容器)     │  │  │
│  │  └────┬────┘  └──────┬───────┘  └──────────┬──────────┘  │  │
│  │       │              │                      │             │  │
│  │       │     wujie bus (EventEmitter)        │             │  │
│  │       │    ┌─────────┴──────────┐           │             │  │
│  └───────┼────┼────────────────────┼───────────┼─────────────┘  │
│          │    │                    │           │                 │
│  ┌───────┴────┴────────────────────┴───────────┴─────────────┐  │
│  │                    子应用沙箱层 (wujie)                      │  │
│  │                                                           │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐   │  │
│  │  │  doc    │ │  mkt    │ │ common  │ │ ibs-manage   │   │  │
│  │  │ :3003   │ │ :3001   │ │ :3006   │ │    :3007     │   │  │
│  │  └─────────┘ └─────────┘ └─────────┘ └──────────────┘   │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐                    │  │
│  │  │commerce │ │operation│ │ general │                    │  │
│  │  │-finance │ │ :3004   │ │ :3005   │                    │  │
│  │  │ :3002   │ │         │ │         │                    │  │
│  │  └─────────┘ └─────────┘ └─────────┘                    │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              共享包 @cmclink/micro-bridge                   │  │
│  │  registry.ts │ url.ts │ types.ts │ bridges                │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

1.2 子应用注册表

所有子应用在 packages/micro-bridge/src/registry.ts 中统一注册:

子应用名称 端口 activeRule entry 状态
mkt 3001 /mkt /mkt/ 待迁移
commerce-finance 3002 /commerce-finance /commerce-finance/ 待迁移
doc 3003 /doc /doc/ ✅ 已迁移
operation 3004 /operation /operation/ 待迁移
general 3005 /general /general/ 待迁移
common 3006 /common /common/ 待迁移
ibs-manage 3007 /ibs-manage /ibs-manage/ ✅ 已迁移

1.3 Monorepo 项目结构

微前端/
├── apps/
│   ├── cmclink-web-micro-main/   # 主应用 @cmclink/main
│   ├── doc/                       # 子应用 @cmclink/doc
│   └── ibs-manage/                # 子应用 @cmclink/ibs-manage
├── packages/
│   ├── micro-bridge/              # 微前端通信 SDK
│   ├── micro-bootstrap/           # 子应用启动器
│   ├── tsconfig/                  # 共享 TS 配置
│   └── vite-config/               # 共享 Vite 配置
├── pnpm-workspace.yaml
└── turbo.json

二、路由设计方案

2.1 URL 设计原则

核心原则:URL 对用户无感,子应用路径直接拼接在主应用路径后。

主应用 base:  /micro-main/
子应用路由:   /micro-main/{appName}/{子应用内部路径}

示例:
  /micro-main/                                          → 主应用首页
  /micro-main/ibs-manage/operation/enterpriseMgmt       → ibs-manage 子应用
  /micro-main/doc/document/blManage                     → doc 子应用
  /micro-main/profile                                   → 主应用个人中心

2.2 主应用路由配置

主应用 Vue Router 采用 createWebHistory(VITE_BASE_PATH) 模式,路由分为三层:

路由树:
├── /login                          # 公开路由(无需登录)
├── /forget-password                # 公开路由
├── /resetPwd                       # 公开路由
├── /                               # AuthenticatedLayout(需登录)
│   ├── /                           # 主应用首页
│   ├── /profile                    # 个人中心
│   ├── /message-center             # 消息中心
│   ├── /mkt/:pathMatch(.*)*        # 子应用占位路由(render null)
│   ├── /commerce-finance/:pathMatch(.*)*
│   ├── /doc/:pathMatch(.*)*
│   ├── /ibs-manage/:pathMatch(.*)*
│   ├── /common/:pathMatch(.*)*
│   ├── /operation/:pathMatch(.*)*
│   └── /general/:pathMatch(.*)*
└── /:pathMatch(.*)*                # 404 兜底

关键设计点

  • 子应用路由使用 /:pathMatch(.*)* 通配符,确保 /ibs-manage/operation/xxx 等深层路径都能匹配
  • 子应用路由的 component 设为 { render: () => null },不渲染任何主应用组件
  • 子应用路由的 meta.appName 标识所属子应用,用于 Tab 管理
  • 所有子应用路由都是 AuthenticatedLayout 的 children,确保 Header/Tab 始终显示

2.3 主应用显隐控制

App.vueAuthenticatedLayout.vue 协同控制主应用内容与子应用容器的显隐:

App.vue:
├── <RouterView>                    # 始终渲染(AuthenticatedLayout 被 keep-alive 缓存)
│   └── <AuthenticatedLayout>
│       ├── <LayoutHeader>          # 始终显示
│       ├── <SiderMenu>             # 始终显示
│       └── <main-app-content>      # v-show="!findAppByRoute(route.path)"
│           └── <RouterView>        # 主应用页面
│
└── <child-app-container>           # v-if="userStore.isSetUser && isChildRoute"
    └── <WujieVue v-for>            # v-show="route.path.startsWith(app.activeRule)"
        ├── doc
        ├── ibs-manage
        └── ...

显隐判断逻辑

场景 main-app-content child-app-container 说明
/ (首页) ✅ 显示 ❌ 隐藏 findAppByRoute 返回 undefined
/profile ✅ 显示 ❌ 隐藏 主应用页面
/ibs-manage/xxx ❌ 隐藏 ✅ 显示 子应用路由,前缀匹配
/doc/xxx ❌ 隐藏 ✅ 显示 子应用路由

2.4 子应用 URL 生成

开发环境和生产环境使用不同的 URL 生成策略:

// packages/micro-bridge/src/url.ts
function getAppUrl(app: MicroAppConfig, envGetter): string {
  // 环境变量 key: VITE_${APP_NAME}_APP_URL
  // 例如: VITE_IBS_MANAGE_APP_URL=http://localhost:3007
  const envKey = `VITE_${app.name.toUpperCase().replace(/-/g, '_')}_APP_URL`
  const envUrl = envGetter(envKey)

  if (envUrl) {
    // 开发环境: http://localhost:3007 + /ibs-manage/ = http://localhost:3007/ibs-manage/
    return `${envUrl}${app.entry}`
  }
  // 生产环境: 直接使用 entry(相对路径,由 nginx 反向代理)
  return app.entry
}

环境变量配置.env.base):

VITE_MKT_APP_URL=http://localhost:3001
VITE_COMMERCE_FINANCE_APP_URL=http://localhost:3002
VITE_DOC_APP_URL=http://localhost:3003
VITE_OPERATION_APP_URL=http://localhost:3004
VITE_GENERAL_APP_URL=http://localhost:3005
VITE_COMMON_APP_URL=http://localhost:3006
VITE_IBS_MANAGE_APP_URL=http://localhost:3007

三、路由同步机制

3.1 wujie sync 模式

采用 wujie 官方推荐的 sync 模式,由框架自动完成主应用与子应用之间的路由同步,无需手动 bus 通信、无需防循环标记

<!-- App.vue -->
<WujieVue
  :name="app.name"
  :url="getAppUrl(app)"
  :alive="true"
  :sync="true"    ← 启用 sync 模式
/>

sync 模式原理

  • wujie 内部劫持子应用的 history.pushState / history.replaceState
  • 子应用路由变化时,wujie 自动将子应用路径同步到主应用 URL
  • 主应用 URL 变化时,wujie 自动驱动子应用路由跳转
  • 整个过程由框架内部处理,无循环风险

3.2 数据流设计

设计原则:路由同步完全委托给 wujie sync 模式,业务层只负责 router.push

┌─────────────────────────────────────────────────────────────┐
│                     路由同步数据流                             │
│                                                             │
│  场景 A: 菜单/Tab 点击                                       │
│  ──────────────────────                                     │
│  menuClick / tabClick                                       │
│    └── router.push('/ibs-manage/operation/xxx')             │
│         │                                                   │
│         ▼                                                   │
│  主应用 URL 变化 → wujie sync 自动驱动子应用路由跳转           │
│    └── 子应用 router 自动跳转到 /operation/xxx               │
│                                                             │
│  ✅ 无需 bus 通信,无循环风险                                  │
│                                                             │
│                                                             │
│  场景 B: 子应用内部导航                                       │
│  ──────────────────────                                     │
│  子应用内部点击链接 → router.push('/operation/yyy')           │
│         │                                                   │
│         ▼                                                   │
│  wujie sync 自动同步到主应用 URL                              │
│    └── 主应用地址栏更新为 /ibs-manage/operation/yyy           │
│                                                             │
│  ✅ 无需 bus 通信,无循环风险                                  │
└─────────────────────────────────────────────────────────────┘

3.3 与手动方案对比

维度 sync 模式(当前方案) 手动 bus 双向通信(旧方案)
路由同步 框架自动处理 手动 bus.$emit + bus.$on
防循环 框架内部处理,无风险 _fromMainApp 标记,存在竞态风险
代码量 零额外代码 setupRouterSync + updateQuery + afterEach
子应用改造 无需任何路由同步代码 每个子应用需实现 setupRouterSync
可维护性 高(依赖框架标准能力) 低(自定义逻辑,排查困难)

3.4 路径规范化

menuClick 中对路径进行规范化,兼容不同来源的路径格式:

// tabs.ts → menuClick
const prefix = `/${tab.appName}`;
let fullPath = tab.path || prefix;
// TO_ROUTE 传来的可能是子应用内部路径(如 /operation/xxx),需要补前缀
if (!fullPath.startsWith(prefix)) {
  fullPath = `${prefix}${fullPath.startsWith("/") ? "" : "/"}${fullPath}`;
}
// wujie sync 模式会自动同步子应用路由,只需 push 即可
router.push(fullPath);

四、通信事件协议

4.1 事件总线

使用 wujie 内置的 bus(基于 EventEmitter),所有子应用共享同一个 bus 实例。

4.2 事件清单

注意:路由同步已由 wujie sync 模式自动处理,bus 事件仅用于业务通信

子应用 → 主应用

事件名 触发时机 数据结构 处理方
TO_ROUTE 子应用请求跨应用跳转 { appName, path, query, name } AuthenticatedLayoutmenuClick
ASSETS_404 子应用静态资源加载失败 { appName } AuthenticatedLayout → 弹窗提示刷新
CLOSE_ALL_TABS 子应用请求关闭所有 Tab { appName } AuthenticatedLayoutremoveTab

主应用 → 子应用

事件名 触发时机 数据结构 处理方
CLOSE_ALL_TAB_TO_CHILD 关闭子应用 Tab { appName } 子应用监听 → 重置状态
REFRESH_CHILD 刷新子应用 { appName } 子应用监听 → 重新加载当前路由

已废弃事件(由 sync 模式替代)

事件名 废弃原因
ROUTE_CHANGE 子→主路由同步已由 sync 模式自动处理
ROUTER_CHANGE_TO_CHILD 主→子路由同步已由 sync 模式自动处理

4.3 事件使用原则

  • 路由同步:完全依赖 wujie sync 模式,禁止通过 bus 手动同步路由
  • 业务通信:跨应用跳转(TO_ROUTE)、资源异常(ASSETS_404)等业务场景仍使用 bus
  • 事件过滤:子应用通过 data.appName 过滤非自身事件

五、子应用管理与保活方案

5.1 wujie alive 模式

所有子应用均使用 alive 保活模式

<!-- App.vue -->
<WujieVue
  v-for="app in loadedApps"
  :key="app.name"
  :alive="true"           ← 保活模式
  v-show="route.path.startsWith(app.activeRule)"
  :name="app.name"
  :url="getAppUrl(app)"
  :props="{ token, userInfo }"
/>

alive 模式特性

  • 子应用首次加载后,实例不销毁,切换时仅做 display: none
  • 子应用的 Vue 实例、Pinia Store、DOM 状态全部保留
  • 切换回来时无需重新初始化,体验接近原生 Tab 切换
  • 子应用内部的表单填写、滚动位置、弹窗状态等全部保留

5.2 按需渲染策略

为避免未启动的子应用触发加载错误,采用按需渲染策略:

// App.vue
const visitedApps = reactive(new Set<string>())

// 仅渲染用户已访问过的子应用
const loadedApps = computed(() =>
  microAppRegistry.filter((app) => visitedApps.has(app.name))
)

// 监听路由变化,标记已访问
watch(() => route.path, (path) => {
  const matched = findAppByRoute(path)
  if (matched) {
    visitedApps.add(matched.name)
  }
}, { immediate: true })

生命周期

用户首次访问 /ibs-manage/xxx
  → visitedApps.add('ibs-manage')
  → loadedApps 包含 ibs-manage
  → WujieVue 组件渲染 → 加载子应用 → 挂载
  → v-show=true(当前激活)

用户切换到 /doc/xxx
  → visitedApps.add('doc')
  → ibs-manage: v-show=false(隐藏但保活)
  → doc: WujieVue 渲染 → 加载 → v-show=true

用户切回 /ibs-manage/yyy
  → ibs-manage: v-show=true(瞬间恢复,无需重新加载)
  → doc: v-show=false(隐藏但保活)

5.3 预加载策略

AuthenticatedLayoutonMounted 中触发预加载,分优先级:

// plugins/wujie.ts
export function preloadChildApps() {
  const highPriority = ['doc', 'mkt']  // 高频子应用

  // 高优先级:立即预加载
  highPriorityApps.forEach(app => {
    preloadApp({ name: app.name, url: getAppUrl(app) })
  })

  // 低优先级:延迟 3 秒后预加载
  setTimeout(() => {
    lowPriorityApps.forEach(app => {
      preloadApp({ name: app.name, url: getAppUrl(app) })
    })
  }, 3000)
}

预加载 vs 按需渲染的区别

维度 预加载 (preloadApp) 按需渲染 (WujieVue)
时机 登录成功后立即 用户首次访问时
作用 提前下载子应用静态资源 创建子应用实例并挂载
资源 仅网络请求 网络 + DOM + JS 执行
目的 减少首次打开延迟 实际渲染子应用

5.4 子应用容器布局

// App.vue
.child-app-container {
  width: 100%;
  height: calc(100vh - 66px);  // 减去 Header 高度
  overflow: hidden;
}

// AuthenticatedLayout.vue
.authenticated-layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
}
.custom-tabs-content {
  flex: 1;
  height: calc(100vh - 66px);
  overflow: hidden;
  position: relative;
}

5.5 子应用 Props 传递

主应用通过 WujieVue 的 :props 向子应用传递共享数据:

<WujieVue
  :props="{ token: userStore.token, userInfo: userStore.userInfo }"
/>

子应用通过 window.__WUJIE.props 读取:

// 子应用 wujie-bridge.ts
export function getWujieProps(): Record<string, any> {
  return (window as any).__WUJIE?.props || {}
}

六、子应用接入规范

6.1 子应用改造清单

每个子应用需要完成以下改造才能接入微前端:

步骤 文件 改动内容
1 vite.config.ts 配置 base: VITE_BASE_PATHserver.headers 添加 CORS
2 .env.dev 设置 VITE_DEV_PORTVITE_BASE_PATHVITE_APP_NAME
3 src/utils/wujie-bridge.ts 新建通信桥接器(环境检测、bus 通信、资源 404 检测)
4 src/main.ts 调用 errorCheck()
5 src/App.vue 移除旧的 iframe postMessage 监听

注意:路由同步由 wujie sync 模式自动处理,子应用无需编写任何路由同步代码。

6.2 子应用 wujie-bridge.ts 标准模板

// 核心导出
export { isInWujie, isInIframe }       // 环境检测
export { notifyMainApp }               // 向主应用发事件
export { onMainAppEvent, offMainAppEvent } // 监听主应用事件
export { errorCheck }                  // 资源 404 检测
export { MESSAGE_TYPE }                // 事件类型常量(TO_ROUTE, ASSETS_404, CLOSE_ALL_TABS)

6.3 主应用注册新子应用

  1. registry.ts 添加子应用配置(childPathList 自动从 registry 派生,无需手动维护)
  2. router/index.ts 添加占位路由 /{appName}/:pathMatch(.*)*
  3. .env.base 添加 VITE_{APP_NAME}_APP_URL
  4. tabs.ts appList 添加 Tab 配置

七、生产环境部署

7.1 Nginx 配置要点

# 主应用
location /micro-main/ {
  try_files $uri $uri/ /micro-main/index.html;
}

# 子应用(以 ibs-manage 为例)
location /ibs-manage/ {
  proxy_pass http://ibs-manage-server/;
  # 或静态文件
  # alias /path/to/ibs-manage/dist/;
  # try_files $uri $uri/ /ibs-manage/index.html;
}

7.2 URL 生成策略

开发环境:
  主应用: http://localhost:3000/micro-main/
  子应用: http://localhost:3007/ibs-manage/  (由环境变量 VITE_IBS_MANAGE_APP_URL 提供)
  WujieVue url = http://localhost:3007/ibs-manage/

生产环境:
  主应用: https://domain.com/micro-main/
  子应用: https://domain.com/ibs-manage/  (由 nginx 反向代理)
  WujieVue url = /ibs-manage/  (相对路径)

八、已知限制与后续规划

8.1 当前限制

限制 说明 影响
子应用使用 WebHistory 子应用 router 使用 createWebHistory(BASE_URL),在 wujie 沙箱中 location 被代理 子应用独立运行和微前端运行行为一致
菜单路径依赖后端 module 字段 buildFullPath 根据 menu.module 拼接 /${appName} 前缀 后端菜单配置需正确设置 module
预加载依赖子应用 dev server 开发环境下子应用未启动时预加载会静默失败 不影响功能,仅影响首次加载速度

8.2 后续规划

阶段 内容 优先级
Phase 3.1 剩余 5 个子应用迁移到 monorepo
Phase 3.2 子应用间直接通信(不经过主应用中转)
Phase 3.3 子应用独立部署 + 版本管理

附录 A:完整数据流时序图

A.1 菜单点击 → 子应用渲染(sync 模式)

用户          SiderMenu      tabs.ts       Vue Router     App.vue        wujie(sync)    子应用
 │               │              │              │             │              │             │
 │──点击菜单──→  │              │              │             │              │             │
 │               │──menuClick──→│              │             │              │             │
 │               │              │──push────────→│             │              │             │
 │               │              │              │──路由变化───→│              │             │
 │               │              │              │             │──标记已访问   │             │
 │               │              │              │             │  (visitedApps)│             │
 │               │              │              │             │──v-show=true  │             │
 │               │              │              │             │              │             │
 │               │              │              │  URL 变化 → wujie sync 自动同步          │
 │               │              │              │             │──────────────→│──replace───→│
 │               │              │              │             │              │             │──渲染页面
 │               │              │              │             │              │             │
 ✅ 无需 bus 通信,无防循环标记,wujie 框架自动处理

A.2 子应用内部导航 → 主应用 URL 同步(sync 模式)

用户          子应用         wujie(sync)    Vue Router     App.vue
 │              │              │              │             │
 │──点击链接──→ │              │              │             │
 │              │──push────────│              │             │
 │              │              │              │             │
 │              │  路由变化 → wujie sync 自动同步到主应用 URL │
 │              │──────────────→│──replace────→│             │
 │              │              │              │──路由变化───→│
 │              │              │              │             │──仅标记已访问
 │              │              │              │             │
 ✅ 无需 bus 通信,地址栏自动更新为 /ibs-manage/operation/yyy

A.3 跨应用跳转(TO_ROUTE 事件)

用户          子应用A        wujie bus     AuthLayout     tabs.ts       Vue Router    wujie(sync)   子应用B
 │              │              │             │              │             │              │             │
 │──操作────→   │              │             │              │             │              │             │
 │              │──emit────────→│             │              │             │              │             │
 │              │  TO_ROUTE     │──收到───────→│              │             │              │             │
 │              │              │             │──menuClick──→│             │              │             │
 │              │              │             │              │──push───────→│              │             │
 │              │              │             │              │             │  URL 变化 → sync 自动同步   │
 │              │              │             │              │             │──────────────→│──replace───→│
 │              │              │             │              │             │              │             │──渲染

从零实现富文本编辑器#11-Immutable状态维护与增量渲染

在先前我们讨论了视图层的适配器设计,主要是全量的视图初始化渲染,包括生命周期同步、状态管理、渲染模式、DOM映射状态等。在这里我们需要处理变更的增量更新,这属于性能方面的考量,需要考虑如何实现不可变的状态对象,以此来实现Op操作以及最小化DOM变更。

从零实现富文本编辑器系列文章

行级不可变状态

在这里我们先不引入视图层的渲染问题,而是仅在Model层面上实现精细化的处理,具体来说就是实现不可变的状态对象,仅更新的节点才会被重新创建,其他节点则直接复用。由此想来此模块的实现颇为复杂,也并未引入immer等框架,而是直接处理的状态对象,因此先从简单的更新模式开始考虑。

回到最开始实现的State模块更新文档内容,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。

delta.eachLine((line, attributes, index) => {
  const lineState = new LineState(line, attributes, this);
  lineState.index = index;
  lineState.start = offset;
  lineState.key = Key.getId(lineState);
  offset = offset + lineState.length;
  this.lines[index] = lineState;
});

这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能。当文档内容非常大的时候,全量计算将会导致大量的状态重建,并且其本身的改变也会导致Reactdiff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。

那么通常来说我们就需要基于变更来确定状态的更新,首先我们需要确定更新的粒度,例如以行为基准则未变更的时候就直接取原有的LineState。相当于尽可能复用Origin List然后生成Target List,这样的方式自然可以避免部分状态的重建,尽可能复用原本的对象。

整体思路大概是先执行变成生成最新的列表,然后分别设置旧列表和新列表的rowcol两个指针值,然后更新时记录起始row,删除和新增自然是正常处理,对于更新则认为是先删后增。对于内容的处理则需要分别讨论单行和跨行的问题,中间部分的内容就作为重建的操作。

最后可以将这部分增删LineState数据放置于Changes中,就可以得到实际增删的Ops了,这样我们就可以优化部分的性能,因为仅原列表和目标列表的中间部分才会重建,其他部分的行状态直接复用。此外这部分数据在applydelta中是不存在的,同样可以认为是数据的补充。

  Origin List (Old)                          Target List (New)
+-------------------+                      +-------------------+
| [0] LineState A   | <---- Retain ------> | [0] LineState A   | (Reused)
+-------------------+                      +-------------------+
| [1] LineState B   |          |           | [1] LineState B2  | (Update)
+-------------------+       Changes        |     (Modified)    | (Del C)
| [2] LineState C   |          |           +-------------------+
+-------------------+          V           | [2] NewState X    | (Inserted)
| [3] LineState D   | ---------------\     +-------------------+
+-------------------+                 --> | [3] LineState D   | (Reused)
| [4] LineState E   | <---- Retain ------> | [4] LineState E   | (Reused)
+-------------------+                      +-------------------+

那么这里实际上是存在非常需要关注的点,我们现在维护的是状态模型,也就是说所有的更新就不再是直接的compose,而是操作我们实现的状态对象。本质上我们是需要实现行级别的compose方法,这里的实现非常重要,假如我们对于数据的处理存在偏差的话,那么就会导致状态出现问题。

此外在这种方式中,我们判断LineState是否需要新建则是根据整个行内的所有LeafState来重建的。也就是说这种时候我们是需要再次将所有的op遍历一遍,当然实际上由于最后还需要将compose后的Delta切割为行级别的内容,所以其实即使在应用变更后也最少需要再遍历两次。

那么此时我们需要思考优化方向,首先是首个retain,在这里我们应该直接完整复用原本的LineState,包括处理后的剩余节点也是如此。而对于中间的节点,我们就需要为其独立设计更新策略,这部分理论上来说是需要完全独立处理为新的状态对象的,这样可以减少部分Leaf Op的遍历。

new Delta().retain(5).insert("xx")
insert("123"), insert("\n") // skip 
insert("456"), insert("\n") // new line state

其中,如果是新建的节点,我们直接构建新的LineState即可,删除的节点则不从原本的LineState中放置于新的列表。而对于更新的节点,我们需要更新原本的LineState对象,因为实际上行是存在更新的,而重点是我们需要将原本的LineStatekey值复用。

这里我们先简单实现实现描述一下复用的问题,比较方便的实现则是直接以\n的标识为目标的State,这就意味着我们要独立\n为独立的状态。即如果在123|456\n|位置插入\n的话,那么我们就是123是新的LineState456是原本的LineState,以此来实现key的复用。

[
  insert("123"), insert("\n"), 
  insert("456"), insert("\n")
]
// ===>
[ 
  LineState(LeafState("123"), LeafState("\n")), 
  LineState(LeafState("456"), LeafState("\n"))
]

其实这里有个非常值得关注的点是,LineStateDelta中是没有具体对应的Op的,而相对应的LeafState则是有具体的Op的。这就意味着我们在处理LineState的更新时,是不能直接根据变更控制的,因此必须要找到能够映射的状态,因此最简单的方案即根据\n节点映射。

LeafState("\n", key="1") <=> LineState(key="L1")

实际上我们可以总结一下,最开始我们考虑先更新再diff,后来考虑的是边更新边记录。边更新边记录的优点在于,可以避免再次遍历一边所有Leaf节点的消耗,同时也可以避免diff的复杂性。但是这里也存在个问题,如果内部进行了多次retain操作,则无法直接复用LineState

不过通常来说,最高频的操作是输入内容,这种情况下首操作一般都是retain,尾操作为空会收集剩余文档内容,因此这部分优化是会被高频触发的。而如果是多次的内容部分变更操作,这部分虽然可以通过判断行内的叶子结点是否变更,来判断是否复用行对象,但是也存在一定复杂性。

关于这部分的具体实现,在编辑器的状态模块里存在独立的Mutate模块,这部分实现在后边实现各个模块时会独立介绍。到这里我们就可以实现一个简单的Immutable状态维护,如果Leaf节点发生变化之后,其父节点Line会触发更新,而其他节点则可以直接复用。

Key 值维护

至此我们实现了一套简单的Immutable Delta+Iterator来处理更新,这种时候我们就可以借助不可变的方式来实现React视图的更新,那么在React的渲染模式中,key值的管理也是个值的探讨的问题。

在这里我们就可以根据状态不可变来生成key值,借助WeakMap映射关系获取对应的字符串id值,此时就可以借助key的管理以及React.memo来实现视图的复用。其实在这里初步看起来key值应该是需要主动控制强制刷新的时候,以及完全是新节点才会用得到的。

但是这种方式也是有问题的,因为此时我们即使输入简单的内容,也会导致整个行的key发生改变,而此时我们是不必要更新此时的key的。因此key值是需要单独维护的,不能直接使用不可变的对象来索引key值,那么如果是直接使用index作为key值的话,就会存在潜在的原地复用问题。

key值原地复用会导致组件的状态被错误保留,例如此时有个非受控管理的input组件列表,在某个输入框内已经输入了内容,当其发生顺序变化时,原始输入内容会跟随着原地复用的策略留在原始的位置,而不是跟随到新的位置,因为其整体列表顺序key未发生变化导致React直接复用节点。

LineState节点的key值维护中,如果是初始值则是根据state引用自增的值,在变更的时候则是尽可能地复用原始行的key,这样可以避免过多的行节点重建并且可以控制整行的强制刷新。

而对于LeafState节点的key值最开始是直接使用index值,这样实际上会存在隐性的问题,而如果直接根据Immutable来生成key值的话,任何文本内容的更改都会导致key值改变进而导致DOM节点的频繁重建。

export const NODE_TO_KEY = new WeakMap<Object.Any, Key>();
export class Key {
  /** 当前节点 id */
  public id: string;
  /** 自动递增标识符 */
  public static n = 0;

  constructor() {
    this.id = `${Key.n++}`;
  }

  /**
   * 根据节点获取 id
   * @param node
   */
  public static getId(node: Object.Any): string {
    let key = NODE_TO_KEY.get(node);
    if (!key) {
      key = new Key();
      NODE_TO_KEY.set(node, key);
    }
    return key.id;
  }
}

通常使用index作为key是可行的,然而在一些非受控场景下则会由于原地复用造成渲染问题,diff算法导致的性能问题我们暂时先不考虑。在下面的例子中我们可以看出,每次我们都是从数组顶部删除元素,而实际的input值效果表现出来则是删除了尾部的元素,这就是原地复用的问题。在非受控场景下比较明显,而我们的ContentEditable组件就是一个非受控场景,因此这里的key值需要再考虑一下。

const { useState, Fragment, useRef, useEffect } = React;
function App() {
  const ref = useRef<HTMLParagraphElement>(null);
  const [nodes, setNodes] = useState(() => Array.from({ length: 10 }, (_, i) => i));

  const onClick = () => {
    const [_, ...rest] = nodes;
    console.log(rest);
    setNodes(rest);
  };

  useEffect(() => {
    const el = ref.current;
    el && Array.from(el.children).forEach((it, i) => ((it as HTMLInputElement).value = i + ""));
  }, []);

  return (
    <Fragment>
      <p ref={ref}>
        {nodes.map((_, i) => (<input key={i}></input>))}
      </p>
      <button onClick={onClick}>slice</button>
    </Fragment>
  );
}

考虑到先前提到的我们不希望任何文本内容的更改都导致key值改变引发重建,因此就不能直接使用计算的immutable对象引用来处理key值,而描述单个op的方法除了insert就只剩下attributes了。

但是如果基于attributes来获得就需要精准控制合并insert的时候取需要取旧的对象引用,且没有属性的op就不好处理了,因此这里可能只能将其转为字符串处理,但是这样同样不能保持key的完全稳定,因此前值的索引改变就会导致后续的值出现变更。

const prefix = new WeakMap<LineState, Record<string, number>>();
const suffix = new WeakMap<LineState, Record<string, number>>();
const mapToString = (map: Record<string, string>): string => {
  return Object.keys(map)
    .map(key => `${key}:${map[key]}`)
    .join(",");
};
const toKey = (state: LineState, op: Op): string => {
  const key = op.attributes ? mapToString(op.attributes) : "";
  const prefixMap = prefix.get(state) || {};
  prefix.set(state, prefixMap);
  const suffixMap = suffix.get(state) || {};
  suffix.set(state, suffixMap);
  const prefixKey = prefixMap[key] ? prefixMap[key] + 1 : 0;
  const suffixKey = suffixMap[key] ? suffixMap[key] + 1 : 0;
  prefixMap[key] = prefixKey;
  suffixMap[key] = suffixKey;
  return `${prefixKey}-${suffixKey}`;
};

slate中我先前认为生成的key跟节点是完全一一对应的关系,例如当A节点变化时,其代表的层级key必然会发生变化。然而在关注这个问题之后,我发现其在更新生成新的Node之后,会同步更新Path以及PathRef对应的Node节点所对应的key值。

for (const [pathRef, key] of pathRefMatches) {
  if (pathRef.current) {
    const [node] = Editor.node(e, pathRef.current)
    NODE_TO_KEY.set(node, key)
  }
  pathRef.unref()
}

在后续观察Lexical实现的选区模型时,发现其是用key值唯一地标识每个叶子结点的,选区也是基于key值来描述的。整体表达上比较类似于Slate的选区结构,或者说是DOM树的结构。这里仅仅是值得Range选区,Lexical实际上还有其他三种选区类型。

{
  anchor: { key: "51", offset: 2, type: "text" },
  focus: { key: "51", offset: 3, type: "text" }
}

在这里比较重要的是key值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到immutable话,很明显直接根据状态对象的引用来映射key会导致整个编辑器DOM无效的重建。例如调整标题的等级,就由于整个行key的变化导致整行重建。

那么如何尽可能地复用key值就成了需要研究的问题,我们的编辑器行级别的key是被特殊维护的,即实现了immutable以及key值复用。而目前叶子状态的key依赖了index值,因此如果调研Lexical的实现,同样可以将其应用到我们的key值维护中。

通过在playground中调试可以发现,即使我们不能得知其是否为immutable的实现,依然可以发现Lexicalkey是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以0起始直接复用,起始非0则创建新key

  1. [123456(key1)][789(bold-key2)]文本,将789的加粗取消,整段文本的key值保持为key1
  2. [123456789(key1)]]文本,将789这段文本加粗,左侧123456文本的key值保持为key1789则是新的key
  3. [123456789(key1)]]文本,将123这段文本加粗,左侧123文本的key值保持为key1456789则是新的key
  4. [123456789(key1)]]文本,将456这段文本加粗,左侧123文本的key值保持为key1456789分别是新的key

因此,此时在编辑器中我们也是用类似偏左的方式维护key,由于我们需要保持immutable,所以这里的表达实际上是尽可能复用先前的key状态。这里与LineStatekey值维护方式类似,都是先创建状态然后更新其key值,当然还有很多细节的地方需要处理。

// 起始与裁剪位置等同 NextOp => Immutable 原地复用 State
if (offset === 0 && op.insert.length <= length) {
  return nextLeaf;
}
const newLeaf = new LeafState(retOp, nextLeaf.parent);
// 若 offset 是 0, 则直接复用原始的 key 值
offset === 0 && newLeaf.updateKey(nextLeaf.key);

这里还存在另一个小问题,我们创建LeafState就立即去获得对应的key值,然后再考虑去复用原始的key值。这样其实就会导致很多不再使用的key值被创建,导致每次更新的时候看起来key的数字差值比较大。当然这并不影响整体的功能与性能,只是调试的时候看起来比较怪。

因此我们在这里还可以优化这部分表现,也就是说我们在创建的时候不会去立即创建key值,而是在初始化以及更新的时候再从外部设置其key值。这个实现其实跟indexoffset的处理方式比较类似,我们整体在update时处理所有的相关值,且开发模式渲染时进行了严格检查。

// BlockState
let offset = 0;
this.lines.forEach((line, index) => {
  line.index = index;
  line.start = offset;
  line.key = line.key || Key.getId(line);
  const size = line.isDirty ? line.updateLeaves() : line.length;
  offset = offset + size;
});
this.length = offset;
this.size = this.lines.length;
// LineState
let offset = 0;
const ops: Op[] = [];
this.leaves.forEach((leaf, index) => {
  ops.push(leaf.op);
  leaf.offset = offset;
  leaf.parent = this;
  leaf.index = index;
  offset = offset + leaf.length;
  leaf.key = leaf.key || Key.getId(leaf);
});
this._ops = ops;
this.length = offset;
this.isDirty = false;
this.size = this.leaves.length;

此外,在实现单元测试时还发现,在leaf上独立维护了key值,那么\n这个特殊的节点自然也会有独立的key值。这种情况下在line级别上维护的key值倒是也可以直接复用\n这个leafkey值。当然这只是理论上的实现,可能会导致一些意想不到的刷新问题。

视图增量渲染

在视图模块最开始的设计上,我们的状态管理形式是直接全量更新Delta,然后使用EachLine遍历重建所有的状态。并且实际上我们维护了DeltaState两个数据模型,建立其关系映射关系本身也是一种损耗,渲染的时候的目标状态是Delta而非State

这样的模型必然是耗费性能的,每次Apply的时候都需要全量更新文档并且再次遍历分割行状态。当然实际上只是计算迭代的话,实际上是不会太过于耗费性能,但是由于我们每次都是新的对象,那么在更新视图的时候,更容易造成性能的损耗,计算的性能通常可接受,而视图更新操作DOM成本更高。

实际上,我们上边复用其key值,解决的问题是避免整个行状态视图re-mount。而即使复用了key值,因为重建了整个State实例,React也会继续后边的re-render流程。因此我们在这里需要解决的问题是,如何在无变更的情况下尽可能避免其视图re-render

由于我们实现了行级不可变状态维护,那么在视图中就可以直接对比状态对象的引用是否变化来决定是否需要重渲染。因此只需要对于ViewModel的节点补充了React.memo,在这个场景下甚至于不需要重写对比函数,只需要依赖我们的immutable状态复用能够正常起到效果。

const LeafView: FC<{ editor: Editor; leafState: LeafState; }> = props => {
  return (
    <span {...{ [LEAF_KEY]: true }} >
      {runtime.children}
    </span>
  );
}
export const LeafModel = React.memo(LeafView);

同样的,针对LineView也需要补充memo,而且由于组件内本身可能存在状态变化,例如Composing组合输入的控制,所以针对于内部节点的计算也会采用useMemo来缓存结果,避免重复计算。

const LineView: FC<{ editor: Editor; lineState: LineState; }> = props => {
  const elements = useMemo(() => {
     // ...
    return nodes;
  }, [editor, lineState]);
  return (
    <div {...{ [NODE_KEY]: true }} >
      {elements}
    </div>
  );
}
export const LineModel = React.memo(LineView);

而视图刷新仍然还是直接控制lines这个状态的引用即可,相当于核心层的内容变化与视图层的重渲染,是直接依赖于事件模块通信就可以实现的。由于每次取lines状态时都是新的引用,所以React会认为状态发生了变化,从而触发重渲染。

const onContentChange = useMemoFn(() => {
  if (flushing.current) return void 0;
  flushing.current = true;
  Promise.resolve().then(() => {
    flushing.current = false;
    setLines(state.getLines());
  });
});

而虽然触发了渲染,但是由于key以及memo的存在,会以line的状态为基准进行对比。只有LineState对象的引用发生了变化,LineModel视图才会触发更新逻辑,否则会复用原有的视图,这部分我们可以直接依赖Reactdevtools录制或Highlight就可以观察到。

视图增量更新这部分其实比较简单,主要是实现不可变对象以及key值维护的逻辑都在核心层实现,视图层主要是依赖其做计算,对比是否需要重渲染。其实类似的实现在低代码的场景中也可以应用,毕竟实际上富文本也就是相当于一个零代码的编辑器,只不过组装的不是组件而是文本。

总结

在先前我们主要讨论了视图层的适配器设计,主要是全量的视图初始化渲染,以及状态模型到DOM结构性的规则设定。在这里则主要考虑更新处理时性能的优化,主要是在增量更新时,如何最小化DOM以及Op操作、key值的维护、以及在React中实现增量渲染的方式。

其实接下来需要考虑输入内容时,如何避免规定的DOM的结构被破坏,主要涉及脏DOM检查、选区更新、渲染Hook等,这部分内容在#8#9的输入法处理中已经有了详细的讨论,因此这里就不再次展开了。

那么接下来我们需要讨论的是编辑节点的组件预设,例如零宽字符、Embed节点、Void节点等。主要是为编辑器的插件扩展提供预设的组件,在这些组件内存在一些默认的行为,并且同样预设了部分DOM结构,以此来实现在规定范围内的编辑器操作。

每日一题

参考

用 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比数平台功能介绍及实现原理|得物技术

文 /寇森

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

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

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

❌