阅读视图

发现新文章,点击刷新页面。

前端并发治理:从 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 成功 → AB 同时拿到新 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?

一天时间,用 Claude Code 蹬了一个 v0 出来(附源码)

最近,出于业务需要,参考 v0 的实现,蹬了一个类似 v0 的平台出来。

先看效果:

整体采用 Next.js 做前后端服务,E2B 提供沙箱,Claude Agent SDK 完成代码生成,沙箱提供预览和代码推送部署能力。

ps: 本文不会包含任何的代码(本身也都是 AI 生成的),只会介绍相关方案的选型、核心的架构和实现原理。同时关于部署的环节,各个公司都有自己的部署流水线,并不具备参考价值,会弱化这个环节的介绍。

方案对比和设计

AI 生成前端代码,一般有这么几种方式:一份 html,一份代码块,以及直接生成项目。

生成 html

生成一份 html,然后增删改查,最终存储 html 即可,不论是预览还是部署,都最为简单。

有很多产品都是这么做的,比如 Claude 的 Artifacts,Google 的 Stitch。

这是最简单,也最轻便的方案。

这里面的关键技术点有几个:

  1. 如何让 AI 生成高质量的 HTML?当然这也无非就是需要一些非常优秀的提示词来约束 AI 的行为。

  2. 如何增量修改?通过在浏览器侧实现一个支持局部替换的 Edit Tool 即可,这也是很多 cli 工具在本地修改代码的常见策略。

  3. 后期的可维护性是这个方案最大的隐患。生成的 HTML 往往是一个几百行甚至上千行的单文件,没有组件拆分,没有模块化,样式和逻辑全部混在一起。如果需要人工介入修改,多年程序员看到这样的代码,大概会有一种被拉回刀耕火种时代的感觉——能改,但很痛苦。这也意味着,一旦走上这条路,后续的迭代就只能继续依赖 AI,项目实际上已经不再适合人来维护。

ps: 这里可能会有人好奇,为什么不是修改某一行某几列的代码,这是因为 AI 对于行号识别不准确,反而直接执行字符搜索并替换更为准确。感兴趣的可以查看 pi-mono 项目中 edit 工具的实现,这也是绝大部分 cli 工具的实现方案。

至于 html 的预览和部署,可谓是极为简单且花费最少了。

生成代码块

另一种方式是:生成代码块,存储在数据库中,预览采用 WebContainer、Sandpack,或者通过 Babel 转 CommonJS 在浏览器端模拟打包等方式来预览前端项目。

这基本是纯前端的方案,不过 WebContainer 要授权,Sandpack 倒是开源,但是加载速度上可能存在一些问题。至于 Babel 转 CommonJS 自行实现编译系统,也是 ok 的,只是要支持 jsx, vue, 要花一点时间,开发的工作量不小。

当然,除了这些建设,如何稳定 AI 的输出,也是这个方案中的一大问题,理想情况下,希望 AI 的产物是 文件名 + 内容 组合成的 json 数组。

一般可以通过几个方案来解决:

  1. 换更好的模型
  2. 运用 XML 这样的提示词技巧,来让 AI 输出的更符合预期

但是这个方案有几个比较大的问题:

  1. 编译工程复杂度比较高
  2. 增量替换的方案,输出格式可能不如工具调用那般精准,在耗时和质量上会更低效一点。
  3. 对于外部依赖的包,需要提前做编译、告知 AI 用法等,相对不那么自由

直接生成项目

直接生成项目,最终预览和部署都和普通的项目一样。这也是 v0 的方案。

这个方案本质上是给用户准备一个沙箱,这个沙箱中,直接启动一个 claude code 或者 codex 这样的工具,可以是 cli 也可以是 sdk。

同时指定一个工作目录,最终的项目生成和运行,都发生在这个工作目录下。用户输入直接指向 claude code,从而完成项目的生成。

这个方案的灵活度最高,同时由于背后是最顶尖的 AI 生成工具,所以在质量上和效率上,其实都不太需要担心。

但是最大的问题就在于需要给每一个用户都提供一个沙箱,对于运维部署的能力要求比较高。

同时沙箱的内存分配和 cpu 分配,资源上也不能少。

不过好在已经有很多服务商提供这样的服务,比如 E2B、Cloudflare 等服务商。付费调 API 的话,准备一个沙箱也很容易。

对比表格

