微前端:14 个子应用是怎么做到不散架的——DeepFlux 前端工程拆解(第102篇)

第二季第 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 个格子,本质上是在用"模块化"换"并行开发"。模块化省下的开发成本是确定的,但多出来的协调成本要算清楚。这套结构用三样东西付掉协调成本:

  1. 单一事实源。 子应用清单只在 uiserve.go 一处定义,壳配置、部署脚本、检查脚本都从目录扫描推出来------从根上杜绝"某处表格少了一行"。
  2. 固定入口 + 单源。 所有子应用统一挂在 /_remotes/<名字>/ 下、入口统一叫 remoteEntry.js,拼装路径零意外。
  3. 单例纪律。 共享库必须 singleton,类型边界手写也要标成债------不写明的共享,迟早变成双实例的幽灵 bug。

还有第四条,藏在每段注释里:教训用日期记账。 07-11、07-12、07-19、08-27------每个缓存决策、每次优化背后都是一次真实事故。等这些日期串成一个文件夹,就是这套结构最值钱的文档。

相关推荐
全栈弄潮儿28 分钟前
真实案例:用 AI 快速定位一次代码问题
aigc·openai·ai编程
机械改造鹅36 分钟前
从零开始拆解Pi系列——(8)compaction 机制
agent
天道kabuto39 分钟前
踩坑实录:JS Map迭代器为什么“用完即焚”?兼谈技术知识沉淀
前端
字节跳动开源43 分钟前
VisActor全新图可视化开源项目:VGraph
前端·设计模式·开源
宋哥转AI43 分钟前
深入理解 AI Agent:从黑盒到全链路——生产级 Agent 的可观测性体系怎么建
人工智能·agent·ai编程
打呵欠的猫44 分钟前
前端接口超时从 15s 改到动态值后,用户投诉降了 60%——AI 帮我分析出的方案
前端·ai编程
岁月宁静1 小时前
二、《从零手撸 Agent》 — 聊聊 LLM 的失忆真相
前端·python·agent
苏灵凯1 小时前
IT疑难杂症诊疗室:从故障定位到根治的技术实战指南
笔记·ai·域名·agent·deepseek
郭邯1 小时前
用纯前端实现一个代码压缩美化工具,我踩了这三个坑
前端