ARTICLE · 1115003
multica 源码 06:一个页面 20 行,一个 Hook 1778 行,前端处处反直觉
Multica 源码走读 · web 前端:薄壳页面、实时同步与三层样式测试
把 Multica 的 web 前端拆开看,最反直觉的一行代码是这个:
staleTime: Infinity
React Query 的全局配置,写在 packages/core/query-client.ts 里。翻译一下:缓存永不过期。
按常见教程的写法,数据放上几十秒就该标记为过期,下次组件挂载时重新拉取。这里直接把这条路堵死了。
那页面上的数据怎么保持新鲜?答案藏在另一条通道里,这篇文章一半的篇幅都在讲它。
这是我 Multica 源码走读的第三篇。前两篇拆了整体架构和数据模型,这一篇进 web 前端:路由怎么组织、界面为什么这么薄、多工作区怎么隔离、看板上的数据怎么会「自己动」。
先交代背景:Multica 是一个可以给 AI Agent 派活的项目管理工具,官网 multica.ai,代码在 GitHub 开源。
web 端用 Next.js 16 写,这次走读基于 2026 年 8 月 26 日的 main 分支。
一、每个页面只有二十行
从路由看起。apps/web/app 下有三个路由组,分工很清楚:
(auth) 管登录和入驻,全是客户端组件;(landing) 是营销站,整个网站唯一以服务端组件为主的区域,要扛 SEO,文档用 fumadocs 渲染 MDX;
[workspaceSlug] 是工作区应用本体,issue、项目、Agent、收件箱都在这里,一共 15 个域页面。
15 个页面有个共同点:薄得像纸。
issues/page.tsx 只有约 20 行:一个 ErrorBoundary,一个 URL 同步 hook,加一个从共享包 import 进来的 IssuesPage 组件。
页面自己不写业务。它只负责「在这个 URL 上,挂这块视图,出错这样兜底」。
顶层的零散路由也一样:auth 回调、billing 返回、join,外加 lark、slack、telegram、钉钉、企业微信五组 /bind 入口。
绑定页面只做参数透传,视图照样在共享包里。
还有个容易忽略的角色:proxy.ts。Next.js 16 把 middleware 改名成了 proxy,强制跑在 Node runtime 上。
它干四件事:解析 locale 塞进请求头;把 /v1、/api、/auth、/uploads、/docs、/ws 反代给 Go 后端。
剩下两件:旧链接重定向到新地址;已登录用户访问根路径,直接跳去他最后待过的工作区。
一张图画全这套结构:

