第七章练习答案
每个答案先实现题目明示的契约,再用会让错误实现失败的断言锁定边界。前三篇的纯函数代码分别保存为独立 .mjs 文件运行,避免同名声明互相影响;水合题沿用正文的多文件实验。
第 01 篇答案:URL 状态与数据加载
// 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 篇答案:路由错误边界与嵌套路由
// 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,其余保留以便答案可独立运行。
// 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:
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 生成后增加确定性服务器检查:
if (!markup.includes('等待客户端') || markup.includes('客户端已连接')) {
throw new Error('服务器初始状态不符合契约')
}这个检查只证明服务器 HTML;浏览器仍需验证初次匹配、Effect 更新和点击行为,不能把字符串检查当完整水合测试。
RSC 三个变体分别是:普通 onClick 闭包不是支持跨边界的可序列化 prop,应在客户端组件创建事件处理器;Counter 直接导入数据库依赖会把它拉向客户端模块边界,数据库查询应留在服务端模块;Page 把产生的标题 JSX 作为 children 传入是可成立的组合,客户端组件接收可渲染内容,不必取得 Page 的函数代码。Server Function 引用有框架专门通道,不能用它为任意函数传输兜底。
章节入口核对
以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。