React Native 没有 CSS 引擎,因此 Web 上用 CSS variables 或 prefers-color-scheme 实现暗色模式的路径在 RN 里走不通。本页对比 RN 暗色模式的三种主要方案,解析 MindGym 项目选择”语义色 + NativeWind dark: 前缀”的理由,以及语义色命名策略的设计动机。
概述
暗色模式在移动端已从锦上添花变成基础需求(iOS 13 / Android 10 之后)。RN 的样式系统是 JS 对象而非 CSS,没有 var()、@media、:root,因此需要在 JS 层面解决主题切换问题。主流方案有三种,各有明确的 trade-off。
工作方式
方案一: Context + StyleSheet
用 React Context 传递主题对象(light/dark 两套色值),组件通过 useContext 读取。
优势: 纯 React,不依赖第三方库;灵活度极高——主题对象可包含任意 token。
劣势:
- 每次主题切换触发整棵组件树重新渲染(Context value 变了)
StyleSheet.create()是静态的(模块加载时执行),和动态主题色值有矛盾——要么放弃 StyleSheet 的缓存优化,要么每次渲染重建 StyleSheet- 每个组件都要
useContext(ThemeContext)+ 手动取值,代码量大
方案二: CSS Variables(Web 思路)
Web 上暗色模式的最优解:
:root { --bg: #ffffff; }
@media (prefers-color-scheme: dark) { :root { --bg: #0f0f0f; } }
.card { background: var(--bg); }
一套 CSS 规则,变量自动切换,零 JS 开销。但 RN 不支持 CSS variables——RN 的样式系统是 JS 对象,不经过浏览器 CSS 引擎。
NativeWind v4 内部用 React Context 模拟了一种类 CSS variables 机制(让 dark: 前缀能工作),但这和 Web 原生的 CSS variables 是两回事——是 JS 层面的查表替换,不是引擎级变量解析。
方案三: NativeWind dark: 前缀
<View className="bg-white dark:bg-zinc-900">
<Text className="text-gray-900 dark:text-gray-100">...</Text>
</View>
NativeWind 在编译期为带 dark: 前缀的类名生成两套 StyleSheet。运行时根据 colorScheme 选择应用哪一套。darkMode: 'class' 配置让切换由应用代码控制(而非跟随系统 media query)。
优势:
- 编译期处理,运行时只做选择,开销低
- 声明式:light/dark 差异一眼可见
- 与 Web 端 Tailwind 的
dark:用法一致
劣势:
- className 变长(每个需暗色适配的属性写两遍)
- 不如 CSS variables 优雅(显式两套 vs 变量自动切换)
三方案对比
| 维度 | Context + StyleSheet | CSS Variables | NativeWind dark: |
|---|---|---|---|
| 运行时开销 | 高(全树 re-render) | 零(引擎级) | 低(编译期+运行时选择) |
| RN 可用性 | 可用 | 不可用 | 可用 |
| 代码侵入度 | 高 | 低 | 中 |
| 灵活度 | 极高 | 中 | 中 |
MindGym 的实现策略
MindGym 选择了介于方案一和方案三之间的策略:语义色抽象层 + Tailwind 自定义色值。
语义色体系
theme/colors.js 定义 light/dark 两套语义色 → tailwind.config.js 注册为自定义 Tailwind 类名 → 业务代码只用语义色:
// 业务代码:只用语义色类名,不用裸 hex 值
<ScrollView className="flex-1 bg-bg">
<View className="bg-surface rounded-xl border border-surface-border">
<Text className="text-text-primary">...</Text>
</View>
</ScrollView>
为什么用语义色而不是裸色值
语义色是一层间接引用——组件说”我要背景色”,不说”我要 #faf9f6”。好处:
- 改主题改一处: 改
theme/colors.js全局生效,不用全局搜索替换 - 加暗色模式容易: 语义色映射表已经有 light/dark 两套,接入
dark:前缀只需改tailwind.config.js
代价:多了一层抽象,团队成员要理解命名约定。
| 类名 | 语义 | Light | Dark |
|---|---|---|---|
bg-bg | 页面底层背景 | #faf9f6 | #0f0f0f |
bg-surface | 卡片/面板 | #ffffff | #1a1a1a |
border-surface-border | 分隔线/边框 | #e2e0db | #2e2e2e |
text-text-primary | 主文本 | #1a1a1a | #f5f5f5 |
text-text-secondary | 次级文本 | #6b7280 | #9ca3af |
darkMode: ‘class’ vs ‘media’
media: 暗色样式跟随设备系统,App 无法覆盖——“始终浅色”选项不起作用class: 暗色样式由应用代码控制,可实现”跟随系统 / 始终浅色 / 始终深色”三选一
MindGym 需要三选一,所以用 class。
数据流
用户点击 "深色"
→ setColorScheme('dark') // Zustand action
→ settings store 更新 + MMKV 持久化
→ app/_layout.tsx 重渲染
→ useResolvedColorScheme() → 'dark'
→ StatusBar 切换图标色
→ (未来 M6) NativeWind colorScheme → dark: 前缀生效
Flexbox 与语义色配合
设置页典型代码结构:
<Pressable className="flex-row items-center justify-between px-4 py-3">
<Text className="text-text-primary text-base">简体中文</Text>
{locale === 'zh' && <Text className="text-primary text-base">✓</Text>}
</Pressable>
flex-row items-center justify-between: row 方向排列,垂直居中,两端对齐- 语义色(
text-text-primary,text-primary)和布局类(flex-row,items-center)在同一个 className 里共存,互不干扰 - 四层色值层次:
bg-bg(页面底) →bg-surface(卡片) →border-surface-border(边框) →text-text-primary(文本)
版本说明
本页基于 MindGym M1 阶段(Expo SDK 54, NativeWind v4, Tailwind v3, 2026-04-20)。
参见
参考
- NativeWind Dark Mode 文档 — darkMode: ‘class’ 配置说明
- Tailwind CSS Dark Mode — Web 端暗色模式设计思路
- NativeWind 官方文档 — 完整的配置和迁移指南