第二季第 2 篇。拆解对象:
web/目录------仓库里模块最多的一个目录。这篇不要求你懂前端框架或构建工具,核心概念会在用到时当场讲。老规矩:只读不改两个仓库,文末有一个跑通的 demo。
先交代真实状态:这个目录里有一个微前端结构------3 个外壳应用、14 个子应用、3 个共享包,每个子应用独立构建、独立测试。这套结构不是纸面设计,它在生产上跑着;而且它出过一次真实事故、经历过两次重量级的性能修复,全部有 commit 可查、有日期可考。
这篇就是要把"一个前端为什么拆成 17 个格子,拆完怎么不散架"讲清楚。
一、先弄懂三个词
SPA(单页应用):打开网页后不刷新整个页面,而是用一个程序动态换内容------你现在读的这类"管理后台"几乎都是 SPA。一个 SPA 压缩后可能有上万个文件、几兆字节。
微前端:把一个 SPA 拆成多个可以独立开发、独立构建的"块",再由一个总壳把它们拼起来。拆分的动机通常是团队和规模------一个几十人团队维护一个几兆字节的单体 SPA,等于所有人挤在一个"巨型家谱"里改代码。微前端就是给前端也做"模块化"。
Module Federation(模块联邦,简称 MF):实现微前端的一种具体技术。它的核心机制是"远程模块"------壳应用在运行的页面上,去另一个 URL 加载一个子应用编译好的文件,把它当成自己的组件用起来。
记住三个词就行:SPA 是单页应用,微前端是把大前端拆开,模块联邦是让拆开的块还能拼接的工作机制。
二、为什么拆:从一个应用膨胀成 17 个格子
DeepFlux 的前端长这样:
- 3 个"壳"(apps/):console(产品主壳,用户日常用的)、ops(运营后台)、docs(文档站)。
- 14 个子应用(remotes/):chat、kb(知识库)、memory、config、agents、approvals、users、skills、tools、usage、tasks、notifications、files、playground------每个是一个独立的功能区,独立构建、独立测试、有自己的一版配置。
- 3 个共享包(packages/):client-sdk(浏览器端工具:鉴权、请求)、ui(共享界面组件)、tokens(设计令牌)。
为什么拆成这样?就因为每个子应用是一个小组的前端------chat 组改对话、knowledge 组改知识库,互不碰对方的代码,也各有各的构建产物。这是拆分的最大收益:十几条独立的开发流水线互不阻塞。
但拆分有代价:拼装的活儿没人自动干。 每个子应用自己可以先跑起来,问题是它们必须拼进壳里、出现在同一个网址下,才算是一个产品。后面的故事全是围绕这个"拼装"发生的。
三、壳怎么知道子应用在哪:一张 14 行的"通讯录"
壳应用(console)的构建配置文件里,有一张 14 行的表。每一行告诉壳:某子应用叫什么名字、它的入口文件在哪里。
ts
remotes: {
chat: { type: "module", name: "chat", entry: "/_remotes/chat/remoteEntry.js" },
kb: { type: "module", name: "kb", entry: "/_remotes/kb/remoteEntry.js" },
// ......一共 14 行
}
remoteEntry.js 是每个子应用编译产物里的"总目录"文件------浏览器加载它,就知道这个子应用的全部内容在哪些文件里。注释里写着这是"Module Federation 主壳 · 9 个远程模块",还特意记了一笔精简史:"contacts/meetings/notes 已删,calendar+reminders 合并为 tasks"------14 个是砍过的结果,不是一开始就 14 个。
注意入口路径的写法:/_remotes/<名字>/remoteEntry.js。前半段是一个固定的前缀,后半段是子应用的名字。这意味着每个子应用的文件,都放在同一个网址的不同子路径下(行话叫"单源 single-origin")。这对性能很重要:同一个域名下请求,浏览器可以复用连接、共享缓存。文档里白纸黑字:"私有化部署时所有 remote 都打到一个 bundle · 走单 origin。"
这里必须说清一个容易搞混的点:/_remotes/chat/remoteEntry.js 是网址(URL),不是磁盘路径。 磁盘上并没有叫 /_remotes/ 的文件夹;真有这个文件的目录在 web/remotes/chat/dist/remoteEntry.js------每个子应用构建完,产物都在 web/remotes/<名字>/dist/ 下。/_remotes/<名字>/ 是后端在运行时把那个磁盘目录"翻译"出来的网址。浏览器打开壳页面时,壳会向服务器要"网址 /_remotes/chat/remoteEntry.js",服务器再从磁盘 web/remotes/chat/dist/ 读出文件返回------网址和磁盘路径是两件东西,隔在中间翻译它们的,就是下一节那个 Go 程序。 (附录:主壳 console 自己的入口文件在 web/apps/console/dist/remoteEntry.js,它挂在网址 /remoteEntry.js、不带 /_remotes/ 前缀------主壳是"总壳",不属于任何子应用,和子应用的挂载是两条路。)
插一句:用编辑器打开 remoteEntry.js,看到的是一堆"乱码"? 那不是乱码------是生产构建默认压缩(minify)后的 JavaScript:去掉所有换行和空格、把变量名压缩成单个字母,为的是让文件更小、浏览器解析更快。它本来就是给浏览器读的,不是给人读的。以 chat 为例,它的 remoteEntry.js 全文只有 134 字节:import 一个带哈希的虚拟入口文件,再 export 两个方法(get / init)------真正的子应用内容都在那个哈希文件里,入口文件本身薄得只剩"标签"。想看人能读的版本,去 web/remotes/chat/src/ 看源码就行(我用 Node 对 console 和 chat 两个 remoteEntry.js 做了语法校验,都是合法的压缩 JS,不是乱码)。
四、谁把这些文件放到同一个网址下:一个 Go 接口
壳的配置只说了"入口在 /_remotes/chat/remoteEntry.js",那么真正把这个 URL 变成真实文件的,是后端的一个 Go 程序:server/internal/uiserve/uiserve.go。
它的核心是一个函数:
go
func discoverRemotes(remotesDir string) []string {
entries, err := os.ReadDir(remotesDir)
// ......遍历,只收集含 dist/ 子目录的目录名,返回字典序列表
}
它扫描 web/remotes/ 目录,谁有构建产物(dist/)就把谁挂载出来 ,路径就是 /_remotes/<名字>/。改一行注释就能看出这个函数的分量:
这是"remote 集合"的单一事实源:硬编码列表曾与 web/remotes// 失同步,/_remotes/agents/remoteEntry.js 在生产 404 过(console.deepflux.cn /agents, 2026-07-19)。以后在 web/remotes/ 下新增的都同时被这里、壳配置、部署脚本一起接住。
"单一事实源"是这套结构最关键的一个词:子应用的清单只在一处定义,别处都从这一处推出来。 那里漏了,就是翻车现场。
五、翻车现场:/agents 404 事故
2026-07-19,生产站点的 /agents 页面打不开。事后查明:agents 子应用编译好的文件没被上传到服务器,而线上没有任何东西发现它少了------因为当时负责检查的脚本,只检查 chat 一个子应用。
旧的冒烟脚本把"要检查哪些子应用"硬编码成了一张表,表里只有 chat。agents、kb、tasks......那 13 个不在表上的,全部无人检查。agents 漏传了,也没有任何检查会发现。
修复方法是把"检查哪些"从硬编码换成自动发现 :新的脚本自己扫描 web/remotes/*/ 目录,得出子应用清单,再逐个验证每个的 remoteEntry.js 可达。任何"记得检查所有子应用"的流程,都会在某次忘记时死亡------除非清单是程序自己扫出来的。
我把这个逻辑写成 demo(/tmp/e102demo/discover.sh),造了 5 个目录、4 个有构建产物,跑自动发现和硬编码对照:
| 方式 | 结果 |
|---|---|
| 自动发现(照抄 uiserve.go) | chat/kb/files/tools 四个有产物的全部列出,agents(无产物)正确跳过,字典序 |
| 硬编码(只写 chat) | agents/kb/files/tools 全部漏检------正是 404 的成因 |
demo 里 agents 因"没有 dist"被跳过------这有讲究:目录扫描不是无脑把目录都算数,而是只认"真的构建出产物"的目录,天然就区分了"存在"和"可用"。
六、缓存:三个用事故换来的头
同一段代码注释里,还记着三个关于缓存的真实教训,每一条都有日期:
remoteEntry.js 必须"不缓存"。 注释原文:"被浏览器启发式缓存后,发新版客户端仍加载旧 chunk 图(2026-07-11 实测踩坑)。"remoteEntry 是子应用的"总目录",它过期等于整个子应用永远显示旧版。所以它单独走 no-cache。
主壳自己的入口也必须不缓存。 2026-07-12 首次生产 SPA 演练实锤:只挂 /assets 不挂主壳入口,MF 运行时找不到入口文件、所有子应用全挂。它同样被迫单独加 no-cache。
带哈希的资源可以放心"永生缓存"。 构建工具产出文件名带内容哈希(内容变→文件名变)。对这种资源下发 Cache-Control: public, max-age=31536000, immutable(缓存一年、不可变),发新版后文件名天然变掉、旧缓存自动失效。这条的收益有实测数字:此前重复访问仍要回源 94 个文件约 2.3MB;改完之后重复访问传输降到约 22KB(有 commit 记录:99f71f41)。
再加上 gzip 压缩------注释记着"冷加载 JS 约 2MB 未压缩,超 300KB 预算~2 倍(2026-07-12 生产 SPA 实测)"。缓存配置里藏着的全是事故日期,不是拍脑袋。
七、瘦身:14 份重复运行时,砍成 1 份
这是 2026-08 一次重量级优化。先说背景:模块联邦有个机制叫 shared------壳和子应用"共享"同一份 React、同一份界面库,避免每个子应用各自再带一份 React。
但优化前有个漏洞:模块联邦自己的运行时(那个负责"加载远程模块"的程序)不是共享的,它被重复打包进了壳和每个子应用。commit a752b25a(2026-08-27)的提交说明写得很直白:
perf(web): enable MF externalRuntime --- host provides shared runtime, 14 remotes read global (drops 14x62KB runtime dup)
翻译:让壳提供共享运行时,14 个子应用读这份全局------去掉 14 份 ×62KB 的重复运行时。那次改动一次触达 15 个文件(1 个壳 + 14 个子应用),每个只加一行配置。14×62KB ≈ 868KB,记忆里"14 份"的账对上型号了。
优化之后还专门做了验证:用无头浏览器静态站点实验,确认"共享运行时配对失败能响亮地报错、正常配对能加载"(commit f5798d24 / 1f67ead3,含 V2)。不是改完就撒手,改了还要验配对。
八、共享包的纪律:单例和手写的类型声明
壳和子应用共享 React 以及两个自有包(client-sdk、ui)。"共享"在配置里写成一个固定集合(web/vite.mf-common.ts):
ts
sharedModules = {
react: { singleton: true, requiredVersion: "^19.2.7" },
"react-dom": { singleton: true, requiredVersion: "^19.2.7" },
"@deepflux/client-sdk": { singleton: true },
"@deepflux/ui": { singleton: true },
}
要点在 singleton: true(单例):这几个库在页面里只能有一份实例。React 存在两份会出诡异的双实例 bug,所以壳和所有子应用都强制指向同一份。注释还提醒:chat 子应用比别的多共享一个 assistant-ui 组件库("chat remote additionally appends @assistant-ui/react"),这是它的特殊需要。
还有一个手写的类型声明文件(remotes.d.ts)。子应用在编译期是"虚拟模块",壳要怎么在代码里引用它们?答案是手写一份 TypeScript 声明,逐个声明 "chat/View 是一个组件"。注释把它列为架构债(T13):全部 View 目前是无 props 组件,若某天有子应用要接收参数,得同时改两边的人工对齐------手工同步的边界,注释老老实实标着。
小结:拆分的代价,就是协调
一个前端拆成 17 个格子,本质上是在用"模块化"换"并行开发"。模块化省下的开发成本是确定的,但多出来的协调成本要算清楚。这套结构用三样东西付掉协调成本:
- 单一事实源。 子应用清单只在 uiserve.go 一处定义,壳配置、部署脚本、检查脚本都从目录扫描推出来------从根上杜绝"某处表格少了一行"。
- 固定入口 + 单源。 所有子应用统一挂在 /_remotes/<名字>/ 下、入口统一叫 remoteEntry.js,拼装路径零意外。
- 单例纪律。 共享库必须 singleton,类型边界手写也要标成债------不写明的共享,迟早变成双实例的幽灵 bug。
还有第四条,藏在每段注释里:教训用日期记账。 07-11、07-12、07-19、08-27------每个缓存决策、每次优化背后都是一次真实事故。等这些日期串成一个文件夹,就是这套结构最值钱的文档。