news 2026/9/25 3:03:45

vinext 并发请求隔离架构解析:基于 AsyncLocalStorage 的两层作用域模型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
vinext 并发请求隔离架构解析:基于 AsyncLocalStorage 的两层作用域模型
  • 后端
  • Web框架
  • SSR

【免费下载链接】vinext

Vite plugin that reimplements the Next.js API surface — deploy anywhere

项目地址:https://gitcode.com/gh_mirrors/vi/vinext
点击查看免费下载

导读

本文深入讲解 vinext(一个可部署到任意平台的 Vite 插件,重实现了 Next.js API 表面)如何基于 Node.js 的AsyncLocalStorage(ALS)为每个请求维护相互隔离的状态。你将理解为什么没有 ALS 时headers()、cookies()、<Head>子元素、缓存标签等会在并发请求间相互“串台”,掌握统一请求上下文(per-request)与每次缓存调用作用域(per-call)两层模型的划分逻辑、shim 注册模式,以及如何在该架构上安全地新增一类请求级状态。

一、背景:并发请求下的状态泄漏问题

vinext 的目标是让 Next.js 风格的 API(headers、cookies、缓存、导航、SSR 等)运行在支持并发请求的运行时上——尤其是 Cloudflare Workers 这类在同一隔离实例内并发处理多个请求的环境。与 Node.js 传统服务端的"每请求单线程栈"不同,Workers 中多个请求的处理过程会交错执行。

如果在模块顶层用单个全局变量保存"当前请求的 headers"或"当前 SSR 收集到的<Head>子元素",那么并发请求 A 与请求 B 会互相覆盖、互相读取对方的状态,导致:

  • 请求 A 读取到请求 B 的 Cookie;
  • 请求 B 的页面<head>里出现请求 A 的标题标签;
  • 缓存标签、动态渲染标记等跨请求串线,缓存命中与失效逻辑错乱。

AsyncLocalStorage正是为这类问题设计的标准方案:通过als.run(store, fn)在某个异步执行上下文中挂载状态,fn及其所有异步延续(async continuations)都能通过als.getStore()读取同一份状态,而不同调用树之间的状态天然隔离。

vinext 的完整设计记录在 ALS-ARCHITECTURE.md,本文以它为骨架,结合 packages/vinext/src/shims 下的真实实现进行纵深展开。

二、两层作用域模型总览

vinext 的 ALS 设计不是"一个请求一个 ALS"的朴素实现,而是把作用域分成两层,对应两种不同的生命周期粒度:

层级生命周期粒度实现存放内容
统一请求上下文每个请求一个(per-request)unified-request-context.ts中单个AsyncLocalStorage<UnifiedRequestContext>该请求的全部扁平化状态
每调用缓存作用域每次缓存函数调用一个(per-call)cacheContextStorage(cache-runtime.ts)与_unstableCacheAls(cache.ts)单个"use cache"/unstable_cache()调用内的标签与生命周期收集

历史上 vinext 曾为 App Router 请求嵌套 5~6 层独立的 ALS 作用域(headers、navigation、cache-state、private-cache、fetch-cache、execution-context 各一个)。PR #450 将这些作用域合并为一个扁平的统一请求上下文,大幅降低了嵌套作用域的复杂度与出错概率。这一点在 unified-request-context.ts 的模块头注释中有明确记录。

三、第一层:统一请求上下文(per-request)

3.1 扁平的UnifiedRequestContext结构

一个AsyncLocalStorage<UnifiedRequestContext>以扁平对象的形式持有请求的全部状态。类型定义位于 unified-request-context.ts,通过交叉类型把多个 shim 的状态切片组合在一起:

