第八章练习答案
第 01 篇答案:组件测试与可访问性
沿用正文的 Vitest、jsdom、Testing Library、user-event 与 jest-dom 配置。React 会在下一次离散用户事件前提交由前一次事件触发的更新,因此这里用 pending 状态和禁用按钮表达重复提交契约;不需要额外的 ref 冒充锁。
jsx
// src/AsyncSignupForm.jsx
import { useState } from 'react'
export default function AsyncSignupForm({ onSave }) {
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState('')
const [state, setState] = useState({ status: 'idle', message: '' })
async function submit(event) {
event.preventDefault()
if (state.status === 'pending') return
setState({ status: 'idle', message: '' })
const normalized = email.trim()
if (!normalized.includes('@')) {
setEmailError('请输入有效邮箱')
return
}
setEmailError('')
setState({ status: 'pending', message: '' })
try {
await onSave(normalized)
setState({ status: 'success', message: '账户已创建' })
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '提交失败'
setState({ status: 'error', message })
}
}
return (
<form onSubmit={submit} noValidate>
<label htmlFor="async-email">邮箱</label>
<input
id="async-email"
name="email"
type="email"
value={email}
aria-invalid={Boolean(emailError)}
aria-describedby={emailError ? 'async-email-error' : undefined}
onChange={event => setEmail(event.target.value)}
/>
{emailError && <p id="async-email-error" role="alert">{emailError}</p>}
<button type="submit" disabled={state.status === 'pending'}>
{state.status === 'pending' ? '提交中…' : '创建账户'}
</button>
{state.status === 'success' && <p role="status">{state.message}</p>}
{state.status === 'error' && <p role="alert">{state.message}</p>}
</form>
)
}jsx
// src/AsyncSignupForm.test.jsx
import { expect, test, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AsyncSignupForm from './AsyncSignupForm'
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
test('非法邮箱显示关联错误且不调用保存', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(<AsyncSignupForm onSave={onSave} />)
await user.type(screen.getByRole('textbox', { name: '邮箱' }), 'not-an-email')
await user.click(screen.getByRole('button', { name: '创建账户' }))
const input = screen.getByRole('textbox', { name: '邮箱' })
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input).toHaveAccessibleDescription('请输入有效邮箱')
expect(onSave).not.toHaveBeenCalled()
})
test('pending 阶段阻止重复提交,成功后给出状态', async () => {
const user = userEvent.setup()
const request = deferred()
const onSave = vi.fn(() => request.promise)
render(<AsyncSignupForm onSave={onSave} />)
const input = screen.getByRole('textbox', { name: '邮箱' })
await user.type(input, 'ada@example.com')
await user.keyboard('{Enter}{Enter}')
expect(onSave).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '提交中…' })).toBeDisabled()
request.resolve()
expect(await screen.findByRole('status')).toHaveTextContent('账户已创建')
expect(screen.getByRole('button', { name: '创建账户' })).toBeEnabled()
})
test('非 Error 拒绝也显示后备消息并允许重试', async () => {
const user = userEvent.setup()
const request = deferred()
render(<AsyncSignupForm onSave={() => request.promise} />)
await user.type(screen.getByRole('textbox', { name: '邮箱' }), 'ada@example.com')
await user.keyboard('{Enter}')
request.reject()
expect(await screen.findByRole('alert')).toHaveTextContent('提交失败')
expect(screen.getByRole('button', { name: '创建账户' })).toBeEnabled()
})
test('成功之后再次提交非法输入,不保留旧成功提示', async () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue(undefined)
render(<AsyncSignupForm onSave={onSave} />)
const input = screen.getByRole('textbox', { name: '邮箱' })
await user.type(input, 'ada@example.com')
await user.keyboard('{Enter}')
expect(await screen.findByRole('status')).toHaveTextContent('账户已创建')
await user.clear(input)
await user.keyboard('{Enter}')
expect(screen.getByRole('alert')).toHaveTextContent('请输入有效邮箱')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(onSave).toHaveBeenCalledOnce()
})测试先锁定正文已有的邮箱校验与错误关联,再分别控制 Promise 的兑现和拒绝。按钮的 pending 状态阻止后续用户提交;如果业务还会从代码里同步重入 submit,那是另一个调用协议,应在调用边界单独防重,而不是把所有普通键盘事件描述成 React 来不及提交。
每次新的提交尝试先清掉上一轮结果,再校验当前输入。否则第一次成功、第二次输入非法时,页面会同时显示旧成功消息与新错误。校验失败不调用保存,也不应继续显示上次服务端的结果;最后一条测试专门验证跨轮状态。
第 02 篇答案:性能剖析与渲染预算
js
// summarize-commits.mjs
import assert from 'node:assert/strict'
export function summarize(samples) {
if (!Array.isArray(samples) || samples.length === 0) {
throw new TypeError('samples 必须是非空数组')
}
if (samples.some(sample => !Number.isFinite(sample.actualDuration) || sample.actualDuration < 0)) {
throw new RangeError('actualDuration 必须是非负有限数')
}
const durations = samples.map(sample => sample.actualDuration).sort((a, b) => a - b)
const middle = Math.floor(durations.length / 2)
const median = durations.length % 2
? durations[middle]
: (durations[middle - 1] + durations[middle]) / 2
const p95Index = Math.ceil(durations.length * 0.95) - 1
return {
count: samples.length,
median,
p95: durations[p95Index],
maxBaseDuration: Math.max(...samples.map(sample => sample.baseDuration)),
}
}
const odd = [{ actualDuration: 9, baseDuration: 12 }, { actualDuration: 1, baseDuration: 4 }, { actualDuration: 5, baseDuration: 8 }]
assert.deepEqual(summarize(odd), { count: 3, median: 5, p95: 9, maxBaseDuration: 12 })
assert.deepEqual(odd.map(item => item.actualDuration), [9, 1, 5])
const even = Array.from({ length: 20 }, (_, index) => ({ actualDuration: index + 1, baseDuration: 30 }))
assert.equal(summarize(even).median, 10.5)
assert.equal(summarize(even).p95, 19)
assert.throws(() => summarize([]), TypeError)
assert.throws(() => summarize([{ actualDuration: -1, baseDuration: 1 }]), RangeError)
console.log('profiler summary contract: ok')最近秩定义用 ceil(n × 0.95) - 1 转成零基索引;20 个样本的 p95 是第 19 个值。先 map 再排序,输入顺序保持不变。真实采样还应验证 baseDuration 为有限非负数,本题只要求取最大值。
第 03 篇答案:发布、监控与升级
js
// release-gate.mjs
import assert from 'node:assert/strict'
export function decideRelease(input) {
for (const name of ['lint', 'test', 'build']) {
if (input.checks[name] !== true) return { action: 'block', reason: `${name}-failed` }
}
if (input.servedRelease !== input.expectedRelease) {
return { action: 'rollback', reason: 'release-mismatch' }
}
const absoluteIncrease = input.errorRate - input.baselineErrorRate
if (input.errorRate > input.baselineErrorRate * 2 && absoluteIncrease >= 0.01) {
return { action: 'rollback', reason: 'error-rate-regression' }
}
return { action: 'promote', reason: 'all-gates-passed' }
}
const healthy = {
checks: { lint: true, test: true, build: true },
expectedRelease: 'abc',
servedRelease: 'abc',
baselineErrorRate: 0.01,
errorRate: 0.015,
}
assert.deepEqual(decideRelease(healthy), { action: 'promote', reason: 'all-gates-passed' })
assert.deepEqual(decideRelease({
...healthy,
checks: { lint: true, test: false, build: true },
servedRelease: 'old',
}), { action: 'block', reason: 'test-failed' })
assert.deepEqual(decideRelease({ ...healthy, servedRelease: 'old' }), {
action: 'rollback', reason: 'release-mismatch',
})
assert.deepEqual(decideRelease({ ...healthy, errorRate: 0.031 }), {
action: 'rollback', reason: 'error-rate-regression',
})
assert.deepEqual(decideRelease({ ...healthy, baselineErrorRate: 0.001, errorRate: 0.003 }), {
action: 'promote', reason: 'all-gates-passed',
})
console.log('release gate contract: ok')先检查构建门禁,因为尚未产生可信候选版本时谈回滚没有意义;版本不符优先于线上错误率,因为指标可能来自旧版。最后一个断言证明“相对翻倍”不足以触发回滚,还要满足绝对增量,避免极小基线制造噪声。真实系统还要定义采样窗口、最小流量、数据缺失和手工熔断,这些属于部署平台契约。
章节入口核对
以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。