Skip to content

08|服务端渲染与 Nuxt 练习答案 ​

第八章 / 参考答案。SSR 题必须同时检查两个请求和两次执行:服务端 render 与客户端首轮 render。

01|四条路由的渲染决策 ​

  • /docs/:slug:内容从版本库/内容库在构建时生成SSG文件;缓存键包含文档版本和slug,内容发布重建失效;无JS仍可读正文,站内搜索可作为后续水合增强;构建遗漏slug会404,需要显式枚举或抓取链接验证。
  • /stock/:symbol:请求时读取行情接口生成SSR,按symbol/市场用短TTL再验证;必须显示行情时间;无JS能读响应时快照,实时订阅和筛选需要水合;源站失败时标为旧数据/不可用,不伪装实时。
  • /account:请求时通过会话读取该用户的账户摘要生成SSR,也可按收益选择CSR;不公共缓存,任何私有缓存按用户/权限隔离并随退出失效;SSR时无JS仍可读已授权摘要,修改表单和即时反馈需要水合;会话过期返回登录/未授权,不能复用上个用户payload。
  • /admin/editor:登录后的工具采用CSR,数据来自逐次鉴权API;不缓存包含私有草稿的公共HTML,客户端草稿按用户/文档身份存储并在退出时清理;无JS展示工具不可用说明,不承诺编辑;编辑交互由客户端挂载而非SSR水合建立;API保存失败保留草稿并允许重试,隐藏按钮不替代后端权限。

这不是唯一答案。例如账户页若服务端渲染收益很小可用 CSR;关键是缓存键、新鲜度和权限合同与选择一致。

02|两次执行与一次点击 ​

app/pages/hydration.vue:

vue
<script setup lang="ts">
const requestId = useState('request-id', () => crypto.randomUUID())
</script>
<template>
  <p data-testid="request-id">{{ requestId }}</p>
  <button @click="requestId = 'clicked'">修改</button>
</template>

刷新页面后,在响应 HTML 查找同一个 requestId;浏览器接管后 p 的文本不变。点击按钮后显示 clicked,证明现有 HTML 已连接到客户端响应式状态。失败版用 const requestId = import.meta.client ? 'client-only' : 'server-only',首轮输入不同;这两个值确定不同,比随机概率更适合复现。恢复 useState 才是修复,隐藏警告不是。

03|并发证明请求隔离 ​

保存为 Vue 实验项目根的 request-isolation.ts,运行 node --experimental-strip-types request-isolation.ts。

ts
import assert from 'node:assert/strict'

function deferred() {
  let resolve!: () => void
  const promise = new Promise<void>(ok => { resolve = ok })
  return { promise, resolve }
}

const shared = { userId: '' }
const aliceGate = deferred()

async function badHandle(id: string, gate?: Promise<void>) {
  shared.userId = id
  if (gate) await gate
  return shared.userId
}

const alice = badHandle('alice', aliceGate.promise)
const bob = await badHandle('bob')
aliceGate.resolve()
assert.equal(bob, 'bob')
assert.equal(await alice, 'bob') // 证明污染;期望 alice 时应失败

把下列修复版接在同一文件末尾,使用新的 gate,保持交错顺序不变:

ts
async function handle(id: string, gate?: Promise<void>) {
  const state = { userId: id }
  if (gate) await gate
  return state.userId
}
const fixedGate = deferred()
const fixedAlice = handle('alice', fixedGate.promise)
const fixedBob = await handle('bob')
fixedGate.resolve()
assert.equal(fixedBob, 'bob')
assert.equal(await fixedAlice, 'alice')
console.log('共享单例污染已复现,请求工厂隔离通过')

每次调用自己的 state 在 await 前后保持同一个局部引用;bob不能写到alice的对象。进入 payload 的对象用允许列表 { id, displayName } 创建;sessionToken 保留在 HttpOnly Cookie/服务端会话中。

04|记录成功与失败流 ​

ts
import type { App } from 'vue'
import { renderToSimpleStream } from 'vue/server-renderer'

