Skip to content

第七章练习答案 ​

每个答案先实现题目明示的契约,再用会让错误实现失败的断言锁定边界。前三篇的纯函数代码分别保存为独立 .mjs 文件运行,避免同名声明互相影响;水合题沿用正文的多文件实验。

第 01 篇答案:URL 状态与数据加载 ​

js
// url-state-answer.mjs
import assert from 'node:assert/strict'

export function readSearch(search) {
  const params = new URLSearchParams(search)
  const rawPage = params.get('page')
  const validPage = /^(?:[1-9]|1\d|20)$/.test(rawPage ?? '')
  return {
    query: params.get('q') ?? '',
    page: validPage ? Number(rawPage) : 1,
  }
}

export function writeSearch(baseUrl, next) {
  const url = new URL(baseUrl.href)
  if (next.query) url.searchParams.set('q', next.query)
  else url.searchParams.delete('q')
  if (next.page === 1) url.searchParams.delete('page')
  else url.searchParams.set('page', String(next.page))
  return url
}

assert.deepEqual(readSearch('?q=react&page=7'), { query: 'react', page: 7 })
for (const invalid of ['0', '3.5', '21', '-1', 'abc', '']) {
  assert.equal(readSearch(`?page=${invalid}`).page, 1)
}

const original = new URL('https://example.test/products?q=old&page=9')
const changed = writeSearch(original, { query: 'cache', page: 1 })
assert.equal(changed.href, 'https://example.test/products?q=cache')
assert.equal(original.href, 'https://example.test/products?q=old&page=9')

console.log('URL contract: ok')

Number('') 会得到 0,parseInt('3.5') 会得到 3,所以二者都不足以表达题目的严格整数语法。正则先约束文本,再转换为数字。writeSearch 从 baseUrl.href 创建副本,因此不会让调用者持有的 URL 悄悄变化。

接入正文组件时,搜索按钮调用 writeSearch(current, { query: nextQuery, page: 1 });翻页按钮保留当前 query 并传入新页码,然后统一 pushState 与更新 React 快照。

第 02 篇答案:路由错误边界与嵌套路由 ​

js
// route-match-answer.mjs
import assert from 'node:assert/strict'

export function matchTask(pathname) {
  const match = /^\/projects\/([^/]+)\/tasks\/([^/]+)$/.exec(pathname)
  if (!match) return { status: 404 }

  try {
    return {
      status: 200,
      params: {
        projectId: decodeURIComponent(match[1]),
        taskId: decodeURIComponent(match[2]),
      },
    }
  } catch {
    return { status: 400 }
  }
}

assert.deepEqual(matchTask('/projects/%E5%89%8D%E7%AB%AF/tasks/42'), {
  status: 200,
  params: { projectId: '前端', taskId: '42' },
})
assert.deepEqual(matchTask('/projects/p1/tasks'), { status: 404 })
assert.deepEqual(matchTask('/projects/p1/tasks/t1/'), { status: 404 })
assert.deepEqual(matchTask('/projects/%E0%A4%A/tasks/t1'), { status: 400 })

console.log('route match contract: ok')

正则只负责形状,解码单独处理输入错误。把 decodeURIComponent 放进 try 是必要的:格式错误的百分号编码会抛 URIError。400 与 404 分开后,应用可以将恶意/损坏地址记录为输入问题,而不是误报成组件崩溃。

第 03 篇答案:缓存、失效与请求竞态 ​

下面给出对正文缓存的完整扩展;新增代码集中在 peek、invalidateKey 和 invalidateWhere,其余保留以便答案可独立运行。

js
// cache-extensions-answer.mjs
import assert from 'node:assert/strict'

function createResourceCache(load, { ttlMs = 30_000, now = Date.now } = {}) {
  const entries = new Map()
  const versions = new Map()
  const versionOf = key => versions.get(key) ?? 0

  function get(key) {
    const current = entries.get(key)
    if (current?.status === 'success' && current.expiresAt > now()) {
      return Promise.resolve(current.value)
    }
    if (current?.status === 'pending') return current.promise

    const controller = new AbortController()
    const startedAtVersion = versionOf(key)
    let loaded
    try {
      loaded = load(key, { signal: controller.signal })
    } catch (error) {
      loaded = Promise.reject(error)
    }
    const promise = Promise.resolve(loaded).then(
      value => {
        if (versionOf(key) === startedAtVersion) {
          entries.set(key, { status: 'success', value, expiresAt: now() + ttlMs })
        }
        return value
      },
      error => {
        if (versionOf(key) === startedAtVersion) entries.delete(key)
        throw error
      },
    )
    entries.set(key, { status: 'pending', promise, controller })
    return promise
  }

  function peek(key) {
    const entry = entries.get(key)
    return entry?.status === 'success' && entry.expiresAt > now()
      ? entry.value
      : undefined
  }

  function invalidateKey(key) {
    versions.set(key, versionOf(key) + 1)
    const entry = entries.get(key)
    if (entry?.status === 'pending') entry.controller.abort()
    entries.delete(key)
  }

  function invalidateWhere(predicate) {
    for (const key of entries.keys()) {
      if (predicate(key)) invalidateKey(key)
    }
  }

  return { get, peek, invalidate: invalidateKey, invalidateWhere }
}

