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-for-browser 不适用于 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} />
}
状态:保持关注点分离
不要将服务端数据复制到客户端 store 中。每个关注点都有自己的归属。
| 关注点 | 常用选择 |
|---|---|
| 服务端状态(远程数据) | 服务端缓存库(TanStack Query, SWR) |
| 客户端/UI 状态 | 轻量级 store(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">你好</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
// 错误:将服务端数据复制到客户端 store 中(两个数据源,数据过期)
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
// 错误:将真正的密钥打包到包中
const STRIPE_SECRET = 'sk_live_...'
// 正确:将特权调用保留在服务端;仅发送受后端规则保护的公钥
最佳实践
- 保持路由文件精简;将逻辑放入屏幕组件和
use*hooks 中。 - 使用 Zod 验证每个外部输入(API 响应、路由参数、推送负载)。
- 让 TanStack Query 拥有服务端状态;保持客户端 store 小巧。
- 始终渲染加载、错误和空状态——绝不只是显示一个没有后备的旋转器。
- 虚拟化列表;记忆化
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 for RN)。security-review—— 通用安全检查清单,补充上述 RN 包/密钥指南。