推荐目录布局
app/
├── _layout.tsx ← Root,读登录态 → 决定去哪个分组
├── (auth)/
│ ├── _layout.tsx ← 认证 Stack(无 header)
│ ├── login.tsx
│ ├── register.tsx
│ └── forgot.tsx
└── (app)/
├── _layout.tsx ← 主应用 Tabs
├── (tabs)/
│ ├── _layout.tsx
│ ├── home.tsx
│ └── profile.tsx
└── settings.tsx
分组是透明的:URL 仍是 /login、/home,但在 Expo Router 眼里它们属于两个独立的 Stack,互不干扰。
Auth Context
// context/auth.tsx import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import * as SecureStore from 'expo-secure-store'; type AuthState = { user: { id: string; name: string } | null; isLoading: boolean; signIn: (token: string) => Promise<void>; signOut: () => Promise<void>; }; const AuthContext = createContext<AuthState | null>(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState<AuthState['user']>(null); const [isLoading, setLoading] = useState(true); useEffect(() => { SecureStore.getItemAsync('token').then(async (token) => { if (token) { const me = await fetchMe(token); setUser(me); } setLoading(false); }); }, []); const signIn = async (token: string) => { await SecureStore.setItemAsync('token', token); setUser(await fetchMe(token)); }; const signOut = async () => { await SecureStore.deleteItemAsync('token'); setUser(null); }; return ( <AuthContext.Provider value={{ user, isLoading, signIn, signOut }}> {children} </AuthContext.Provider> ); } export const useAuth = () => { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内'); return ctx; };
expo-secure-store
iOS Keychain、Android EncryptedSharedPreferences,用来存 token/密码。比 AsyncStorage 安全,但写入性能略差,存小数据(token 字符串)刚好。
iOS Keychain、Android EncryptedSharedPreferences,用来存 token/密码。比 AsyncStorage 安全,但写入性能略差,存小数据(token 字符串)刚好。
Root Layout 守卫
// app/_layout.tsx import { Stack, useRouter, useSegments } from 'expo-router'; import { useEffect } from 'react'; import { AuthProvider, useAuth } from '../context/auth'; function RootNavigator() { const { user, isLoading } = useAuth(); const segments = useSegments(); const router = useRouter(); useEffect(() => { if (isLoading) return; const inAuthGroup = segments[0] === '(auth)'; if (!user && !inAuthGroup) { router.replace('/login'); } else if (user && inAuthGroup) { router.replace('/'); } }, [user, segments, isLoading]); return ( <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="(auth)" /> <Stack.Screen name="(app)" /> </Stack> ); } export default function RootLayout() { return ( <AuthProvider> <RootNavigator /> </AuthProvider> ); }
核心就是 useEffect 里四条分支:
- 加载中 → 什么也不做(可以返回 Splash)
- 未登录 & 不在 (auth) → 踢去 /login
- 已登录 & 在 (auth) → 踢去 /(home)
- 其它 → 放行
Splash 启动屏
import * as SplashScreen from 'expo-splash-screen'; SplashScreen.preventAutoHideAsync(); // 启动时阻止自动隐藏 function RootNavigator() { const { isLoading } = useAuth(); useEffect(() => { if (!isLoading) SplashScreen.hideAsync(); }, [isLoading]); if (isLoading) return null; // 让 Splash 继续占屏 return ...; }
login.tsx
// app/(auth)/login.tsx import { useState } from 'react'; import { View, Text, TextInput, Pressable } from 'react-native'; import { Link } from 'expo-router'; import { useAuth } from '../../context/auth'; export default function Login() { const { signIn } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const handleLogin = async () => { const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ email, password }), }); const { token } = await res.json(); await signIn(token); // 登录后 Root 监听到 user 变化,自动重定向 }; return ( <View> <TextInput value={email} onChangeText={setEmail} placeholder="邮箱" /> <TextInput value={password} onChangeText={setPassword} secureTextEntry /> <Pressable onPress={handleLogin}><Text>登录</Text></Pressable> <Link href="/register">还没账号?</Link> </View> ); }
Redirect 声明式重定向
// app/(app)/settings/billing.tsx:仅 Pro 可见 import { Redirect } from 'expo-router'; import { useAuth } from '../../../context/auth'; export default function Billing() { const { user } = useAuth(); if (!user?.isPro) return <Redirect href="/upgrade" />; return <BillingContent />; }
<Redirect> 是组件,可以直接返回,不用 useEffect。适合简单的「条件跳转」。
Root Layout 异步初始化模式
function RootNavigator() { const [appReady, setAppReady] = useState(false); const { isLoading } = useAuth(); useEffect(() => { async function prepare() { await Font.loadAsync({ Inter: require('./assets/Inter.ttf') }); await warmUpCache(); setAppReady(true); } prepare(); }, []); if (!appReady || isLoading) return null; // Splash return ...; }
鉴权失败自动 signOut
// 拦截 fetch,401 时踢登录 async function apiFetch(url: string, init: RequestInit = {}) { const token = await SecureStore.getItemAsync('token'); const res = await fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` }, }); if (res.status === 401) { await signOut(); router.replace('/login'); } return res; }
社交登录(OAuth)
import * as WebBrowser from 'expo-web-browser'; import * as AuthSession from 'expo-auth-session'; const [request, response, promptAsync] = AuthSession.useAuthRequest( { clientId: 'your-github-client-id', scopes: ['user:email'], redirectUri: AuthSession.makeRedirectUri({ scheme: 'myapp' }), }, { authorizationEndpoint: 'https://github.com/login/oauth/authorize' } ); useEffect(() => { if (response?.type === 'success') { const { code } = response.params; exchangeCodeForToken(code).then(signIn); } }, [response]); <Pressable onPress={() => promptAsync()}><Text>用 GitHub 登录</Text></Pressable>
生物识别
import * as LocalAuthentication from 'expo-local-authentication'; const result = await LocalAuthentication.authenticateAsync({ promptMessage: '指纹解锁', fallbackLabel: '用密码', }); if (result.success) { /* 放行 */ }
本章小结
- 目录按
(auth)/(app)分组,URL 不受影响,逻辑隔离 - Root
_layout.tsx+ Auth Context 监听登录态,useSegments判当前在哪一区 - token 存
expo-secure-store,不用 AsyncStorage <Redirect>是声明式跳转组件,router.replace是命令式- Splash 用
preventAutoHideAsync等初始化完再隐藏,避免闪烁