第一章练习答案
先独立完成题目,再用实现与验收点定位差异。三个代码块都分别是完整的 src/App.jsx,不要拼到同一个文件。
答案 01:会员价格牌
jsx
const products = [
{ id: 'tea', name: '乌龙茶', price: 28, discount: 0 },
{ id: 'cup', name: '玻璃杯', price: 39, discount: 0.2 },
]
function MemberPrice({ price, discount = 0, children }) {
if (!Number.isFinite(price) || price < 0 ||
!Number.isFinite(discount) || discount < 0 || discount > 1) {
throw new RangeError('price 和 discount 超出契约')
}
const finalPrice = price * (1 - discount)
return (
<p>
{discount === 0 ? (
<strong>¥{price.toFixed(2)}</strong>
) : (
<>
<del>¥{price.toFixed(2)}</del>{' '}
<strong>¥{finalPrice.toFixed(2)}</strong>
</>
)}
{' '}{children}
</p>
)
}
export default function App() {
return (
<main>
<h1>会员价</h1>
{products.map(product => (
<article key={product.id}>
<h2>{product.name}</h2>
<MemberPrice price={product.price} discount={product.discount}>
<small>{product.discount > 0 ? '会员专享' : '日常价'}</small>
</MemberPrice>
</article>
))}
</main>
)
}验收:页面应出现 ¥28.00;玻璃杯同时出现被划掉的 ¥39.00 和 ¥31.20。计算只生成局部变量,没有给 product 赋值;children 的位置由 MemberPrice 决定。范围检查让非法输入失败可见,而不是显示误导价格。
答案 02:可筛选的座位备注
jsx
import { useState } from 'react'
const seats = [
{ id: 'A-01', label: '靠窗 1', zone: 'A' },
{ id: 'A-02', label: '靠窗 2', zone: 'A' },
{ id: 'B-01', label: '吧台 1', zone: 'B' },
]
function SeatRow({ seat, note, onNoteChange }) {
return (
<li>
<label>
{seat.id} {seat.label}
<input
aria-label={`${seat.id} 备注`}
value={note}
onChange={event => onNoteChange(seat.id, event.target.value)}
/>
</label>
</li>
)
}
export default function App() {
const [zone, setZone] = useState('all')
const [reversed, setReversed] = useState(false)
const [notes, setNotes] = useState({})
const filtered = seats.filter(seat => zone === 'all' || seat.zone === zone)
const visible = reversed ? [...filtered].reverse() : filtered
function changeNote(id, value) {
setNotes(current => ({ ...current, [id]: value }))
}
return (
<main>
<h1>座位备注</h1>
<button onClick={() => setZone('all')}>全部</button>
<button onClick={() => setZone('A')}>A 区</button>
<button onClick={() => setZone('C')}>C 区</button>
<button onClick={() => setReversed(current => !current)}>切换顺序</button>
{visible.length === 0 ? <p>没有座位</p> : (
<ul>
{visible.map(seat => (
<SeatRow
key={seat.id}
seat={seat}
note={notes[seat.id] ?? ''}
onNoteChange={changeNote}
/>
))}
</ul>
)}
</main>
)
}这里使用 seat.id 关联重排前后的行,并用同一个 id 索引父组件的备注映射。筛选掉 B-01 会卸载行组件,但备注所有者仍在,因此恢复“全部”后值仍存在。C 区验证空结果分支;它不会破坏备注。稳定 key 负责仍在树中的身份匹配,提升后的映射负责跨卸载保存数据,两者职责不同。
答案 03:带确认的通知设置
jsx
import { useState } from 'react'
export default function App() {
const [email, setEmail] = useState('')
const [subscribed, setSubscribed] = useState(true)
const [history, setHistory] = useState([])
const normalizedEmail = email.trim()
const isValid = normalizedEmail.includes('@')
function handleSubmit(event) {
event.preventDefault()
if (!isValid) return
setHistory(current => [
...current,
{ id: crypto.randomUUID(), email: normalizedEmail, subscribed },
])
setEmail('')
}
return (
<main>
<h1>通知设置</h1>
<form onSubmit={handleSubmit}>
<label>
邮箱
<input
type="text"
inputMode="email"
value={email}
onChange={event => setEmail(event.target.value)}
/>
</label>
<label>
<input
type="checkbox"
checked={subscribed}
onChange={event => setSubscribed(event.target.checked)}
/>
接收周报
</label>
{!isValid && email.length > 0 && <p role="alert">邮箱必须包含 @</p>}
<p>预览:{normalizedEmail || '未填写'} / {subscribed ? '订阅' : '不订阅'}</p>
<button type="submit" disabled={!isValid}>保存</button>
</form>
<h2>提交历史</h2>
<ul>
{history.map(item => (
<li key={item.id}>
{item.email} / {item.subscribed ? '订阅' : '不订阅'}
</li>
))}
</ul>
</main>
)
}逐项验收:初始 email === '',所以文本输入从第一次渲染起就是受控的;checkbox 从 subscribed 读取布尔值;handleSubmit 对无效值提前返回,即使以后有人移除 disabled 也不会写入;追加历史时生成一次 id 并存进数据,后续渲染不会改变 key;提交只清空 email,符合“保留订阅选择”的契约。
这里用文本输入搭配 inputMode="email" 提示移动键盘,校验完全遵守题目的“包含 @”简化规则。type="email" 会额外启用浏览器的邮箱语法校验,使 @ 这样的字符串虽然通过题目规则,却无法提交。真实邮箱验证应另定契约,不能把这个教学条件当成完整验证器。
返回第一章导读。