WEBKT

告别Promise地狱:Redux Thunk 中 async/await 的异步流程扁平化实践

5 0 0 0

在前端开发中,尤其是在使用 Redux 管理应用状态时,异步操作是不可避免的。Redux Thunk 作为一个常用的中间件,允许我们在 action creator 中返回函数来处理异步逻辑。然而,当异步请求链变得复杂,比如您提到的登录流程中,先登录、再获取用户信息、失败时刷新 Token 等一系列操作,很容易陷入 Promise 嵌套的困境,代码变得难以阅读和维护。

正如您所说,Promise.then().catch() 链式调用固然强大,但在处理复杂业务逻辑时,多层嵌套确实会导致“回调地狱”式的体验。幸运的是,ES2017 引入的 async/await 语法,为我们提供了一种更扁平、更同步化的方式来编写异步代码,极大地提升了可读性和可维护性。

本文将以您遇到的登录/登出及刷新 Token 场景为例,深入探讨如何利用 async/await 优化 Redux Thunk 中的异步操作。

问题重现:Promise 嵌套的 Thunk Action

假设您原先的登录 Thunk Action 可能长这样(简化版):

// actions/auth.js
import axios from 'axios';

export const loginSuccess = (user, token) => ({
  type: 'LOGIN_SUCCESS',
  payload: { user, token },
});

export const loginFailure = (error) => ({
  type: 'LOGIN_FAILURE',
  payload: error,
});

export const fetchUserProfileSuccess = (profile) => ({
  type: 'FETCH_USER_PROFILE_SUCCESS',
  payload: profile,
});

export const fetchUserProfileFailure = (error) => ({
  type: 'FETCH_USER_PROFILE_FAILURE',
  payload: error,
});

export const refreshTokenSuccess = (newToken) => ({
  type: 'REFRESH_TOKEN_SUCCESS',
  payload: newToken,
});

export const refreshTokenFailure = (error) => ({
  type: 'REFRESH_TOKEN_FAILURE',
  payload: error,
});

// 原始的 Promise 嵌套实现
export const loginUser = (credentials) => {
  return (dispatch) => {
    dispatch({ type: 'LOGIN_REQUEST' });
    return axios.post('/api/login', credentials)
      .then(response => {
        const { token } = response.data;
        dispatch(loginSuccess(null, token)); // 登录成功,保存token
        // 接下来获取用户信息
        return axios.get('/api/user/profile', {
          headers: { Authorization: `Bearer ${token}` }
        })
          .then(profileResponse => {
            dispatch(fetchUserProfileSuccess(profileResponse.data));
            // 整个登录流程成功
            return Promise.resolve();
          })
          .catch(profileError => {
            // 获取用户信息失败,尝试刷新Token或直接失败
            if (profileError.response && profileError.response.status === 401) {
              return axios.post('/api/refresh-token')
                .then(refreshResponse => {
                  const newAccessToken = refreshResponse.data.accessToken;
                  dispatch(refreshTokenSuccess(newAccessToken));
                  // 刷新Token成功后,再次尝试获取用户信息 (这里又可能形成嵌套)
                  // 为了简化,此处假设刷新成功后不立即重试获取用户信息,而是让用户重新操作或重新登录
                  dispatch(loginFailure('Token expired, please try again.')); // 或者处理成更友好的提示
                  return Promise.reject(new Error('Token refreshed, but profile fetch failed again.'));
                })
                .catch(refreshError => {
                  dispatch(refreshTokenFailure(refreshError.message));
                  dispatch(loginFailure('Failed to refresh token.'));
                  return Promise.reject(refreshError);
                });
            } else {
              dispatch(fetchUserProfileFailure(profileError.message));
              dispatch(loginFailure(profileError.message));
              return Promise.reject(profileError);
            }
          });
      })
      .catch(error => {
        dispatch(loginFailure(error.message));
        return Promise.reject(error);
      });
  };
};

这段代码的 Promise 链式调用已经开始显现出复杂性,特别是错误处理和条件逻辑的加入,使得代码的可读性和维护性大打折扣。

解决方案:使用 async/await 扁平化 Thunk Action

async/await 是在 Promises 基础上构建的语法糖,它允许我们以同步的方式编写异步代码。async 关键字用于声明一个函数是异步的,该函数总是返回一个 Promise。await 关键字只能在 async 函数中使用,它会暂停 async 函数的执行,直到其后面的 Promise 解决(fulfilled)或拒绝(rejected),然后恢复 async 函数的执行并返回解决值。

让我们用 async/await 重构上面的 loginUser action:

// actions/auth.js (优化后)
import axios from 'axios';

// ... (省略 action types 和 action creators,与之前相同)

