告别Promise地狱:Redux Thunk 中 async/await 的异步流程扁平化实践
在前端开发中,尤其是在使用 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);
}
};
};
优化后的优势:
- 可读性大幅提升: 代码从左到右,从上到下,更符合人类的思维习惯,如同编写同步代码一般。
await关键字让异步操作的顺序一目了然。 - 错误处理更集中: 使用
try...catch块来捕获异步操作中的错误,可以像同步代码一样集中处理异常,避免了.catch()链式调用的分散处理。 - 调试更方便: 当出现错误时,堆栈跟踪会更清晰,因为它看起来更像传统的同步代码。
- 逻辑更清晰: 复杂的条件逻辑(如判断 401 状态码后尝试刷新 Token)在
async/await结构下,嵌套层级大大减少,更容易理解业务流。
进阶提示:
- 封装 API 请求: 为了进一步提升代码质量,您可以将
axios.post或axios.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 的可维护性。在现代前端开发中,这已经是处理异步逻辑的首选方式。