Skip to content

第三章练习答案 ​

每个答案都是独立、完整的 src/App.jsx。纯逻辑断言在模块加载时运行,任何失败都会阻止页面以“看起来正常”蒙混过关。

答案 01:测验流程 reducer ​

jsx
import { useReducer } from 'react'

const initialState = { status: 'answering', answer: '', attempts: 0 }

export function quizReducer(state, action) {
  switch (action.type) {
    case 'answer_changed':
      return state.status === 'answering'
        ? { ...state, answer: action.value }
        : state
    case 'submitted':
      return state.status === 'answering' && state.answer.trim()
        ? { ...state, status: 'submitted', attempts: state.attempts + 1 }
        : state
    case 'retry':
      return state.status === 'submitted'
        ? { ...state, status: 'answering', answer: '' }
        : state
    case 'finish':
      return state.status === 'submitted'
        ? { ...state, status: 'finished' }
        : state
    default:
      throw new Error(`未知 action:${action.type}`)
  }
}

function check(condition, message) {
  if (!condition) throw new Error(message)
}

const answered = quizReducer(initialState, { type: 'answer_changed', value: 'React' })
const submitted = quizReducer(answered, { type: 'submitted' })
check(submitted.status === 'submitted' && submitted.attempts === 1, '合法提交失败')
check(initialState.answer === '' && initialState.attempts === 0, '输入 state 被修改')
check(quizReducer(initialState, { type: 'submitted' }) === initialState, '空答案应原样返回')
check(quizReducer(initialState, { type: 'finish' }) === initialState, '非法 finish 应原样返回')
check(quizReducer(submitted, { type: 'submitted' }) === submitted, '重复提交不能增加次数')
const retrying = quizReducer(submitted, { type: 'retry' })
check(retrying.status === 'answering' && retrying.answer === '' && retrying.attempts === 1, '重答只清空答案')
const answeredAgain = quizReducer(retrying, { type: 'answer_changed', value: 'UI 库' })
const submittedAgain = quizReducer(answeredAgain, { type: 'submitted' })
const finished = quizReducer(submittedAgain, { type: 'finish' })
check(finished.status === 'finished' && finished.attempts === 2, '重答后累计次数并完成')
check(quizReducer(finished, { type: 'retry' }) === finished, '完成后不能重答')
check(submitted.answer === 'React' && submitted.attempts === 1, '后续转换不得修改旧快照')
let unknownThrew = false
try { quizReducer(initialState, { type: 'typo' }) } catch { unknownThrew = true }
check(unknownThrew, '未知 action 必须抛错')

export default function App() {
  const [state, dispatch] = useReducer(quizReducer, initialState)

  if (state.status === 'finished') {
    return <h1>测验完成,共提交 {state.attempts} 次</h1>
  }

  return (
    <main>
      <h1>React 是什么?</h1>
      <input
        value={state.answer}
        disabled={state.status !== 'answering'}
        onChange={event => dispatch({ type: 'answer_changed', value: event.target.value })}
      />
      <p>提交次数:{state.attempts}</p>
      {state.status === 'answering' ? (
        <button onClick={() => dispatch({ type: 'submitted' })}>提交</button>
      ) : (
        <>
          <p>已提交:{state.answer}</p>
          <button onClick={() => dispatch({ type: 'retry' })}>重答</button>
          <button onClick={() => dispatch({ type: 'finish' })}>完成</button>
        </>
      )}
    </main>
  )
}

每个 action 都先检查当前 status,所以非法顺序返回同一对象;合法提交一次性改变状态与次数,不会出现“已提交但次数没加”的半完成状态。断言覆盖正常路径、空值、非法时序、未知 action 与纯函数边界。

答案 02:可嵌套的单位设置 ​

jsx
import { createContext, useContext, useState } from 'react'

const UnitContext = createContext(null)

function useUnit() {
  const unit = useContext(UnitContext)
  if (unit === null) throw new Error('useUnit 必须在 UnitContext 内使用')
  return unit
}

function Layout({ children }) {
  return <section>{children}</section>
}

function Distance({ meters }) {
  const unit = useUnit()
  const text = unit === 'metric'
    ? `${meters.toFixed(2)} m`
    : `${(meters * 3.28084).toFixed(2)} ft`
  return <p>{text}</p>
}

export default function App() {
  const [unit, setUnit] = useState('metric')

  return (
    <UnitContext value={unit}>
      <main>
        <h1>距离</h1>
        <button onClick={() => setUnit(current => (
          current === 'metric' ? 'imperial' : 'metric'
        ))}>切换外层单位</button>
        <Layout>
          <h2>跟随设置</h2>
          <Distance meters={10} />
          <UnitContext value="metric">
            <Layout>
              <h2>固定公制</h2>
              <Distance meters={10} />
            </Layout>
          </UnitContext>
        </Layout>
      </main>
    </UnitContext>
  )
}

外层切到英制后应显示 32.81 ft,内层仍显示 10.00 m。Layout 不知道单位存在;Distance 从最近 Provider 读取并即时计算结果。null 默认值配合 useUnit 会让漏包 Provider 立即失败,而不会伪装成一个合法单位。

答案 03:外部选择 Store ​

jsx
import { useSyncExternalStore } from 'react'

