前端并发治理:从 Token 刷新聊起,一个 Promise 就够了
前端没有多线程,按理说不该有并发问题。但只要你写过稍微复杂一点的项目,就一定踩过这些坑:用户连点按钮提交了两次订单、搜索框的旧结果覆盖了新结果、五个请求同时 401 触发了五次 Token 刷新……
这些问题看着各不相同,但背后其实是同一件事——多个异步流程在抢同一个资源。而解决它们的核心思路,往往只需要一个 Promise。
本文从最常见的 Token 刷新场景出发,一步步拆解前端并发问题的本质和通用解法。
前端鉴权那些事
前端处理登录态,方案其实挺多的,不同项目的选择差异很大。
最传统的是 Cookie + Session:登录后服务端种一个 Cookie,之后浏览器每次请求自动带上,前端几乎不用操心。很多项目至今还在用,简单可靠。
前后端分离流行之后,JWT Token 成了主流:后端返回一个 Token,前端存在 localStorage 里,请求时塞进 Header。至于 Token 过期怎么办,不同团队的处理方式五花八门——
最简单的是 401 直接跳登录页,干脆利落,很多内部系统就是这么干的,够用了。
稍微讲究一点的会做滑动续期:后端在每次请求时检查 Token 是否快过期,快过期就在响应头里塞一个新 Token,前端替换掉旧的,类似 Session 的自动续期。还有一种是前端自己算过期时间,快到期时主动刷新,不等 401 再处理。
再往上就是双 Token 机制:一个短期的 access token 用于日常请求(比如 15 分钟过期),一个长期的 refresh token 用于续期(比如 7 天过期)。access token 过期时,前端用 refresh token 静默换一个新的,用户无感知。
说实话,双 Token 是不是"最佳实践",社区一直有争论——有人觉得在自家系统里是过度设计,滑动续期就够了;也有人觉得职责分离确实更安全。这个争论不是本文的重点,但双 Token 的前端实现确实是最能体现并发问题的场景——因为它涉及"Token 过期后静默刷新并重发请求",而这个过程很容易在并发时出 bug。
所以我们就用它作为切入点。双 Token 的前端实现几乎形成了一个固定范式——请求前统一注入 Token,响应后统一拦截刷新。
请求发出前,从存储中取出 access token,塞进请求头:
axios.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
收到 401 响应时,不直接报错,而是悄悄用 refresh token 换一个新的 access token,然后把刚才失败的请求重新发一遍,用户甚至感知不到:
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const newToken = await refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axios(originalRequest);
}
return Promise.reject(error);
}
);
如果 refresh token 也过期了呢?那就退化回最简单的方案——清除登录态,跳回登录页。双 Token 机制不是消灭了"跳登录",只是把它推迟到了最后一刻:
async function refreshToken() {
try {
const { data } = await axios.post('/auth/refresh', {
refresh_token: localStorage.getItem('refresh_token')
});
localStorage.setItem('access_token', data.access_token);
return data.access_token;
} catch {
localStorage.clear();
window.location.href = '/login';
return Promise.reject();
}
}
打个比方:请求拦截器负责"带上门禁卡",响应拦截器负责"门禁卡过期时自动换卡再刷一次",换卡也失败就"回前台重新办卡"。
到这里一切看起来很完美。但有一个问题被我们忽略了——如果页面上同时有 5 个请求,它们几乎在同一瞬间都收到了 401,会发生什么?
答案是:5 个请求各自触发一次 refreshToken(),连发 5 次刷新请求。
这显然不对。
并发难题:5 个 401 只该刷新一次
这是前端 Token 鉴权最经典的并发问题。传统方案是维护一个 isRefreshing 标志位加一个等待队列:第一个请求负责刷新,后续请求排队等结果。这种方案能用,但代码比较啰嗦。
其实有一个更简洁的思路:不用队列,直接缓存那个 refresh 的 Promise。 多个请求发现 Token 过期时,如果已经有一个 refresh 在进行中,就直接 await 同一个 Promise——大家等的是同一件事,拿到的是同一个结果:
let refreshPromise = null;
function getNewToken() {
if (refreshPromise) return refreshPromise;
refreshPromise = axios
.post('/auth/refresh', {
refresh_token: localStorage.getItem('refresh_token'),
})
.then(({ data }) => {
localStorage.setItem('access_token', data.access_token);
return data.access_token;
})
.catch(err => {
localStorage.clear();
window.location.href = '/login';
return Promise.reject(err);
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
整个逻辑就靠一个变量 refreshPromise:有值说明刷新正在进行,所有人直接 await 它;没值就发起刷新并把 Promise 存起来。finally 里清空,这样下一轮过期时又能重新触发。
这个模式就叫 Promise Cache 吧。
等一下,标志位不就够了吗?
看到这里你可能会想:搞什么 Promise Cache,我用一个布尔标志位挡住重复调用不就行了?
let isRefreshing = false;
async function refreshToken() {
if (isRefreshing) return;
isRefreshing = true;
try {
const { data } = await axios.post('/auth/refresh');
localStorage.setItem('access_token', data.access_token);
} finally {
isRefreshing = false;
}
}
对于某些场景确实够了——比如埋点上报、按钮防连点,你只需要"别重复执行",不关心结果。但 Token 刷新不行。看看会发生什么:
请求 A 收到 401 → 发起 refresh,isRefreshing = true
请求 B 收到 401 → 发现 isRefreshing → return → 拿到 undefined → 没有新 token → 重发失败
请求 A 的 refresh 成功了 → 但 B 已经错过了
标志位把 B "挡回去"了,但 B 还需要结果啊。Promise Cache 不一样,B 不是被拒绝,而是"挂在同一个 Promise 上等":
请求 A 收到 401 → 发起 refresh,缓存 Promise
请求 B 收到 401 → await 同一个 Promise → 等着
refresh 成功 → A 和 B 同时拿到新 token → 各自重发
所以判断标准很简单:调用者只需要"别重复执行"→ 标志位就够。调用者还需要"等到结果再继续"→ 必须用 Promise Cache。打个比方,前者是"门卫拦人",后者是"拼车到终点"。
举一反三:前端并发问题的两大类
Token 刷新只是冰山一角。一旦你理解了 Promise Cache 的本质,就会发现前端到处都有类似的并发场景。它们大致分两类:
第一类:多次触发,只该执行一次
这正是 Promise Cache 的主场。除了 Token 刷新,还有——
多个组件同时请求同一个接口。 比如页面上三个组件都需要用户信息,几乎同时调 GET /user,没必要发三次:
const pending = new Map();
function dedupRequest(key, requestFn) {
if (pending.has(key)) return pending.get(key);
const p = requestFn().finally(() => pending.delete(key));
pending.set(key, p);
return p;
}
dedupRequest('user-info', () => axios.get('/user'));
按钮防重复提交。 用户手快连点了三次"下单":
let submitPromise = null;
async function handleSubmit(data) {
if (submitPromise) return submitPromise;
submitPromise = axios.post('/order', data).finally(() => {
submitPromise = null;
});
return submitPromise;
}
模式完全一样:有在飞的 Promise 就复用,没有就新建一个。
第二类:多次触发,只保留最后一次
搜索联想是最典型的例子。用户快速输入 a → ab → abc,三个请求飞出去,但 a 的请求可能最后才返回,把 abc 的正确结果覆盖掉。
这里要做的不是合并,而是丢弃过期的结果。最简单的方案是用一个自增 ID:
let currentRequestId = 0;
async function search(keyword) {
const id = ++currentRequestId;
const res = await axios.get('/search', { params: { q: keyword } });
if (id !== currentRequestId) return; // 已经过时了,丢掉
setResults(res.data);
}
更彻底的做法是用 AbortController 直接取消上一次请求,连响应都不用判断:
let controller = null;
async function search(keyword) {
controller?.abort();
controller = new AbortController();
const res = await axios.get('/search', {
params: { q: keyword },
signal: controller.signal,
});
setResults(res.data);
}
你可能会问:用时间戳代替自增 ID 行不行?能用,但有坑。浏览器里 Date.now() 精度通常只有 1ms,有些浏览器出于安全考虑(防 Spectre 攻击)甚至故意降到 5ms。用户快速输入时,两次调用完全可能拿到同一个时间戳,竞态又回来了。自增 ID 就没这个问题,每次 ++ 天然唯一、严格递增,不依赖任何平台特性。至于溢出?Number.MAX_SAFE_INTEGER 约 9 千万亿,每秒自增 1000 次也要 2.85 亿年才会用完,页面一刷新还归零。
异步单例:当 Promise Cache 遇上设计模式
聊完了接口层的并发,再看一个更"架构"的场景——SDK 初始化。
单例模式大家都熟悉:
class SDK {
static instance = null;
static getInstance() {
if (!this.instance) this.instance = new SDK();
return this.instance;
}
}
同步实例化时没问题。但前端 SDK 的初始化往往是异步的——加载远程脚本、拉取配置、建立 WebSocket 连接。这时候单例就有一个微妙的 bug:
模块 A 调用 getInstance() → instance 为 null → new SDK() → 开始异步 init()...
模块 B 调用 getInstance() → instance 已经存在!→ 直接返回 → 拿到一个还没初始化完的实例 → 💥
问题出在哪?单例只保证了"只 new 一次",但没保证"等初始化完再给你"。这恰好是 Promise Cache 能解决的:
class SDK {
static initPromise = null;
static getInstance() {
if (!this.initPromise) {
const sdk = new SDK();
this.initPromise = sdk.init().then(() => sdk);
}
return this.initPromise;
}
}
const sdk1 = await SDK.getInstance(); // 触发初始化
const sdk2 = await SDK.getInstance(); // 挂在同一个 Promise 上等
// sdk1 === sdk2,且都是初始化完成的
单例保证"只创建一个实例",Promise Cache 保证"只执行一次异步过程,且所有人都能等到结果"。 可以说,Promise Cache 就是异步世界的单例模式。
但这样有个代价:async 传染
上面的方案解决了并发问题,却带来了一个新的烦恼——初始化只需要等一次,但之后每次调用 getInstance() 都要写 await,即使 Promise 早就 resolved 了。虽然性能上没问题(只是一个 microtask),但 async 像病毒一样"传染",逼着所有调用方都变成异步函数。
一种改进是两层缓存——初始化阶段缓存 Promise,完成后缓存实例:
class SDK {
static instance = null;
static initPromise = null;
static getInstance() {
if (this.instance) return this.instance; // 已完成,同步返回
if (this.initPromise) return this.initPromise; // 进行中,返回 Promise
this.initPromise = new SDK().init().then(sdk => {
this.instance = sdk;
this.initPromise = null;
return sdk;
});
return this.initPromise;
}
}
但这带来了新的心智负担:getInstance() 有时返回实例,有时返回 Promise,调用方需要知道当前是哪个阶段。
更干净的做法是把初始化和获取拆成两个方法,各司其职:
class SDK {
static instance = null;
static initPromise = null;
static init() {
if (this.initPromise) return this.initPromise;
this.initPromise = new SDK().setup().then(sdk => {
this.instance = sdk;
return sdk;
});
return this.initPromise;
}
static getInstance() {
if (!this.instance) throw new Error('SDK 未初始化,请先调用 SDK.init()');
return this.instance; // 永远同步
}
}
使用起来职责清晰:
// 应用入口,只调一次
await SDK.init();
// 之后所有地方,同步获取
const sdk = SDK.getInstance();
sdk.doSomething();
这也是大部分主流 SDK 的实际做法——在应用启动时 await 一次初始化,之后全同步访问。
当然,这意味着调用方需要自己保证时序——getInstance() 必须在 init() 完成之后才能调。实践中一般把 init() 卡在应用挂载之前来解决这个问题:
async function bootstrap() {
await SDK.init();
app.mount('#root'); // SDK 就绪后才启动应用
}
bootstrap();
这也是为什么 Vue 的 app.use()、各种插件的 install() 都设计在 mount() 之前——用启动流程的顺序来隐式保证时序。
归根结底是一个取舍:Promise Cache 让框架替你管时序,调用方无脑 await 就行,但 async 会传染;init/getInstance 分离给了你同步访问的清爽,但得自己控制好初始化入口。SDK 是全局基础设施、入口明确的,分离方案更干净;初始化时机不确定、调用方散落各处的,Promise Cache 更安全。
总结
前端的"并发问题"大多不是真正的多线程竞争,而是多个异步流程在抢同一个资源。折腾到最后,核心解法就两个:
而 Promise Cache 的本质,就是异步世界的单例模式。一个变量,一个 if 判断,一个 finally 清理——三行逻辑,解决一大类问题。
下次再遇到"多个地方同时调、但只该执行一次"的需求,别急着加锁、加队列、加标志位。先想想:能不能缓存那个 Promise?