第四章练习答案
答案 04-01:排序仍是派生值
完整 src/App.jsx:
import { useState } from 'react'
const products = [
{ id: 1, name: 'Beta', stock: 2 },
{ id: 2, name: 'Alpha', stock: 2 },
{ id: 3, name: 'Gamma', stock: 0 },
]
function sortProducts(items, mode) {
return [...items].sort((a, b) => mode === 'stock-desc'
? b.stock - a.stock || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name))
}
export default function App() {
const [query, setQuery] = useState('')
const [onlyInStock, setOnlyInStock] = useState(false)
const [sortMode, setSortMode] = useState('name')
const normalized = query.trim().toLowerCase()
const visibleProducts = products.filter((product) =>
product.name.toLowerCase().includes(normalized)
&& (!onlyInStock || product.stock > 0),
)
const sortedProducts = sortProducts(visibleProducts, sortMode)
const first = sortedProducts[0]?.name ?? 'none'
return (
<main>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<label>
<input
type="checkbox"
checked={onlyInStock}
onChange={(event) => setOnlyInStock(event.target.checked)}
/>
仅库存
</label>
<select value={sortMode} onChange={(event) => setSortMode(event.target.value)}>
<option value="name">名称</option>
<option value="stock-desc">库存降序</option>
</select>
<p>first: {first}</p>
<ul>
{sortedProducts.map((product) => (
<li key={product.id}>{product.name}({product.stock})</li>
))}
</ul>
</main>
)
}独立断言:
function sortProducts(items, mode) {
return [...items].sort((a, b) => mode === 'stock-desc'
? b.stock - a.stock || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name))
}
const source = [
{ name: 'Beta', stock: 2 },
{ name: 'Alpha', stock: 2 },
{ name: 'Gamma', stock: 0 },
]
const sorted = sortProducts(source, 'stock-desc')
if (sorted.map((item) => item.name).join(',') !== 'Alpha,Beta,Gamma') {
throw new Error('库存相同时必须按名称升序')
}
if (source.map((item) => item.name).join(',') !== 'Beta,Alpha,Gamma') {
throw new Error('不得修改输入数组')
}
if ((sortProducts([], 'name')[0]?.name ?? 'none') !== 'none') {
throw new Error('空结果必须得到 none')
}排序模式是用户选择,需要 state;结果和 first 可由当前输入确定,留在渲染中。复制数组保证 sort() 不修改已有数据。
答案 04-02:只允许当前请求提交
完整 src/App.jsx:
import { useEffect, useState } from 'react'
function loadProfile(id, { signal }) {
const delay = id === 'alice' ? 900 : id === 'fail' ? 200 : 100
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(signal.reason); return }
function abort() {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', abort)
if (id === 'fail') reject(new Error('profile unavailable'))
else resolve({ id, name: id === 'alice' ? 'Alice' : 'Bob' })
}, delay)
signal.addEventListener('abort', abort, { once: true })
})
}
export default function App() {
const [id, setId] = useState('')
const [view, setView] = useState({ phase: 'idle' })
useEffect(() => {
if (!id) {
setView({ phase: 'idle' })
return
}
const controller = new AbortController()
let current = true
setView({ phase: 'loading' })
loadProfile(id, { signal: controller.signal }).then(
(profile) => current && setView({ phase: 'success', profile }),
(error) => {
if (current && error.name !== 'AbortError') {
setView({ phase: 'error', message: error.message })
}
},
)
return () => {
current = false
controller.abort()
}
}, [id])
return (
<main>
<select value={id} onChange={(event) => setId(event.target.value)}>
<option value="">请选择</option>
<option value="alice">Alice</option>
<option value="bob">Bob</option>
<option value="fail">失败</option>
</select>
{view.phase === 'idle' && <p>请选择用户</p>}
{view.phase === 'loading' && <p>加载中…</p>}
{view.phase === 'error' && <p role="alert">{view.message}</p>}
{view.phase === 'success' && <p>{view.profile.name}</p>}
</main>
)
}手动断言:800ms 内选择 Alice 再选 Bob,900ms 后仍只能是 Bob;选失败项出现精确文本 profile unavailable;再选 Alice 会从加载恢复到 Alice。若数据源忽略 signal,current 仍拒绝旧结果,这就是两层防线的原因。
答案 04-03:回调 Ref 维护动态节点
完整 src/App.jsx:
import { useRef, useState } from 'react'
const allItems = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta' },
{ id: 'c', label: 'Gamma' },
]
export default function App() {
const nodesRef = useRef(new Map())
const [currentId, setCurrentId] = useState('a')
const [showB, setShowB] = useState(true)
const items = allItems.filter((item) => showB || item.id !== 'b')
function focusNext() {
const index = items.findIndex((item) => item.id === currentId)
const next = items[(index + 1 + items.length) % items.length]
nodesRef.current.get(next.id)?.focus()
setCurrentId(next.id)
}
return (
<main>
<p>当前:{currentId}</p>
<label>
<input
type="checkbox"
checked={showB}
onChange={(event) => setShowB(event.target.checked)}
/>
显示 b
</label>
{items.map((item) => (
<label key={item.id}>
{item.label}
<input
ref={(node) => {
nodesRef.current.set(item.id, node)
return () => { nodesRef.current.delete(item.id) }
}}
onFocus={() => setCurrentId(item.id)}
/>
</label>
))}
<button onClick={focusNext}>聚焦下一项</button>
</main>
)
}断言:连续点击时活动元素按 b、c、a 循环;隐藏 b 并从 a 开始后应为 c、a。若仍能命中 b,说明 ref 清理没有删除旧节点。Map 属于不参与渲染的 DOM 索引,当前 id 属于可见 UI,因此分别放 Ref 与 state。
答案 04-04:保留单向数据流的命令
完整 src/App.jsx:
import { useImperativeHandle, useRef, useState } from 'react'
function Field({ ref, value, onChange }) {
const input = useRef(null)
useImperativeHandle(ref, () => ({
focusAndSelect() {
input.current.focus()
input.current.select()
},
clearAndFocus() {
onChange('')
input.current.focus()
},
}), [onChange])
return <label>姓名<input ref={input} value={value}
onChange={event => onChange(event.target.value)} /></label>
}
export default function App() {
const field = useRef(null)
const [name, setName] = useState('Ada')
return <main>
<Field ref={field} value={name} onChange={setName} />
<button onClick={() => field.current.clearAndFocus()}>清空并聚焦</button>
<button onClick={() => field.current.focusAndSelect()}>聚焦并选中</button>
<p>当前姓名:{name}</p>
</main>
}清空通过 onChange 更新父 state,输入的 value 随下一次提交变空;focus 命令只改变焦点,当前输入已存在,所以不需要等新节点挂载。如果改为 input.current.value = '',屏幕文字和 React state 就可能分歧。handle 捕获 onChange,因此列为依赖;即使本调用方传入稳定的 setter,组件本身也不能假设所有调用方永远传同一个函数。
检查:输入 Grace,清空后输入和正文均无姓名,焦点在输入;再输入 Ada 并选择,selectionStart 为 0、selectionEnd 为 3。再把父组件传入的 onChange 换成会额外记录调用的函数,确认清空调用的是当前版本。这是对接口的检查,不要求父组件得到内部 DOM 才能使用它。
答案 04-05:Portal 中的两份表单
完整 src/App.jsx:
import { useId, useState } from 'react'
import { createPortal } from 'react-dom'
function NameField({ label, stopBubble }) {
const id = useId()
return <section onClick={event => {
if (stopBubble) event.stopPropagation()
}}>
<label htmlFor={`${id}-input`}>{label}</label>
<input id={`${id}-input`} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>请填写姓名。</p>
</section>
}
export default function App() {
const [hits, setHits] = useState(0)
return <main>
<p>父级点击:{hits}</p>
<section onClick={() => setHits(value => value + 1)}>
React 父级
{createPortal(<div>
<NameField label="联系人" stopBubble={false} />
<NameField label="收件人" stopBubble={true} />
</div>, document.body)}
</section>
</main>
}两个 useId 调用属于不同组件实例,label 与说明各自关联。直接点击联系人输入时,React 点击到达父 section;收件人所在 section 停止冒泡,父级不增加。点击标签仍应聚焦正确输入,但标签激活控件可能带来额外点击,因此不要拿标签点击检验“恰好增加一次”。Portal 没有改变 HTML 标签关联,也没有阻止 React 传播;两件事分别由 id 与 stopPropagation 决定。
返回第四章导读。
章节入口核对
以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。