- 后端
- Web框架
- SSR
【免费下载链接】vinext
Vite plugin that reimplements the Next.js API surface — deploy anywhere
导读
本文深入讲解 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、cookies | headers.ts | null |
dynamicUsageDetected、phase | 动态渲染状态 | navigation-state.ts | false/"render" |
pendingSetCookies、draftModeCookieHeader | Cookie 变更 | headers.ts | []/null |
i18nContext | 地区(locale)信息 | i18n-state.ts | null |
serverContext、serverInsertedHTMLCallbacks | RSC 服务端上下文 | navigation-state.ts | null/[] |
requestScopedCacheLife | 请求级缓存生命周期覆盖 | cache-request-state.ts | null |
_privateCache | 请求级私有缓存 Map | cache-runtime.ts | null |
currentRequestTags | 重新验证标签 | fetch-cache.ts | [] |
executionContext | Cloudflare Workers 执行上下文 | request-context.ts | 从独立 ALS 继承 |
ssrContext | Pages Router 的 SSR 上下文(供useRouter()) | router-state.ts | null |
ssrHeadChildren、documentInitialHead | SSR 期间收集的<Head>子元素 | head-state.ts | [] |
rootParams | 根参数 | root-params.ts | null |
此外还包含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>状态为例:
- 基础 shim 提供回退状态与注册入口:
head.ts持有模块级回退状态,并暴露_registerHeadStateAccessors()注册函数; - 状态模块注册 ALS 背书的访问器:head-state.ts 导入统一上下文,注册
getSSRHeadChildren()、resetSSRHead()、getDocumentInitialHead()、setDocumentInitialHead()等访问器; - 状态模块判断作用域归属:
_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 侧使用的状态则不需要。
六、如何新增一类请求作用域状态:六步清单
文档给出的操作步骤与源码一一对应,完整整理如下:
- 在
UnifiedRequestContext中新增字段:unified-request-context.ts 的类型定义中加上字段; - 在 request-state-types.ts 中导出新类型,保持类型来源集中;
- 在
createRequestContext()中设置默认值(unified-request-context.ts),并同步更新runWithUnifiedStateMutation的"引用类型字段需替换而非原地修改"清单注释; - 在 shim 中通过
isInsideUnifiedScope()读写:作用域内读统一 store,作用域外回退到独立 ALS / fallback; - 若该状态在 dev 的 SSR 期间被 React 组件访问,在 dev-server.ts 中通过
ModuleImporter接口调用runner.import()加载状态模块(仅 node 侧使用的状态不需要); - 若该状态是 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
相关推荐
数据版本控制终极指南:Awesome open>数据版本控制终极指南:Awesome open data centric AI中的DVC与lakeFS全方位对比分析 在数据驱动的AI开发中,有效的数据版本控制
后端humanlayer-wui 热键作用域系统实战指南:基于 react-hotkeys-hook 的层级快捷键隔离架构
humanlayer wui 热键作用域系统实战指南:基于 react hotkeys hook 的层级快捷键隔离架构 本篇技术指南完整解析 humanlaye
人工智能AI Agent后端MCP 服务CLI桌面应用Teleport Scopes:基于路径层次的作用域隔离与委派管理设计全解析
Teleport Scopes:基于路径层次的作用域隔离与委派管理设计全解析 导读 本文基于仓库 rfd/0229 scopes.md https://link
网络安全认证鉴权运维后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考