使用 Context 和 useReducer 的 React Native 全局状态管理方案


介绍

React Native 全局状态(如登录后的用户信息)的管理方案通常可以是 ReduxMobx 或者原生的 Context

全局状态的管理要解决两个问题:一个是数据的存储,全局状态需要有一个可以存储的地方;另一个是全局的访问,需要方便地在任何位置获取和修改。

基于 React Native 自带的能力,本文使用 useReducer 处理数据的存储,使用 Context 使状态可以全局读取和修改。(本文的功能服务于函数组件,类组件使用时由于无法使用 Hook 就没那么方便了。)

实现

我们实现一个用户登录信息的管理工具,用于用户登录信息的读取和管理。其代码实现如下:

// AuthContex.tsx

import React, {
  createContext,
  ReactNode,
  useReducer,
  useEffect,
  useContext,
  useMemo,
} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

type UserInfo = {
  username: string;
};

type AuthState = {
  isLoading: boolean;
  token?: string;
  userInfo?: UserInfo;
};

type AuthAction = {
  type: 'retrieveToken' | 'login' | 'logout';
  token?: string;
  userInfo?: UserInfo;
};

function reducer(state: AuthState, action: AuthAction) {
  switch (action.type) {
    case 'retrieveToken':
      return {
        ...state,
        isLoading: false,
        token: action.token,
        userInfo: action.userInfo,
      };
    case 'login':
      return {
        ...state,
        isLoading: false,
        token: action.token,
        userInfo: action.userInfo,
      };
    case 'logout':
      return {
        ...state,
        isLoading: false,
        token: undefined,
        userInfo: undefined,
      };
  }
}

const initialState = {
  isLoading: true,
  token: undefined,
  userInfo: undefined,
};

const AuthContext = createContext<
  | {
      state: AuthState;
      dispatch: React.Dispatch<AuthAction>;
    }
  | undefined
>(undefined);

const AuthProvider = ({children}: {children: ReactNode}): JSX.Element => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    async function retrieveToken() {
      try {
        const values = await AsyncStorage.multiGet(['token', 'userInfo']);
        if (values[0][1] && values[1][1]) {
          const tokenValue = values[0][1];
          const userInfoValue = JSON.parse(values[1][1]);
          dispatch({
            type: 'retrieveToken',
            token: tokenValue,
            userInfo: userInfoValue,
          });
        } else {
          dispatch({
            type: 'retrieveToken',
            token: undefined,
            userInfo: undefined,
          });
        }
      } catch {
        dispatch({
          type: 'retrieveToken',
          token: undefined,
          userInfo: undefined,
        });
      }
    }
    retrieveToken();
  }, []);

  return (
    <AuthContext.Provider value={{state, dispatch}}>
      {children}
    </AuthContext.Provider>
  );
};

const useAuth = (): {
  state: AuthState;
  actions: {
    login: (token: string, userInfo: UserInfo) => Promise<void>;
    logout: () => Promise<void>;
  };
} => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used in AuthProvider!');
  }

  const authActions = useMemo(
    () => ({
      login: async (token: string, userInfo: UserInfo) => {
        await AsyncStorage.multiSet([
          ['token', token],
          ['userInfo', JSON.stringify(userInfo)],
        ]);
        context.dispatch({type: 'login', token: token, userInfo: userInfo});
      },
      logout: async () => {
        await AsyncStorage.multiRemove(['token', 'userInfo']);
        context.dispatch({
          type: 'logout',
          token: undefined,
          userInfo: undefined,
        });
      },
    }),
    [context],
  );

  return {state: context.state, actions: authActions};
};

export {AuthProvider, useAuth};

使用

我们最终暴露了 AuthProvider 这样一个组件和 useAuth 这样一个 Hook

AuthProvider

AuthProvider 用于提供一个在其内部可以任意访问和修改全局状态的 Context,它的唯一使用是将该组件包裹在业务组件树的最外层,如可以在 App.js 中作为组件的最外层。

function App(): JSX.Element {
  return (
    <AuthProvider>
      <AppContent /> // 这里是具体的业务组件
    </AuthProvider>
  );
}

这样,我们在内部组件中,就可以在任何位置访问到该 Context

useAuth

真正业务中,我们需要读取用户 token、用户信息,同时还需要有用户登录和退出登录等操作,而这些只需要 useAuth 这一个 Hook 即可。

这个 Hook 返回的对象中包含了一个 state 对象和一个 actions 对象。前者规定了用户信息的存储内容,其中 UserInfo 需要根据项目中需要存储的字段自主配置。使用时,我们直接读取即可。

// 1、根据 token 是否存在判断当前界面是主页面还是登录页面
const {state} = useAuth();
return (
  <NavigationContainer>
    {state.token ? <MainStack /> : <LoginStack />}
  </NavigationContainer>
);

// 2、读取用户的 username 并展示
const {state} = useAuth();
return (
  <Text style={styles.username}>
    {state.userInfo?.username ?? '未设置用户名'}
  </Text>
);

actions 对象中,预置了 loginlogout 两个操作(需要实现注册操作只需以类似方式添加即可),使用时只需要在合理的位置调用,即可实现登录信息的设置和全局状态的更改。

// 3、登录接口调用成功后执行登录逻辑(本地数据存储,全局状态设置)
const {actions: {login}} = useAuth();
// ...
<TouchableOpacity
  style={styles.login}
  onPress={() => {
    // 先调用登录接口,成功后回调
    login('token123', {username: 'qiweipeng'})
      .then(() => {
        console.log('登录成功!');
      })
      .catch(() => {
        console.log('因为本地没有存储成功导致登录失败!');
      });
  }}>
  <Text style={styles.loginText}>Sign In</Text>
</TouchableOpacity>
// ...

// 4、执行退出登录逻辑(清除本地数据,全局状态更改)
const {actions: {logout}} = useAuth();
// ...
<TouchableOpacity
  style={styles.logout}
  onPress={() => {
    logout()
      .then(() => {
        console.log('退出登录成功!');
      })
      .catch(() => {
        console.log('因为本地数据清除失败导致退出登录失败!');
      });
  }}>
  <Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
// ...

思考

1、本文使用 useReducer 进行数据的存储,实际上使用 useState 也不是不行,如果数据比较简单,也是可以使用 useState 的,需要寄托在 Context 中都是可以实现数据的全局读写的。 2、useReducer 使用了类似 Redux 的思想,通过 dispatch 函数操作 action 继而影响 state。如果没有封装直接使用的话,我们可能会考虑在需要更改状态的位置直接调用 dispatch 函数。但是本文为了较好的封装,同时也不希望 reducer 这个函数直接对外暴露,因此在设计 useAuth 的时候,定义了 loginlogout 这样的函数,从而将reducer 的细节封装在了内部。比如 retrieveToken(应用启动后从本地读取数据到内存的步骤)这样一个 action 就是没必要暴露出去的,我们在内部就完成了其全部逻辑。