Multica 移动端 RNR 迁移实践:shadcn 式组件体系、CSS 变量暗色主题与三阶段落地方案

发布时间:2026/9/6 19:43:15
Multica 移动端 RNR 迁移实践:shadcn 式组件体系、CSS 变量暗色主题与三阶段落地方案 Multica 移动端 RNR 迁移实践shadcn 式组件体系、CSS 变量暗色主题与三阶段落地方案【免费下载链接】multicaMake humans and AI agents work as one team — open-source and self-hostable.项目地址: https://gitcode.com/GitHub_Trending/mu/multica本篇基于 Multica 移动端的迁移设计文档 rnr-migration.md完整拆解一次手写 React Native 组件 → react-native-reusablesRNR 类级暗色模式的迁移工程为什么选 RNR 而不是 Tamagui/Gluestack如何用iOS 原生 RNR 讨论瀑布收敛 18 个手写 Sheet如何用 CSS 变量 darkMode: class搭出 Light / Dark / System 三档主题切换以及三阶段Phase 0/1/2/3的推进节奏与已验证的源码实现。读完你可以复用到自己的 NativeWind 4 Expo SDK 55 项目中并了解每个设计决策背后的源码依据。1. 背景迁移要解决的三个问题文档开篇给出了迁移前的基线状态注意这描述的是 Phase 1 之前的状态主题部分如今已修复组件部分仍是待办apps/mobile/components/ui/下有21 个手写组件约 1,379 行全部基于裸View/Text/Pressable/Modal构建apps/mobile/components/下有18 个手写 sheet/modal 文件全部复制同一种形态Modal transparent fade 手绘背景遮罩。移动端基线文档 CLAUDE.md 的 Lesson 6 早已记录这种模式对多数内容形态是错的并由此衍生出一串 bug键盘挤压、maxHeight截断 FlatList、Modal内部useSafeAreaInsets返回 0没有任何暗色/亮色主题基础设施tailwind.config.js用硬编码 hex 值global.css只有三条tailwind指令没有 CSS 变量、没有darkMode、没有主题切换器。移动端基线CLAUDE.md自 SDK 55 bootstrap 起就把 RNR 定位为 shadcn 的移动端等价物但 RNR 从未真正安装。本文档记录了为什么迁移到 RNR、评估过哪些替代方案、迁移如何分阶段推进三件事面向所有会动到apps/mobile/components/ui/或给移动端加新 UI 的开发者。2. 备选方案评估评估维度有四项(a) 与现有技术栈的契合度NativeWind 4、Tailwind 3.4、Expo SDK 55、React 19(b) 所有权模型锁定 vs 复制粘贴(c) 打包/编译开销(d) 无障碍基线。库栈契合度所有权构建成本无障碍结论react-native-reusables (RNR)NativeWind 4 RN-Primitives CVA ——与本项目完全一致复制粘贴代码归你零只是 CSS JSXRN-Primitives焦点管理、ARIA 等价物入选Tamagui自带编译器 自带 styled API非 NativeWind库锁定Babel/bundler 配置 学习成本内置否决 —— 核心价值是 webnative 同代码库移动端独立用不上Gluestack UI v2自带 styled API可以配合 NativeWind 用但非原生路径可复制粘贴低react-native-aria强否决 —— 已有 NativeWind 在跑换样式体系没有收益NativeBase / RN Paper / RN UI Lib成熟但库绑定锁定低视情况否决 —— 长生命周期安装型 App 需要不依赖上游发版就能打组件补丁的能力复制粘贴哲学很关键RNR 的组件归你所有模型还与桌面/Web 端的模式packages/ui/components/ui/也是 shadcn 复制粘贴保持一致——整个代码库一种心智模型。3. 决策RNR 及其治理承诺采用 RNR 时文档立下了若干承诺。前两条是原则约束迁移期每个 PR 以及之后的每个 UI 决策3.1 默认值优先Defaults first使用任何 RNR 组件时接受它的默认 variant、默认尺寸、默认间距、默认调色板。除非有明确的产品需求不要加包装层、不要做改进版默认值、不要造variantmulticaCustom之类的样式。手写遗留代码恰恰就是因为有人去要了标准原语的稍微改进版——在 RNR 底下重演这个模式等于白迁。具体推论Phase 1 原样使用 shadcn 默认 neutral 调色板light dark。Multica 自有自定义 tokenbrand、success、warning、info、priority、code-surface追加进去但暗色值不在有实证需求之前预先创作——它们先复制亮色值直到真正使用它们的页面在暗色下出问题为止。Phase 2/3npx rnr/cli add component写出文件后不要立刻调它的样式API 有差异时改调用方。Tier C基础升级指的是把裸Text换成 RNR 的Text、把内联条件判断换成cva——不指重新设计或添加组件原本没有的 variant。3.2 iOS 原生 RNR 讨论新增任何交互时走这条瀑布命中即停iOS / RN 有原生 API直接用不要用Modal包一层去模拟RNR 有对应组件npx rnr/cli add name用默认 variant都没有。停下来问用户不要闷头手写。第 4 节 Tier B 的修订分类就依赖这个原生 API 层——很多既有 sheet 换成ActionSheetIOS/Alert.prompt/ 原生日期选择器后会整个消失。场景原生 API文本输入提示单字段Alert.prompt(title, msg, callback)确认 / 破坏性操作提示Alert.alertN 选 1 动作表ActionSheetIOS.showActionSheetWithOptions日期 / 时间选择react-native-community/datetimepicker已安装图片 / 相机expo-image-picker已安装文档expo-document-picker已安装分享react-native的Share.share触感反馈确认 / 错误 / 选中expo-haptics已安装3.3 主题方案类级暗色模式darkMode: class CSS 变量应用内 Settings → Appearance 提供light/dark/system三档选择选择持久化在expo-secure-store。RNR 的默认 Tailwind 配置就是 class 模式与需要显式用户设置的需求匹配纯 media-query 方案不允许用户覆盖 OS 偏好。3.4 组件三层分类不做一刀切全量迁移。部分遗留组件是领域 UI 而非通用原语留在原地但使用 RNR 的地基Text组件、语义化 token、CVA variant。3.5 硬规则写入 CLAUDE.md新组件要么来自 RNR要么先走原生 API 层。迁移删除手写遗留 variant这条规则防止再次积累。这一规则如今已固化在 CLAUDE.md 的 UI components theming 章节作为迁移完成后依然有效的持久规则。4. 组件清单与三层分类Tier A —— RNR 直接替换迁移对象通用原语中 RNR 有近乎同形等价物的直接用npx react-native-reusables/clilatest add name的输出替换手写文件然后清扫调用方现有文件行数RNR 组件备注components/ui/button.tsx63buttonPhase 2 的 canarycomponents/ui/input.tsx9input平凡components/ui/text-field.tsx34inputlabel用 RNR 组合components/ui/card.tsx36card可直接替换components/ui/text.tsx18text到处都在用要小心做清扫所有 importcomponents/ui/autosize-textarea.tsx89textarea需核对一致性 —— 自动增高行为可能要基于 RNR textarea 重实现components/ui/otp-input.tsx68RNR 目前无等价物继续用input-otp-native移入 Tier Bcomponents/ui/modal-close-button.tsx25无平凡 —— sheet 迁移后并入Dialog关闭模式合计约 270 行待替换含调用方清扫约 4 小时工作量。从当前仓库结构看components/ui/ 目录在文档撰写之后已经出现了collapsible、dropdown-menu、radio-group、separator、skeleton、switch、tabs等文件可以推断 Tier A 之后的组件引入已按 RNR 模式在推进但按文档 Status 行Phase 2/3 的收尾工作仍以文档记录为准。Tier B —— Sheets / Modals套用 §3.2 瀑布这一层收益最大CLAUDE.md Lesson 6 已给重复出现的 sheet bug 建了档。按iOS 原生 RNR 讨论瀑布18 个 sheet 三分B.1 —— 原生 API 替换文件直接删现有 sheet替换为原因components/issue/comment-action-sheet.tsxActionSheetIOS.showActionSheetWithOptionsN 选 1 动作菜单 —— 正是 ActionSheetIOS 的用途。推荐作 Phase 3 起手可见的删除、无样式问题components/issue/pickers/due-date-picker-sheet.tsxreact-native-community/datetimepicker内联选择器日期选择 —— 原生 API 已安装B.2 —— 改为 formSheet 路由已完成原计划是把每个 picker-sheet 换成 RNRSelect。mobile-sheet-rollout PR 系列最终收敛到了不同形态每个原 picker-sheet 都拆成components/domain/pickers/下的纯XxxPickerBody组件嵌入 Expo Router formSheet 路由app/(app)/[workspace]/context/picker/field.tsx。这拿到了 iOSUISheetPresentationController的原生 chromegrabber detents 弹簧拖拽关闭同时省掉了 RNRSelect仍需要的每个调用点状态与可见性 prop 的周折。本行文件已全部删除无后续动作。B.3 —— 真正需要自定义内容 sheetRNRDialogpageSheet现有 sheet为什么保留为 Dialogcomponents/issue/issue-filter-sheet.tsx一个 sheet 里多个控件筛选表单不是列表选择components/issue/runs-sheet.tsx运行历史行 操作不是 N 选 1components/chat/session-sheet.tsx待定 —— 到时重新审视可能归 B.2components/chat/agent-picker-sheet.tsx大概率 B.2Select—— 待重审components/project/add-resource-sheet.tsx待定 —— 取决于单选还是小表单B.4 —— RNR 没有的空。唯一一项components/issue/emoji-picker-sheet.tsx已通过采纳rn-emoji-keyboard解决评论表情反应流程迁移到 formSheet 路由app/(app)/[workspace]/issue/[id]/comment/[commentId]/emoji-picker.tsx。移动端现在在每条评论动作表的更多表情溢出项后提供完整表情集与 Web 对齐。执行规则不要批量替换sheet-shell.tsx。它被 18 个文件引用原子替换 18 处同时坏掉。按 CLAUDE.md Lesson 6一个 sheet 一个 PR一个 PR 一次验证。B.1 是删除类 —— 应当先做因为每个都只是删文件、零替换代码从父组件调ActionSheetIOS即可。复合收益代码更少 符合默认值优先 符合iOS 原生优先。B.2 简化调用点但引入的组件是新代码排在 B.1 之后。B.3 保留结构复杂度迁移主要是Modal换 RNRDialog。视觉变化最小但 bug 修复最大拖拽关闭、焦点管理、安全区都由 RNR 处理。Tier C —— 领域 UI保留升级地基这些不是通用 shadcn 组件 —— RNR 没有优先级图标或参与者头像的等价物。它们留在components/ui/以兼容现有 import但内部构建块必须迁到 RNR 地基用 RNR 的Text替代裸Text用cva做 variant 表而非内联条件用语义化 tokentext-foreground永不用#1f1f23actor-avatar.tsx158 行—— 底层用 RNR 的avatar原语但业务逻辑保留app-header-actions.tsx51、avatar-stack.tsx97、presence-dot.tsx44priority-icon.tsx80、project-icon.tsx49、project-priority-icon.tsx71、project-status-icon.tsx130、status-icon.tsx163pulse-dot.tsx52、screen-header.tsx37Tier C 不排期 —— 某个 bug 或功能触碰到文件时顺手在该 PR 里升级地基机会主义而非排期驱动。5. 主题架构CSS 变量 类级暗色模式目标Settings → Appearance 提供Light / Dark / System三档选择持久化在expo-secure-store键theme-preference值light/dark/system。5.1 分层结构global.css CSS 变量定义于 :root 与 .dark:root颜色的唯一事实来源 tailwind.config.js darkMode: class 工具类映射到 hsl(var(--...)) lib/theme.ts CSS 变量的 TypeScript 镜像导出给 React Navigation 用的 NAV_THEME lib/use-color-scheme.ts 包装 NativeWind 的 useColorScheme expo-secure-store 持久化 app/_layout.tsx 启动时读取持久化偏好调 setColorScheme(...) 用 ThemeProvider(NAV_THEME[...]) 包 Stack 为 RNR dialog/popover 挂载 PortalHost /5.2 各层实现细节源码佐证global.cssPhase 1 之前tailwind.config.js里约 20 个语义化 token 是硬编码 hex。现在它们成了global.css中:root亮色与.dark:root暗色下的 CSS 变量。亮色基础值即 shadcn neutral 默认如--background: 0 0% 100%、--primary: 0 0% 9%Multica 自定义 token 追加其后/* Multica custom tokens */ --brand: 225 71% 58%; --brand-foreground: 0 0% 98%; --success: 142 71% 45%; --warning: 48 89% 47%; --info: 217 91% 60%; --priority: 25 95% 53%; --code-surface: 240 4% 92%;实际实现比文档更进一步补了一套 5 级表面高程阶梯--surface-1/--surface-2按 Refactoring UI≥5% L 差异为可感知阈值校准亮色下 L100 页面底 → L98 surface-1评论气泡→ L96.1 shadcn secondary → L90 surface-2气泡内嵌代码块→ L84 border暗色下明度随高程升高阴影在暗色下不成立色调抬升才是高程线索--code-surface是自定义 token 中唯一需要真实暗色值的例外240 4% 18%——亮色值近白暗色下会把代码块衬得比页面还亮。tailwind.config.jsdarkMode: class所有颜色工具类映射为hsl(var(--background))形式borderWidth注册了hairline: hairlineWidth()来自nativewind/theme插件区注册tailwindcss-animate并保留移动端的borderRadius覆盖module.exports { darkMode: class, content: [./app/**/*.{ts,tsx}, ./components/**/*.{ts,tsx}], presets: [require(nativewind/preset)], theme: { extend: { colors: { background: hsl(var(--background)), primary: { DEFAULT: hsl(var(--primary)), foreground: hsl(var(--primary-foreground)) }, // ... 以及 brand / success / warning / code-surface 等自定义 token }, borderWidth: { hairline: hairlineWidth() }, }, }, plugins: [require(tailwindcss-animate)], };lib/theme.tsCSS 变量的 TS 镜像。THEME是原始 token 对象供内联样式、动画等 Tailwind class 够不到的场景NAV_THEME是给react-navigation/native的ThemeProvider用的主题header、modal、返回键跟随明暗。lib/use-color-scheme.ts包装 NativeWind 的useColorScheme()加上expo-secure-store持久化键theme-preference。暴露{ colorScheme, isDarkColorScheme, setPreference }。文档没有细说但源码里写明的一个取舍首挂载时是异步读取保存的偏好读取完成前 NativeWind 默认行为跟随 OS生效——意味着选了 Dark 的用户在亮色 OS 上冷启动可能短暂闪一下亮色可接受因为 secure-store 没有同步后端。app/_layout.tsx实际接线处。RootLayout调用useColorScheme()拿到colorScheme/isDarkColorScheme用ThemeProvider value{NAV_THEME[colorScheme]}包住StackStatusBar颜色随模式翻转PortalHost /rn-primitives/portal挂在 provider 子树末尾供 RNR dialog/popover 使用。components.json标准 RNR/shadcn 配置 ——style: new-york、baseColor: neutral、cssVariables: true别名指向/components、/lib/utils。metro.config.jswithNativeWind(config, { input: ./global.css, inlineRem: 16 })——inlineRem: 16是 Phase 1 清单里的关键项保证 CSS 变量按 16px 基准编译。5.3 为什么是 class 模式而不是 media-query 模式media-query 模式media (prefers-color-scheme: dark)是更简单的默认但应用无法覆盖它。Settings → Appearance 需要覆盖 OS 偏好必须 class 模式。代价是启动时一次setColorScheme()调用来应用保存的偏好首次绘制前一次性付清。5.4system选项用户选system时调用setColorScheme(system)NativeWind v4 原生支持框架通过Appearance.addChangeListener跟随 OS无需自己订阅isDarkColorScheme响应式更新。5.5 暗色调色板策略旧配置里不存在暗色值必须创作。两个选项直接用 shadcn neutral-base 暗色调色板RNR 默认安装的--background: 0 0% 3.9%等—— 最快立刻有能跑的暗色模式品牌对齐可能要二遍手工创作暗色对齐 Web/桌面暗色主题 —— 慢但与桌面视觉一致。Phase 1 选了选项 1换取速度。现在基础设施已验证后续可以按桌面的 tokens.css 暗色主题做校准但注意桌面用 oklch / Tailwind v4移动端用 hsl / Tailwind v3.4跨版本共享不现实分歧是刻意保留的基线。6. 三阶段推进计划Phase 0 —— 研究与文档本文档✅通读 RNR 安装与定制文档、检查移动端现状tailwind.config.js、global.css、app/_layout.tsx、metro.config.js、babel.config.js、更新 CLAUDE.md 的 UI 与主题规则、写下本文档最后过用户验证关卡。Phase 1 —— 基础基础设施 ✅ 已完成目标装上 RNR 脚手架但不碰任何一个现有组件。验证标准 App 构建运行与之前完全一致设置里的主题切换器端到端可用。Phase 1 的 10 项清单全部交付依赖按 RNR 手动安装第 3 步npx expo install tailwindcss-animate class-variance-authority clsx tailwind-merge rn-primitives/portalcva/clsx/tailwind-merge/rn-primitives/slot确认已存在新增的只有tailwindcss-animate和rn-primitives/portalmetro.config.js设inlineRem: 16重写global.css:root.dark:root双调色板含 Multica 自定义 token重写tailwind.config.jshsl(var(--...))映射、darkMode: class、tailwindcss-animate插件、hairlineWidth()边框宽度保留移动端专属覆盖新建lib/theme.tsTS 镜像 NAV_THEME新建lib/use-color-scheme.ts持久化包装新建components.json标准 RNR 配置更新app/_layout.tsx启动时读持久化偏好、首绘前setColorScheme(...)、ThemeProvider(NAV_THEME[...])包Stack、provider 末尾挂PortalHost /Settings → Appearance 选择器三行Light / Dark / System调setPreference 持久化UI 复用既有行模式无新依赖验证构建通过、亮色下所有既有页面渲染不变、切 Dark 背景/文字翻转、切 System 跟随模拟器 OS 外观、杀进程重开偏好仍在。Phase 1 不替换任何现有组件。按钮、输入、sheet 仍是手写版。暗色模式之所以对既有 hex 派生的语义 token 能用是因为只是把它们改道经过 CSS 变量 —— 同一个bg-backgroundclass 现在解析到有一个值变两个值的变量。残留风险带入 Phase 3硬编码 hex 的组件如Ionicons color#71717a、bg-[#fafafa]不响应主题切换。Phase 1 做过一次#[0-9a-fA-F]{3,6}的 grep 清扫每个命中要么换 token要么标TODO(rnr-migration):留给 Phase 3。Phase 2 —— 首个组件 canary未开始目标在批量做 20 个之前用最简单且非平凡的组件验证迁移机械流程。选定button.tsx。理由调用方数量高能验证 import 清扫模式、RNR 的 button 带variant/sizeprop 与 shadcn 同形验证 API 对等、到处可见视觉回归立刻明显。步骤npx react-native-reusables/clilatest add button—— CLI 覆盖现有文件旧文件成为 git 里的 diff 基线新旧 diff记录 prop 或视觉差异RNRvariant枚举 vs 自有的、默认尺寸差异清扫所有调用方API 有差异就在同一 PR 里改调用方模拟器视觉 diff逐个打开用到 button 的页面截图与 main 对比亮色暗色双模式验证。Phase 2 就是一个 PR。验收标准所有既有按钮仍工作亮色无回归暗色下默认值合理。Phase 3 —— 其余全部顺序其余 Tier A 原语input、text-field、card、text、textarea—— 一个组件或一组一个 PR同 canary 模式text特殊处理几乎每个文件都 import 它单独排 PRcodemod 是机械的import { Text } from /components/ui/text已存在背后文件变化即可Tier B sheets —— 一个 sheet 一个 PR顺序 B.1原生 API 替换删文件→ B.2Select替换→ B.3Dialog迁移。首个 sheet PR comment-action-sheet.tsx→ActionSheetIOS干净的删除、且在实践中验证 §3.2 瀑布。每个 PR 后停下来重新验证Tier C 地基升级 —— 机会主义无排期 PR收尾 PR删除不再被引用的遗留文件、清掉 Phase 1 hex 清扫留下的 TODO 注释最终pnpm typecheck pnpm lint干净。停止规则连续 3 个 PR 引入视觉回归就暂停重新审视 token 映射不要硬推Tier B sheet 迁不干净RNRdialog不适合该场景时在本文档记录分歧保留手写版并标记为刻意例外而非待办。7. 已知陷阱写进反射弧研究阶段踩过的坑动手前先固化darkMode: class.dark:root是 NativeWind v4 类控制模式下唯一可用的组合。不要用标准.dark选择器 —— NativeWind 需要:root后缀才能全局应用。global.css 中的选择器就是.dark:root。setColorScheme()来自 NativeWind不是 React Native。从react-nativeimport 得到的是只读的 OS 值。NativeWind 版支持setColorScheme(light | dark | system)并触发重渲染。同步陷阱lib/theme.ts与global.css必须互为镜像。只改一边Tailwind class 样式化的组件看着对但直接读THEME的地方内联样式、动画、React Navigation chrome就错了。两份文件的头部注释都写明改一边必须改另一边见 rnr-migration.md §5。AbortSignal.timeout/AbortSignal.any在 Hermes 上仍不存在CLAUDE.md Lesson 5。与 RNR 迁移无直接关系但任何自己发网络请求的新组件都要手动 AbortController 模式。RNRDialog内部的useSafeAreaInsets与裸Modal一样不可靠。CLAUDE.md Lesson 6 的 pageSheet 陷阱仍适用 —— 在父组件读 insets把bottomInset当 prop 传进去。rn-primitives/portal的PortalHost /位置敏感。必须是所有 provider 的最后一个子节点如果挂在频繁重渲染的 provider 内dialog 会被意外卸载。只在 app/_layout.tsx 放一次。CLI 覆盖文件无确认。components/ui/button.tsx存在时add button直接替换。对迁移是期望行为但对 Tier C 文件误跑是灾难 —— 每次add后都查 git status。NativeWind 5 尚未采纳。基线钉在 v4RNR v1 两者都支持本项目走 v4 安装路径。8. 开放问题暗色品牌色当前品牌色#4571e0仅亮色。暗色等价值要定 —— 保留深色底上对比度高还是偏移留给设计 pass。code-surfacetoken亮色下是#e8e8eb比secondary深 5%。暗色等价是比暗色secondary亮 5%。实际实现已在 .dark:root 中给出240 4% 18%理由注释齐全。设置页 UXAppearance 选择器可内联三行直接摆也可做成打开 picker sheet 的行。v1 选内联更简单sheet 变体等 Tier B 迁完再说。暗色 token 是否与桌面共享桌面packages/ui/styles/tokens.css用 oklchTailwind v4移动端 hslTailwind 3.4跨版本共享不现实接受分歧为既有基线移动端升级到 NativeWind 5 Tailwind v4 时再议。9. 小结与延伸阅读这次迁移的方法论可以浓缩为四条复制粘贴所有权优先于库绑定默认值优先拒绝稍微改进版原语用原生 组件库 讨论瀑布消灭不必要的抽象层基础设施先行、canary 组件验证、批量推进时设停止规则。配套文档迁移主文档 rnr-migration.md、持久 UI/主题规则 CLAUDE.md UI components theming 章节、主题实现的四个文件 global.css / tailwind.config.js / lib/theme.ts / lib/use-color-scheme.ts以及接线入口 app/_layout.tsx。【免费下载链接】multicaMake humans and AI agents work as one team — open-source and self-hostable.项目地址: https://gitcode.com/GitHub_Trending/mu/multica创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考