从 4 个 URL 到 1 个入口:Peaks-Loop 驱动的微前端聚合实践

从 4 个 URL 到 1 个入口:Peaks-Loop 驱动的微前端聚合实践

当你的团队维护着 5 个独立 Git 仓库、4 个不同的业务平台(Dify、RAGFlow、Higress Console、Hermes Studio),每个都要独立部署、独立发布,但用户需要在一个统一的界面里操作它们------你会怎么做?

一、背景:为什么会有这个需求

事情要从我们团队的现状说起。

我们手上有几个业务平台:Dify (LLM 应用开发平台)、RAGFlow (知识库检索)、Higress Console (网关管理)、Hermes Studio(内部工具链)。它们原本是各自独立的系统,部署在不同的域名下,用户需要在多个标签页之间来回切换。

痛点很直接:

  • 用户要记 4 个 URL,每次切换都要重新登录
  • 视觉风格不统一,每个平台有自己的 UI 组件和主题
  • 跨平台跳转靠跳转链接,体验割裂
  • 共享代码靠复制粘贴,类型定义、工具函数、SDK 调用散落在各个仓

我们决定做一件事:把这 4 个业务平台合成一个微前端体系,让它们同域部署、按需加载、一次登录全平台可用。

最终落地成果:5 个独立 Git 仓(1 主 + 4 子),~80 个 commit,主仓源码仅 ~950 行,一次 tag 发布全量镜像,总 wall-time 15-30 分钟。

二、技术选型:为什么是 qiankun 不是 Module Federation

选型阶段,我们在 qiankun 和 Module Federation 之间犹豫过。

我们的约束条件

  1. 子应用必须能独立开发、独立部署、独立发布------每个子仓有自己的 GitLab CI、自己的 tag 流程
  2. 子应用必须是完全独立的 Git 仓------不能合并到一个 monorepo 里
  3. 运行时隔离------子应用之间的样式、全局变量不能互相污染
  4. 团队使用 umijs/max------框架内置了 qiankun 集成

为什么没选 Module Federation

Module Federation 的强项是运行时共享模块,但它有一个致命问题:它会破坏子应用的独立部署。MF 要求子应用在构建时就知道彼此的模块依赖关系,一旦某个子应用更新了共享模块,所有消费方都要重新构建。

而我们的场景是:4 个子仓由不同的团队维护,它们有自己的发布节奏。用 MF 意味着每次发布都要协调所有子仓的版本------这恰恰是我们想避免的。

qiankun 的沙箱隔离机制天然契合我们的诉求:子应用作为独立 SPA 构建,qiankun 在运行时通过 beforeLoad → bootstrap → mount 生命周期加载它们,样式隔离由 antd v6 的 cssVar + @layer 方案解决。

最终技术栈

类别 选型 版本
应用框架 @umijs/max 4.7.8
UI 库 antd 6.6.1
微前端 qiankun 2.10.16
包管理 pnpm 9.15.0
CI/CD GitLab CI ---
容器化 Docker + BuildKit docker:24

三、5 仓架构:主仓 + 4 子仓的布局

物理目录结构

复制代码
platform-web/                    ← 工作根目录(不建 git)
├── platform-web-main/           # 主仓(qiankun Master)
│   ├── apps/shared-mf/           # MF producer
│   ├── packages/shared/          # 共享 SDK 源码
│   │   ├── shared-types/
│   │   ├── shared-utils/
│   │   ├── shared-sdk/
│   │   ├── shared-router/
│   │   └── shared-ui/
│   ├── src/                     # 主应用源码
│   ├── docker/                  # Dockerfile + 辅助脚本
│   └── .gitlab-ci.yml           # 主仓 4-stage pipeline
├── platform-web-agent/          # 子仓 1
├── platform-web-governance/     # 子仓 2
├── platform-web-higress/        # 子仓 3
└── platform-web-rag/            # 子仓 4