export type UnifiedRequestContext = { executionContext: ExecutionContextLike | null; // request-context.ts requestCache: WeakMap<(...args: any[]) => any, unknown>; // cache-for-request.ts afterContext: AfterRequestContext; // next/server after() } & VinextHeadersShimState & I18nState & NavigationState & CacheState & PrivateCacheState & FetchCacheState & RouterState & HeadState & RootParamsState;

各状态切片对应字段及来源模块(与文档表格一致,并补充了源码中的默认值):

字段含义来源模块默认值(见createRequestContext())
headersContext请求/响应 headers、cookiesheaders.tsnull
dynamicUsageDetected、phase动态渲染状态navigation-state.tsfalse/"render"
pendingSetCookies、draftModeCookieHeaderCookie 变更headers.ts[]/null
i18nContext地区(locale)信息i18n-state.tsnull
serverContext、serverInsertedHTMLCallbacksRSC 服务端上下文navigation-state.tsnull/[]
requestScopedCacheLife请求级缓存生命周期覆盖cache-request-state.tsnull
_privateCache请求级私有缓存 Mapcache-runtime.tsnull
currentRequestTags重新验证标签fetch-cache.ts[]
executionContextCloudflare Workers 执行上下文request-context.ts从独立 ALS 继承
ssrContextPages Router 的 SSR 上下文(供useRouter())router-state.tsnull
ssrHeadChildren、documentInitialHeadSSR 期间收集的<Head>子元素head-state.ts[]
rootParams根参数root-params.tsnull

此外还包含afterContext(next/server的after()延迟任务生命周期:callbacks、responseClosed、pendingCallbacks、pendingPromises、completion、resolveCompletion)以及大量 fetch 缓存相关字段(cacheableFetchUrls、currentFetchSoftTags、currentFetchCacheMode、dynamicFetchUrls、nextFetchId等),完整默认值可在 createRequestContext() 中查证。所有状态类型集中在 request-state-types.ts 统一导出。

3.2 生命周期:创建与运行

统一上下文在每个请求开始时创建,随后整个请求执行(包括 React 渲染)都运行在该作用域内:

import { createRequestContext, runWithRequestContext } from "vinext/unified-request-context"; // 每个请求开始: const ctx = createRequestContext({ /* 可按需预填充部分字段 */ }); // 整个请求体(含所有异步延续): await runWithRequestContext(ctx, async () => { // React 渲染、headers()、cookies()、缓存读写……都读取同一个 ctx });

runWithRequestContext(ctx, fn)的底层实现就是_als.run(ctx, fn)(unified-request-context.ts),它保证fn内部所有异步操作(await、setTimeout、Promise.all等)都延续同一份 store。

在 vinext 的服务端代码中,createRequestContext/runWithRequestContext被页面边界、路由分发等入口调用,例如 app-page-dispatch.ts、app-page-boundary.ts、app-rsc-handler.ts 与 pages-page-handler.ts,说明这套机制同时服务 App Router 与 Pages Router 两条渲染管线。

3.3 跨环境共享:globalThis+Symbol.for

一个关键工程细节是:Vite 的多环境架构(RSC / SSR / client)以及 HMR 可能让同一个源码模块以多个不同 specifier 加载出多个模块实例。如果每个实例都new AsyncLocalStorage(),请求状态会在实例间“分叉”——一个环境里headers()写入的状态,另一个环境的connection()读不到。

为此,所有 ALS 实例都存放在globalThis上,通过Symbol.for(...)全局符号注册表寻址(unified-request-context.ts):

const _REQUEST_CONTEXT_ALS_KEY = Symbol.for("vinext.requestContext.als"); const _als = getOrCreateAls<UnifiedRequestContext>("vinext.unifiedRequestContext.als");

Symbol.for(key)在全局符号注册表中返回同一个符号,globalThis[sym]是所有模块实例共享的同一个槽位,配合??=(仅当槽位为空时赋值)保证"第一个调用者创建、后续所有调用者复用同一个 ALS"。这条模式被封装为 als-registry.ts 中的getOrCreateAls<T>(key),约定 key 使用vinext.xxx.als点号命名空间。

该注册表还有两个重要衍生机制:

  • NoopAsyncLocalStorage 兜底:在浏览器/client bundle 中node:async_hooks会解析为无构造函数的 stub(如 Vite 的__vite-browser-external),直接new AsyncLocalStorage()会在模块求值期抛错。getOrCreateAls检测到构造函数不可用时,返回一个 no-op 实现——getStore()恒返回undefined,让 shim 自然回退到非 ALS 代码路径,run/exit等仍正常调用回调(als-registry.ts)。这镜像了 Next.js 的FakeAsyncLocalStorage设计。
  • runOutsideRequestScopes(fn):注册表维护了所有已创建 ALS 的集合,可以一次性把所有请求作用域全部exit掉,供模块级一次性求值等场景使用——因为动态import()会把 ALS 传播进被导入模块的顶层求值,若不退出,第一个请求的状态会泄漏进模块作用域并残留到后续所有请求(als-registry.ts)。

3.4 嵌套子作用域:runWithUnifiedStateMutation

统一上下文并不排斥嵌套。runWithUnifiedStateMutation(mutate, fn)从当前 store 浅拷贝出一个子上下文,允许重置或覆盖某一片状态后运行fn,同时保持子作用域内创建的异步延续的正确隔离(unified-request-context.ts):

const childCtx = { ...parentCtx }; // 浅拷贝 mutate(childCtx); // 例如 childCtx.currentRequestTags = [] return _als.run(childCtx, fn);

使用上有严格约定:引用类型字段(数组、Set、Map、对象)必须替换(如ctx.currentRequestTags = [])而不是原地修改(如ctx.currentRequestTags.push(...)),否则父作用域也会观察到变更。requestCacheWeakMap 特意保持共享——同一请求内的嵌套作用域应当看到相同的缓存值。

head-state.ts与router-state.ts的runWithHeadState/runWithRouterState就是它的典型消费者(见下文第四节)。

四、第二层:每次缓存调用的作用域(per-call)

有两类 ALS 刻意保持独立于统一上下文,因为它们的作用域不是"整个请求",而是"请求内某一次函数调用":

4.1cacheContextStorage("use cache"运行时)

定义于 cache-runtime.ts:

export const cacheContextStorage = getOrCreateAls<CacheContext>("vinext.cacheRuntime.contextAls");

CacheContext(cache-runtime.ts)收集本次缓存函数执行中的:

  • tags—— 执行期间通过cacheTag()声明的标签;
  • lifeConfigs—— 通过cacheLife()收集的生命周期配置;
  • variant—— 缓存变体("default"/"remote"/"private");
  • readRootParamNames、hasExplicitRevalidate、hasExplicitExpire、dynamicNestedCacheError、invalidDynamicUsageError等。

标记"use cache"的函数会被vinext:use-cacheVite 插件改写为调用registerCachedFunction()包装。包装后的调用链路(registerCachedFunction→runCachedFunctionWithContext)会用cacheContextStorage.run(ctx, async () => fn(...args))包裹真实函数执行(cache-runtime.ts),并且通过_registerCacheContextAccessor注册访问器,让cacheLife()/cacheTag()无需直接 import(避免循环依赖)即可访问当前上下文。

一个请求可以同时运行多个缓存函数,每个都需要独立的标签/生命周期收集。如果把这个作用域合并进统一上下文,就需要为每次缓存调用管理嵌套作用域;独立的 ALS 实现更简单也更正确。

4.2_unstableCacheAls(unstable_cache())

定义于 cache.ts:

const _unstableCacheAls = getOrCreateAls<boolean>("vinext.unstableCache.als");

每次unstable_cache()调用时运行_unstableCacheAls.run(true, () => fn(...args))(cache.ts),只携带一个布尔true标记。它的作用是让headers()、cookies()、connection()能检测到自己身处缓存作用域内并抛出异常——动态 API 不允许在缓存作用域中使用。通过isInsideUnstableCacheScope()(返回_unstableCacheAls.getStore() === true,cache.ts)即可完成该检测。它只是一个布尔标志,没有需要合并的状态。

4.3 为什么这两层必须分离

文档给出了精辟的总结:统一上下文是 per-request,缓存作用域是 per-call。缓存作用域嵌套在请求内部,但绑定到具体的缓存函数调用——一个请求可能同时运行零个、一个或多个缓存函数,每个函数需要隔离的标签/生命周期追踪。混在一起会让"每次缓存的收集"和"整个请求的共享状态"两个不同生命周期纠缠不清。

值得补充的是:"use cache"运行时还借助workUnitAsyncStorage(work-unit-async-storage.ts)标记工作单元类型("cache"/"private-cache"),并在缓存 MISS 后将effectiveLife(minimum-wins 规则合并后的生命周期)与标签向上冒泡到父缓存作用域与请求级 store,驱动revalidateTag的按标签失效(issue #1453 相关逻辑见 cache-runtime.ts 的propagateCacheTagsToRequest)。

五、shim 注册模式:以head-state.ts与router-state.ts为例

每个 shim 模块都遵循统一的"基础 shim + 状态模块 + 注册访问器"三步模式。以<Head>状态为例:

  1. 基础 shim 提供回退状态与注册入口:head.ts持有模块级回退状态,并暴露_registerHeadStateAccessors()注册函数;
  2. 状态模块注册 ALS 背书的访问器:head-state.ts 导入统一上下文,注册getSSRHeadChildren()、resetSSRHead()、getDocumentInitialHead()、setDocumentInitialHead()等访问器;
  3. 状态模块判断作用域归属:_getState()中先检查isInsideUnifiedScope()——在统一作用域内就从统一 store 读取,否则回退到独立的vinext.head.als或模块级 fallback(head-state.ts):
function _getState(): HeadState { if (isInsideUnifiedScope()) { return getRequestContext(); // 统一 store } return _als.getStore() ?? _fallbackState; // 独立 ALS / 回退单例 }

这个"统一优先、独立回退"的模式非常关键:它让同一个 shim 在 App Router 统一作用域、Pages Router 独立作用域以及测试环境(无任何作用域)下都能正确工作。router-state.ts的结构完全一致,为 Pages Router 的useRouter()提供请求级隔离的ssrContext(pathname、query、asPath、locale 等),并通过registerRoutePatternForWarningAccessor把 SSR 路由模式发布给 Link shim 的重复斜杠告警使用(router-state.ts)。

dev 与 prod 的加载差异

  • dev:Vite 为不同环境(node 与 ssr)维护独立的模块图。状态模块必须在每个使用它的环境中加载,dev server 通过ModuleImporter接口调用runner.import("vinext/head-state")确保注册发生在 ssr 模块图中;
  • prod:打包把所有内容折叠进单一模块图,注册通过静态 import 自然完成。

这意味着在 dev 下由 React 组件在 SSR 期间访问的状态模块,必须显式经ModuleImporter加载;仅 node 侧使用的状态则不需要。

六、如何新增一类请求作用域状态:六步清单

文档给出的操作步骤与源码一一对应,完整整理如下:

  1. 在UnifiedRequestContext中新增字段:unified-request-context.ts 的类型定义中加上字段;
  2. 在 request-state-types.ts 中导出新类型,保持类型来源集中;
  3. 在createRequestContext()中设置默认值(unified-request-context.ts),并同步更新runWithUnifiedStateMutation的"引用类型字段需替换而非原地修改"清单注释;
  4. 在 shim 中通过isInsideUnifiedScope()读写:作用域内读统一 store,作用域外回退到独立 ALS / fallback;
  5. 若该状态在 dev 的 SSR 期间被 React 组件访问,在 dev-server.ts 中通过ModuleImporter接口调用runner.import()加载状态模块(仅 node 侧使用的状态不需要);
  6. 若该状态是 per-call 而非 per-request(如缓存作用域),请把它放在独立的 ALS 中,不要塞进统一上下文。

遵循这份清单,新状态就能自动获得:并发请求隔离、跨 Vite 环境共享、dev/prod 一致行为、与既有 shim 相同的注册与回退语义。

七、小结:架构取舍

  • 一个扁平 store 优于多层嵌套:PR #450 把 5~6 层嵌套 ALS 合并为一个UnifiedRequestContext,让请求级状态的读写、默认值、类型与测试都集中化,显著降低心智负担;
  • per-request 与 per-call 分层是正确粒度:请求生命周期与缓存调用生命周期不同,强行统一反而需要复杂的嵌套管理;
  • globalThis+Symbol.for是 Vite 多环境下的必备手段:没有它,模块实例分叉会让并发状态静默错乱;
  • isInsideUnifiedScope()双路回退是兼容性基石:同一 shim 在 App Router、Pages Router、测试与无 ALS 运行时都能正常工作。

如需深入源码,建议从 ALS-ARCHITECTURE.md 出发,依次阅读 unified-request-context.ts、als-registry.ts、head-state.ts、router-state.ts、cache-runtime.ts 与 cache.ts,并结合 app-page-dispatch.ts 等入口观察统一上下文在实际请求管线中的装配过程。

  • 后端
  • Web框架
  • SSR

【免费下载链接】vinext

Vite plugin that reimplements the Next.js API surface — deploy anywhere

项目地址:https://gitcode.com/gh_mirrors/vi/vinext
点击查看免费下载
上一篇:10个技巧快速掌握Android远程控制 - 终极指南 🚀
下一篇:番茄小说下载器使用指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 3:02:34

本地开发接入Jev模型:TaoToken测试Key的配置与踩坑实践

最近在本地做一个 Jev 接入的小项目&#xff0c;要敲定开发阶段的接入方案&#xff0c;结果卡在一个非常典型的决策上&#xff1a;TaoToken 那边只发测试 Key&#xff0c;正式环境的 Key 暂时拿不到。很多人遇到这种情况&#xff0c;第一反应就是“那怎么搞&#xff0c;没法联调…

作者头像 李华
网站建设 2026/9/25 3:02:30

DeepSeek V4.1 Flash内测实操指南:API接入、Codex配置与64GB内存临界验证

1. 这不是“又一个大模型API接入教程”&#xff0c;而是V4.1 Flash内测期的真实水位线DeepSeek V4.1 Flash刚放出内测通道时&#xff0c;我第一时间填了申请表——不是冲着“最新版”这个名头&#xff0c;而是被它官网技术文档里一句轻描淡写的“64GB内存可本地承载全量推理”钉…

作者头像 李华
网站建设 2026/9/25 3:01:32

MindSpeed LLM支持哪些模型?Qwen3/DeepSeek/GLM等100+大模型清单全解

MindSpeed LLM支持哪些模型&#xff1f;Qwen3/DeepSeek/GLM等100大模型清单全解 【免费下载链接】MindSpeed-LLM 昇腾LLM分布式训练框架 项目地址: https://gitcode.com/Ascend/MindSpeed-LLM MindSpeed LLM 是面向华为昇腾&#xff08;Ascend&#xff09;芯片生态的大语…

作者头像 李华