Skip to content

第四章练习答案 ​

答案 04-01:排序仍是派生值 ​

完整 src/App.jsx:

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>
  )
}

独立断言:

js
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:

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:

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:

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:

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 决定。

返回第四章导读。

章节入口核对 ​

以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。

用心记录每一步成长