03|服务端数据与客户端状态
第八章 / 03。目标:区分模块单例、请求状态、水合快照和浏览器长期状态。
最危险的差异:模块在服务器上会复用
// bad-store.ts
export const session = reactive({ userId: '' })浏览器刷新会得到新的模块实例;长驻 SSR 进程通常只加载一次模块,后续请求复用该对象。若请求 A 写入用户 id,而请求 B 未覆盖或并发读取,B 可能看到 A 的数据,这叫 cross-request state pollution。
正确边界是每次请求创建完整应用、router 和 store:
export function createAppContext() {
const app = createSSRApp(App)
const store = reactive({ userId: '', cart: [] as string[] })
app.provide(StoreKey, store)
return { app, store }
}两请求实验
import assert from 'node:assert/strict'
function createStore() { return { userId: '' } }
async function handle(userId: string) {
const store = createStore()
store.userId = userId
await Promise.resolve()
return store.userId
}
const [a, b] = await Promise.all([handle('alice'), handle('bob')])
assert.deepEqual([a, b], ['alice', 'bob'])把 store 移到模块顶层,再在两个 handle 中加入可控 gate 改变完成顺序,断言会暴露覆盖。只顺序跑一次不一定复现并发泄漏。
Nuxt 的三类状态入口
useState(key, init):SSR 友好的全局响应式状态,会进 payload;适合小而可序列化的公开首屏状态。useAsyncData(key, handler)/useFetch:服务端获取数据,把结果加入 payload,水合时复用,避免立即重复请求;key、transform、pick 等选项要在同 key 调用间一致。$fetch:普通请求工具,不自动承担水合去重;事件处理和只发生在客户端的后续请求更合适。
<script setup lang="ts">
const route = useRoute()
const productId = computed(() => String(route.params.id))
const { data, status, error, refresh } = await useAsyncData(
() => `product:${productId.value}`,
() => $fetch(`/api/products/${productId.value}`)
)
</script>payload 是发送给浏览器的公开数据面。即使内容不渲染到模板,用户也能读取响应和脚本。HttpOnly Cookie、服务端令牌、数据库字段和内部授权结果必须留在服务器;客户端只得到渲染与交互所需的最小投影。
改变参数时是谁发起新请求
上面的片段放在 app/pages/products/[id].vue 的 setup,沿用第一节 API。key 的 getter 读取 productId,id 变化会切换数据条目并请求新结果,不必同时再加 watch 重复表达同一触发条件。data 是成功结果,status 区分 idle/pending/success/error,error 是失败信息,refresh() 是主动刷新当前条目。
在页面增加 <button @click="refresh()">刷新</button> 和 <p>{{ status }}</p>;直接刷新页面时观察服务端请求以及浏览器是否立即重复请求,再点击刷新观察客户端请求。useAsyncData 的 handler 要返回可用数据,不能用只执行副作用且返回 undefined 的 handler 期待稳定复用。若使用同一 key,多处调用是在共享同一份异步数据,应共享一致的 handler 与选项,不把它当任意的缓存装饰字符串。
练习:并发证明请求隔离
实现带可控 gate 的 handleRequest(userId)。先写共享单例版本,让 alice 在写入后暂停,bob 写入并完成,再恢复 alice;断言 alice 错误地读到 bob。随后改为工厂状态,断言两者各自返回原 id。答案还要列出允许进入 payload 的 { id, displayName } 与禁止字段 sessionToken。
参考答案:第 8 章第 03 题。