export function collectStream(app: App) {
  const chunks: { at: number; text: string }[] = []
  const started = performance.now()
  let firstError: unknown = null
  // 输入是本次请求新建、尚未配置错误处理器的 app;本函数拥有其错误收集。
  if (app.config.errorHandler) throw new Error('请传入独立实验 app')
  app.config.errorHandler = error => { firstError ??= error }
  return new Promise<{ chunks: typeof chunks; html: string; ended: boolean; error: unknown }>(resolve => {
    const finish = (error: unknown = null) => {
      firstError ??= error
      resolve({ chunks, html: chunks.map(x => x.text).join(''),
        ended: firstError === null, error: firstError })
    }
    renderToSimpleStream(app, {}, {
      push(chunk) {
        if (chunk === null) finish()
        else chunks.push({ at: performance.now() - started, text: chunk })
      },
      destroy: finish
    })
  })
}

保存为 collect-stream.ts,与正文 stream-lab.mjs 同目录。ended 表示本题应用渲染成功完成,不能仅用传输层出现 push(null) 推断它。组件处理器保存第一个未被局部错误边界吞掉的异常,destroy 保存输出失败;正常拼接结束时统一判断。已有业务错误处理器时应由应用明确组合,本实验拒绝偷偷覆盖它。

以下保存为 stream-test.ts,运行 node --experimental-strip-types stream-test.ts:

ts
import assert from 'node:assert/strict'
import { createSSRApp, defineComponent, h } from 'vue'
import { collectStream } from './collect-stream.ts'
const expected = new Error('upstream')
function makeApp(fail: boolean) {
  const Part = defineComponent({
    async setup() {
      await Promise.resolve()
      if (fail) throw expected
      return () => h('strong', '正文')
    },
    render: () => h('span', '失败占位')
  })
  return createSSRApp({ render: () => h('main', [h('h1', 'shell'), h(Part)]) })
}
const ok = await collectStream(makeApp(false))
assert.equal(ok.ended, true)
assert.equal(ok.error, null)
assert.ok(ok.html.includes('shell') && ok.html.includes('正文'))
const bad = await collectStream(makeApp(true))
assert.equal(bad.ended, false)
assert.equal(bad.error, expected)
console.log('成功与组件失败合同通过')

若去掉 errorHandler,失败题就会失败而不是被正常流结束蒙混通过。真实 HTTP 应在提交前决定鉴权/关键错误,已发出的 bytes 无法撤回;本题只是内存收集器,不实现 HTTP 重试协议。

05|从页面模型派生元信息 ​

ts
const PUBLIC_ORIGIN = 'https://example.test'

export function toSeo(
  article: { slug: string; title: string; summary: string; imagePath: string },
  baseUrl: string
) {
  const base = new URL(baseUrl)
  if (base.origin !== PUBLIC_ORIGIN) throw new TypeError('untrusted public origin')
  const slug = article.slug.trim()
  const title = article.title.trim()
  if (!slug || !title) throw new TypeError('slug/title required')

  const canonical = new URL(`/articles/${encodeURIComponent(slug)}`, base)
  const image = new URL(article.imagePath, base)
  if (image.origin !== PUBLIC_ORIGIN) throw new TypeError('untrusted image origin')

  return {
    title,
    description: Array.from(article.summary.trim()).slice(0, 160).join(''),
    canonical: canonical.href,
    ogImage: image.href
  }
}

生产中 PUBLIC_ORIGIN 来自受控部署配置,不从请求 Host 直接接受。下面的断言分别证明正常投影、Unicode 截断和三个拒绝边界:

ts
import assert from 'node:assert/strict'

const result = toSeo({
  slug: ' vue hydration ',
  title: ' 水合过程 ',
  summary: '😀'.repeat(161),
  imagePath: '/images/hydration.png'
}, 'https://example.test/from-request')

assert.equal(result.title, '水合过程')
assert.equal(result.canonical, 'https://example.test/articles/vue%20hydration')
assert.equal(result.ogImage, 'https://example.test/images/hydration.png')
assert.equal(Array.from(result.description).length, 160)
assert.throws(() => toSeo(
  { slug: 'x', title: ' ', summary: '', imagePath: '/x.png' },
  'https://example.test'
), TypeError)
assert.throws(() => toSeo(
  { slug: 'x', title: 'X', summary: '', imagePath: '/x.png' },
  'https://attacker.example'
), TypeError)
assert.throws(() => toSeo(
  { slug: 'x', title: 'X', summary: '', imagePath: '//cdn.attacker.example/x.png' },
  'https://example.test'
), TypeError)

