IT加油站
Article Cover

Nuxt useState vs ref():服务端状态为何跨用户泄漏

19浏览 2天前 软件教程 MA122988

原文:https://dev.to/parsajiravand/nuxt-usestate-vs-ref-why-server-state-leaks-across-users-47n1(作者 @parsajiravand)

同一秒内,两个人访问你的 Nuxt 商店页面。其中一人刷新购物车,却看到别人的商品。没有堆栈跟踪,没有 500 错误,网络面板里也没有失败的请求——只是数据错了,静悄悄地,落在某个倒霉用户头上。你在 localhost 上花了一个小时想复现,却复现不出来,因为在 localhost 上同一时刻永远只有一个请求在进行。

问题不在购物车逻辑里,而在承载购物车状态的那个状态被声明在哪里

你将学到什么

读完本文后,你将能够:

  • 解释为什么在 Nuxt 中,模块作用域声明的 ref() 或普通对象会被服务器处理的每一个请求共享,而不仅仅是被创建它的那个请求使用
  • 正确使用 useState()——它的 key 做什么、什么时候省略,以及它实际上隔离了什么
  • 在 Nitro 服务器路由中识别同样的危险,而不只是在组件和 composable 中
  • 区分危险的模块作用域状态(用户数据)与安全的模块作用域状态(共享缓存、常量)
  • 针对某一块状态,选择正确的工具——useStateevent.context 或普通 ref

读者对象

你已经至少构建过一个 Nuxt 页面,并且用过 composable。你不需要任何 SSR 经验——本文会从零建立“什么代码在哪里运行”的心智模型。

本文基于 Nuxt 4.5.x 编写(已根据 npm 和 GitHub 上 nuxt 包的发布历史核实,截至 2026 年 8 月;写作时的最新补丁版本为 4.5.2,Nuxt 3 已于 2026 年 7 月 31 日结束生命周期)。代码和目录路径使用 Nuxt 4 的 app/ 约定(app/composables/app/plugins/app/pages/——server/ 仍位于项目根目录,不在 app/ 内)。如果你的项目仍使用扁平化的 Nuxt 3 布局(composables/plugins/pages/ 在根目录),相同代码无需修改即可运行;只是文件夹位置不同,后文会标注一次。

目录

问题:不属于你的购物车

下面是一个看起来完全普通的 composable:

// app/composables/useCart.ts
import { ref } from 'vue'

// 看起来像一个普通的共享 store —— 一个 ref,哪里需要购物车就在哪里导入。
const cart = ref<{ id: string; qty: number }[]>([])

export function useCart() {
  function add(id: string) {
    cart.value.push({ id, qty: 1 })
  }
  return { cart, add }
}


在页面中使用:

<script setup lang="ts">
const { cart, add } = useCart()
add('sku-123')
</script>


在你自己的机器上、一个浏览器标签页中,这一切正常。添加一件商品,在购物车中看到它,刷新后会话期间它还在。看起来没有任何问题。

现在想象两个真实用户——用户 A 和用户 B——在同一毫秒内访问你的服务器。在正常流量下,这完全现实。useCart.ts 顶部的 const cart = ref([]) 只会在 Node 在服务器启动时第一次导入该文件时运行一次。之后每一个请求都复用的是同一个 ref 对象。用户 A 添加了一件商品;在极短的时间窗口内,用户 B 的服务器渲染 HTML 可能会包含它。低流量时很难暴露;几百个并发请求一来,这个 bug 就不再罕见了。

心智模型:一个进程,多个请求

核心心智模型: Nuxt 服务器(通过 Nitro)是一个长期运行的进程——或者在 serverless 中,是一个热函数实例——通过交错处理来服务多个请求,而不是每个请求一个进程。服务端代码中的每一个 await 都是一个节点,Node 可以在你的请求恢复之前开始处理另一个请求。任何在模块作用域声明的东西——不在组件的 setup() 内、不在 composable 函数体内、不在 defineEventHandler 内——都只会在模块首次导入时被求值一次,并伴随进程的整个生命周期。它的作用域是服务器,而不是请求

setup() 内或 composable 函数体内创建的 ref() 则不同:每次该函数运行时都会新建一个。但 useCart()cart 并不是在函数内创建的——它是在文件顶部、useCart() 之外创建的,所以每次调用 useCart() 返回的都是同一个对象,每个请求都如此。

关键概念: “模块作用域”和“请求作用域”不是同一个生命周期。这类 bug 永远是一个状态变量被写得好像它们两者是同一个生命周期似的。

