Skip to content

第一章练习答案 ​

先独立完成题目,再用实现与验收点定位差异。三个代码块都分别是完整的 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" 会额外启用浏览器的邮箱语法校验,使 @ 这样的字符串虽然通过题目规则,却无法提交。真实邮箱验证应另定契约,不能把这个教学条件当成完整验证器。

返回第一章导读。

用心记录每一步成长