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/目錄)設定路由 - 決定狀態歸屬(伺服器快取與用戶端儲存庫與路由參數與表單)
- 使用 TanStack Query 連接資料擷取並使用 Zod 驗證回應
- 渲染長列表或重型列表
- 選擇或應用樣式方法(NativeWind 或 StyleSheet)
- 存取原生裝置 API(相機、位置、通知)或安全儲存
- 審查 RN 程式碼中的行動裝置特定問題
不要在此處使用 Web/React-DOM 模式 — URL-as-state、<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} />
}
狀態:保持關注點分離
不要將伺服器資料複製到用戶端儲存庫。每個關注點都有自己的歸宿。
| 關注點 | 常見選擇 |
|---|---|
| 伺服器狀態(遠端資料) | 伺服器快取庫(TanStack Query、SWR) |
| 用戶端/UI 狀態 | 輕量級儲存庫(Zustand、Jotai)或 Context |
| 路由/導航狀態 | Expo Router 參數 |
| 表單狀態 | 表單庫(例如 React Hook Form)+ 結構描述驗證 |
| 秘密/令牌 | expo-secure-store |
| 非秘密持久化 | AsyncStorage / MMKV |
偏好使用本機 useState,直到狀態真正需要共用為止。
資料擷取:快取庫 + Zod
使用伺服器快取庫(TanStack Query、SWR)而不是 fetch-in-useEffect。在邊界處進行驗證,並從結構描述推斷類型。明確處理載入、錯誤和空狀態。(範例使用 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 中對大型陣列進行 map
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:包裝在鉤子中,清理副作用
將 Expo SDK 呼叫和訂閱保留在 use* 鉤子內部,而不是 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 內部對大型陣列進行 map(無虛擬化、卡頓、高記憶體)
<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
// 錯誤:真正的秘密隨套件一同發佈
const STRIPE_SECRET = 'sk_live_...'
// 正確:將特權呼叫保留在伺服器端;僅發佈受後端規則保護的公鑰
最佳實務
- 保持路由檔案精簡;將邏輯放在螢幕元件和
use*鉤子中。 - 使用 Zod 驗證每一個外部輸入(API 回應、路由參數、推播負載)。
- 讓 TanStack Query 擁有伺服器狀態;保持用戶端儲存庫小巧。
- 始終渲染載入、錯誤和空狀態 — 絕不要只有一個 spinner 而沒有備援。
- 虛擬化列表;記憶化
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,RN 使用 Maestro/Detox)。security-review— 一般安全檢查清單,補充上述 RN 套件/秘密指南。