EXAM-20260729-01 阅卷报告:Anatomy 列表状态流
总览
- 考生:Dano Day
- 得分:63 / 90(70%)
- 评级:🌕🌕🌕🌑🌑 — 及格,有需改进之处
- 致命问题:3 个
- Git 标准答案基准:
0e800f1d0b7ced0a105b0a8ae3df3acd76b84072 - 试卷文件:
apps/app/src/hooks/use-debounce.tsapps/app/src/pages/AnatomiesPage/AnatomiesCreateDialog.tsxapps/app/src/pages/AnatomiesPage/AnatomiesPageContent.tsxapps/app/src/pages/AnatomiesPage/AnatomiesPage.tsx
本次不对翻译 key、是否使用 t(...)、直接文案、复杂 className 还原度或布局视觉差异扣分。按钮名称、错误/加载/空状态、列表 key、事件边界和可操作性仍按功能评分。
成绩汇总
| 题号 | 文件 | 知识点 | 得分 | 错误类型 | 主要结论 |
|---|---|---|---|---|---|
| 1 | use-debounce.ts | 泛型 Hook、副作用清理 | 9/10 | 洁癖 | 功能与依赖正确,仅有缩进、分号和尾随空格 |
| 2 | AnatomiesCreateDialog.tsx | 表单校验、提交边界 | 3/10 | 功能 | 空名称仍提交,且提交值缺少必需的 structure |
| 3 | AnatomiesCreateDialog.tsx | 受控输入、可访问表单 | 8/10 | 模式/洁癖 | 标签、alert、按钮完整;输入未绑定 value,存在注释 import 与题目标记 typo |
| 4 | AnatomiesPageContent.tsx | URL 与本地状态同步 | 7/10 | 功能/模式 | 基本同步成立;未把空白搜索归一为 undefined,未即时重置本地页码 |
| 5 | AnatomiesPageContent.tsx | Refine 列表查询、安全派生值 | 8/10 | 模式 | filters、分页和总页数正确;缺少稳定空数组回退,memo 依赖过宽 |
| 6 | AnatomiesPageContent.tsx | mutation 参数与生命周期 | 6/10 | 功能 | 创建分支正确;归档 values 应为 { id } 而非字符串 |
| 7 | AnatomiesPageContent.tsx | 路由、事件边界、分页 | 6/10 | 功能/模式 | 路由和分页基本正确;行点击未排除按钮,且两个 Hook 依赖不完整 |
| 8 | AnatomiesPageContent.tsx | 异步 JSX、列表交互、可访问性 | 6/10 | 功能/洁癖 | 状态顺序进步明显;搜索未受控、错误无重试、按钮冒泡、分页未禁用、option 缺 key |
| 9 | AnatomiesPage.tsx | 路由适配层 | 10/10 | — | Props 映射和组件职责完全正确 |
| 合计 | 63/90 | 70% |
逐题批改
第1题:实现可清理的泛型防抖 Hook — 9/10
知识点:泛型 Hook、Effect 清理、依赖数组。错误类型:代码洁癖。
考生核心代码:
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
标准答案核心:
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
- 功能:状态、延迟更新、旧定时器清理和依赖数组全部正确。
- 模式:泛型输入与输出保持一致,没有闭包时序问题。
- 洁癖:第25行有尾随空格;缩进和分号不统一,但不影响行为。
- 改进:提交前运行 formatter 或
git diff --check,这题即可满分。
第2题:校验并提交 Anatomy 草稿 — 3/10
知识点:早返回、Schema 输入边界、表单规范化。错误类型:功能错误。
考生核心代码:
setError(null)
const name1 = name.trim()
if (name1.length === 0) setError("name length is 0")
onCreate({ name: name1, purpose })
标准答案核心:
if (!name.trim()) {
setError(/* 必填提示 */);
return;
}
const draft = createEmptyAnatomyDraft(name.trim(), purpose.trim());
onCreate(draft as unknown as Record<string, unknown>);
- 功能:空名称会设置错误,但没有
return,仍然继续提交;这是上次“互斥分支缺少提前结束”的同类错误。 - 功能:
createEmptyAnatomyDraft还会生成structure.schemaVersion/defaultPolicies/root。只提交{ name, purpose }会缺少创建 Schema 的必需结构。 - 模式:
useCallback依赖正确;名称修剪正确,但 purpose 未修剪。 - 洁癖:
name1可改成语义更明确的trimmedName。 - 改进:校验失败路径先终止,再由共享工厂生成完整领域对象,不要手写一个“看起来差不多”的部分对象。
第3题:组合可访问的创建对话框 — 8/10
知识点:输入标签、错误语义、UI 组件复用、受控输入。错误类型:模式与洁癖。
考生核心代码:
{error && <div role="alert">{error}</div>}
<Label htmlFor="name">name</Label>
<Input id="name" onChange={handleNameChange} />
<Label htmlFor="purpose">purpose</Label>
<Textarea id="purpose" onChange={handlePurposeChange} />
<Button type="button" onClick={handleSave}>submit</Button>
<Button type="button" onClick={handleClose}>close</Button>
标准答案关键行为:
<input value={name} onChange={handleNameChange} />
<textarea value={purpose} onChange={handlePurposeChange} />
{error && <div>{error}</div>}
- 功能:错误条件渲染、
role="alert"、Label 关联、取消和提交操作都存在;使用项目 UI 组件是正确方向。 - 模式:Input/Textarea 缺少
value,目前是非受控输入。输入仍能触发状态,但外部状态变化或清理时 UI 不会同步。 - 洁癖:保留了注释 import,题目标记被改成
文件 2/{4},并有多项 Props 顺序 lint 错误。 - i18n:直接使用 Create/name/purpose/submit/close 不扣分。
- 改进:JSX 不需要复刻复杂样式,但数据绑定三件套
id + value + onChange应完整。
第4题:双向同步 URL、筛选状态与防抖搜索 — 7/10
知识点:URL 单一数据源、防抖时序、Effect 依赖。错误类型:功能边界与模式。
考生核心代码:
if (prevDebouncedRef.current !== debouncedSearch) {
void routeNavigate({
to: ".",
search: { ...searchParams, search: debouncedSearch, page: undefined },
})
prevDebouncedRef.current = debouncedSearch
}
标准答案核心:
const value = debouncedSearch.trim() || undefined;
if (value !== searchParams.search) {
void routeNavigate({
to: ".",
search: { ...searchParams, search: value, page: undefined },
});
setPage(1);
}
- 功能:外部 search/status/page 回填和防抖后导航都已实现。
- 功能:输出搜索词没有 trim,也没有把空字符串转为 undefined;URL 会保留
search=。 - 功能:导航清除了 URL page,但没有同时
setPage(1),新路由回填前可能短暂用旧页查询。 - 模式:lint 报告
searchParams与routeNavigate缺失依赖。标准实现为避免 searchParams 对象引发循环进行了有意识的抑制;你的版本没有解释该取舍。 - 改进:先完成“规范化 → 与 URL 比较 → 导航 → 本地同步”四步,ref 只负责判断是否处理过输入值。
第5题:建立筛选条件与安全的 Refine 列表查询 — 8/10
useMemo的安全数组 知识点:Refine useList、条件数组、稳定派生值。错误类型:模式问题。
考生核心代码:
const filters = useMemo(() => {
const result = [];
if (search) result.push({ field: "search", operator: "contains", value: search });
if (status) result.push({ field: "status", operator: "eq", value: status });
return result;
}, [search, status]);
const { result, query } = useList<AnatomySummary>({
pagination: { currentPage: page, pageSize: PAGE_SIZE },
resource: ResourceName.anatomies,
filters,
});
const anatomies = useMemo(() => result?.data, [result]);
标准答案核心:
const anatomies = useMemo(
() => result?.data ?? [],
[result?.data],
);
const total = result?.total ?? 0;
- 功能:两个筛选条件、分页、资源、total 和 totalPages 都正确;TypeScript 未在本题报告类型错误。
- 模式:缺少
?? [],没有兑现“始终提供安全数组”的接口;依赖整个 result 比依赖result?.data更宽。 - 疑问:这里的 useMemo 不是为了减少昂贵计算,而是为了让“暂无数据时的空数组”保持稳定引用。若没有下游依赖引用稳定性,完全可以写
const anatomies = result?.data ?? [],或使用模块级EMPTY_ARRAY。total是 number 原始值,不存在数组/对象引用不断变化的问题,因此无需 memo。 - 改进:不要为了 useMemo 而 useMemo;先判断下游是否依赖引用稳定性。
第6题:完成创建与归档 mutation 生命周期 — 6/10
知识点:普通 mutation、自定义 mutation、响应导航。错误类型:功能错误。
考生创建代码与标准行为等价:
createRecord(
{ resource: ResourceName.anatomies, values: _values },
{ onSuccess: /* 关闭并按响应 id 导航 */ },
)
归档代码:
archiveRecord({
url: AnatomyCustomAction.archive,
values: _id,
method: "post",
}, { onSuccess: () => query?.refetch?.() })
标准答案:
archiveRecord({
url: AnatomyCustomAction.archive,
method: "post",
values: { id },
}, { onSuccess: () => query?.refetch?.() });
- 功能:创建请求、成功关闭、安全读取 id 和详情导航均正确。
- 功能:归档 payload 错误。
archiveAnatomyInputSchema明确要求{ id: string },字符串会在 data provider 的 Zod parse 阶段被拒绝。 - 模式:
_values和_id实际被使用,不应使用下划线前缀。 - 疑问:
useCustomMutation的第一个参数描述 action 身份和 payload。Hook 使用字段名values,data provider 内部把它交给custom的 payload,再由该 action 对应的 Zod schema 校验。最快确认方法是看AnatomyCustomAction.archive的 data provider 分支和相邻测试,而不是猜对象形状。 - 疑问:
router被回调闭包读取,所以必须写进依赖数组。即使当前 router 引用通常稳定,Hook 规则也要求把外部读取值声明出来,避免未来实现变化形成陈旧闭包。
第7题:维护弹窗、详情导航与分页边界 — 6/10
知识点:路由适配、事件冒泡、分页边界、Hook 依赖。错误类型:功能与模式。
考生行点击代码:
const handleRowClick = useCallback(
(_event, id) => handleNavigateDetail(id),
[handleNavigateDetail],
);
标准答案核心:
const target = event.target as HTMLElement;
if (target.closest("button")) return;
handleNavigateDetail(id);
- 功能:关闭弹窗、清理 create 参数、详情路由、上一页和下一页计算基本正确;下一页主动 clamp 到 totalPages 是合理增强。
- 致命问题:行点击没有排除 button。点击 Archive 会同时冒泡到行,既归档又进入详情页。
- 模式:handleCloseDialog 展开了完整 searchParams,但依赖只写
searchParams.create;handleNextPage 使用 totalPages,却未把它放进依赖数组。 - 疑问:
Route.useNavigate()是当前文件路由生成的类型化导航器,擅长to: "."和当前路由 search 更新;useRouter().navigate()来自全局 router 实例,适合明确的跨路由绝对目标。这里局部筛选用 routeNavigate,跳详情用 router.navigate,职责不同。 - 改进:行可点击且内部还有按钮时,优先在行处理器里用
closest("button")做统一防线;也可在操作区 stopPropagation,二选一至少要可靠存在。[event.target.closest()](../../../../浏览器&网络/浏览器环境/Web API/事件/event.target.closest().md)
第8题:组合完整的 Anatomy 列表异步界面 — 6/10
知识点:异步状态机、受控筛选、列表 key、交互边界、可访问性。错误类型:功能与洁癖。
考生状态骨架:
query.isLoading ? <LoadingState />
: query.isError ? <div>ERROR</div>
: anatomies.length <= 0 ? <EmptyState />
: anatomies.map(/* rows */)
标准状态骨架:
query.isLoading ? <LoadingState />
: query.isError ? <ErrorState action={/* refetch */} />
: anatomies.length === 0 ? <EmptyState />
: anatomies.map(/* rows with key and isolated actions */)
- 进步:这次已经严格排对
loading → error → empty → data,列表行也有稳定 key;这是相对上次最明确的提升。 - 功能:搜索 Input 没有 value,清筛选时输入框不会跟随状态清空,URL 初始搜索也不会显示。
- 功能:清筛选按钮只判断
searchParams.status,仅有搜索词时不会出现。 - 功能:错误态有可理解文本,但没有重试入口。
- 功能:Archive 按钮总是显示,已归档记录仍可再次操作;上一页/下一页按钮没有 disabled 状态。
- 接口:第7题的按钮冒泡问题在本题真实发生;已声明的 handleStopPropagation 未接入 JSX。
- 洁癖:status option 缺 key;map 的 index 未使用;有空 div、未使用 import、
20xp拼写错误。 - 样式:没有复刻复杂 grid、颜色或像素值不扣分;
20xp仅作为无效 class 的清理问题记录。 - 改进:JSX 先守住五件事:状态顺序、value/onChange、map key、按钮 disabled、事件隔离。视觉类名放在这些行为之后。
第9题:用路由参数组合页面内容组件 — 10/10
知识点:路由包装层、Props 映射、职责分离。错误类型:无。
考生代码:
const { page, search, status } = Route.useSearch();
return (
<AnatomiesPageContent
initialPage={page}
search={search}
status={status}
/>
);
- 功能:三个参数映射完全正确。
- 模式:没有在路由包装层复制本地状态或数据请求,职责边界清楚。
- 洁癖:空行较多,但未构成功能或 lint 问题,不扣分。
多文件数据流与接口一致性
Route search → AnatomiesPage → AnatomiesPageContent → useDebounce/useList 的主链路已接通;AnatomiesCreateDialog → onCreate → handleCreate → useCreate → 详情路由 的 Props 和回调类型也一致。
真正的接口断点有两个:Dialog 交付的是不完整草稿,无法满足 Anatomy 创建 Schema;归档 mutation 交付字符串而不是 { id },无法通过 action Schema。交互层还有一个断点:列表行点击与行内归档按钮没有隔离。
疑问解答
1. void routeNavigate(...) 是做什么的?
导航通常返回 Promise。前面加 void 表示“我明确选择忽略这个返回值”,也能满足部分 no-floating-promises 规则。它不会捕获失败,也不会让操作更安全;需要根据结果继续处理时使用 await,需要错误反馈时使用 await 或 .catch(...)。
2. 第4题的防抖 URL 逻辑哪里需要修?
顺序应是:修剪防抖值 → 空字符串变 undefined → 与当前 URL search 比较 → 不同时导航并清 page → 同步本地 page 为 1。这样既不保留 search=,也不会为同值重复导航。
3. 为什么 anatomies 用 useMemo,total 不用?
不是因为 result?.data 计算很重,而是空数组是对象,每次写 ?? [] 都可能产生新引用。若数组会进入 Effect、memo 子组件或其他依赖,稳定引用有价值。number 是原始值,result?.total ?? 0 不会制造对象引用问题。你认为“计算本身不重”是正确的;没有引用稳定需求时可以不用 useMemo。
4. 为什么 router 是 useCallback 依赖?
回调函数读取了外层 router,所以 router 属于闭包依赖。依赖数组不是只列“会变化的业务状态”,而是列回调从外部读取的值。它当前可能稳定,但显式声明能避免未来陈旧闭包。
5. 自定义 mutation 的 values 怎么确认?
沿着 AnatomyCustomAction.archive → dataProvider.custom → archiveAnatomyInputSchema 查。这个 schema 是 { id: z.string() },所以 values 必须是 { id }。相邻的 dataProvider.test.ts 也直接展示了合法 payload。
6. routeNavigate 和 router.navigate 的区别?
Route.useNavigate() 绑定当前路由,类型推导更了解当前 search schema,适合相对导航和局部 URL 状态;useRouter().navigate() 是全局 router 实例方法,适合带绝对路径和 params 的跨页面导航。两者底层都走 TanStack Router,但类型上下文和使用范围不同。
验证结果
| 检查 | 结果 | 说明 |
|---|---|---|
bun run check-types | 失败 | 8 个错误;6 个在答卷范围,其中 useTranslation 未使用按规则不扣分;另 2 个来自试卷外既有文件 |
| 目标文件 oxlint | 失败 | 14 个 error、12 个 warning;关键项为 Hook 依赖、按钮冒泡相关未消费回调、option key 和未使用项 |
bun run test | 部分通过 | 17 个文件中 14 个通过;61/65 测试通过,4 个失败均位于试卷外文件 |
dataProvider.test.ts | 通过 | 10/10,通过测试证明归档 action 需要对象 payload |
git diff --check | 失败 | use-debounce.ts 有 1 行尾随空格;另有 LF/CRLF 提示 |
| 调试遗留扫描 | 通过 | 没有 console.log、数字测试标签或测试颜色类;仍有注释 import、空 div 和未使用项 |
致命问题
- 创建流程断裂:空名称仍提交,合法名称又缺少 AnatomyDraftInput 必需的 structure。
- 归档 mutation 把字符串作为 payload,无法通过
{ id: string }Schema 校验。 - 点击列表内 Archive 会冒泡触发行导航,导致归档动作同时进入详情页。
三大改进方向
- 把“失败路径必须终止”和“领域对象由共享工厂创建”设为提交边界检查项。
- 自定义 mutation 不猜 values;固定沿 action 常量、data provider 分支、Zod schema、测试四处追类型。
- JSX 先完成行为骨架:受控值、四态分支、列表 key、disabled、事件隔离;样式最后处理。
下次考试的 JSX 教学模式
根据本次反馈,后续试卷固定调整为:
- JSX 保留约 70%–85% 骨架,仅挖掉关键条件、数据绑定、事件回调、key、disabled 和可访问属性,不再整段清空。
- className 只独立考常用布局:
flex/grid、items-*、justify-*、gap-*、常见间距、w-full、truncate、disabled:*。 - 生僻内容如任意值 grid、复杂像素、主题颜色、组合选择器全部保留,并在旁边注释“它控制什么”,不要求背诵。
- 每道 JSX 题增加一小段“边做边学”:先解释当前骨架,再只完成一个关键行为,最后用 2 个可观察结果自检。
- 一场考试只安排一题中等 JSX 综合题,其余 JSX 题拆成短小局部题,避免体力消耗掩盖真实知识点。
下一次建议训练
下一场优先用较小组件复测三件事:表单失败分支早返回、自定义 mutation 的 Zod payload、可点击行与内部按钮的事件隔离。异步四态顺序已经明显进步,改为保留骨架的复习题,不再要求从空白重写整段 JSX。