#mermaid-svg-pzwLX1ANrtfRmte2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pzwLX1ANrtfRmte2 .error-icon{fill:#552222;}#mermaid-svg-pzwLX1ANrtfRmte2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pzwLX1ANrtfRmte2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 .marker.cross{stroke:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pzwLX1ANrtfRmte2 p{margin:0;}#mermaid-svg-pzwLX1ANrtfRmte2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster-label text{fill:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster-label span{color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster-label span p{background-color:transparent;}#mermaid-svg-pzwLX1ANrtfRmte2 .label text,#mermaid-svg-pzwLX1ANrtfRmte2 span{fill:#333;color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .node rect,#mermaid-svg-pzwLX1ANrtfRmte2 .node circle,#mermaid-svg-pzwLX1ANrtfRmte2 .node ellipse,#mermaid-svg-pzwLX1ANrtfRmte2 .node polygon,#mermaid-svg-pzwLX1ANrtfRmte2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .rough-node .label text,#mermaid-svg-pzwLX1ANrtfRmte2 .node .label text,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape .label,#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pzwLX1ANrtfRmte2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .rough-node .label,#mermaid-svg-pzwLX1ANrtfRmte2 .node .label,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape .label,#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape .label{text-align:center;}#mermaid-svg-pzwLX1ANrtfRmte2 .node.clickable{cursor:pointer;}#mermaid-svg-pzwLX1ANrtfRmte2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 .arrowheadPath{fill:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pzwLX1ANrtfRmte2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pzwLX1ANrtfRmte2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pzwLX1ANrtfRmte2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pzwLX1ANrtfRmte2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pzwLX1ANrtfRmte2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster text{fill:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster span{color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pzwLX1ANrtfRmte2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape p,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape .label rect,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pzwLX1ANrtfRmte2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pzwLX1ANrtfRmte2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pzwLX1ANrtfRmte2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 子仓 4
子仓 3
子仓 2
子仓 1
主仓 platform-web-main
webpack alias 引用
webpack alias 引用
webpack alias 引用
webpack alias 引用
qiankun 运行时加载
qiankun 运行时加载
qiankun 运行时加载
qiankun 运行时加载
apps/shared-mf
packages/shared
src
docker
platform-web-agent
platform-web-governance
platform-web-higress
platform-web-rag

关键设计:共享代码的管理

这是这个架构中最"反直觉"的部分------5 个仓没有用 pnpm workspace 跨仓

每个子仓是完全独立 的 Git 仓,有自己的 tag 和 release 流程。共享代码放在主仓的 packages/shared/ 下,子仓在 dev 期间通过 webpack alias 直接引用主仓的源码路径:

ts 复制代码
// platform-web-main/.umirc.ts
const sharedRepoRoot = join(__dirname, "packages", "shared");
export default defineConfig({
  alias: {
    "@platform/shared-types": join(sharedRepoRoot, "shared-types", "src"),
    "@platform/shared-utils": join(sharedRepoRoot, "shared-utils", "src"),
    "@platform/shared-sdk": join(sharedRepoRoot, "shared-sdk", "src"),
    "@platform/shared-router": join(sharedRepoRoot, "shared-router", "src"),
    "@platform/shared-ui": join(sharedRepoRoot, "shared-ui", "src"),
  },
});

dev 工作流:所有应用必须在主仓同目录的兄弟位置。修改 shared SDK 源码 → 主仓 dev server 自动热重载 → 所有子应用立刻看到新代码------不需要 publish、不需要 install、不需要 restart。

生产工作流 :子仓 pnpm max build 只输出自己的 /out/dist不包含 shared 代码 。shared 由主仓的 build-shared job 构建为独立镜像,最终在 assemble-main 阶段通过 buildx --build-context 拼装到一起。

这里有个坑(踩过了)

这个方案的先决条件是:所有开发者在同一台机器上,把所有 5 个仓 clone 到兄弟目录。如果远程开发、或者 CI 环境,这套 webpack alias 就失效了。

我们的解法是:只在 dev 环境用 alias,CI 构建完全隔离。子仓 CI 只构建自己的源码,shared 的注入点是主仓的 assemble 阶段。这不是一个"优雅"的方案,但它在 5 仓独立发布的约束下工作得很好。

四、CI/CD:最复杂的部分

微前端方案本身不复杂,真正复杂的是 5 个独立仓的 CI/CD 编排

