Onyx 移动端(React Native + Expo)开发标准实战指南:从设计令牌到数据层与测试规范
2026/9/10 2:48:55 网站建设 项目流程

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.mdmobile/目录(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)

在手工编写任何组件或页面之前,先检查是否已有匹配的组件,按以下顺序排查:

  1. 移动端已有?直接复用:优先扫描mobile/src/components/ui/*(基础 UI 原语)、外壳布局mobile/src/components/{settings,sidebar,auth,chat}mobile/src/icons/*,以及其他mobile/src/components/*目录。实际仓库中ui/下已有button.tsxtext.tsxtext-input.tsxicon.tsxcard.tsxsheet.tsxtabs.tsxswitch.tsxspinner.tsxpopover.tsxseparator.tsxcontent.tsxline-item-button.tsxselect-button.tsx等现成原语(见 mobile/src/components/ui)。
  2. 只有 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 命名令牌)
8pxp-2p-8
16pxp-4p-16
24pxp-6p-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 无法使用remvar()作为尺寸单位,所以尺寸必须在构建期解析为具体像素值。

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.tsxmobile/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.tsxText组件渲染,其 props 为font/color字符串枚举(TextFont/TextColor,类型源自@onyx-ai/shared/contracts)。绝不直接使用 React Native 的Text(包括测试代码中)

从实现看(mobile/src/components/ui/text.tsx),Text内部把枚举映射为字面量类名(font-heading-h1text-text-04等),这样 NativeWind 的类扫描器才能拾取;同时提供nowrap(单行裁剪不显示省略号)与maxLines(最多 N 行、尾部省略)两个便捷 prop,内部转换为numberOfLines+ellipsizeMode

注意:react-nativeTextInputText无关,可以放心使用;但字段输入优先用mobile/src/components/ui/text-input.tsx

4.2 图标:默认导出 +Icon组件

图标以默认导出形式存在于mobile/src/icons/*,通过mobile/src/components/ui/icon.tsxIcon组件渲染:

<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.mjsjs/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.tsapiFetch<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_000gcTime与持久化窗口一致(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

  • 已排除:meagentsworkspaceSettingsuserProjectsuserProjectuserRecentFiles等前缀;
  • 另有一条默认拒绝规则:键首段以chat-开头的查询一律不持久化,未来新增聊天类键无需再手动登记;
  • 选择器类键(connector 类型、per-agent 工具 id)有意保留持久化:它们本身不含可读 PII,持久化能让"启动后立刻发送"时仍尊重用户已保存的来源/工具选择。

同时,切换账号由sessionManagerpurgeCache负责:登录/登出都会同时清空内存与磁盘缓存,与持久化排除名单互为补充。

六、导航: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()主题)→KeyboardProviderSafeAreaProviderPersistQueryClientProviderSidebarProviderAuthGateStack,且PortalHost主题根节点的最后一个子节点,保证侧边栏浮层渲染在所有页面之上并继承vars()主题与安全区 insets。

七、测试规范

7.1 运行器与门禁

  • 运行器:jest-expo。测试放在__tests__/(匹配src/**/__tests__/**/*.test.ts?(x));
  • 门禁命令:bun run typecheckbun run lintbunx 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-mock

7.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/nativeRN 专属(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 则通过moduleNameMappersrc解析。

8.1 preinstall 构建链:为什么必须是 preinstall

移动端 package.json 中有一个关键脚本:

"preinstall": "cd ../web/lib/shared && bun install && bun run build"

它在 bun 链接file:依赖之前构建web/lib/shareddist(一个被 git 忽略的构建产物),从而保证全新环境执行bun install时不会因@onyx-ai/shared/dist不存在而失败。

必须是preinstall而不是postinstall,原因在于 bun 的链接农场(link farm)机制:file:依赖被链接进node_modules时,只有dist在链接时刻已存在才会被包含。日常迭代中,对令牌/源码的活跃修改仍可通过在web/lib/shared下执行bun run dev热重建。

九、总结:移动端开发的四把尺子

  1. 样式:间距数字即像素(Web 步进 ×4),颜色只用语义类、由vars()运行时切换深浅色,杜绝dark:与裸颜色;
  2. 组件:先扫components/ui/*与外壳布局再动手,Web 是设计源,分歧必须记录;
  3. 数据apiFetch统一请求、查询键挂serverUrl防串库、PII 键禁止落盘到未加密 MMKV;
  4. 协作:测试走 jest-expo + 叶子导入,共享包改动需重建distpreinstall保证安装链路完整。

遵循这些标准,可以在保持与 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询