首页指南参考教程

Expo 路由中的身份验证

如何使用 Expo Router 实现身份验证和保护路由。


借助 Expo Router,所有路由均已定义且可访问。你可以使用运行时逻辑将用户重定向到特定屏幕,具体取决于用户是否经过身份验证。有两种不同的技术可用于对路由内的用户进行身份验证。本指南提供了一个示例,演示标准原生应用的功能。

¥With Expo Router, all routes are always defined and accessible. You can use runtime logic to redirect users away from specific screens depending on whether they are authenticated. There are two different techniques for authenticating users within routes. This guide provides an example that demonstrates the functionality of standard native apps.

使用 React 上下文和路由组

¥Using React Context and Route Groups

将特定路由限制为未经身份验证的用户是很常见的。这可以通过使用 React Context 和 Route Groups 以有组织的方式实现。考虑以下项目结构,其中具有始终可访问的 /sign-in 路由和需要身份验证的 (app) 组:

¥It's common to restrict specific routes to users who are not authenticated. This is achievable in an organized way by using React Context and Route Groups. Consider the following project structure that has a /sign-in route that is always accessible and a (app) group that requires authentication:

app
_layout.tsx
sign-in.tsxAlways accessible
(app)
  _layout.tsxProtects child routes
  index.tsxRequires authorization

1

为了遵循上面的示例,设置一个可以向整个应用公开身份验证会话的 React 上下文提供者。你可以实现自定义身份验证会话提供程序或使用下面示例身份验证上下文中的提供程序。

¥To follow the above example, set up a React Context provider that can expose an authentication session to the entire app. You can implement your custom authentication session provider or use the one from the Example authentication context below.

Example authentication context

该提供程序使用模拟实现。你可以用你自己的 认证提供者 替换它。

¥This provider uses a mock implementation. You can replace it with your own authentication provider.

ctx.tsx
import { useContext, createContext, type PropsWithChildren } from 'react';
import { useStorageState } from './useStorageState';

const AuthContext = createContext<{
  signIn: () => void;
  signOut: () => void;
  session?: string | null;
  isLoading: boolean;
}>({
  signIn: () => null,
  signOut: () => null,
  session: null,
  isLoading: false,
});

// This hook can be used to access the user info.
export function useSession() {
  const value = useContext(AuthContext);
  if (process.env.NODE_ENV !== 'production') {
    if (!value) {
      throw new Error('useSession must be wrapped in a <SessionProvider />');
    }
  }

  return value;
}

export function SessionProvider({ children }: PropsWithChildren) {
  const [[isLoading, session], setSession] = useStorageState('session');

  return (
    <AuthContext.Provider
      value={{
        signIn: () => {
          // Perform sign-in logic here
          setSession('xxx');
        },
        signOut: () => {
          setSession(null);
        },
        session,
        isLoading,
      }}>
      {children}
    </AuthContext.Provider>
  );
}

以下代码片段是一个基本钩子,可将令牌安全地保存在 expo-secure-store 的原生上和 Web 上的本地存储中。

¥The following code snippet is a basic hook that persists tokens securely on native with expo-secure-store and in local storage on web.

useStorageState.ts
import * as SecureStore from 'expo-secure-store';
import * as React from 'react';
import { Platform } from 'react-native';

type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];

function useAsyncState<T>(
  initialValue: [boolean, T | null] = [true, null],
): UseStateHook<T> {
  return React.useReducer(
    (state: [boolean, T | null], action: T | null = null): [boolean, T | null] => [false, action],
    initialValue
  ) as UseStateHook<T>;
}

