第三章练习答案
每个答案都是独立、完整的 src/App.jsx。纯逻辑断言在模块加载时运行,任何失败都会阻止页面以“看起来正常”蒙混过关。
答案 01:测验流程 reducer
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:可嵌套的单位设置
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
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:可筛选的任务清单
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 和热更新会影响日志次数;不要用某个固定次数作为功能断言。去掉这些教学日志后再做性能测量,也不要因这个小清单使用了缓存,就推断业务中每个列表都值得同样处理。
返回第三章导读。