Skip to content

第六章练习答案 ​

答案 06-01:用户名申请 ​

完整 src/App.jsx:

jsx
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function apply(previous, formData) {
  await wait(400)
  const name = String(formData.get('username') ?? '').trim()
  if (name.length < 3 || name.length > 12) {
    return { ...previous, message: '', error: '用户名长度必须为 3–12' }
  }
  if (name.toLowerCase() === 'taken') {
    return { ...previous, message: '', error: '用户名已占用' }
  }
  return { message: `已申请:${name}`, error: '' }
}

function Submit() {
  const { pending, data } = useFormStatus()
  return (
    <button disabled={pending}>
      {pending ? `正在提交 ${data?.get('username')}…` : '申请'}
    </button>
  )
}

export default function App() {
  const [username, setUsername] = useState('')
  const [state, action] = useActionState(apply, { message: '', error: '' })
  return (
    <main>
      <form action={action}>
        <input
          name="username"
          value={username}
          onChange={(event) => setUsername(event.target.value)}
        />
        <Submit />
      </form>
      {state.error && <p role="alert">{state.error}</p>}
      {state.message && <p>{state.message}</p>}
    </main>
  )
}

断言:ab 得到长度错误;taken 得到占用错误;随后提交 Ada 只显示 已申请:Ada。输入是受控 state,所以任何结果都不会隐式清空;每条返回路径显式清除另一种旧消息。

验证 Action 的数据契约时,把上面 wait、apply 两个函数定义复制到独立的 apply-check.mjs,追加以下代码,用 node apply-check.mjs 运行。这个检查不替代浏览器中的 pending 与受控输入观察。

js
import assert from 'node:assert/strict'
let previous = { message: '', error: '' }
for (const [name, message, error] of [
  ['ab', '', '用户名长度必须为 3–12'],
  ['TAKEN', '', '用户名已占用'],
  [' Ada ', '已申请:Ada', ''],
]) {
  const data = new FormData()
  data.set('username', name)
  previous = await apply(previous, data)
  assert.deepEqual(previous, { message, error })
}
console.log('用户名错误→恢复通过')

答案 06-02:乐观删除与恢复 ​

完整 src/App.jsx:

jsx
import { useOptimistic, useState, useTransition } from 'react'

const initial = [
  { id: 'a', title: 'Alpha' },
  { id: 'locked', title: 'Locked' },
  { id: 'c', title: 'Charlie' },
]
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
export const removeById = (items, id) => items.filter((item) => item.id !== id)

async function removeOnServer(id) {
  await wait(500)
  if (id === 'locked') throw new Error('item is locked')
}

export default function App() {
  const [items, setItems] = useState(initial)
  const [error, setError] = useState('')
  const [optimisticItems, removeOptimistic] = useOptimistic(items, removeById)
  const [deleting, startDelete] = useTransition()

  async function removeAction(id) {
    removeOptimistic(id)
    setError('')
    try {
      await removeOnServer(id)
      setItems((current) => removeById(current, id))
    } catch (reason) {
      setError(reason.message)
    }
  }

  return (
    <main>
      {deleting && <p>删除中</p>}
      {error && <p role="alert">{error}</p>}
      <ul>
        {optimisticItems.map((item) => (
          <li key={item.id}>
            {item.title}
            <button disabled={deleting} onClick={() => {
              startDelete(() => removeAction(item.id))
            }}>删除</button>
          </li>
        ))}
      </ul>
    </main>
  )
}

纯函数与权威状态断言:

js
const base = [{ id: 'a' }, { id: 'locked' }, { id: 'c' }]
const optimistic = removeById(base, 'locked')
if (optimistic.map((x) => x.id).join(',') !== 'a,c') {
  throw new Error('乐观层必须立即隐藏 locked')
}
if (base.map((x) => x.id).join(',') !== 'a,locked,c') {
  throw new Error('reducer 不得修改权威输入')
}
const successBase = removeById(base, 'a')
if (successBase.map((x) => x.id).join(',') !== 'locked,c') {
  throw new Error('成功后权威 state 必须删除 a')
}
// 失败路径不调用 setItems,因此权威 base 保留 locked;Action 结束后它按原位置恢复。

失败时没有写 items,optimistic 层结束后 React 重新以原数组渲染,所以 locked 恢复原位置;成功才更新权威 state。删除按钮用第五章已学的 useTransition 建立 Action 上下文;pending 跟踪删除工作,不从两个列表长度是否相等猜测。这个答案逐次处理删除,等待时禁用所有删除按钮;若要并发,需要额外维护每次操作和错误的身份。

答案 06-03:一个 Action 处理保存与删除 ​

完整 src/App.jsx:

jsx
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function submitProfile(previous, formData) {
  await wait(400)
  const intent = formData.get('intent')
  const email = String(formData.get('email') ?? '').trim()
  if (intent === 'save') {
    return email.includes('@')
      ? { message: `saved ${email}`, error: '' }
      : { ...previous, message: '', error: 'invalid email' }
  }
  if (intent === 'delete') {
    return formData.get('confirm') === 'on'
      ? { message: 'deleted', error: '' }
      : { ...previous, message: '', error: 'confirm deletion' }
  }
  return { ...previous, message: '', error: 'unknown intent' }
}

function Buttons() {
  const { pending } = useFormStatus()
  return (
    <>
      <button name="intent" value="save" disabled={pending}>保存</button>
      <button name="intent" value="delete" disabled={pending}>删除</button>
    </>
  )
}

export default function App() {
  const [email, setEmail] = useState('')
  const [state, action] = useActionState(
    submitProfile,
    { message: '', error: '' },
  )
  return (
    <main>
      <form action={action}>
        <input
          name="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
        />
        <label><input type="checkbox" name="confirm" />确认删除</label>
        <Buttons />
      </form>
      {state.error && <p role="alert">{state.error}</p>}
      {state.message && <p>{state.message}</p>}
    </main>
  )
}

断言:保存 ada 返回 invalid email 且输入仍是 ada;保存 ada@example.com 返回 saved;未勾选删除返回 confirm deletion;勾选后返回 deleted。点击的 submitter 才会把对应 intent 放入 FormData。

把 wait、submitProfile 复制到独立 profile-check.mjs,追加以下数据分支检查;浏览器还需实际点击两个按钮核对 submitter。

js
import assert from 'node:assert/strict'
let previous = { message: '', error: '' }
for (const [fields, expected] of [
  [{ intent: 'save', email: 'ada' }, { message: '', error: 'invalid email' }],
  [{ intent: 'save', email: 'ada@example.com' }, { message: 'saved ada@example.com', error: '' }],
  [{ intent: 'delete' }, { message: '', error: 'confirm deletion' }],
  [{ intent: 'delete', confirm: 'on' }, { message: 'deleted', error: '' }],
  [{ intent: 'other' }, { message: '', error: 'unknown intent' }],
]) {
  const data = new FormData()
  for (const [key, value] of Object.entries(fields)) data.set(key, value)
  previous = await submitProfile(previous, data)
  assert.deepEqual(previous, expected)
}
console.log('提交意图与错误恢复通过')

这仍是客户端模拟:源码和逻辑都在浏览器,任何人都能绕过检查。真实删除必须由服务端重新鉴权、校验确认策略并保证幂等。

返回第六章导读。

章节入口核对 ​

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

用心记录每一步成长