Skip to content

03|服务端数据与客户端状态 ​

第八章 / 03。目标:区分模块单例、请求状态、水合快照和浏览器长期状态。

最危险的差异:模块在服务器上会复用 ​

ts
// bad-store.ts
export const session = reactive({ userId: '' })

浏览器刷新会得到新的模块实例;长驻 SSR 进程通常只加载一次模块,后续请求复用该对象。若请求 A 写入用户 id,而请求 B 未覆盖或并发读取,B 可能看到 A 的数据,这叫 cross-request state pollution。

正确边界是每次请求创建完整应用、router 和 store:

ts
export function createAppContext() {
  const app = createSSRApp(App)
  const store = reactive({ userId: '', cart: [] as string[] })
  app.provide(StoreKey, store)
  return { app, store }
}

两请求实验 ​

ts
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:普通请求工具,不自动承担水合去重;事件处理和只发生在客户端的后续请求更合适。
vue
<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 题。

用心记录每一步成长