维度 生成 HTML 生成代码块 直接生成项目
实现复杂度
预览方案 直接渲染 iframe WebContainer / Sandpack / Babel 转 CommonJS 沙箱内启动 dev server
部署复杂度 极低,存 HTML 即可 低,纯前端方案 高,需要为每个用户分配沙箱
增量修改精准度 高(字符串 Edit Tool) 中(输出格式不如工具调用稳定) 高(Agent SDK 原生工具调用)
AI 输出稳定性 高(单文件,约束简单) 中(需要结构化 JSON 输出,依赖提示词技巧) 高(由 Agent 工具链保证)
外部依赖支持 弱(只能用 CDN 引入) 弱(需要提前编译、告知 AI 用法) 强(npm install 自由安装)
代码可维护性 低(不适合人工维护) 高(标准项目结构)
资源消耗 极低 高(沙箱需要分配内存和 CPU)
灵活度
代表产品 Claude Artifacts、Google Stitch Bolt.new(基于 StackBlitz WebContainer) v0、本文实现

架构设计

整体的架构图如上,分为三块:

  1. Next.js 前端:聊天输入框、消息流展示、代码文件树、实时预览 iframe,以及打断/重试等交互控制。

  2. Next.js 后端:接收前端消息,维护会话与沙箱的映射关系,将消息转发给对应沙箱内的 Agent,并将 Agent 的流式输出透传回前端。

  3. E2B 沙箱:基于自定义模板启动,模板内预装了 Node.js 环境和项目脚手架。沙箱内运行 Claude Agent SDK,负责代码的生成与修改;同时启动 dev server 并通过 E2B 的端口暴露能力对外提供预览。

消息流转

用户操作路径如下:

  1. 用户打开平台,发起第一条消息,后端按需创建 E2B 沙箱(冷启动约需几秒)
  2. 沙箱就绪后,后端将消息投递给沙箱内的 Claude Agent SDK
  3. Agent SDK 开始工作:调用文件读写工具生成或修改代码
  4. Agent 的输出以流式事件的形式,经后端透传回前端实时展示
  5. 代码变更同步到文件树,预览 iframe 直接加载沙箱暴露的端口

会话与沙箱管理

多用户场景下,每个会话对应一个独立的沙箱实例,隔离性天然满足。

上下文的维护完全交给 Agent SDK,后端只需持久化"会话 ID → 沙箱 ID"的映射即可。考虑到沙箱有闲置超时机制,需要在映射层做好沙箱的重建和恢复逻辑,一般沙箱的服务方基本都会内置这些能力。

部署发布

代码的部署和发布,一个比较通用的方案是在沙箱内完成 Git 提交,推送到远程仓库后触发 CI/CD 流水线,从而完成项目的上线。由于这部分强依赖各公司自身的发布体系,本文不展开。

整体来讲技术卡点并不多。最核心的 AI 代码生成能力,借助 Agent SDK 即可完成,质量和直接使用 Claude Code 打平。沙箱管理和前端页面反而是 AI 最擅长的部分,蹬起来毫无压力。

心得体会

整体蹬一个 v0,让 AI 写代码花费的时间其实并不多,大概一天左右就能蹬出来。

但是有一说一,这个方案,其实来来回回跟 AI 拉扯了几天,大到从生成 HTML,到生成片段代码,再到最后的沙箱方案,而小到增量更新的解决方案,Babel 转义的优劣,都属于考量的范畴。

包括是用 Agent SDK,还是直接用 Claude CLI,也是经过多方权衡后的结果。

一切方案落定,Plan Mode 开启,Opus 一开,反而是最轻松的时刻。

基本上第一次的产物,就能达到最小 demo 的效果。

至于交互上的细节,比如打断输入,补充说明,向用户提问明确需求,这些细节上的打磨,也是花点心思就能解决的地方。

整体来讲,在没有 AI 介入之前,其实是不太能这么快完成这样一个系统的。单单是沙箱方案的选型,可能都要花费个几天,比如沙箱的暂停和恢复,费用的对比等等,也是 AI 辅助决策的结果,有了决策,实现又是几天,确确实实在效率上提升非常大。

在这个过程中,我本身也是直接退订了 Cursor,因为完全不需要自己再上手手动修代码了,单说执行这块,AI 绝对是夯爆了。

很难说不焦虑,但又感觉不必太过焦虑。这次最大的体感不是"AI 写代码很快",而是整个过程中,花时间最多的地方依然是人在做的事——判断方案的取舍,理解各种工具的边界,决定什么值得做、什么可以砍掉。执行层 AI 确实夯爆了,但执行之前的那些决策,AI 只是参谋,拍板的还得是人。

所以与其焦虑被替代,不如想清楚自己在一件事里到底在做什么。毕竟 AI 还是得有人蹬,至于蹬到哪里去,这个问题 AI 替你答不了。

源码

本文的 POC(Proof of Concept,概念验证)代码已开源,即用最小的实现跑通"用户输入 → Agent 生成代码 → 沙箱预览"这条核心流程,感兴趣的可以查看:github.com/yuzai/code-…

❌