Pipeline 流程

复制代码
打 tag → fan-out 4 子仓 → 4 子仓并行 build → wait-and-pull → build-shared → assemble-main

核心挑战:错峰调度

我们的 GitLab Runner 是单 host 部署,concurrent=4。这意味着同时只能跑 4 个 job。如果 fan-out 同时触发 4 个子仓的 build,每个子仓还要同时 build amd64 和 arm64 两个架构,那就是 8 个 job 同时竞争 4 个 slot------直接死锁

解法:错峰 120s。fan-out 依次触发 4 个子仓,每个间隔 120 秒,让前一组的 job 先占住 slot 再触发下一个。这样 4 个子仓的 build 串行排队,但每个子仓内部的 amd64 + arm64 可以并行。

镜像命名约定

统一命名空间是解决"多仓镜像管理"的关键:

复制代码
${REGISTRY}/platform-web/${BRANCH_PATH}/${app}:${tag}-${arch}

这个约定让 wait-and-pull 阶段的镜像探活逻辑简化为简单的字符串拼接。

关键变量:SUB_APPS

这是整个设计中最灵活的部分------通过 GitLab CI 变量 SUB_APPS 来控制要构建哪些子应用:

bash 复制代码
# 场景 A:只构建 agent 做集成测试
SUB_APPS=agent
COMBO_NAME=agent-only-test

# 场景 B:生产灰度(3 子应用,从 release 分支拉)
SUB_APPS=agent,governance,higress
SUB_APP_BRANCH=release-v2.3

# 场景 C:紧急跳过某个子应用(它 build 坏了)
SUB_APPS=agent,higress,rag  # 跳过 governance

这意味着不需要改任何代码,直接在 GitLab UI 的 "Run pipeline" 页面填变量,就能控制发布内容。

五、Phase 2 实战:10 个真实问题

Phase 2 的目标是把子仓构建从"主仓 inline git clone"改为"子仓独立 CI"。这个改动涉及 5 个仓的 CI 配置、Dockerfile、GitLab 权限的综合改造。以下是我们在 code-image-server 上实际跑通时遇到的 10 个问题,按出现顺序排列。

1. heredoc 变量展开(最隐蔽的根因)

症状pnpm max buildNo package.json found in /src

根因 :一个辅助脚本用 echo "name=$1" 写入,$1 在 echo 时被 expand 成空字符串。case statement 永远不 match,所有子仓都走了 else 分支创建空目录。

修复 :改用 cat > script <<'EOF'(quoted heredoc),$1 在运行时才 expand。

感悟:写 Shell 脚本时,单引号 heredoc 和双引号 heredoc 的区别经常被忽略,但它在 CI 环境里能让你排查半小时。

2. GitLab CI token 用户名格式

症状fatal: Authentication failed

根因oauth2: 前缀只对 Personal Access Token 有效;CI_JOB_TOKEN 必须是 gitlab-ci-token:CI_JOB_TOKEN 格式。

3. 内网 DNS 解析

症状Could not resolve host: gitlab.bigmodel.local

根因:BuildKit 容器内无法解析内网 GitLab 域名。

修复.gitlab-ci.ymldocker buildx build 命令加 --add-host "gitlab.bigmodel.local:172.20.130.30"

4. Alpine 镜像源限速

症状apk add 卡 5+ 分钟

修复RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories

5. DockerHub 基础镜像限速

症状:metadata 拉取 100+ 秒

修复 :改 FROM 为 docker.m.daocloud.io/library/node:22-alpine(道客云 DockerHub mirror)。

6. esbuild 辅助函数冲突

症状[esbuildHelperChecker] Found conflicts in esbuild helpers

修复 :每个子仓 .umirc.tsesbuildMinifyIIFE: true

7. fan-out API token header 用错

症状POST /repository/tags 返回 404 Project Not Found(GitLab 用 404 隐藏 401)

根因 :PAT 必须用 PRIVATE-TOKEN: header,不是 JOB-TOKEN:

8. 分支名 URL 编码

症状POST /repository/tags?ref=feat/pro-template-migration 返回 404