二、业务界面不在 web 仓库里
薄壳引出下一个问题:界面本体在哪?
在 @multica/views,一个 web 和桌面端共享的业务视图包。issue 看板、设置页、收件箱,真正的那几千行组件全住在这里。
我原本以为 features/ 目录会塞满业务功能。走读发现里面只有两个东西:auth,约 40 行,读写一个 web 独有的登录 cookie;landing,营销页组件。
那 features/ 和 platform/ 的界线是什么?答案有点意外:不按业务域分,按「是否触及平台专属 API」分。
platform/ 是整个仓库唯一允许 import next/* 导航 API 的位置,这条是仓库 CLAUDE.md 里的硬约束。导航、滚动恢复、页面标题、操作系统适配,全收在这个目录。
桌面端用 react-router 实现了同一套适配器接口。这就是一套 views 能同时跑在浏览器和 Electron 里的机制核心:平台差异被圈死在一个目录里。
components/ 则是全局装配层:CoreProvider 推导 API 地址和 WS 地址、切换 cookie 鉴权;通知桥把浏览器通知的点击,送回它来源的那个工作区收件箱。
三、URL 就是工作区身份
[workspaceSlug] 这个动态路由名,暗示了整个隔离方案:slug 就是身份。
工作区 layout 先用 React Query 把 slug 解析成工作区,成功后调用 setCurrentWorkspace,把身份写进一个平台级单例。
从这一刻起,所有 API 请求自动带上 X-Workspace-Slug 头,WebSocket 也绑定到对应工作区的房间。切工作区,等于换一套连接。
进门有三道闸:没登录,去 /login;登录了但没完成入驻,去 /onboarding;slug 解析不出来,进 NoAccessPage。
第三道闸有个细节:工作区不存在和你没权限,返回同一个页面,刻意不区分 404 和 403。目的是防 slug 枚举,别人猜你的工作区地址,猜不出来。
服务端还有纵深防御:所有查询按 workspace_id 过滤,加 membership 门禁;WS 升级请求同样查权限;query key 一律带 wsId,多工作区的缓存互不污染。
这次走读我最想全文摘抄的,是 layout 里的一段注释。
它记录了一个真实 bug:App Router 并行挂载新旧两套工作区布局时,没按 pathname 守卫的 setCurrentWorkspace 来回翻转单例。
结果是 WebSocket 反复拆了又建,@mention 列表一阵空白。
最后的收束是一句设计原则:URL 是工作区身份的唯一事实源。 单例可能出错,URL 不会。
四、数据怎么「自己动」起来
回到开头那行 staleTime: Infinity。
先看初始获取:数据几乎不走 RSC fetch。页面挂载后,由共享包里的域 hooks 发请求,query key 带 wsId。
响应一律过 zod schema,解析失败就降级容错,防后端字段漂移把页面打崩。
初始数据进了缓存。之后呢?
之后全靠 WebSocket 事件推。整条链路五步,一张图讲完:

Go 后端写完库,publish 一条 issue:updated 事件,带上工作区 ID 和载荷,进进程内的事件总线。
监听器把它序列化,交给 realtime Hub 按工作区房间广播;发给个人的事件点对点送达;多节点部署用 Redis Streams 中继,还留存可重放。
还有一个 /health/realtime 端点,暴露 QPS 和慢客户端驱逐指标。
浏览器端的 WSClient 是全双工连接,鉴权靠 HttpOnly cookie,随升级请求自动携带;断了就指数退避重连,1 秒到 30 秒,带抖动。
最后一步是这篇文章的主角:useRealtimeSync,1778 行,集中消费约 50 种事件。
我的第一反应是,一个 hook 写 1778 行?读进去发现长度本身就是信息:所有实时处理收敛在一处,没有散落在各个页面里。
它对事件分两类处理。
第一类,精确补丁。issue:updated 进来,先按 revision 守卫,乱序到达的旧事件直接丢弃,然后原地更新看板、列表、详情的缓存。
服务端还会标注 assignee、status、project 哪些变了,前端据此决定哪些列表要重新拉。
第二类,前缀失效。没被特判的事件,按类型前缀做 100 毫秒防抖的批量失效;Agent 的流式输出 task:message,同样 100 毫秒合并一批,防止长任务把渲染打成风暴。
缓存一变,订阅它的视图自动重渲染。断线重连后,还有一次全量失效兜底。
最后是一条铁律,CLAUDE.md 明文写的:WS 事件只允许失效或补丁 Query 缓存,绝不把服务端数据镜像进 Zustand。
Zustand 只放筛选条件、草稿、弹窗开关这类纯客户端状态。权限敏感的聚合数据,比如聊天未读徽标,只失效不乐观写,防止越权信息提前回显。
现在回头看开头那行 staleTime: Infinity,它不是偷懒。这套机制里,新鲜度不靠时间,靠事件。
五、样式也写成测试
这个仓库还有个让我意外的角落:apps/web 下有一批样式测试文件。
他们不做截图级视觉回归,而是把设计系统约束做成三层「可计算断言」,全在 node 环境直接算,不起 jsdom。
第一层,token 数学。text-contrast.test.ts 解析 tokens.css 里的 oklch 颜色值,换算成 WCAG 对比度。
断言很具体:次要文字颜色在明暗两套主题、14 种背景上都达到 4.5:1。
第二层,编译级联模拟。brand-variant-cascade.test.ts 把 web 和桌面端的 globals.css 真的跑一遍 Tailwind 编译。
测试里实现了 CSS 优先级和源顺序决胜,断言 brand 按钮最终赢的是哪条规则。
这条测试的头注我原样抄给你:「a class name in the DOM proves nothing about the pixel」。
DOM 里有那个 class,什么都证明不了。它把一次真实回归固化成了用例。
第三层,源码扫描即测试。type-scale.test.ts 禁止 text-[13px] 这类脱离字号阶梯的写法。
对比度扫描器禁止 text-muted-foreground/70 这种拿透明度当层级的写法。
检测器自带误报漏报自测矩阵。另有四语言 i18n key 对齐等一批同类测试。
工程含义很直接:设计系统的破坏性修改,会在 CI 里以一条精确断言失败,而不是等肉眼 review 或者线上走样。而且 node 测试跑得飞快,成本远低于截图回归。
六、最后一张图:依赖只有一条路
前端讲完了,最后看包之间的依赖关系。

数字很说明问题:views 往 core 压了 663 个文件的依赖,往 ui 压了 357 个;web 和 desktop 各自只直接用 views、ui、core 里的几十个文件。
docs 站只用 ui;mobile 刻意隔离,只允许 import type 用 core,113 个文件,不共享任何组件。
服务端到 TypeScript 世界的唯一代码生成,是 reserved-slugs.json 变成 core/paths/。
方向被硬约束锁死:views 只能依赖 core 和 ui;core 与 ui 互相独立。
ui 禁止 import core;core 禁止碰 react-dom、localStorage、process.env。
这就是「薄壳」能成立的底层原因:方向清晰,才有复用。
这篇的边界
三个要说清楚的点。
分析基线是 2026 年 8 月 26 日的 main 分支,之后的前端演进不在这套结论里。文中所有数字,20 行、1778 行、663 个文件,都是那个时点的快照。
这篇只讲了 web 这半边。桌面端怎么用 react-router 实现同一套适配器,留给这个系列的下一篇。
这些约束是 Multica 团队当下的选择。唯一 import 入口、禁镜像 Zustand、样式三层断言,搬进另一个团队、另一个阶段,未必都是最优解。它们值得看,不值得照抄。
文末只给一个动作:打开仓库,搜 useRealtimeSync,把这个 1778 行的文件从头读到尾。
它是这篇文章最好的注脚。「别人改了数据,你的屏幕怎么知道」这个问题,它从第一行答到最后一行。