export async function setStorageItemAsync(key: string, value: string | null) {
  if (Platform.OS === 'web') {
    try {
      if (value === null) {
        localStorage.removeItem(key);
      } else {
        localStorage.setItem(key, value);
      }
    } catch (e) {
      console.error('Local storage is unavailable:', e);
    }
  } else {
    if (value == null) {
      await SecureStore.deleteItemAsync(key);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  }
}

export function useStorageState(key: string): UseStateHook<string> {
  // Public
  const [state, setState] = useAsyncState<string>();

  // Get
  React.useEffect(() => {
    if (Platform.OS === 'web') {
      try {
        if (typeof localStorage !== 'undefined') {
          setState(localStorage.getItem(key));
        }
      } catch (e) {
        console.error('Local storage is unavailable:', e);
      }
    } else {
      SecureStore.getItemAsync(key).then(value => {
        setState(value);
      });
    }
  }, [key]);

  // Set
  const setValue = React.useCallback(
    (value: string | null) => {
      setState(value);
      setStorageItemAsync(key, value);
    },
    [key]
  );

  return [state, setValue];
}

2

在根布局中使用 SessionProvider 为整个应用提供身份验证上下文。必须在触发任何导航事件之前安装 <Slot />。否则,将引发运行时错误。

¥Use the SessionProvider in the root layout to provide the authentication context to the entire app. It's imperative that the <Slot /> is mounted before any navigation events are triggered. Otherwise, a runtime error will be thrown.

app/_layout.tsx
import { Slot } from 'expo-router';
import { SessionProvider } from '../ctx';

export default function Root() {
  // Set up the auth context and render our layout inside of it.
  return (
    <SessionProvider>
      <Slot />
    </SessionProvider>
  );
}

3

创建一个嵌套 布局路由,用于在渲染子路由组件之前检查用户是否经过身份验证。如果用户未经过身份验证,此布局路由会将用户重定向到登录屏幕。

¥Create a nested layout route that checks whether users are authenticated before rendering the child route components. This layout route redirects users to the sign-in screen if they are not authenticated.

app/(app)/_layout.tsx
import { Text } from 'react-native';
import { Redirect, Stack } from 'expo-router';

import { useSession } from '../../ctx';

export default function AppLayout() {
  const { session, isLoading } = useSession();

  // You can keep the splash screen open, or render a loading screen like we do here.
  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  // Only require authentication within the (app) group's layout as users
  // need to be able to access the (auth) group and sign in again.
  if (!session) {
    // On web, static rendering will stop here as the user is not authenticated
    // in the headless Node process that the pages are rendered in.
    return <Redirect href="/sign-in" />;
  }

  // This layout can be deferred because it's not the root layout.
  return <Stack />;
}

4

创建 /sign-in 屏幕。它可以使用 signIn() 切换身份验证。由于此屏幕位于 (app) 组之外,因此在渲染此屏幕时不会运行该组的布局和身份验证检查。这可以让注销的用户看到此屏幕。

¥Create the /sign-in screen. It can toggle the authentication using signIn(). Since this screen is outside the (app) group, the group's layout and authentication check do not run when rendering this screen. This lets logged-out users see this screen.

app/sign-in.tsx
import { router } from 'expo-router';
import { Text, View } from 'react-native';

import { useSession } from '../ctx';

export default function SignIn() {
  const { signIn } = useSession();
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text
        onPress={() => {
          signIn();
          // Navigate after signing in. You may want to tweak this to ensure sign-in is
          // successful before navigating.
          router.replace('/');
        }}>
        Sign In
      </Text>
    </View>
  );
}

5

实现允许用户注销的经过身份验证的屏幕。

¥Implement an authenticated screen that lets users sign out.

app/(app)/index.tsx
import { Text, View } from 'react-native';

import { useSession } from '../../ctx';

export default function Index() {
  const { signOut } = useSession();
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text
        onPress={() => {
          // The `app/(app)/_layout.tsx` will redirect to the sign-in screen.
          signOut();
        }}>
        Sign Out
      </Text>
    </View>
  );
}

现在,你拥有一个可以在检查初始身份验证状态时渲染加载状态的应用,并在用户未通过身份验证时重定向到登录屏幕。如果用户访问任何经过身份验证检查的路由的深层链接,他们将被重定向到登录屏幕。