根因 :分支名里的 / 没编码,被 GitLab 当 URL 分隔符解析。

修复SUB_APP_REF_ENCODED="${SUB_APP_REF//\//%2F}"

9. Runner 超时设置覆盖

症状Job failed: execution took longer than 10m0s

根因 :GitLab Runner config.toml 的 script_timeout = 600 覆盖 job-level 的 timeout: 30 minutes

修复 :管理员改 /etc/gitlab-runner/config.toml,每个 [[runners]] 段加 script_timeout = 3600

10. docker pull 浪费 2 分钟

症状:wait-and-pull job 超 10 分钟

根因docker pull 在 docker:dind 下 daemon 跨 job 不共享,pull 出来没意义。

修复 :只做 docker manifest inspect,不 pull。assemble-main 用 --build-context docker-image://... 重新解析时 BuildKit 会按需拉取。

六、新业务接入:3 步走

这个架构设计时就把"可扩展性"作为一等公民。未来接入新业务(比如再接入一个 Dify Console),只需要 3 步:

  1. 子仓 :复制任意子仓模板,改名为 platform-web-<新业务>
  2. 子仓.gitlab-ci.yml 只改一行变量 APP: "<新业务>"
  3. 主仓.gitlab-ci.ymlSUB_APPS 默认值追加 "<新业务>"

不需要改主仓的 webpack 配置、CI 编排、Dockerfile。所有新业务自动获得:

  • ✅ 构建:fan-out 自动触发新子仓 build
  • ✅ 运行时:qiankun 自动加载新子应用
  • ✅ 共享 SDK:新子仓加同样 webpack alias 即可
  • ✅ 回滚:主仓 SUB_APPS 去掉新业务 → 下次 tag 自动剔除

七、量化成果

最后用数据说话:

维度 数值
仓库总数 5(1 主 + 4 子)
总 commit 数 ~80
主仓源码行数 ~950 行
子应用并行 concurrent=4
fan-out 错峰间隔 120s
总 wall-time 15-30 min
Phase 2 实战修复 10 个

Phase 1 的时候,一次发布要 1.5 小时以上,DNS 解析失败、网络限速、超时重试轮番上演。Phase 2 改完后,稳定在 15-30 分钟------速度提升了 3-6 倍

八、总结与思考

回看这个项目,最值得分享的不是技术方案本身,而是我们在"独立仓 vs monorepo"这个经典难题上的取舍

我们既没有走 monorepo(把所有代码合并到一个仓),也没有走纯微服务(每个服务完全独立没有任何共享)。我们走了一条中间路线:5 个独立 Git 仓 + 主仓内部 monorepo + qiankun 运行时组装

这个方案不完美------webpack alias 跨仓引用、dev 环境依赖目录结构、shell 脚本里的坑------但它在一个关键指标上赢了:团队可以独立开发、独立发布、互不阻塞

如果你也在做类似的微前端聚合,希望这 10 个踩坑记录能帮你少走一些弯路。

相关推荐
Hilaku26 分钟前
技术好就能升职是前端圈最大的谎言!
前端·javascript·程序员
lhldsg28 分钟前
树洞倾诉的核心需求与产品定位误区
java·前端·小程序
光影少年40 分钟前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架
hunterandroid1 小时前
HarmonyOS 弱网与离线优先架构实战:请求队列、本地缓存与增量同步
前端·前端框架
lhldsg1 小时前
宠物同城领养平台开发实战:从需求分析到上线部署指南
java·前端·小程序·需求分析·宠物
hunterandroid1 小时前
Android 内存泄漏排查实战:从 LeakCanary 报警到根因定位
android·前端
leoZ2311 小时前
AI+前端提效-12 AI辅助前端性能优化与监控:从开发到线上全流程提效
前端·人工智能·神经网络·自然语言处理·性能优化·keras·知识图谱
阿图灵1 小时前
LangGraph 实战 03:Workflows 与 Agents——六种工作流模式与智能体实战(附 6 个可运行示例)
java·前端·javascript·工作流·ai agent·智能体·langgraph
狂炫冰美式1 小时前
电脑合盖之后 Cursor 还在偷我电?看看为啥
前端·人工智能·后端