React Native / Expo 模式
构建生产级 Expo React Native 应用的实用模式。涵盖导航、状态、数据获取、列表、样式和原生 API。与 rules/react-native/ 规则集配合使用:规则说明要强制执行什么,本技能展示如何实现。
下面列出的库(NativeWind、Zustand/Jotai、TanStack Query)是常见且成熟的选择,仅作示意 — 模式比具体包更重要,任何等效方案均可。使用 Zod 进行验证,以与 ECC 现有的 typescript/ 规则保持一致。
这些模式假设在新架构(近期 Expo SDK 默认可选,SDK 55+ 后为强制项)上使用托管 Expo 工作流(Expo Router、EAS、expo-* 模块)。它们不假设浏览器 DOM — React Native 没有 <div>、没有 URL 栏,也没有 Web 数据获取的默认行为。
何时激活
在以下情况下使用此技能:
- 构建或编辑 React Native / Expo 屏幕、组件或导航
- 使用 Expo Router(基于文件系统的
app/目录)设置路由 - 决定状态归属(服务器缓存 vs 客户端存储 vs 路由参数 vs 表单)
- 使用 TanStack Query 进行数据获取,并用 Zod 验证响应
- 渲染长列表或重型列表
- 选择或应用样式方案(NativeWind 或 StyleSheet)
- 访问原生设备 API(相机、位置、通知)或安全存储
- 审查移动端特定问题的 RN 代码
不要在此使用 Web/React-DOM 模式 — URL 即状态、<div> 以及浏览器专用的 SWR 不适用于 React Native。
核心概念
项目结构(Expo Router)
基于文件系统的路由位于 app/ 下。保持路由文件精简:它们读取并验证参数,然后委托给位于 components/ 或 features/ 中的屏幕组件。
app/
_layout.tsx # 根堆栈
(tabs)/
_layout.tsx # 标签导航器
index.tsx # 首页
user/[id].tsx # 动态路由
components/
features/
user/UserProfile.tsx
导航:验证路由参数
深度链接和动态路由传递不可信字符串。使用 Zod 在使用前进行验证。
// app/user/[id].tsx
import { useLocalSearchParams, router } from 'expo-router'
import { z } from 'zod'
import { UserProfile } from '@/features/user/UserProfile'
const Params = z.object({ id: z.string().uuid() })
export default function UserRoute() {
const parsed = Params.safeParse(useLocalSearchParams())
if (!parsed.success) {
router.replace('/not-found')
return null
}
return <UserProfile userId={parsed.data.id} />
}
状态:保持关注点分离
不要将服务器数据复制到客户端存储中。每个关注点都有自己的归属。
| 关注点 | 常用选择 |
|---|---|
| 服务器状态(远程数据) | 服务器缓存库(TanStack Query、SWR) |
| 客户端/UI 状态 | 轻量级存储(Zustand、Jotai)或 Context |
| 路由/导航状态 | Expo Router 参数 |
| 表单状态 | 表单库(例如 React Hook Form)+ 模式验证 |
| 密钥/令牌 | expo-secure-store |
| 非机密持久化 | AsyncStorage / MMKV |
优先使用本地 useState,直到状态确实需要共享。
数据获取:缓存库 + Zod
使用服务器缓存库(TanStack Query、SWR)替代 useEffect 中的 fetch。在边界处进行验证,并从模式中推断类型。显式处理加载、错误和空状态。(示例使用 TanStack Query。)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
const User = z.object({ id: z.string(), email: z.string().email() })
type User = z.infer<typeof User>
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => User.parse(await api.getUser(id)),
})
}
export function useUpdateEmail(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (email: string) => api.updateEmail(id, email),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
})
}
列表:虚拟化,绝不在 ScrollView 中映射大型数组
import { FlatList } from 'react-native'
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem} // 已记忆化
initialNumToRender={10}
windowSize={5}
/>
对于大型或异构列表,使用 FlashList(Shopify)。
样式:选择一种方案
StyleSheet.create() 是框架原生的选项;工具类库(例如 NativeWind)是常见的替代方案。选择一种并保持一致。绝不在热路径上的 JSX 中内联构建样式对象。
// NativeWind
<View className="p-4 rounded-2xl bg-white">
<Text className="text-base font-semibold">Hello</Text>
</View>
// StyleSheet
const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } })
<View style={styles.card}>...</View>
原生 API:封装在 hooks 中,清理副作用
将 Expo SDK 调用和订阅放在 use* hooks 内部,而不是 JSX 中。始终进行清理。
import { useEffect, useState } from 'react'
import * as Location from 'expo-location'
type LocationState =
| { status: 'loading' }
| { status: 'denied' }
| { status: 'granted'; coords: Location.LocationObjectCoords }
export function useCurrentLocation() {
// 跟踪状态,而不仅仅是坐标——这样 UI 可以区分"仍在加载"和"权限被拒绝",并显示可操作的消息。
const [state, setState] = useState<LocationState>({ status: 'loading' })
useEffect(() => {
let active = true
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
if (active) setState({ status: 'denied' })
return
}
const pos = await Location.getCurrentPositionAsync({})
if (active) setState({ status: 'granted', coords: pos.coords })
})()
return () => { active = false } // 组件卸载后忽略过时结果
}, [])
return state
}
令牌安全存储
import * as SecureStore from 'expo-secure-store'
await SecureStore.setItemAsync('auth_token', token) // Keychain / Keystore
const token = await SecureStore.getItemAsync('auth_token')
代码示例
完整屏幕:路由 → 查询 → 列表 → 状态
// app/(tabs)/orders.tsx
import { memo, useCallback } from 'react'
import { FlatList, Text, View } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() })
const OrdersSchema = z.array(OrderSchema)
type Order = z.infer<typeof OrderSchema>
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: async () => OrdersSchema.parse(await api.listOrders()),
})
}
// 已记忆化,使其引用在渲染间保持稳定(参见列表指南)。
const OrderRow = memo(function OrderRow({ item }: { item: Order }) {
return (
<View className="px-4 py-3 border-b border-neutral-200">
<Text className="font-medium">#{item.id}</Text>
<Text className="text-neutral-500">{item.status} · ${item.total}</Text>
</View>
)
})
export default function OrdersScreen() {
const { data, isLoading, isError, refetch, isRefetching } = useOrders()
const renderItem = useCallback(({ item }: { item: Order }) => <OrderRow item={item} />, [])
if (isLoading) return <Centered><Text>加载中…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">无法加载订单。</Text></Centered>
if (!data?.length) return <Centered><Text>暂无订单。</Text></Centered>
return (
<FlatList
data={data}
keyExtractor={(o) => o.id}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={renderItem}
/>
)
}
表单:React Hook Form + Zod 解析器
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Button, Text } from 'react-native'
const Schema = z.object({ email: z.string().email('无效的电子邮件') })
type FormValues = z.infer<typeof Schema>
export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '' },
})
return (
<>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="电子邮件地址"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="保存" onPress={handleSubmit(onSubmit)} />
</>
)
}
反模式
// 错误:在 ScrollView 中映射大型数组(无虚拟化、卡顿、高内存)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// 正确:FlatList / FlashList
// 错误:将服务器数据复制到客户端存储(两个数据源、过时数据)
const useStore = create((set) => ({ users: [], setUsers: (u) => set({ users: u }) }))
useEffect(() => { getUsers().then(setUsers) }, [])
// 正确:useQuery 拥有服务器状态;按需派生
// 错误:令牌存储在 AsyncStorage 中(未加密)
await AsyncStorage.setItem('auth_token', token)
// 正确:expo-secure-store
// 错误:信任深度链接参数
const { id } = useLocalSearchParams(); fetchUser(id)
// 正确:使用前用 Zod 验证
// 错误:在热路径上每次渲染都重新创建内联样式对象
<View style={{ padding: 16, backgroundColor: '#fff' }} />
// 正确:在模块作用域使用 StyleSheet.create,或使用 NativeWind className
// 错误:将真实密钥打包在 bundle 中
const STRIPE_SECRET = 'sk_live_...'
// 正确:将特权调用保留在服务端;仅提供受后端规则保护的公钥
最佳实践
- 保持路由文件精简;将逻辑放在屏幕组件和
use*hooks 中。 - 使用 Zod 验证每个外部输入(API 响应、路由参数、推送负载)。
- 让 TanStack Query 拥有服务器状态;保持客户端存储小巧。
- 始终渲染加载、错误和空状态 — 绝不仅仅是一个没有后备方案的加载指示器。
- 虚拟化列表;记忆化
renderItem;提供稳定的keyExtractor。 - 使用
react-native-reanimated进行动画(UI 线程);避免在 JS 线程上进行繁重工作。 - 将令牌存储在
expo-secure-store中;绝不相信客户端进行授权。 - 从一开始就尊重安全区域、动态类型和可访问性角色/标签。
- 在发布前确认每个原生依赖项的新架构兼容性。
相关技能
frontend-patterns— React/Next.js(Web)模式;对于共享的 React 概念很有用,但特定于 DOM。coding-standards— 适用于 RN 代码的 TypeScript/JavaScript 惯用法。tdd-workflow、e2e-testing— 测试流程(使用 Jest + React Native Testing Library、Maestro/Detox 进行 RN 测试)。security-review— 补充上述 RN bundle/密钥指南的通用安全检查清单。