export const loginUser = (credentials) => {
  return async (dispatch) => { // 将 Thunk 函数标记为 async
    dispatch({ type: 'LOGIN_REQUEST' });
    try {
      // 1. 发送登录请求
      const loginResponse = await axios.post('/api/login', credentials);
      const { token } = loginResponse.data;
      dispatch(loginSuccess(null, token));

      // 2. 登录成功后,获取用户信息
      try {
        const profileResponse = await axios.get('/api/user/profile', {
          headers: { Authorization: `Bearer ${token}` }
        });
        dispatch(fetchUserProfileSuccess(profileResponse.data));
        // 整个登录流程成功
        return Promise.resolve('Login successful.'); // 返回成功状态
      } catch (profileError) {
        // 3. 获取用户信息失败,处理 Token 过期情况
        if (profileError.response && profileError.response.status === 401) {
          console.warn("Access token expired, attempting to refresh...");
          try {
            const refreshResponse = await axios.post('/api/refresh-token');
            const newAccessToken = refreshResponse.data.accessToken;
            dispatch(refreshTokenSuccess(newAccessToken));

            // Token 刷新成功后,可以考虑重新尝试获取用户信息
            // 注意:这里需要谨慎处理,防止无限循环或重复请求
            // 最简单的处理是:刷新成功后,提示用户重新操作或直接更新 token,
            // 应用程序的 axios 拦截器可以自动使用新 token 重试失败的请求。
            // 这里我们假设刷新成功后,前端Store中的Token已更新,下次请求会带上新Token。
            // 如果需要立即重试获取用户信息,则可以再次调用获取用户信息的逻辑。
            // 为了避免深度嵌套,我们可以将获取用户信息的逻辑封装成一个辅助函数。
            // 为了本例的清晰性,我们暂时不立即重试,而是抛出需要用户重新尝试的错误。
            dispatch(loginFailure('Token refreshed, please try logging in again.'));
            return Promise.reject(new Error('Token refreshed, but original profile fetch failed.')); // 仍视为本次操作失败
          } catch (refreshError) {
            dispatch(refreshTokenFailure(refreshError.message));
            dispatch(loginFailure('Failed to refresh token. Please re-login.'));
            return Promise.reject(refreshError);
          }
        } else {
          // 其他获取用户信息失败的情况
          dispatch(fetchUserProfileFailure(profileError.message));
          dispatch(loginFailure(profileError.message));
          return Promise.reject(profileError);
        }
      }
    } catch (loginError) {
      // 捕获登录请求本身的错误
      dispatch(loginFailure(loginError.message));
      return Promise.reject(loginError);
    }
  };
};

// 登出流程 (通常较简单,但同样可以使用 async/await 优化)
export const logoutUser = () => {
  return async (dispatch) => {
    dispatch({ type: 'LOGOUT_REQUEST' });
    try {
      await axios.post('/api/logout'); // 假设有登出 API
      dispatch({ type: 'LOGOUT_SUCCESS' });
      // 清除本地存储的 Token 等
      localStorage.removeItem('jwtToken');
      return Promise.resolve('Logout successful.');
    } catch (error) {
      dispatch({ type: 'LOGOUT_FAILURE', payload: error.message });
      console.error('Logout failed:', error);
      // 即便登出 API 失败,前端通常也会清除用户状态,确保用户体验
      localStorage.removeItem('jwtToken');
      dispatch({ type: 'LOGOUT_SUCCESS' }); // 确保前端状态清理
      return Promise.reject(error);
    }
  };
};

优化后的优势:

  1. 可读性大幅提升: 代码从左到右,从上到下,更符合人类的思维习惯,如同编写同步代码一般。await 关键字让异步操作的顺序一目了然。
  2. 错误处理更集中: 使用 try...catch 块来捕获异步操作中的错误,可以像同步代码一样集中处理异常,避免了 .catch() 链式调用的分散处理。
  3. 调试更方便: 当出现错误时,堆栈跟踪会更清晰,因为它看起来更像传统的同步代码。
  4. 逻辑更清晰: 复杂的条件逻辑(如判断 401 状态码后尝试刷新 Token)在 async/await 结构下,嵌套层级大大减少,更容易理解业务流。

进阶提示:

  • 封装 API 请求: 为了进一步提升代码质量,您可以将 axios.postaxios.get 等请求封装成独立的函数,如 api.login(credentials)api.getProfile(token),这样 Thunk 中的代码会更专注于业务逻辑。
  • Axios 拦截器处理 Token 刷新: 对于 Token 刷新这种横切关注点,更优雅的方案是在 Axios 拦截器中进行处理。当遇到 401 错误时,拦截器自动尝试刷新 Token,并用新 Token 重新发送原先失败的请求。这样,您的 Redux Thunk action 就不需要显式处理 Token 刷新逻辑,代码会更加简洁。
  • 统一错误处理: 可以在 try...catch 块中封装一个统一的错误处理函数,避免在每个 catch 中重复 dispatch 错误 action。

通过 async/await,我们能够将复杂的异步流程转化为清晰、易读的同步风格代码,这不仅解决了 Promise 嵌套的问题,也大幅提升了 Redux Thunk action 的可维护性。在现代前端开发中,这已经是处理异步逻辑的首选方式。

DevOps小A asyncawait异步编程

评论点评