const requests = []
const load = (key, { signal }) => new Promise((resolve, reject) => {
  const request = { key, resolve, signal }
  signal.addEventListener('abort', () => reject(
    new DOMException('aborted', 'AbortError'),
  ), { once: true })
  requests.push(request)
})

const cache = createResourceCache(load)
const p1 = cache.get('project:1')
const p2 = cache.get('project:2')
const user = cache.get('user:1')
requests[0].resolve({ id: 1 })
requests[2].resolve({ name: 'Ada' })
await p1
await user

cache.invalidateWhere(key => key.startsWith('project:'))
assert.equal(cache.peek('project:1'), undefined)
assert.deepEqual(cache.peek('user:1'), { name: 'Ada' })
assert.equal(requests[1].signal.aborted, true)
await assert.rejects(p2, error => error.name === 'AbortError')

// 模拟不理会 abort 的加载器:版本门仍禁止旧结果回填。
let resolveSlow
const stubborn = createResourceCache(() => new Promise(resolve => { resolveSlow = resolve }))
const old = stubborn.get('project:3')
stubborn.invalidateWhere(key => key.startsWith('project:'))
resolveSlow({ id: 3, stale: true })
await old
assert.equal(stubborn.peek('project:3'), undefined)

let attempts = 0
const syncFailureCache = createResourceCache(() => {
  attempts++
  if (attempts === 1) throw new Error('同步失败')
  return Promise.resolve({ id: 4 })
})
await assert.rejects(syncFailureCache.get('project:4'), /同步失败/)
assert.deepEqual(await syncFailureCache.get('project:4'), { id: 4 })
assert.equal(attempts, 2)

console.log('cache extension contract: ok')

遍历的是 entries.keys();在迭代中删除当前 key 对 JavaScript Map 迭代是确定的。版本先递增,旧请求即便兑现也无法写回。同步抛出的 loader 错误也进入拒绝通道并清除条目,因此下一次可以重试。peek 不删除过期项,因为它的契约只是只读观察;实际产品还应设计定期或按容量清理策略。

第 04 篇答案:一致的水合与模块边界 ​

仅替换 hydration-lab/App.mjs,保留正文 render.mjs 与 client.mjs:

js
import { createElement as h, useEffect, useState } from 'react'

export default function App({ initialCount }) {
  const [count, setCount] = useState(initialCount)
  const [connected, setConnected] = useState(false)
  useEffect(() => { setConnected(true) }, [])
  return h('section', null,
    h('h1', null, '同一份初始内容'),
    h('p', null, connected ? '客户端已连接' : '等待客户端'),
    h('button', { onClick: () => setCount(n => n + 1) }, `计数 ${count}`),
  )
}

重新执行 node hydration-lab/render.mjs 再刷新页面,否则浏览器读到的是旧生成文件。服务器不会运行 Effect,因此输出等待文本;客户端首次也从 false 开始,匹配已有 HTML。Effect 是提交后才执行的更新,不参与两边首次结构比较。连接后文字改变,计数仍从 2 开始,点击变为 3,不应出现 recoverable error。

可给 render.mjs 在 markup 生成后增加确定性服务器检查:

js
if (!markup.includes('等待客户端') || markup.includes('客户端已连接')) {
  throw new Error('服务器初始状态不符合契约')
}

这个检查只证明服务器 HTML;浏览器仍需验证初次匹配、Effect 更新和点击行为,不能把字符串检查当完整水合测试。

RSC 三个变体分别是:普通 onClick 闭包不是支持跨边界的可序列化 prop,应在客户端组件创建事件处理器;Counter 直接导入数据库依赖会把它拉向客户端模块边界,数据库查询应留在服务端模块;Page 把产生的标题 JSX 作为 children 传入是可成立的组合,客户端组件接收可渲染内容,不必取得 Page 的函数代码。Server Function 引用有框架专门通道,不能用它为任意函数传输兜底。

返回第七章导读

章节入口核对 ​

以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。

用心记录每一步成长