¥You now have an app that can present a loading state while it checks the initial authentication state and redirects to the sign-in screen if the user is not authenticated. If a user visits a deep link to any routes with the authentication check, they'll be redirected to the sign-in screen.

替代加载状态

¥Alternative loading states

使用 Expo Router,在加载初始身份验证状态时必须将某些内容渲染到屏幕上。在上面的示例中,应用布局渲染一条加载消息。或者,你可以将 index 路由设为加载状态,并将初始路由移动到 /home 之类的路由,这与 X 的工作方式类似。

¥With Expo Router, something must be rendered to the screen while loading the initial auth state. In the example above, the app layout renders a loading message. Alternatively, you can make the index route a loading state and move the initial route to something such as /home, which is similar to how X works.

模态和每条路由的身份验证

¥Modals and per-route authentication

另一种常见模式是在应用顶部渲染登录模式。这使你能够在身份验证完成后关闭并部分保留深层链接。但是,此模式要求在后台渲染路由,因为这些路由需要在没有身份验证的情况下处理数据加载。

¥Another common pattern is to render a sign-in modal over the top of the app. This enables you to dismiss and partially preserve deep links when the authentication is complete. However, this pattern requires routes to be rendered in the background as these routes require handling data loading without authentication.

app
_layout.tsxDeclares global session context
(app)
  _layout.tsx
  sign-in.tsxModal presented over the root
  (root)
   _layout.tsxProtects child routes
   index.tsxRequires authorization
app/(app)/_layout.tsx
import { Stack } from 'expo-router';

export const unstable_settings = {
  initialRouteName: '(root)',
};

export default function AppLayout() {
  return (
    <Stack>
      <Stack.Screen name="(root)" />
      <Stack.Screen
        name="sign-in"
        options={{
          presentation: 'modal',
        }}
      />
    </Stack>
  );
}

无需导航即可导航

¥Navigating without navigation

当应用尝试在 根布局 中未安装导航器的情况下执行导航时,你可能会遇到以下错误。

¥You may encounter the following error when the app attempts to perform navigation without a navigator mounted in the root layout.

Error: Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.

要解决此问题,请添加一个组并将条件逻辑下移一个级别。

¥To fix this, add a group and move conditional logic down a level.

之前

¥Before

app
_layout.tsx
about.tsx
app/_layout.tsx
export default function RootLayout() {
  React.useEffect(() => {
    // This navigation event will trigger the error above.
    router.push('/about');
  }, []);

  // This conditional statement creates a problem since the root layout's
  // content (the Slot) must be mounted before any navigation events occur.
  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  return <Slot />;
}

之后

¥After

app
_layout.tsx
(app)
  _layout.tsxMove conditional logic down a level
  about.tsx
app/_layout.tsx
export default function RootLayout() {
  return <Slot />;
}
app/(app)/_layout.tsx
export default function RootLayout() {
  React.useEffect(() => {
    router.push('/about');
  }, []);

  // It is OK to defer rendering this nested layout's content. We couldn't
  // defer rendering the root layout's content since a navigation event (the
  // redirect) would have been triggered before the root layout's content had
  // been mounted.
  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  return <Slot />;
}

中间件

¥Middleware

传统上,网站可能会利用某种形式的服务器端重定向来保护路由。Web 上的 Expo Router 目前仅支持构建时静态生成,不支持自定义中间件或服务。将来可以添加此功能,以提供更优化的网络体验。同时,可以通过客户端重定向和加载状态来实现身份验证。

¥Traditionally, websites may leverage some form of server-side redirection to protect routes. Expo Router on the web currently only supports build-time static generation and has no support for custom middleware or serving. This can be added in the future to provide a more optimal web experience. In the meantime, authentication can be implemented by using client-side redirects and a loading state.

Expo 中文网 - 粤ICP备13048890号