只检查返回字段存在,会漏掉恶意 origin 和 UTF-16 截断问题;拒绝断言必须真的让错误实现失败。

06|序列化公开状态 ​

ts
type User = {
  id: string; displayName: string; email: string
  passwordHash: string; sessionToken: string
}

export function serializePublicState(user: User) {
  const publicUser = { id: user.id, displayName: user.displayName }
  return JSON.stringify(publicUser).replaceAll('<', '\\u003c')
}

下面的断言同时锁定最小公开投影、HTML 源码边界和数据可恢复性:

ts
import assert from 'node:assert/strict'

const attack = '</script><script>alert(1)</script>'
const source = serializePublicState({
  id: 'u1',
  displayName: attack,
  email: 'private@example.com',
  passwordHash: 'hash-secret',
  sessionToken: 'token-secret'
})
const restored = JSON.parse(source)

assert.deepEqual(Object.keys(restored).sort(), ['displayName', 'id'])
assert.deepEqual(restored, { id: 'u1', displayName: attack })
assert.equal(source.includes('</script>'), false)
for (const forbidden of ['email', 'passwordHash', 'sessionToken', 'private@example.com', 'token-secret']) {
  assert.equal(source.includes(forbidden), false)
}

若错误实现直接返回 JSON.stringify(user),禁止字段与 key 断言会失败;若粗暴删除 displayName 中的 <,恢复值断言会失败。运行方式是把函数与断言保存到同一个 .mjs 文件后执行 node 文件名.mjs,成功时无输出,任一边界失败都会以非零状态退出。

这只是解释 script 上下文的练习。Nuxt payload 还要处理更多可序列化类型、恢复协议与版本边界,应使用框架序列化,不复制这段简化实现代替它。

07|评论区的首次交互合同 ​

组件和页面采用正文两个完整文件。在 Nuxt 项目运行 npm install -D --save-exact @playwright/test@1.63.0,再 npx playwright install chromium。新建 playwright.config.ts,沿用第九章 E2E 的配置形式,baseURL 使用 http://127.0.0.1:3000,webServer command 改为 npm run build && npm run preview。保存为 e2e/comments.spec.ts:

ts
import { expect, test } from '@playwright/test'

test('父级无关更新不提前接管,首次聚焦后输入正常', async ({ page, request }) => {
  const response = await request.get('/comments')
  expect(await response.text()).toContain('已有两条评论')
  const chunks: string[] = []
  page.on('request', request => {
    if (request.url().includes('/_nuxt/')) chunks.push(request.url())
  })
  await page.goto('/comments')
  await expect(page.getByTestId('hydrated')).toHaveText('waiting')
  await page.getByRole('button', { name: '无关计数 0' }).click()
  await expect(page.getByRole('button', { name: '无关计数 1' })).toBeVisible()
  await expect(page.getByTestId('hydrated')).toHaveText('waiting')
  const input = page.getByLabel('评论', { exact: true })
  await input.focus()
  await expect(page.getByTestId('hydrated')).toHaveText('ready')
  await expect(input).toBeFocused()
  await input.fill('hello')
  await expect(input).toHaveValue('hello')
  await expect(page.getByTestId('draft')).toHaveText('hello')
  await expect(page.getByTestId('input-events')).toHaveText('1')
  expect(await page.evaluate(() => performance.getEntriesByName('comments:hydrated').length)).toBe(1)
  await test.info().attach('nuxt-chunks', { body: JSON.stringify(chunks), contentType: 'application/json' })
})

运行 npx playwright test e2e/comments.spec.ts。网络记录用于比较构建/预取行为,真正的交互门槛由 hydrated 标记证明。再把 :article-id="String(unrelated)" 传入并先改计数,waiting 断言应失败:prop 改变会提前触发水合。最后恢复稳定 prop。此测试覆盖浏览器 fill 的一次 input,真实连续键入、输入法和慢网下第一事件仍应按目标设备补测。

答案页自检 ​

  • SSR/缓存选择都写明了生成时机、新鲜度与权限。
  • 两请求实验使用可控交错,不靠“跑一次没泄漏”。
  • 错配、流失败和敏感字段都有会失败的断言。

用心记录每一步成长