一份代码,三个壳:Multica 怎么让 web、桌面、移动端共用一套契约
本文是 Multica 拆解系列的第 8 篇。前七篇拆完了 Agent 的工位、大脑、生命周期、技能、组队、自治、实时层。这一篇换个视角,不拆后端逻辑,拆前端工程结构——一个代码库怎么同时喂饱浏览器、桌面、手机三个完全不同的运行时。
这一篇要解决的问题
你在 MacBook 上用 Multica 的网页版看任务看板,切到桌面 App 看到的看板一模一样;掏出手机,看板又以原生控件的样子出现,信息密度变低但数据完全同步。三个端背后是三套截然不同的渲染管线——浏览器是 DOM,桌面是 Electron 里套的 DOM,手机是 React Native 映射到原生视图。它们怎么共用代码?
最朴素的答案是"复制粘贴",但这条路一走就死:同一个 Issue 类型在三处各定义一遍,后端改个字段,三端都得手动对齐,漏一端就是一个线上 bug。再往上一层,任务列表的"拉数据 + 缓存 + 失效"逻辑,三端各写一遍,缓存键稍有不同步就会显示陈旧数据。
我通读了 packages/ 下的五个包,Multica 的答案是一个有方向的依赖图:把代码按"谁能依赖谁"切成三层契约,用 package.json 的 exports 字段把契约钉死。核心是一条反直觉的规则——移动端故意不碰那两个看起来"最通用"的 UI 包。
心智模型:三层契约,依赖只向下流
先记住三个包各自是什么:
@multica/core——数据契约层。类型定义、API 客户端、WebSocket 客户端、React Query 的查询/缓存逻辑。不渲染任何 UI,peerDependencies 只有 React。
@multica/ui——DOM 原语层。Button、Dialog、Sheet 这些 shadcn 风格的展示型组件。不依赖 core,不含任何业务逻辑。
@multica/views——特征视图层。IssueDetail、IssuesPage 这些组装好的业务界面。同时依赖 core 和 ui,是两者之间的胶水。
三个应用按需取用:

