
“谷雨”是我们这边SaaS产品线的内部代号这几年从后端到运维一路写下来今天终于轮到前端架构。先交代一句我们的谷雨跟网上那款蓝牙串口调试工具只是重名没有半点关系你要是搜到那个工具别怀疑自己走错了片场。为什么说前端在谷雨体系里经历了“从矮化到核心”的蜕变因为这个过程真实发生过而且我相信很多正在做多租户SaaS的团队都和我踩过差不多的坑。早期的谷雨前端说白了就是后端接口的“装修队”后端定义数据结构前端照着把页面填满发布节奏完全跟着后端走线上出了问题第一个被怀疑的也是前端。前端在整个可持续交付链路里被矮化成了一堆静态资源文件没有自己的架构决策权没有版本策略更没有灰度能力。直到餐饮SaaS客户开始要品牌主题定制AI点单、智能菜品推荐这些高频迭代交互需求扑过来旧架构彻底接不住了我们才意识到前端从来都是核心只是在错误的架构里被迫边缘化。这篇文章我会完整复盘谷雨SaaS前端架构的拆分思路、模块联邦选型、可持续交付流水线、灰度与回滚方案以及我们在实战里踩过的坑。如果你正在做多租户SaaS平台或者前端团队想从“跟着后端发版”变成“独立交付、快速试错”这篇内容应该能给你一个可以直接抄作业的参考。1. 前端为何被“矮化”可持续交付视角下的审视先别急着谈技术方案得先把“矮化”这个词说透。在我这里“矮化”不是指前端团队不受重视而是指前端在架构和交付流程中失去了独立性变成了整个发布链路里最被动的那个环节。谷雨早期是典型的单体交付模式后端用Spring Boot做了一套服务前端是一整个仓库的Vue应用几十个页面堆在一起路由文件三千行全局状态大家随便挂。这种架构在业务简单的时候确实能跑但一旦业务进入多租户、多定制、多频次迭代的阶段问题就会集中爆发。最致命的一点是每一次前端变更都要配合后端的发布节奏而可持续交付的核心恰恰是要让每一次变更都能独立、安全、快速地到达用户手里。当前端连独立发版都做不到的时候它就永远只能是链路上的瓶颈。1.1 矮化期的三个典型症状我复盘了一下谷雨前端最黑暗的那段时间主要症状有三个。第一架构上没有独立性。所有页面共享一个全局storeA页面的定时器跑到B页面去清组件和组件之间通过事件总线互相调用改一个门店列表组件可能影响会员页的布局。多租户支持就更不用说了租户A的数据在内存里串到租户B排查了半天才发现是某个全局变量没清理。这种代码结构下别说快速迭代连每次发版的回归测试都充满不确定性。第二交付上没有话语权。当时前端产物是作为后端流水线里的一步发布的Jenkins里有一个“upload dist”的步骤由运维去执行前端连日志权限都没有。发布完成后一旦发现线上问题想回滚没有版本管理的概念服务器上的文件直接被新包覆盖回滚就等于重新构建一次代码再传上去。我记得有一次周四下午发现某个页面的样式被改坏了但因为后端那周的版本已经封板前端要修复只能等下周一客户在周末就顶着坏页面跑了两天。第三质量上没有纵深。整个项目只有一个单元测试还是一段测试工具函数的demoE2E全靠人工点点点。每次发布之后前端要花半天时间手工回归核心流程还不敢保证覆盖完整。这个问题在开发阶段不显眼但交付频率一旦提上来人肉回归就是最不可持续的一环。1.2 压垮“矮化”的几件具体事理论说多了容易飘讲几件真实发生的事。第一件事关于缓存灰度。有一次后端接口升级了金额字段的精度前端也跟着改了展示逻辑但因为没有前端自己的灰度机制线上部分老商户还是拿着旧的remote版本在跑结果下单页的金额显示错位客服电话被打爆。排查了很久才发现前端根本没有环境维度的概念所有人都以为“发布”就是把文件传上去就结束了。第二件事关于多租户定制。餐饮SaaS的客户经常要求品牌定制最基础的就是换Logo、换主题色、换菜单分类样式。当时我们的做法是复制一份项目改SCSS变量于是同一套系统维护了两套代码升级一个组件要在两个仓库里同步改维护成本直接翻倍。再往后客户数量增加这种复制粘贴模式很快变成灾难。第三件事关于AI集成。客户要在点餐页面集成一个AI助手用户输入“两个人吃辣、预算一百五”这类描述系统要流式返回推荐菜品。这种交互需要前端有成熟的状态管理、异步渲染和错误恢复机制而旧代码里全是回调嵌套连一个统一的请求层都没有。我们评估了一下在当时的架构上硬加AI交互相当于在豆腐渣地基上盖三层楼。你看前端不是不重要而是被旧架构捆住了手脚变成了整个业务交付里最“矮”的那块木板。2. 谷雨前端架构的蜕变分层、模块联邦与设计系统既然旧架构撑不住了2023年我们做了一个决定重构谷雨前端。当时团队里也吵过要不要直接上微前端要不要换框架最后定下来的方向是不搞激进的全量微前端用Webpack 5的Module Federation做应用级拆分配合一套强约束的设计系统和权限模型让平台既能独立交付又能按需组合。先解释一下为什么不选iframe、不选qiankun。它们都能做隔离但在SaaS多租户场景里都有绕不开的麻烦方案优点在谷雨场景里的致命问题iframe隔离性强、接入成本低状态割裂、弹窗和鉴权跨域麻烦、体验割裂设计系统无法跨应用共享qiankun社区成熟、样式隔离做好主应用强约束子应用要适配沙箱共享公共依赖别扭灰度版本切换不够灵活Module Federation依赖共享、独立部署、版本解耦要求团队有webpack功底公共依赖版本约得不严会翻车最终选Module Federation最关键的原因是它把“多个应用共享同一份运行时依赖”这件事变成了常规操作。在SaaS场景里不同租户可能停留在不同版本上产品线不能因为一个订单应用的发布就全量发版。MF让各个业务应用能独立部署同时还能复用壳层的React实例、UI组件这些公共资源这才符合可持续交付的底层逻辑。2.1 四层架构从核心基础到应用编排重构后的谷雨前端分为四层每一层都有明确的边界和发布策略。最底层是核心基础层包含React 18、React Router、状态管理、请求库、日志SDK和工具函数。这一层是所有应用共享的版本收敛得特别严格升级一个核心库必须走单独的评审流程因为它影响的是平台上所有应用。说白了核心基础层就是地基地基不能随便动。第二层是设计系统层内部代号“谷雨UI”。它不是一套普通组件库而是基于design token体系的颜色、间距、圆角、字号全部定义成CSS变量租户开通时生成一份主题变量文件注入到页面上。这样餐饮客户要红色主题、茶饮客户要绿色主题前端根本不用复制代码改一份token配置就行。第三层是业务域层按业务领域拆出的微应用比如订单应用、会员应用、菜品应用、AI助手应用。每个应用都有自己的仓库、自己的流水线、自己的版本号暴露给壳层的是几个关键组件而不是整个页面。最上面是应用编排层也就是主壳Shell。Shell负责登录态、租户上下文、全局布局、菜单注册、应用懒加载和错误兜底。它本身不承载业务逻辑只做编排和装载。这套分层的关键思路是我们不是按“页面”拆的而是按“交付单元”拆的。每个业务域都可以独立发布、独立灰度、独立回滚这才是“可持续交付”在架构层面真正的落地。2.2 模块联邦的配置细节与选型心得很多团队一上来就全量上MF结果被shared依赖搞到心态爆炸。我分享一下谷雨订单应用的实际配置给你当模板参考。// 订单应用 webpack.config.js const { ModuleFederationPlugin } require(webpack).container; module.exports { plugins: [ new ModuleFederationPlugin({ name: order_app, filename: remoteEntry.js, exposes: { ./OrderBoard: ./src/pages/OrderBoard, ./OrderDetail: ./src/pages/OrderDetail, }, shared: { react: { singleton: true, requiredVersion: ^18.2.0 }, react-dom: { singleton: true, requiredVersion: ^18.2.0 }, react-router-dom: { singleton: true, requiredVersion: ^6.20.0 }, guyu/ui: { singleton: true, requiredVersion: ^1.8.0 }, guyu/core: { singleton: true, requiredVersion: ^1.4.0 }, }, }), ], };这里有几个配置项值得反复强调。singleton: true必须打开尤其React和ReactDOM如果不配置成单例壳层和子应用各加载一份React轻则hooks状态错乱重则整个应用白屏崩溃。requiredVersion也很关键它相当于给公共依赖加了一把锁npm装了一个不兼容版本webpack会在运行时直接报警而不是悄悄打包两份。另一个心得是exposes的粒度。我们暴露的是组件不是整个应用。这样Shell可以按需加载比如首页只需要OrderBoard就不需要加载整个订单应用的路由和菜单代码。这个设计对首屏性能和AB实验都有好处想试新方案时直接换个组件入口就行。不要指望业务团队人人精通webpack。我们在脚手架层把这些配置封装成了内部模板业务开发只需要写组件和页面公共依赖版本统一收敛。架构上的事情不能让业务团队背复杂度。2.3 多租户主题与权限模型的设计多租户SaaS前端的两个硬骨头一个是怎么做品牌定制一个是怎么做权限控制。谷雨的办法是“配置中心 token注入 权限前置”。主题定制走的是设计token路线。每个租户在控制台开通时会生成一个theme-config.json里面是几十个token变量。前端加载应用前先请求这份配置转换成CSS变量挂到根节点上:root { --guyu-color-primary: #c0392b; --guyu-color-bg: #fdf7f4; --guyu-radius-md: 6px; }客户要换主题运营在后台拖拽几下调色保存后前端无需发版、用户刷新页面就能生效。这件事在矮化阶段是想都不敢想的。权限模型走的是“服务端下发 前端初始化前置”的路线。后端在登录后返回一个权限码数组比如[order:view, member:edit, ai:assistant]前端store在初始化阶段把它们加工成路由访问表和按钮级权限表等这份数据准备好了再挂载路由和渲染菜单。为什么要前置因为如果页面先渲染出来再根据权限跳转用户会看到内容闪一下再被弹走体验非常差而且还有越权渲染的窗口期。租户上下文也很重要。每个请求都要带上tenantId前端把它挂在请求拦截器和路由参数里避免多租户数据串号。我们的原则是租户数据一旦进入前端应用就只存在租户上下文对象里不允许业务组件各自从localStorage里乱读不然分分钟出安全事故。3. 可持续交付链路流水线、灰度与回滚的可复制方案架构只是骨架可持续交付还得靠发布链路来兜底。谷雨前端的发布体系一句话概括就是“发布只是送出去回得来才是本事”。如果你的发布系统只能往前冲、不能往后撤那不是可持续交付那是跳崖。3.1 前端独立流水线的质量门禁我们先定一个目标前端每个应用必须能独立跑到生产环境且从代码合并到生产可用不超过60分钟。基于这个目标谷雨用GitLab CI搭了这么一条流水线stages: - install - validate - test - build - publish - deploy install: stage: install script: - corepack enable - pnpm install --frozen-lockfile cache: key: pnpm-cache paths: - node_modules validate: stage: validate script: - pnpm lint - pnpm typecheck test: stage: test script: - pnpm test --coverage - pnpm exec playwright test --grep smoke artifacts: when: always reports: junit: test-results/**/*.xml build: stage: build script: - pnpm build --app order - node scripts/check-bundle-size.mjs artifacts: paths: - dist这里有几个设计细节lint和typecheck放在MR阶段就跑没通过不让合入主分支。test阶段除了单元测试还跑一组Playwright冒烟用例只覆盖登录、首页加载、下单链路和AI助手四条路径。“只跑四条”是刻意为之——全量E2E太脆、太慢一旦跑挂了团队会习惯性跳过反而失去门口的意义。质量门禁不追求全追求的是“每次必跑且必须过”。包体积检查脚本check-bundle-size.mjs是后来加的。模块联邦最大的隐患是公共依赖被意外打包进业务应用导致每个应用都带着一份React体积悄悄膨胀。我们定了个规则主bundle超过250KB告警超过400KB直接fail流水线。这个阈值能拦住大多数不健康的依赖增长又不至于误伤正常业务。构建完成后产物不会直接部署到服务器而是发布到对象存储和版本目录。发布和部署是两个阶段这为后面的回滚留下了空间。3.2 产物管理与版本化发布谷雨的产物管理长这样releases/ order-app/ 2024.06.01-abc123/ remoteEntry.js index.html static/js/main.8f3a4b.js static/css/main.4c9d2e.css 2024.06.08-xyz789/ ...每次构建生成的目录都带上日期和Git短SHA目录内文件不可变只有latest指针会变动。线上的Nginx通过一个内部变量来决定具体加载哪个目录相当于每一次发布都存了一份完整快照。Nginx这边有个关键配置专门处理remoteEntry.js的缓存策略location /order/ { alias /data/releases/$app_version/; try_files $uri $uri/ /order/index.html; } location /order/remoteEntry.js { alias /data/releases/$app_version/remoteEntry.js; add_header Cache-Control public, max-age120; }很多团队从传统单体前端切到模块联邦后第一个踩的坑就是remoteEntry.js被设置了immutable缓存。带hash的静态资源可以设一年缓存但remoteEntry.js不能它相当于一个“活指针”经常要更新来指向新的chunk清单。如果你给它也加上长缓存用户浏览器里会一直拿着旧入口找不到新发布的代码块结果就是线上发版了用户那边却报ChunkLoadError。谷雨的做法是index.html设no-cache带hash的js/css设immutableremoteEntry.js设短缓存或干脆带版本参数访问。3.3 灰度与回滚的最小可用方案灰度是SaaS平台前端的保命技能。谷雨的灰度不依赖复杂的发布平台只做了两层开关服务端下发灰度标记前端运行时决定加载哪个版本的remoteEntry。具体流程是用户请求页面时携带Cookie里的租户标识后端根据租户ID哈希决定返回“stable”还是“canary”版本号前端运行时读取这个版本号加载对应版本的remoteEntry。同时支持在URL上加?guyu_envcanary强制指定灰度环境方便测试同学和研发排查问题。加载远程模块的代码也不复杂核心就这段async function loadRemote(entry, scope, module) { await loadScript(entry); const factory await window[scope].get(module); return factory(); } const entry await getEntryByTenant(ctx.tenantId); const OrderBoard React.lazy(() loadRemote(entry, order_app, ./OrderBoard) );回滚策略是保留最近五个版本产物用release.json记录版本列表。回滚不是重新构建而是改一个指针把latest指向前一个稳定版本。这个操作可以在两分钟内完成我们每个月会做一次回滚演练确保流程顺手。可持续交付的本质不是每次发布都成功而是失败了可以在几分钟之内恢复失败率再高都能兜住。4. 实战中的高频问题与排查手册架构设计和流水线搭建只是开始真正磨人的是线上问题。谷雨这段时间踩了不少坑我把最高频的几个整理成了一份速查手册看完也许能帮你省下几个通宵。4.1 模块联邦依赖冲突运行时出现两个React这是MF架构绕不开的问题也是最典型的。现象是控制台报警告“You are importing createRoot from react-dom which is not a known export”或者某个组件用了hooks之后状态错乱。根因一般是某个子应用把React打包进了自己的产物体积里。shared配置生效是有前提的如果子应用里的React版本不满足requiredVersionwebpack就不会共享而是退化成各自打包。排查方法很简单用webpack-bundle-analyzer看每个应用的产物分析图如果react和react-dom出现在某个业务应用的chunk里说明shared配置没有拦住它。解决方式就是检查那个应用的package.json把React版本和壳层对齐并且确保shared里了配置singleton: true和requiredVersion。还有一个隐蔽场景团队里有人图方便在子应用里直接import React from react但没有把react列入shared的exposes依赖webpack会认为这是子应用私有依赖。所以在脚手架里要做个强制校验发现业务应用在自己的dependencies里声明了react或react-dom直接让流水线报错别留侥幸空间。4.2 ChunkLoadError旧版本产物被清理导致症状很统一用户在一个页面上停留了很久再点某个路由时页面崩溃控制台报“ChunkLoadError: Loading chunk X failed”。我们的用户大多是餐饮门店他们的浏览器标签页可能开几天都不关这个问题特别明显。根本原因是部署新版本时旧的静态文件被清理了但用户页面仍挂在旧版本上再去请求旧的chunk自然404。我们踩过一次最狠的坑为了省对象存储费用发布后自动清理前一天产物结果当天就有几十个门店白屏。解法分两层。第一层是避免保留至少N-2版本的产物不急着清理费用和稳定性之间取一个平衡。第二层是兜底在前端捕获ChunkLoadError自动刷新页面拉取新版本但要加一个sessionStorage标志位防止刷新后再次报错导致循环刷新。这个兜底逻辑看起来简单当年救了不少门店。4.3 灰度白屏与缓存失效灰度功能上线初期我们遇到一个诡异的白屏某些金丝雀租户打开页面直接空白刷新也没用但看灰度配置明明是对的测试环境也复现不出来。排查到最后发现是HTTP缓存的问题。金丝雀租户访问的remoteEntry.js带的是旧版本参数因为Nginx在边缘节点缓存了旧入口文件用户拿到一个已经不存在的chunk清单。解决办法分三步走入口HTML统一设成Cache-Control: no-cacheremoteEntry.js的URL加上版本号参数如remoteEntry.js?v2024.06.08-xyz789灰度Cookie设置path为/防止子路径下Cookie丢失导致版本判断不一致。排查这种问题有一个实用技巧先看浏览器Network面板里remoteEntry的实际请求URL和当前灰度版本对比如果不一致问题多半在缓存如果一致还白屏再看控制台是不是有模块加载异常。链路问题要按链路查别在代码里瞎找。4.4 样式污染与权限控制闪动用完MF之后样式污染没有完全消失而是换了形态。如果两个业务应用都引了全局CSS重置切换应用时页面样式就会“抖”一下。谷雨的约定是所有组件类名强制加命名空间前缀比如gy-btn、gy-card公共样式只通过CSS变量和CSS Modules注入业务应用不允许写全局选择器。这个约定写进lint规则跑不动就别提MR。权限控制闪动是另一个心头痛。早期我们让Shell先渲染再等权限导致用户打开页面的前几百毫秒能看到按钮随后又被隐藏。后来改成“权限先于路由”路由守卫在拿到完整的权限映射前只渲染一个骨架屏不渲染任何业务组件。用户看到骨架屏的代价是感知上慢了半拍换来的是绝无泄露权限和闪烁的可能性这笔账算得过来。5. 前端核心化之后组织协作与度量技术架构改完之后人的协作方式也得跟着变。很多团队以为上了模块联邦就算“核心化”了其实远远不够前端如果没有独立的协作节奏和度量体系很快会退回“围着后端转”的老路上。5.1 契约驱动的前后端协作谷雨前端能和后端解耦一个关键动作是引入了契约驱动。后端接口统一维护在OpenAPI规范里前端通过代码生成工具直接产出TypeScript类型和请求函数。后端改字段不再是通过即时通讯工具喊一声而是在契约仓库里提一个MR由前端参与review。这样做的好处是接口变更的破坏性在代码层面立刻暴露类型对不上前端流水线直接红而不是发完版线上才炸。另一个好处是联调成本大幅下降前端可以使用契约仓库里的Mock服务先行开发不需要等后端环境就绪。我们现在的协作节奏基本是后端并行开发前端并行开发集成时只要契约一致失败率就很低。前端应用还统一通过BFF层聚合后端微服务接口不直接连多个后端服务。这一层本身很薄主要做数据聚合和裁剪前端业务应用只跟BFF说话。BFF由前端团队维护代码风格也是TypeScript这样前端对“自己消费的数据形态”有了完全的掌控权彻底告别“后端给什么前端就渲染什么”的被动模式。5.2 用DORA指标盯住可持续交付可持续交付不能凭感觉得看数据。我们前端团队引入了DORA四指标部署频率、变更前置时间、变更失败率、恢复时间MTTR。不用太复杂的平台一张看板就够了。谷雨前端目前的基线是核心应用每周至少两次发版变更前置时间控制在60分钟以内变更失败率低于5%一次回滚控制在3分钟以内。说实话这套指标刚推的时候团队压力很大大家觉得“一周发两次版是不是太激进了”。但从“每月发版、如临大敌”到“每周发版、习以为常”用了不到一个季度原因是灰度、回滚和自动化测试这三个底座稳住了发版的心理成本就降下来了。不要为了快而快。如果团队还没有灰度能力硬逼着每周发两次版结果就是变更失败率飙升客户信任崩塌。谷雨的经验是先补底座再提频率。交付频率是可持续交付的结果不是手段。5.3 给正在转型的团队几点建议如果看完这些你也想动手改造前端架构我给你几条用真金白银换来的建议。第一不要一上来就搞微前端或者模块联邦。先把你当前单体仓库的模块边界理清楚把公共依赖和业务代码分开至少花两个月做“结构化”而不是“架构革命”。谷雨第一次尝试MF的时候就是因为业务边界没理清拆到一半发现订单和会员互相引用最后又回退了一版。第二前端核心化的第一个目标不是“性能优化”而是“业务可以被安全地快速验证”。所以优先建设灰度、回滚和可观测能力再谈性能优化。性能再快发布一次崩一次客户照样不买账。第三不要追求每块业务都完美解耦。谷雨现在仍是“核心应用 三个业务应用”的格局有些边缘模块还老老实实放在壳层里。拆分的目的是让高迭代频次的业务域拥有独立性而不是为了架构洁癖把团队拖进泥潭。第四也是我最想强调的一点基础设施一旦定下来就不要轻易改。React版本升级、webpack配置调整、公共依赖大版本更新这些都要走单独的评审和演练。持续交付最怕的不是慢而是基础设施在用户眼皮底下悄悄变化。最后再分享一个谷雨正在做的小事。我们在尝试把AI助手的组件拆成独立的应用因为它迭代太快了几乎每周都有新的交互形态把它隔离出来之后主应用的发布频率可以继续保持稳定。这样前端架构的核心化才算真正闭环不仅技术上是核心交付节奏上也真正成为业务的引擎而不是瓶颈。