第一阶段:复现数据泄漏

不必等真实流量,直接模拟两个重叠请求,就能具体看到问题:

// 模拟两个"请求"与这个有缺陷的模块级购物车并发竞争。
const cart: string[] = [] // 模块作用域——只创建一次

async function handleRequest(user: string, item: string) {
  cart.push(item)
  await new Promise((r) => setTimeout(r, 10)) // 模拟异步操作:数据库调用、渲染中的 await
  console.log(`${user} sees cart:`, cart) // 两个用户读取的是同一个数组
}

handleRequest('User A', 'sku-A')
handleRequest('User B', 'sku-B')


运行这段代码,两个日志都会打印 ['sku-A', 'sku-B']。用户 B 从未添加过 sku-A,却也看到了它——因为 cart 从来就不是用户 B 独自拥有的。剥离 Nuxt 之后,整个 bug 就浓缩在这十二行里:共享的可变状态加上并发执行。

第二阶段:"每个请求"到底意味着什么

Nuxt 确实会在服务端为每个请求创建全新的 Vue 应用实例——这部分隔离是正确的。组件实例、它们 setup() 中的局部变量,以及组合式函数自身函数体内创建的任何内容,都是请求作用域的,因为该函数每次渲染都会重新执行。真正的陷阱是在任何函数外部创建的值——它们在任何应用实例存在之前就已经存在了。

所以修复方式不是"避免 ref()"——setup() 里的 ref() 没有问题。修复需要的是一个工具,它既是请求作用域的,又为了方便仍然在组合式函数顶部声明一次。这就是 useState

第三阶段:用 useState 修复

// app/composables/useCart.ts
export function useCart() {
  const cart = useState<{ id: string; qty: number }[]>('cart', () => [])

  function add(id: string) {
    cart.value.push({ id, qty: 1 })
  }
  return { cart, add }
}


这里有两个关键改动。第一,cart 现在是在 useCart() 内部创建的——但 useState 并不是每次调用都简单地新建一个普通 ref;它会在当前 Nuxt 应用实例的状态中查找(或创建)一个以 'cart' 为键的值。由于每个请求都有独立的应用实例,每个请求也就有了独立的 'cart' 条目。第二,初始化函数 () => [] 只在每个应用实例中该键首次被请求时运行。

在底层,useState(key, init) 把值存储在 nuxtApp.payload.state[key] 中。服务端将该 payload 序列化到发送给客户端的 HTML 中;客户端在 hydration 期间读取同一个 payload,并复用该值,而不是重新运行初始化函数——既不会重复计算,也不会闪现不同的内容。

关键概念: useState 的键就是隔离边界。在你应用中的任何位置调用两次 useState('cart', ...)——同一个组件、不同组件、甚至插件里——在同一个请求/应用实例内返回的是同一个响应式值,而在不同请求中则与同键值不同。省略键时,Nuxt 会根据调用位置自动生成一个键,但显式写字符串是值得的:这是你以后 grep 的依据,也能避免两个无关组合式函数意外撞上恰好相同的自动生成键。

第四阶段:Nitro 服务端路由中的同一个 bug