关键看图里的箭头方向:依赖只向下流。views 可以用 core 和 ui,但 core 和 ui 互不相依;三个 app 里,web 和桌面依赖全部三个包,而手机只依赖 core,完全绕开了 ui 和 views。这条绕行不是图省事,是这一篇全部的张力所在。
深度点一(免费):core 不渲染任何东西,所以谁都能用
@multica/core 的 package.json 里,peerDependencies 只有一行:
// packages/core/package.json
"peerDependencies": {
"react": "catalog:"
}
这是它跨端的资格证。它依赖的全是"任何 React 运行时都有的"东西:@tanstack/react-query、zustand、zod、i18next、posthog-js。没有 react-dom,没有 next,没有任何假设"代码跑在浏览器里"的依赖。这意味着它那些导出——类型、API 客户端、查询钩子、缓存键——既能被 Next.js 的服务端组件用,也能被 Electron 的渲染进程用,还能被 React Native 用。
最能说明这层契约的,是 issues/queries.ts 里的缓存键工厂 issueKeys。我读到的时候挺意外,它不是一个简单的数组,而是一个嵌套的工厂,每层带语义注释:
// packages/core/issues/queries.ts
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
/** PREFIX for invalidation — no sort. */
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
/** FULL KEY for queryOptions — includes sort. */
listSorted: (wsId: string, sort?: IssueSortParam) =>
[...issueKeys.list(wsId), sort ?? {}] as const,
/** All "my issues" queries — use for bulk invalidation. */
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
// ...
};
注释里反复区分"PREFIX for invalidation"(失效用的前缀)和"FULL KEY for queryOptions"(查询用的完整键)。这个区分是 React Query 缓存模型的核心:invalidateQueries({ queryKey: issueKeys.list(wsId) }) 会失效所有以它为前缀的缓存,但具体查询用的是带 sort 的 listSorted。把这个工厂放在 core 里,意味着"缓存怎么组织"是一个全产品统一的契约,不是每个 app 自己拍脑袋。
web 上你在看板拖动一条 issue 状态变了,useUpdateIssue 这个 mutation(也在 core 的 issues/mutations.ts 里)会精确地失效相关缓存键;桌面端做同样的操作,跑的是同一份失效逻辑。前端缓存的"真相"只有一份,这就是 core 作为契约层的价值。
反直觉:很多人会把"共用逻辑"理解成"抽个 utils 函数"。但 Multica 的 core 抽的不是工具函数,是数据获取和缓存策略——它定义了"一个 Issue 在前端应该怎么被拉、怎么缓存、怎么失效"。这是一套产品级的约定,放进 utils 里太重,放进某个 app 里又没法共享。单独成包,恰好。
深度点二:ui 是 DOM 原语,views 才是契约的视觉实现
理解了 core 是纯逻辑,下一个问题:为什么 ui 和 views 要分成两层?
答案是它们对 React 运行时的假设不同。@multica/ui 里的东西全是 DOM 组件——Button 渲染成 <button>,Dialog 渲染成一层 portal 覆盖层。它的 peerDependencies 老老实实写着 react-dom:
// packages/ui/package.json
"peerDependencies": {
"i18next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-i18next": "catalog:"
}
而 @multica/views 同时依赖 core 和 ui:
// packages/views/package.json
"dependencies": {
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
// ...一堆编辑器、拖拽、图表库
}
我打开一个真实的视图文件 issues/components/issue-detail.tsx,它的 import 就是这个分层最清晰的注脚——上半段从 ui 拿原语,下半段从 core 拿数据:
// packages/views/issues/components/issue-detail.tsx
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Sheet, SheetContent } from "@multica/ui/components/ui/sheet";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@multica/ui/components/ui/dialog";
// ...
import type { Attachment, Issue, IssueStatus, IssuePriority } from "@multica/core/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useUpdateIssue } from "@multica/core/issues/mutations";
一个文件里同时出现两个 @multica/* 的来源,这不是偶然,是 views 的本职工作:把"core 怎么拿数据"翻译成"ui 怎么显示数据"。useUpdateIssue 是 core 提供的 mutation 钩子,Button 是 ui 提供的 DOM 原语,views 把它们接在一起,产出一个"改 issue 状态"的交互。
这个分工的全栈闭环是这样的:你在网页上点状态选择器 → views 调 core 的 useUpdateIssue → core 发 HTTP 请求到 Go 服务器 → 服务器返回后 core 失效对应缓存键 → React Query 重新触发 views 里的查询 → views 用 ui 的组件重新渲染。三层各管一段,边界干净。

为什么不把 ui 和 views 合并?因为合并后,任何"想用 Multica 业务逻辑但不想用这套 DOM 组件"的消费者都会被拖着一堆不相关的 UI 依赖。分开之后,ui 还能独立演化(换一套设计系统不影响 views 的数据逻辑),views 也能独立测试。
深度点三:手机故意不碰 ui 和 views
回到那张依赖图里最反直觉的一条线:手机只依赖 core。
我一开始以为这是图省事,翻完 apps/mobile/package.json 和它的源码才明白,这是一个深思熟虑的架构决策。手机的依赖列表里没有 @multica/ui、没有 @multica/views,只有 @multica/core:
// apps/mobile/package.json —— 关键依赖
"dependencies": {
"@multica/core": "workspace:*",
"@react-native-community/datetimepicker": "8.6.0",
"@rn-primitives/avatar": "^1.4.0",
"@rn-primitives/dropdown-menu": "^1.4.0",
"@rn-primitives/switch": "^1.4.0",
"expo": "~55.0.23",
"nativewind": "^4.1.23",
"react-native": "0.83.6",
// ...
}
手机用的是 @rn-primitives/*(React Native 原生控件桥接)和 nativewind,不是 DOM 组件。ui 包里的 Button 渲染成 <button> 标签,在 React Native 里根本不存在这个元素——硬塞进去只会白屏。所以手机绕开 ui 和 views,只从 core 取类型,然后用自己的原生控件重新实现界面:
// apps/mobile/app/(app)/[workspace]/issue/[id].tsx
import type { Issue } from "@multica/core/types";
// 注意:只 import type,不 import 任何运行时逻辑
而且手机连查询逻辑都是自建的——它有自己的 @/data/queries/workspaces,不直接用 core 的查询钩子:
// apps/mobile/app/(app)/select-workspace.tsx
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useQuery } from "@tanstack/react-query";
const { data, isLoading } = useQuery(workspaceListOptions());
为什么?因为 React Native 的网络模型、缓存生命周期、内存约束都和浏览器不同(后台切前台、弱网重连的语义完全不一样),手机需要自己的查询策略。但类型必须共享——Issue 长什么样、有哪些状态枚举,这些是和后端的契约,绝不能各定义一份。所以 core 的 ./types 导出成了手机唯一的、也是最重要的依赖:它不共享运行时,只共享"数据的形状"。
反直觉:共享代码的最大陷阱是"共享得太多"。如果 core 里混进了 DOM 操作,手机就用不了它;如果 views 里混进了 Next.js 的
'use server',桌面和手机都用不了它。Multica 的解法是让 core 刻意保持贫瘠——只有类型、纯函数和与 React(不是 React DOM)绑定的钩子。这份贫瘠是跨端的门票,多一个依赖就少一个可用的运行时。
这条原则甚至体现在版本管理上。整个 monorepo 用 pnpm 的 catalog: 机制统一版本——react: "19.2.3" 在 pnpm-workspace.yaml 里写一次,所有包的 package.json 里写 "react": "catalog:" 就能引用:
# pnpm-workspace.yaml
catalog:
react: "19.2.3"
react-dom: "19.2.3"
zustand: "^5.0.0"
"@tanstack/react-query": "^5.96.2"
zod: "^4.1.5"
三端用同一个 React 版本、同一个 React Query、同一个 zustand,从根上消除了"版本漂移导致行为不一致"的可能。
这就是 Multica 对"多端共用代码"的答案。它没有用 React Native Web 那种"一套代码跑三端"的幻觉方案,而是老实地承认:web/桌面可以共享 DOM 层(共用 ui + views),手机必须用原生层(只用 core 的类型)。三层契约用 package.json 的 exports 和依赖方向钉死,谁该依赖谁、谁不能依赖谁,写得明明白白。2 个人维护 10 个 Agent 的产品,代码组织上经不起混乱——这套结构让"改一处、三端同步"变成可验证的事实,而不是祈祷。
下一篇:这个被精心组织的系统,怎么在你自己机器上跑起来?Multica 的自托管实战——三个 Docker 容器、一条 migrate 命令,以及为什么它默认只绑 127.0.0.1。
想看 AI 拆完下一篇?留个邮箱,自动推送。