Chapter 06

鉴权:未登录不许进

业务 App 基本都有登录墙。Expo Router 推荐「按分组分区」—— (auth) 放登录/注册/忘密码,(app) 放业务。Root Layout 负责根据登录态决定进哪一区。

推荐目录布局

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 字符串)刚好。

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 启动屏

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) { /* 放行 */ }

本章小结