const allowedIds = new Set(['red', 'green', 'blue'])

export function createSelectionStore(initialId) {
  if (!allowedIds.has(initialId)) throw new RangeError('非法 initialId')
  let selectedId = initialId
  const listeners = new Set()

  return {
    getSnapshot() {
      return selectedId
    },
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
    select(id) {
      if (!allowedIds.has(id)) throw new RangeError(`非法 id:${id}`)
      if (id === selectedId) return
      selectedId = id
      listeners.forEach(listener => listener())
    },
  }
}

function check(condition, message) {
  if (!condition) throw new Error(message)
}

const probe = createSelectionStore('red')
let calls = 0
const unsubscribe = probe.subscribe(() => { calls += 1 })
probe.select('red')
check(calls === 0, '重复选择不应通知')
probe.select('green')
check(calls === 1 && probe.getSnapshot() === 'green', '变化应通知一次')
unsubscribe()
probe.select('blue')
check(calls === 1, '退订后不应通知')
let invalidThrew = false
try { probe.select('orange') } catch (error) { invalidThrew = error instanceof RangeError }
check(invalidThrew, '非法 id 必须抛 RangeError')

const selectionStore = createSelectionStore('red')

function useSelection() {
  return useSyncExternalStore(
    selectionStore.subscribe,
    selectionStore.getSnapshot,
    () => 'red',
  )
}

function Preview({ label }) {
  const selected = useSelection()
  return <p>{label}:{selected}</p>
}

export default function App() {
  const selected = useSelection()
  return (
    <main>
      <h1>选择颜色:{selected}</h1>
      {['red', 'green', 'blue'].map(id => (
        <button key={id} onClick={() => selectionStore.select(id)}>{id}</button>
      ))}
      <Preview label="预览 A" />
      <Preview label="预览 B" />
    </main>
  )
}

字符串快照未变化时保持 Object.is 相同。select 先校验、再排除重复值,最后更新并逐个通知;因此每个真实变化对每位 listener 恰好一次。退订闭包删除的是订阅时收到的同一个函数。两个预览和标题虽各自订阅,却读取同一 Store。

答案 04:可筛选的任务清单 ​

jsx
import { memo, useCallback, useMemo, useState } from 'react'

const initialTasks = [
  { id: 'read', title: '阅读组件身份', done: false },
  { id: 'trace', title: '推演更新队列', done: false },
  { id: 'run', title: '启动实验环境', done: true },
]

const TaskList = memo(function TaskList({ items, onToggle }) {
  console.log('[TaskList] 执行组件')
  if (items.length === 0) return <p>全部完成</p>
  return (
    <ul>
      {items.map(task => (
        <li key={task.id}>
          <label>
            <input
              type="checkbox"
              checked={task.done}
              onChange={() => onToggle(task.id)}
            />
            {task.title}
          </label>
        </li>
      ))}
    </ul>
  )
})

export default function App() {
  const [tasks, setTasks] = useState(initialTasks)
  const [onlyOpen, setOnlyOpen] = useState(true)
  const [dark, setDark] = useState(false)

  const visible = useMemo(() => {
    console.log('[tasks filter] 重新计算')
    return tasks.filter(task => !onlyOpen || !task.done)
  }, [tasks, onlyOpen])

  const toggleTask = useCallback(id => {
    setTasks(current => current.map(task => (
      task.id === id ? { ...task, done: !task.done } : task
    )))
  }, [])

  return (
    <main style={{ color: dark ? '#fff' : '#111', background: dark ? '#222' : '#fff' }}>
      <h1>学习任务</h1>
      <button onClick={() => setDark(current => !current)}>切换配色</button>
      <label>
        <input
          type="checkbox"
          checked={onlyOpen}
          onChange={event => setOnlyOpen(event.target.checked)}
        />
        只看未完成
      </label>
      <TaskList items={visible} onToggle={toggleTask} />
    </main>
  )
}

首次出现两项未完成任务。依次完成它们后显示“全部完成”;取消“只看未完成”,三项都出现且全部勾选,证明第二次更新没有覆盖第一次。再取消任意任务的完成状态,重新开启筛选,应只剩这项。每次操作都沿 current → 新数组 → 目标新对象 更新,其余任务对象保留引用。

useMemo 读取 tasks 与 onlyOpen,所以两者都在依赖中。toggleTask 只把纯更新函数交给稳定 setter,不读取闭包里的 tasks,因此空依赖正确。若写成 setTasks(tasks.map(...)) 却仍留 [],回调会反复从初始 tasks 计算,后一次点击可能还原前一次结果。

清空首次渲染日志再切配色,筛选和 TaskList 通常都可以跳过;勾选任务或更改筛选条件,则应重新计算并显示正确结果。删除 useMemo 后,每次 filter 都产生新数组;删除 useCallback 后,父组件每次产生新 onToggle。任何一个 prop 变了,memo 就不能跳过本轮父级更新。UI 在这些变体中都应正确,区别只在重复工作。

Strict Mode 和热更新会影响日志次数;不要用某个固定次数作为功能断言。去掉这些教学日志后再做性能测量,也不要因这个小清单使用了缓存,就推断业务中每个列表都值得同样处理。

返回第三章导读。

用心记录每一步成长