EXAM-20260728-01 阅卷报告:代码片段管理
总览
- 考生:Dano Day
- 得分:76 / 100(76%)
- 评级:🌕🌕🌕🌑🌑 — 及格,有需改进之处
- 致命问题:2 个
- 试卷文件:
moveCrateCodeSnippet.tsCrateCodeSnippetDialog.tsxCrateCodeSnippetsPanel.tsx
本次按用户要求,不对 className、布局美化、翻译 key、t(...) 使用方式或具体 i18n 文案扣分。按钮是否可理解、错误/加载/空状态是否存在,以及图标按钮是否有可访问名称,仍按功能和可访问性评分。
成绩汇总
| 题号 | 知识点 | 得分 | 主要结论 |
|---|---|---|---|
| 1 | 不可变数组重排 | 9/10 | 核心行为正确,边界判断有无效条件且留有废弃注释 |
| 2 | 表单默认值 | 9/10 | 行为正确,写法可直接用可选链简化 |
| 3 | React Hook Form + Zod + reset | 10/10 | 与标准行为一致 |
| 4 | 表单关闭与提交桥接 | 9/10 | 校验链正确,未复制提交值对象 |
| 5 | 表单 JSX 与错误反馈 | 8/10 | 四字段和提交状态完整,服务端错误缺少 alert 语义 |
| 6 | Refine 查询与 mutation 声明 | 9/10 | 查询、分页、启用条件和泛型均正确,存在无关 import |
| 7 | 新增/编辑分流 | 4/10 | 缺少 return,编辑会同时 update 和 create |
| 8 | 删除、重排与重试 | 5/10 | 代码行为正确,但重排 mutation 明确为恢复答案,不计独立掌握 |
| 9 | 不可变 Set 与 pending 状态 | 10/10 | 完成正确 |
| 10 | 查询状态与管理面板 | 3/10 | 有基本列表和按钮,但状态分支、展开、边界禁用和可访问性未完成 |
| 合计 | 76/100 |
逐题批改
第1题:实现不可变的代码片段上下移动 — 9/10
知识点:不可变更新、数组重排、边界条件。
考生核心代码:
const len = snippets.length;
const targetIndex = index + direction;
if (len < 0 || targetIndex < 0 || targetIndex >= len) return snippets;
const re = snippets.slice();
const targetSnippet = re.splice(index, 1)[0];
if (!targetSnippet) return snippets;
re.splice(targetIndex, 0, targetSnippet);
return re.map((snippet, index) => ({ ...snippet, sortOrder: index }));
标准答案核心:
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= snippets.length) return snippets;
const reordered = [...snippets];
const [snippet] = reordered.splice(index, 1);
if (!snippet) return snippets;
reordered.splice(nextIndex, 0, snippet);
return reordered.map((item, sortOrder) => ({ ...item, sortOrder }));
- 功能:测试覆盖的移动、越界和 sortOrder 归一化全部正确。
- 模式:
slice()同样满足不可变要求,不因写法不同扣功能分。 - 洁癖:
len < 0永远不成立;// if...、// const ans是废弃注释;lint 还提示可优先使用展开语法。 - 改进:保留真正参与判断的条件,提交前清掉探索性注释。
第2题:把编辑数据转换为表单默认值 — 9/10
知识点:受控表单默认值、空值处理。
考生代码:
if (initialData) {
return {
name: initialData.name ?? "",
language: initialData.language ?? "",
code: initialData.code ?? "",
description: initialData.description ?? "",
};
}
return { name: "", language: "", code: "", description: "" };
标准答案:
return {
name: initialData?.name ?? "",
language: initialData?.language ?? "",
code: initialData?.code ?? "",
description: initialData?.description ?? "",
};
- 功能:完全正确,没有把
undefined传给表单字段。 - 模式:显式分支与标准答案等价,不扣功能分。
- 洁癖:单行对象过长,可选链能减少重复。
- 疑问答复:这题确实简单,你没有写错;简单题考的是能否守住受控输入的空值边界。
第3题:初始化表单并同步弹窗身份 — 10/10
知识点:React Hook Form、Zod resolver、表单身份同步、Effect 依赖。
考生代码与标准答案等价:
const form = useForm<CrateCodeSnippetFormValues>({
resolver: zodResolver(crateCodeSnippetFormSchema),
defaultValues: getDefaultValues(initialData),
});
useEffect(() => {
if (open) form.reset(getDefaultValues(initialData));
}, [form, initialData, open]);
- 功能:正确。
- 模式:resolver、默认值、打开时 reset 与依赖数组均正确。
- 洁癖:无实质问题。
- 疑问答复:你的“不安”可以放下,这里已经够了。
defaultValues只负责首次初始化,弹窗复用时由reset同步新身份。
第4题:桥接关闭与合法提交事件 — 9/10
知识点:稳定回调、表单校验入口、数据边界。
考生代码:
const handleClose = useCallback(() => onClose(), [onClose]);
const handleSubmit = form.handleSubmit((_values) => {
onSubmit(_values);
});
标准答案:
const handleSubmit = form.handleSubmit((values) => {
onSubmit({ ...values });
});
- 功能:校验失败不会进入回调,关闭和提交行为正确。
- 模式:题目要求向父级交付独立对象,少了
{ ...values };当前父级不修改对象,所以暂不构成功能 Bug。 - 洁癖:
_values实际被使用,不需要下划线前缀。
第5题:渲染可校验的新增/编辑弹窗 — 8/10
知识点:表单注册、错误反馈、提交状态、可访问性。
考生实现包含四个字段、FormProvider、onSubmit、取消按钮和提交禁用。标准答案额外保证:
{serverError && (
<p className="text-sm text-destructive" role="alert">
{serverError}
</p>
)}
- 功能:四个字段均已注册,代码框关闭拼写检查,提交状态存在。
- 模式:
description使用Input而非Textarea不影响数据提交;当前没有子组件调用useFormContext,因此FormProvider现在确实可以移除。保留它的价值主要是方便未来抽取字段子组件。 - 可访问性:
serverError没有role="alert";code/description 无错误时仍渲染空 alert 节点,建议条件渲染。 - 洁癖:存在注释掉的无关 Schema import、
action=""和多处占位注释。 - i18n:标题、标签和按钮使用直接文案不扣分;拼写也未计入本题分数。
第6题:建立列表查询与四类变更通道 — 9/10
知识点:Refine useList、查询状态、mutation 泛型、稳定派生数据。
考生代码与标准答案的查询配置一致:
const { result, query } = useList<CrateCodeSnippet>({
resource: ResourceName.crateCodeSnippets,
filters: [{ field: "crateId", value: crateId, operator: "eq" }],
pagination: { currentPage: 1, pageSize: 0 },
queryOptions: { enabled: crateId.length > 0 },
});
const snippets = useMemo(
() => (result?.data ?? []) as CrateCodeSnippet[],
[result?.data],
);
- 功能:过滤、分页、启用条件、空数组回退和四种 mutation 类型均正确。
- 模式:这次已经正确使用
query承载加载/错误状态,说明历史薄弱点有改善。 - 洁癖:误加了未使用的
import { map } from "zod",导致类型检查和 lint 报错。 - 疑问答复:你写出的部分已经完整,没有漏配置。
第7题:实现新增与编辑弹窗的数据流 — 4/10
知识点:CRUD 分支、异步回调、早返回。
考生代码的关键问题:
if (editingSnippet) {
updateMutation.mutate(...);
}
createMutation.mutate(...);
标准答案:
if (editingSnippet) {
updateMutation.mutate(...);
return;
}
createMutation.mutate(...);
- 功能:新增、打开、编辑、关闭以及两个 mutation 参数本身都写对了。
- 致命 Bug:编辑分支缺少
return,每次编辑都会同时创建一条新片段。 - 模式:这是典型互斥分支问题;完成一个终止分支后必须显式提前返回,或使用
else。 - 洁癖:无额外主要扣分。
第8题:实现删除、重排与重试控制 — 5/10
知识点:Refine mutation、自定义 action、失效后重取、确认框状态。
当前代码与标准答案行为一致,但注释明确说明重排 mutation 是直接恢复的标准代码:
reorderMutation.mutate(
{
url: DataProviderCustomAction.reorderCrateCodeSnippets,
method: "post",
values: { crateId, snippetIds: reordered.map((snippet) => snippet.id) },
},
{
onSuccess: () => void query.refetch(),
onError: (error) => setActionError(error.message),
},
);
- 功能:提交后的代码可正确运行。
- 掌握度:恢复答案的部分不作为独立完成计分;删除目标判空、重试和确认框状态给予部分分。
- 学习结论:
mutate的第一个参数描述“请求什么”,第二个参数描述“请求结束后怎么办”。普通删除使用{ resource, id };自定义操作使用{ url, method, values }。
第9题:维护多项展开状态与异步派生状态 — 10/10
知识点:函数式状态更新、不可变 Set、异步派生状态。
考生代码与标准答案等价:
setExpandedSnippetIds((current) => {
const next = new Set(current);
if (next.has(snippetId)) next.delete(snippetId);
else next.add(snippetId);
return next;
});
const isSaving =
createMutation.mutation.isPending || updateMutation.mutation.isPending;
const isReordering = reorderMutation.mutation.isPending;
- 功能:支持多项展开与独立收起,pending 派生正确。
- 模式:没有原地修改只读 Set。
- 洁癖:无实质问题。
第10题:组合完整的代码片段管理面板 — 3/10
知识点:异步状态机、条件渲染、列表 key、交互边界、可访问性。
考生条件分支:
{query.isLoading
? query.isError
? result.data.length > 0
? <LoadingState />
: <ErrorState />
: <EmptyState />
: result.data.map(...)}
标准分支骨架:
{query.isLoading ? (
<LoadingState />
) : query.isError ? (
<ErrorState action={/* retry */} />
) : snippets.length === 0 ? (
<EmptyState action={/* add */} />
) : (
snippets.map(...)
)}
- 功能:已提供添加、编辑、删除、上下移动和两个 Dialog 的基本绑定。
- 致命 Bug:判断层级写反;常见的
isError=true, isLoading=false会进入result.data.map,可能因数据不存在而崩溃。 - 缺失:重试入口、展开按钮/条件代码区、移动边界禁用、重排期间禁用、列表
key。 - 可访问性:
actionError缺少role="alert",操作按钮没有包含片段名称的可访问标签。 - 洁癖:遗留
console.log、空<div>、未使用的状态/回调/import;这些导致类型检查和 lint 失败。 - 样式/i18n:所有 className、图标和翻译差异均未扣分。
多文件数据流与接口一致性
moveCrateCodeSnippet → handleMove → reorderMutation 的类型和数据格式一致;Dialog 的 onSubmit 与 Panel 的 handleSubmit 类型也一致。主要断点不在接口,而在 Panel 内部流程:编辑分支没有终止,最终 JSX 没有消费第9题的展开与 pending 产物。
疑问解答
1. 怎么快速知道 useForm 配置对象有哪些属性?
最快顺序:
- 光标放在
useForm({ ... })对象内部按编辑器补全快捷键,TypeScript 会根据UseFormProps<T>列出属性; - 悬停
useForm或跳转到类型定义,直接查看UseFormProps<CrateCodeSnippetFormValues>; - 在仓库搜索
useForm<,参考项目里已经通过检查的同类表单; - 只有类型注释仍解释不清行为时再查官方文档。
类型补全适合回答“能填什么”,文档适合回答“这个选项具体在什么时候生效”。 这个提示确实很难读——它展示的是“完整函数类型”,不是给人看的配置说明。你不需要一次看懂全部,只要拆成三段。
useForm<表单字段类型>(配置对象): 表单操作对象
对应你的代码:
useForm<CrateCodeSnippetFormValues>({
resolver: zodResolver(crateCodeSnippetFormSchema),
defaultValues: getDefaultValues(initialData),
});
你截图里的类型是什么意思
useForm<
{ name; language; code; description }, // ① 表单输入类型
any, // ② Context 类型,一般不用管
{ name; language; code; description } // ③ 校验转换后的输出类型
>(
props?: Partial<{ ... }> // ④ 可选配置
): UseFormReturn<...> // ⑤ 返回表单操作方法
关键是这一段:
props?: Partial<{
mode: Mode;
disabled: boolean;
defaultValues: ...;
values: ...;
// ...
}>
它表达的是:
props?:整个配置对象可以不传。Partial:配置对象里的每个属性都可以不写。- 花括号里的属性:所有可选配置项。
... 11 more ...:IDE 把剩余属性折叠了。UseFormReturn:最终返回register、reset、handleSubmit、formState等操作。
不要靠 Hover 找配置项
Hover 适合确认类型,不适合探索 API。
把光标放到对象内部:
const form = useForm<CrateCodeSnippetFormValues>({
// 光标放这里
});
然后按:
Ctrl + Space:列出所有可以填写的配置项。Ctrl + Shift + Space:显示当前函数参数提示。- 选中某个配置项后再看右侧说明。
- 对不理解的类型按
F12或“转到类型定义”。
也就是说,不需要先读懂那一大坨类型。让 IDE 先列出选项,再逐个查看。
最常用的配置其实只有几个
useForm<FormValues>({
// 初始值
defaultValues: {},
// 接入 Zod 等校验器
resolver: zodResolver(schema),
// 什么时候首次校验
mode: "onSubmit",
// 首次失败后,什么时候重新校验
reValidateMode: "onChange",
// 卸载字段时是否删除其值
shouldUnregister: false,
// 出错时是否自动聚焦
shouldFocusError: true,
// 禁用整个表单
disabled: false,
});
日常表单大约 80% 的场景只需要:
resolver
defaultValues
所以你第3题写的:
const form = useForm<CrateCodeSnippetFormValues>({
resolver: zodResolver(crateCodeSnippetFormSchema),
defaultValues: getDefaultValues(initialData),
});
已经完整正确,不需要因为类型提示里还有十几个配置项就把它们全部写上。
一个实用判断方法
先从需求反推配置,而不是从类型列表猜:
| 需求 | 使用什么 |
|---|---|
| 设置初始值 | defaultValues |
| 接入 Zod | resolver |
| 控制校验时机 | mode |
| 编辑对象变化后重新填值 | form.reset() |
| 注册输入框 | form.register() |
| 包装提交校验 | form.handleSubmit() |
| 获取错误 | form.formState.errors |
| 观察某个字段 | form.watch() |
你的题目需求只有“Zod 校验、初始化、切换编辑对象”,所以答案自然就是:
resolver
defaultValues
useEffect + form.reset
重点不是看懂整个 useForm 类型,而是学会:需求 → 找对应 API → 用 Ctrl + Space 验证。
2. defaultValues + reset 真的就够了吗?
够。defaultValues 只在表单实例首次建立时读取;同一弹窗切换编辑对象时,必须用 reset 应用新的快照。你把 form、initialData、open 都放进依赖数组也正确。
3. FormProvider 没有子组件消费时是不是一样?
是。当前字段全部直接使用 form.register,没有调用 useFormContext 的后代,因此移除 FormProvider 不改变行为。若之后把字段拆成独立组件,并在子组件中通过 useFormContext 取表单,它才是必需的。
4. 错误节点不写 && 行不行?
画面上通常只是空白,但会长期渲染空的 role="alert" 节点,给辅助技术制造无意义的提醒区域。建议只在错误存在时渲染完整 alert。
5. Refine 的 mutate 怎么记?
把它拆成两部分:
- 第一个参数:请求身份和请求数据;
- 第二个参数:
onSuccess/onError生命周期。
先由 Hook 泛型确定 values 类型,再让编辑器从 mutate( 的参数位置给出补全,不需要背整个对象结构。
验证结果
| 检查 | 结果 | 说明 |
|---|---|---|
bun run check-types | 失败 | 11 个未使用声明错误;其中两个 t 未使用按新 i18n 规则不扣分,其余主要来自第10题未消费导入/状态及无关 zod.map import |
| 目标文件 oxlint | 失败 | 7 个 error、25 个 warning;包括 return 空行、Props 顺序、未使用项、缺少列表 key 和调试遗留 |
| 两个相关测试文件 | 通过 | 2 个测试文件、4 个测试全部通过 |
致命问题
- 第7题编辑提交同时触发 update 与 create,会产生重复数据。
- 第10题异步状态分支错误,错误状态可能访问不存在的
result.data并崩溃。
三大改进方向
- 把 mutation 当成“请求参数 + 生命周期”两段练习,并强化互斥分支的提前返回。
- 固定使用
loading → error → empty → data顺序编写异步 UI,渲染统一消费安全的snippets,不要回退访问原始可空结果。 - 提交前做一次机械清理:
console.log、无关 import、空节点、未使用回调、列表 key 和占位注释。
下一次建议训练
下一场优先使用不同业务场景复测 Refine mutation、互斥 CRUD 分支和异步状态渲染;不可变数组与表单 reset 已建立较好基线,可降为组合题中的次要考点。