Onyx 移动端(React Native + Expo)开发标准实战指南:从设计令牌到数据层与测试规范
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
导读
本文基于 Onyx(danswer)仓库中mobile/CLAUDE.md这份移动端开发标准文档,系统讲解在mobile/(React Native + Expo 应用)中构建 UI、使用间距与设计令牌、组织 HTTP 与数据层、导航、编写测试以及消费共享包@onyx-ai/shared的全套工程规范。读完本文,你将掌握移动端与 Web 端在样式体系上的本质差异(尤其是"类名数字即像素"这一最大陷阱)、跨端设计令牌的落地链路,以及一套可直接套用的组件复用、数据缓存与单元测试实践。
一、文档定位:mobile/ 的独立标准与 Web 的边界
mobile/CLAUDE.md是mobile/目录(Onyx 的 React Native + Expo 应用)中 AI Agent 与开发者的权威开发标准。它补充但不继承web/AGENTS.md的规则:移动端没有 DOM,使用 NativeWind(而非 Web 的 Tailwind)、expo-router 和 RN 原生组件,因此 Web 侧关于 HTML/CSS、useSWR、Opal 组件等规则在这里均不适用。
唯一跨端共享的是设计令牌词汇表,通过@onyx-ai/shared包提供。这一点从 mobile/tailwind.config.js 可以印证:它通过require("@onyx-ai/shared/nativewind-theme")和require("@onyx-ai/shared/nativewind-typography")引入跨端主题扩展,而不是像 Web 那样直接使用 Tailwind 默认主题。
适用前提:本文所有路径、命令与配置均以当前仓库实际内容为准,运行环境为 Node + bun(移动端脚本在 mobile/package.json 中定义)。
二、构建 UI 的第一原则:复用优先(reuse before you build)
在手工编写任何组件或页面之前,先检查是否已有匹配的组件,按以下顺序排查:
- 移动端已有?直接复用:优先扫描
mobile/src/components/ui/*(基础 UI 原语)、外壳布局mobile/src/components/{settings,sidebar,auth,chat}、mobile/src/icons/*,以及其他mobile/src/components/*目录。实际仓库中ui/下已有button.tsx、text.tsx、text-input.tsx、icon.tsx、card.tsx、sheet.tsx、tabs.tsx、switch.tsx、spinner.tsx、popover.tsx、separator.tsx、content.tsx、line-item-button.tsx、select-button.tsx等现成原语(见 mobile/src/components/ui)。 - 只有 Web 有?Web(Opal 的
web/lib/opal/src/,或web/src/refresh-components/)是设计的事实来源(source of truth)。不要手工写一个分叉的相似品——先停下来确认:要么通过port-web-component-to-mobileskill 做像素/行为级精确移植,要么用现有原语组合实现。
移植时应尽量在布局、间距、颜色、交互上与 Web 对应组件保持一致,平台无法做到完全一致的,必须记录有意的偏差(document any deliberate divergence)。
三、间距系统:类名数字即像素(最大的移植陷阱)
这是从 Web 移植到移动端时最大的一个坑。
3.1 两种完全不同的命名语义
移动端:间距类名解析为与类名数字相等的像素值——px-24= 24px,gap-8= 8px,h-12= 12px。
Web 端:使用Tailwind 默认步进标度,p-6= 第 6 步 =1.5rem=24px。物理尺寸相同,但命名完全不同:
| 物理尺寸 | Web(Tailwind 步进) | 移动端(px 命名令牌) |
|---|---|---|
| 8px | p-2 | p-8 |
| 16px | p-4 | p-16 |
| 24px | p-6 | p-24 |
3.2 底层原理:设计令牌的生成链路
移动端这个"数字即像素"的标度并非魔法,而是由共享包的设计令牌构建链生成的。整个流水线位于 web/lib/shared/style-dictionary.config.mjs(Style Dictionary v4 编程式 API),其 TOKEN MODEL 为:
- 间距令牌在 web/lib/shared/tokens/size.json 中以rem定义,例如
spacing-block-24: "1.5rem"、spacing-block-16: "1rem"; toPx辅助函数执行parseFloat(v) * 16(rem × 16 转为 px);js/nativewind-themeformat 把spacing-block-*/spacing-inline-*解析为纯 px 数字(例如spacing["24"] = 24),同时把radius-*解析为 px 圆角、颜色令牌映射为var(--name);- 生成
dist/nativewind-theme.cjs,被 mobile/tailwind.config.js 作为theme.extend消费。
之所以要"烘焙"成 px,是因为React Native 无法使用rem或var()作为尺寸单位,所以尺寸必须在构建期解析为具体像素值。
3.3 必须遵守的规则
- 绝不把 Web 的间距类名数字照搬到移动端。换算规则:Web Tailwind 步进
N→ 移动端N × 4(px);或者直接使用你实际想要的 px 值——在移动端,数字本身就是 px。 - 只用标度上真实存在的令牌键:
0,2,4,6,8,10,12,16,20,24,28,32,36,40,44,48,…(size.json 中spacing-block-*覆盖到 160)。不存在的键(如p-3)会回退到 Tailwind 默认 rem 标度——务必避免。 - 把反复出现的间距集中到布局原语中,不要在每个页面重复硬编码。屏幕内边距(gutter)由外壳组件持有:
mobile/src/components/auth/AuthScreenShell.tsx、mobile/src/components/chat/ChatScreen.tsx(其中的CenteredContent持有居中屏幕 gutter)。新建页面/空状态应组合现有外壳,而不是硬写px-24。
这个"复用外壳"的设计在 mobile/src/components/auth 与 mobile/src/components/chat 目录中可见一斑:认证、聊天等场景都沉淀为独立的 shell/layout 组件供页面组合。
四、文本、输入框、图标与颜色规范
4.1 文本:一律走@/components/ui/text
所有文本必须通过mobile/src/components/ui/text.tsx的Text组件渲染,其 props 为font/color字符串枚举(TextFont/TextColor,类型源自@onyx-ai/shared/contracts)。绝不直接使用 React Native 的Text(包括测试代码中)。
从实现看(mobile/src/components/ui/text.tsx),Text内部把枚举映射为字面量类名(font-heading-h1、text-text-04等),这样 NativeWind 的类扫描器才能拾取;同时提供nowrap(单行裁剪不显示省略号)与maxLines(最多 N 行、尾部省略)两个便捷 prop,内部转换为numberOfLines+ellipsizeMode。
注意:
react-native的TextInput与Text无关,可以放心使用;但字段输入优先用mobile/src/components/ui/text-input.tsx。
4.2 图标:默认导出 +Icon组件
图标以默认导出形式存在于mobile/src/icons/*,通过mobile/src/components/ui/icon.tsx的Icon组件渲染:
<Icon as={SvgFoo} size={…} className="text-text-…" />4.3 颜色:只用 Onyx 语义类,无dark:修饰符
使用 Onyx 语义类(bg-background-*、text-text-*、border-border-*),它们在运行时通过 mobile/src/app/_layout.tsx 中的vars()provider 解析(light/dark 取自@onyx-ai/shared/native):
const lightTheme = vars(varsLight); const darkTheme = vars(varsDark); const themeVars = colorScheme === "dark" ? darkTheme : lightTheme; // <GestureHandlerRootView style={themeVars} className="flex-1">禁止dark:修饰符,禁止裸写 Tailwind 颜色。背后的原理同样是 RN 无法像 Web CSS 那样运行时翻转变量:web/lib/shared/style-dictionary.config.mjs的js/native-varsformat 直接生成两份已解析的具体十六进制值映射varsLight/varsDark(如varsLight["--text-05"] = "#000000e5"、varsDark["--text-05"] = "#fffffff2"),由根布局按系统配色方案切换——这与 Web 用 CSS:root/.dark变量的模型是对偶的。
五、HTTP 与数据层
5.1 HTTP 客户端:apiFetch<T>
所有请求走mobile/src/api/client.ts的apiFetch<T>:自动注入 bearer token、把错误归一化为ApiError。关键点是getBaseUrl()已经自动追加了/api前缀,所以调用时路径是裸的:
apiFetch("/chat/..."); apiFetch("/me");从 mobile/src/api/config.ts 可以看到前缀处理逻辑:API_PREFIX默认/api(适配 nginx 前置部署,代理会剥离它;裸后端开发时可设空串),且base URL 是惰性解析的——优先取会话中存储的服务器地址(getStoredServerUrl()),没有时才回退到EXPO_PUBLIC_API_URL(仅开发用,且EXPO_PUBLIC_*会打进客户端包,只能放 base URL,绝不能放密钥)。每次请求惰性解析意味着切换实例后下一次调用立即生效,配置错误也会表现为可捕获的 rejected query 而非模块加载崩溃。
apiFetch的其他实现细节(见 mobile/src/api/client.ts):
- 普通对象 body 自动 JSON 序列化并补
Content-Type;字符串 /FormData/URLSearchParams/Blob/ArrayBuffer/ 类型化数组原样透传(防止二进制上传被误 JSON 化); auth?: boolean | "stored"控制鉴权行为,"stored"跳过刷新等待,供刷新 token 自身调用使用;- 非 2xx 统一转
ApiError(解析 FastAPI 的detail字符串或校验错误数组{loc, msg, type}); - 对"非 JSON 的 2xx"做了防护,避免原始
SyntaxError逃逸并被错误重试。
唯一的例外是流式聊天调用:它使用expo/fetch以获得可读的响应体,详见 docs/mobile-chat。
5.2 服务端状态:TanStack Query + serverUrl 键
服务端状态使用 TanStack Query,且查询键以serverUrl为键(见 mobile/src/api/query-keys.ts:me: (serverUrl) => ["me", serverUrl]、chatSession: (serverUrl, sessionId) => ["chat-session", serverUrl, sessionId]等),这样切换实例后绝不会串用上一个后端的数据。
缓存默认配置(mobile/src/query/client.ts):staleTime: 30_000、gcTime与持久化窗口一致(24h)、认证错误不重试、refetchOnWindowFocus必须为 true(否则query/focus.ts的 AppState→focusManager 桥失效)。
5.3 隐私与持久化:未加密 MMKV 的排除名单
缓存持久化到未加密的 MMKV(通过@tanstack/query-sync-storage-persister+makeMmkvStorage),因此任何 PII 键(聊天内容、身份信息)都必须通过NON_PERSISTED_KEY_PREFIXES排除,该名单定义在mobile/src/query/client.ts:
- 已排除:
me、agents、workspaceSettings、userProjects、userProject、userRecentFiles等前缀; - 另有一条默认拒绝规则:键首段以
chat-开头的查询一律不持久化,未来新增聊天类键无需再手动登记; - 选择器类键(connector 类型、per-agent 工具 id)有意保留持久化:它们本身不含可读 PII,持久化能让"启动后立刻发送"时仍尊重用户已保存的来源/工具选择。
同时,切换账号由sessionManager的purgeCache负责:登录/登出都会同时清空内存与磁盘缓存,与持久化排除名单互为补充。
六、导航:expo-router 与认证门
导航使用expo-router。规则要点:
- 路由组是路径透明的:
app/(app)/index.tsx=/; - 认证路由是命令式的,位于
mobile/src/components/auth/AuthGate.tsx(纯逻辑抽在authRoute.ts)。实现上不使用<Redirect>(根布局里useFocusEffect没有可绑定的聚焦路由),而是router.replace()+ 渲染覆盖层(错误时AuthUnreachable可重试、加载时AuthSplash); - 导航表面是可折叠的侧边栏浮层(基于 Portal,
mobile/src/components/sidebar),不是 tab bar; - 布局中用
useGlobalSearchParams,页面中用useLocalSearchParams。
从根布局 mobile/src/app/_layout.tsx 可见其组合结构:GestureHandlerRootView(携带vars()主题)→KeyboardProvider→SafeAreaProvider→PersistQueryClientProvider→SidebarProvider→AuthGate→Stack,且PortalHost是主题根节点的最后一个子节点,保证侧边栏浮层渲染在所有页面之上并继承vars()主题与安全区 insets。
七、测试规范
7.1 运行器与门禁
- 运行器:
jest-expo。测试放在__tests__/(匹配src/**/__tests__/**/*.test.ts?(x)); - 门禁命令:
bun run typecheck、bun run lint、bunx jest(脚本定义见 mobile/package.json)。
7.2 jest 全局变量必须显式导入
从@jest/globals导入describe/it/expect/jest/beforeEach——因为 TS 配置没有携带 ambient 测试类型。所有 import 放在文件顶部,之后再写jest.mock(...)(babel 会提升 mock;这也满足import/first规则)。
7.3 原生 mock 集中管理
MMKV 自 mock、expo-secure-store手动 mock 位于__mocks__/、重置逻辑在jest.setup.ts,全部集中化,测试文件无需各自造轮子。
7.4jest.mocked()对泛型函数的坑
对于apiFetch<T>这类泛型函数,jest.mocked()会推断出never,需要显式 cast:
apiFetch as unknown as Mock< (p: string, i?: ApiFetchInit) => Promise<unknown> >; // Mock 来自 jest-mock7.5 不要在单元测试中 import reanimated 的 barrel 导出
@/components/sidebar(→Sidebar.tsx→ reanimated)在 jest 下会崩溃("Worklets not initialized")。应直接 import 叶子组件(如@/components/sidebar/SidebarTab)来保持组件可单测。这与 mobile/src/components/sidebar/index.ts 的 barrel 导出结构直接相关——barrel 会连带拉起重依赖,叶子导入则不会。
八、共享包@onyx-ai/shared的协作规则
@onyx-ai/shared(位于 web/lib/shared)承载跨平台设计令牌+中立契约/类型/工具:
@onyx-ai/shared/native是RN 专属(NativeWind 主题 / vars / 排版);跨平台类型放/contracts,绝不放进/native(例如TextFont这个唯一的规范联合类型定义在web/lib/shared/src/contracts/typography.ts,Web 与移动端共用);- 采用extract-on-proven-reuse(先验证被复用了再抽取)策略,而不是提前抽象。移动端chat 层是有意原生写在
mobile/src/chat/的(不共享),决策背景见 docs/mobile-chat/05-pr-roadmap.md(PR 2 Decision); - 修改共享包后必须重建其
dist(在web/lib/shared下执行bun run build),移动端以file:依赖消费dist;Web jest 则通过moduleNameMapper从src解析。
8.1 preinstall 构建链:为什么必须是 preinstall
移动端 package.json 中有一个关键脚本:
"preinstall": "cd ../web/lib/shared && bun install && bun run build"它在 bun 链接file:依赖之前构建web/lib/shared的dist(一个被 git 忽略的构建产物),从而保证全新环境执行bun install时不会因@onyx-ai/shared/dist不存在而失败。
必须是preinstall而不是postinstall,原因在于 bun 的链接农场(link farm)机制:file:依赖被链接进node_modules时,只有dist在链接时刻已存在才会被包含。日常迭代中,对令牌/源码的活跃修改仍可通过在web/lib/shared下执行bun run dev热重建。
九、总结:移动端开发的四把尺子
- 样式:间距数字即像素(Web 步进 ×4),颜色只用语义类、由
vars()运行时切换深浅色,杜绝dark:与裸颜色; - 组件:先扫
components/ui/*与外壳布局再动手,Web 是设计源,分歧必须记录; - 数据:
apiFetch统一请求、查询键挂serverUrl防串库、PII 键禁止落盘到未加密 MMKV; - 协作:测试走 jest-expo + 叶子导入,共享包改动需重建
dist,preinstall保证安装链路完整。
遵循这些标准,可以在保持与 Web 端视觉一致的前提下,让 Onyx 移动端拥有独立、健壮且可测试的工程形态。
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考