Skip to content

第八章练习答案 ​

第 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')

先检查构建门禁,因为尚未产生可信候选版本时谈回滚没有意义;版本不符优先于线上错误率,因为指标可能来自旧版。最后一个断言证明“相对翻倍”不足以触发回滚,还要满足绝对增量,避免极小基线制造噪声。真实系统还要定义采样窗口、最小流量、数据缺失和手工熔断,这些属于部署平台契约。

返回第八章导读

章节入口核对 ​

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

用心记录每一步成长