同样的错误也会出现在 server/api/*.ts 文件中,而且很容易漏掉,因为 Nitro 处理器看起来像是请求作用域的,实际上却不是:

// server/api/rate-limit.ts — 有 bug
const requestCounts = new Map<string, number>() // 模块作用域:整个进程共享一个 Map

export default defineEventHandler((event) => {
  const ip = getRequestIP(event) ?? 'unknown'
  const count = (requestCounts.get(ip) ?? 0) + 1
  requestCounts.set(ip, count)
  return { requests: count }
})


这个特例实际上是有意的模块作用域——限流器本来就需要一个跨所有请求共享的计数器。这个 Map 本身不是 bug;真正的问题是像这样共享某个用户的标识性数据(购物车、会话、"当前用户"对象)。如果这个文件缓存的是一次请求中获取的完整用户资料,然后不管下一位调用者是谁都直接复用,那就是同样的泄漏,只不过发生在 server/ 而不是 app/

对于仅存在于请求作用域、只在服务端使用的数据——在中间件和处理器之间传递、永远不会送达客户端的数据——应该使用 event.context

// server/middleware/auth.ts
export default defineEventHandler((event) => {
  event.context.user = verifyToken(getHeader(event, 'authorization'))
})


// server/api/profile.ts
export default defineEventHandler((event) => {
  return { name: event.context.user?.name }
})


event 由 Nitro 为每个请求全新创建,因此 event.context 上的任何内容都自动是请求作用域的——无需键,不会泄漏,而且与 useState 不同,它永远不会被序列化到客户端。

阶段 5:模块作用域到底适合什么

并非文件顶层的所有内容都是 bug。模块作用域适用于那些每个请求都相同的内容:常量(配置、编译后的正则、解析好的 schema)、无状态工具函数(纯函数,没有可泄漏的东西)、按设计就应是进程级的状态(限流器的计数器、以 输入 为键的缓存——cache.get(productId) 没问题,cache.get('currentUser') 则不行),以及连接/客户端(数据库连接池的存在正是为了在请求间复用)。

分界线不是“它是否在模块作用域声明”,而是“这个值是否保存了某个特定用户或请求的数据”。以 IP 为键的限流 Map 是按设计存在的进程级状态,是正确的。一个本意是“当前用户的购物车”的 ref([]) 则是因为意外而成为进程级状态,是错误的。

边界情况与陷阱

  • Serverless 并不能拯救你。 大多数服务提供商会为了性能在多次调用间复用(“预热”)同一个函数容器,因此模块作用域泄漏仍然可能出现在那里——只是比常驻 Node 服务器上更少见。
  • `useState` 的值必须是可序列化的。 其有效负载使用 Nuxt 基于 devalue 的序列化器,该序列化器可以处理普通对象、数组、MapSetDate——但不能处理函数或带方法的类实例。存储数据,而不是行为。
  • 客户端的 `useState` 是按浏览器标签页隔离的,而不是跨标签页按用户共享。 水合之后,应用中任意位置的 useState('cart', ...) 调用都会为当前这次页面加载返回同一个客户端 ref——这是正确的,因为一个标签页恰好只属于一个用户。这是一种不同于上文服务端泄漏的安全“共享”。
  • 预渲染(SSG)构建也可能出现竞态。 nuxi generate 会并行渲染多个路由;一个路由渲染期间被修改的模块作用域值可能渗入另一个路由的输出——这是同一 bug 的构建时版本。
  • Nuxt 3 的扁平目录结构同样适用。 在项目根目录下的 composables/plugins/server/ 目录中(没有顶层 app/),以上所有结论保持不变——只是文件夹路径不同。

最佳实践:不同场景用哪个工具

  • `useState(key, init)` —— 在 SSR 期间计算、需要在水合后不重新计算而保留、并且只属于当前请求/用户的任何响应式值。覆盖了大多数你原本会放在模块作用域 ref 中的“共享状态”。
  • `event.context` —— 请求级数据,仅服务器需要(已认证的用户对象、解析后的令牌),永远不会序列化到客户端。
  • `setup()` 中的普通 `ref()` —— 组件本地状态,不涉及跨请求问题。代码库中的大多数 ref 正是这种,也从来不是问题。
  • 模块作用域 —— 常量、纯工具函数、真正设计为整个进程共享的状态(以输入为键的缓存、连接池、限流器)。永远不要存放特定用户的数据。
  • 审计经验法则: 在 composables 和 Nitro 处理器中 grep ref(reactive( 或任何位于函数 之外 的裸对象/数组字面量。每个命中要么是正确实现的模块作用域,要么就是泄漏——请有意识地判断。

<!-- playground:start -->

自己试试

[打开交互式 Playground →](https://bestpractic.org/blog/nuxt-weekly-cross-request-state-leak/playground)

_直接在浏览器中运行——动手实验,实时观察这个概念的反应。_

<!-- playground:end -->

值得模拟的边界情况

因为这个 bug 依赖于真实的并发,单靠阅读代码很难发现——下面的交互式模拟会在两种存储策略下并排运行两个“请求”,让你亲眼看到泄漏发生和消失。

FAQ

为什么我在本地测试时没有出现这个 bug?

因为开发服务器通常一次只处理一个浏览器标签页的一个请求。导致泄漏的交错执行需要两个请求真正重叠——也就是真实的并发流量,或者像 Stage 1 那样刻意错开的测试,而不是同一个标签页连续刷新两次。

useState 和 Pinia store 是一回事吗?

相关,但不相同。useState 是 Nuxt 内置的、SSR 安全的原语,用于存储单个值并自动进行 payload 序列化。Pinia 是一个完整的 store 库(支持 actions、getters、devtools),并且同样做到了 SSR 安全——每个 store 实例都是按应用实例创建的,而不是在模块作用域创建,所以它也不会有这个 bug。少量值用 useState;一旦有真正的 store 逻辑需要组织,再上 Pinia。

useAsyncDatauseFetch 有同样的泄漏风险吗?

默认没有——它们都通过 useState 内部使用的同一个按应用实例的 payload 机制来关联结果,所以正确指定 key 的调用是按请求隔离的。只有在你自己把它们的 结果 缓存到组合式函数之外的模块作用域变量里时,风险才会重新出现。

如果我真的想让所有用户共享一个值,比如实时访客数,该怎么办?

那是合法的模块作用域状态(Stage 5)——直接声明并修改它,跳过 useState,因为 useState 的存在意义恰恰是按请求隔离,与你这里的需求正好相反。

这会影响 Nuxt 2 吗?

Nuxt 2 没有 useState;同样的陷阱当时存在于 Vuex store 周围。Nuxt 2 多年前已经停止维护——把任何残留的 @nuxtjs/composition-api 代码视为待完成的迁移,而不是值得扩展的模式。

如何在代码审查时发现这个问题,而不是等到生产环境出事故?

composables/plugins/server/ 目录中 grep 模块作用域的 ref(reactive( 或对象/数组字面量,并对每一个问自己:"如果两个用户的请求同时触及这个值,结果是正确的吗?" 限流器的 Map:正确。任何持有某个用户的购物车、个人资料或会话的东西:不正确。

速查表

| 场景 | 使用 | 原因 |

| --- | --- | --- |

| SSR 期间计算出的响应式值,必须在 hydration 后存活,且按用户隔离 | useState('key', () => init) | 存储在按请求的 payload 中;自动序列化到客户端 |

| 仅在服务端使用、在中间件和处理函数之间传递的数据 | event.context.foo = … | 天然按请求隔离;从不序列化到客户端 |

| 组件局部状态,无需考虑 SSR/hydration | setup() 内的 ref() | 每次函数执行时都会全新创建 |

| 按输入做 key 的缓存,对所有用户相同(如 cache.get(id)) | 模块作用域 Map/对象 | 进程生命周期的正确用法——里面没有任何用户特定数据 |

| 限流器、连接池、编译后的配置 | 模块作用域值 | 设计上就是要跨所有请求持续存在 |

| 一个用户的请求写入、而另一个用户的请求会错误读到的值 | 绝不能用模块作用域 | 这就是泄漏——移到 useStateevent.context |

// 修复方案,对比:
// ❌ 模块作用域 —— 整个服务端进程只有一个实例
const cart = ref<Item[]>([])

// ✅ useState —— 每个请求/应用实例一个实例,显式指定 key
export function useCart() {
  const cart = useState<Item[]>('cart', () => [])
  return { cart }
}


关键要点

  • 在组件 setup() 或组合式函数函数体 之外 声明的任何东西,都只会在服务端进程启动时运行一次——而不是每个请求一次。
  • Nuxt 确实会为每个请求创建新的应用实例;这个 bug 的根源是存在于任何应用实例创建 之前 的状态。
  • useState(key, init) 就是修复方案:它按应用实例做 key 隔离,因此每个请求都拿到自己独立的值,并且能通过 SSR payload 在 hydration 后存活。
  • 同样的陷阱也存在于 Nitro 服务端路由(server/apiserver/middleware)中——在那里使用 event.context 存放按请求隔离、仅服务端可见的数据。
  • 模块作用域本身没有错——只有当它持有某个用户的数据时才是错的。按输入做 key 的缓存、限流器、连接池都是它的正确用法。

回到购物车

那个显示错误商品的购物车,问题并不出在结算逻辑里的竞态条件,也不是数据库读到了过期数据——它的病因是 const cart = ref([]) 被写高了一层作用域。把它移进 useCart() 内部,用 useState('cart', () => []) 来承载,同一份组件代码在真实流量下就不再是抛硬币了。

下周日的续集会接着“每请求”这个话题往下讲:useAsyncDatauseFetch 如何判断两次调用是不是同一个请求并去重,以及什么时候这种去重不是修复,反而是 bug 本身。

有没有哪个“这怎么可能发生”的生产环境 bug,最后查出来只是状态声明在了错误的作用域?是什么线索暴露了它?

原文:https://dev.to/parsajiravand/nuxt-usestate-vs-ref-why-server-state-leaks-across-users-47n1(作者 @parsajiravand)

#Nuxt #useState #ref #服务端渲染 #状态管理 #SSR
1