React Native / Expo 模式
用於使用 Expo 建構生產級 React Native 應用程式的實用模式。涵蓋導航、狀態、資料擷取、列表、樣式設計和原生 API。與 rules/react-native/ 規則集搭配使用:規則說明要強制執行什麼,此技能則展示如何做。
下方列出的程式庫(NativeWind、Zustand/Jotai、TanStack Query)是常見且成熟的選項,此處僅供示意 — 模式本身比特定套件更重要,任何等效的替代方案均可使用。Zod 用於驗證,以與 ECC 現有的 typescript/ 規則保持一致。
這些模式假設採用 Managed Expo 工作流程(Expo Router、EAS、expo-* 模組),並基於新架構(近期 Expo SDK 的預設值,從 SDK 55+ 起為強制要求)。它們不假設有瀏覽器 DOM — React Native 沒有 <div>、沒有網址列,也沒有網路資料擷取的預設行為。
何時啟用
請在以下情況使用此技能:
- 建構或編輯 React Native / Expo 畫面、元件或導航時
- 使用 Expo Router(基於檔案的
app/目錄)設定路由時 - 決定狀態歸屬時(伺服器快取 vs 客戶端儲存 vs 路由參數 vs 表單)
- 使用 TanStack Query 處理資料擷取並使用 Zod 驗證回應時
- 渲染長列表或大型列表時
- 選擇或應用樣式方案(NativeWind 或 StyleSheet)時
- 存取原生裝置 API(相機、位置、通知)或安全儲存時
- 評審 React Native 程式碼中的行動裝置特定問題時
請勿在此使用網頁/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)+ schema 驗證 |
| 機密 / 令牌 | expo-secure-store |
| 非機密持久化 | AsyncStorage / MMKV |
在狀態真正需要共享之前,優先使用本地的 useState。
資料擷取:快取函式庫 + Zod
使用伺服器快取函式庫(TanStack Query、SWR)而非在 useEffect 中進行 fetch。在邊界進行驗證並從 schema 推斷類型。明確處理載入、錯誤和空狀態。(範例使用 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:包裝在 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 內部對大型陣列進行 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
// 錯誤:將真實機密包含在 bundle 中
const STRIPE_SECRET = 'sk_live_...'
// 正確:將特權呼叫保留在伺服器端;僅傳送由後端規則保護的公開金鑰
最佳實踐
- 保持路由檔案精簡;將邏輯放在畫面元件和
use*hooks 中。 - 使用 Zod 驗證每個外部輸入(API 回應、路由參數、推播負載)。
- 讓 TanStack Query 擁有伺服器狀態;保持客戶端儲存小型。
- 始終渲染載入、錯誤和空狀態 — 絕不僅僅顯示 spinner 而沒有備用方案。
- 虛擬化列表;將
renderItem記憶化;提供穩定的keyExtractor。 - 使用
react-native-reanimated進行動畫(UI 執行緒);避免在 JS 執行緒上進行繁重工作。 - 將令牌儲存在
expo-secure-store中;切勿信任客戶端進行授權。 - 從一開始就尊重安全區域、Dynamic Type 以及無障礙角色/標籤。
- 在發布前,確認每個原生依賴項都與新架構相容。
相關技能
frontend-patterns— React/Next.js(網頁)模式;對共享的 React 概念有用,但特定於 DOM。coding-standards— 適用於 React Native 程式碼的 TypeScript/JavaScript 慣用寫法。tdd-workflow、e2e-testing— 測試流程(針對 React Native 使用 Jest + React Native Testing Library、Maestro/Detox)。security-review— 一般安全檢查清單,與上述 React Native bundle/機密指南相輔相成。