表单、表格、虚拟列表与拖拽
在输入、校验、提交和集合操作之间保持身份、可见范围、状态及可访问交互一致。
输入怎样成为可提交数据
原生表单以字段名、输入值和提交动作组织数据。非受控输入让 DOM 保存当前值,受控输入由 React 值与变化回调连接。表单库管理注册、错误、脏状态、字段数组和提交,选择绑定方式时要符合组件接口。
React Hook Form 的 register 适合提供原生输入接口的控件,Controller 可以接到受控第三方组件。Zod resolver 把解析结果与错误接入表单;存在变换时,原始输入与提交输出类型要分别说明。表单库使用的默认值、重新载入值和用户已修改输入是不同事实。
下面把解析、输入输出类型、异步请求、响应校验和错误反馈接到一起。假设同源 /api/titles 接受 { title },返回 { id, title },运行时依赖 react-hook-form、相容的 Zod resolver 和 Zod:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const inputSchema = z.object({ title: z.string().trim().min(1).max(200) });
const resultSchema = z.object({ id: z.string().min(1), title: z.string() });
type Input = z.input<typeof inputSchema>;
type Output = z.output<typeof inputSchema>;
export function TitleForm() {
const form = useForm<Input, unknown, Output>({
resolver: zodResolver(inputSchema),
defaultValues: { title: '' },
});
async function save(values: Output) {
try {
const res = await fetch('/api/titles', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Save rejected');
const result = resultSchema.parse(await res.json());
form.reset({ title: result.title });
} catch {
form.setError('root', { message: '保存失败,请检查后重试。' });
}
}
return (
<form onSubmit={form.handleSubmit(save)}>
<label htmlFor="title">标题</label>
<input id="title" {...form.register('title')}
aria-invalid={!!form.formState.errors.title}
aria-describedby="title-error" />
<p id="title-error">{form.formState.errors.title?.message}</p>
<p role="status">{form.formState.errors.root?.message}</p>
<button disabled={form.formState.isSubmitting} type="submit">保存</button>
</form>
);
}解析先裁剪空白,提交输出来自 schema;reset 用服务器接受结果建立新基线。catch 保留输入并显示失败,实际产品还需按字段拒绝、登录失效、冲突和结果未知区分恢复。禁用按钮只减少当前表单重复点击;服务端仍需授权、解析和效果幂等。此例没有运行浏览器或写入服务。RHF resolver 的输入输出接口
异步载入编辑数据时,明确是否覆盖脏字段。服务器返回新版本,可以提示冲突、保留用户改动或按字段合并;直接把 values 指向每次重取结果,可能覆盖正在编辑的输入。字段数组用稳定身份接回 DOM,删除和重排后错误也应对应正确字段。React Hook Form API
集合的数据与交互各由谁管理
表格逻辑处理列、行、排序、过滤、分页和选择,呈现负责 DOM 和样式。TanStack Table 属于 headless 模型,需要组合渲染;shadcn 的表格组件和指南承担呈现与接线,不自动提供所有数据规则。API 变化时按实际版本读取指南,归档声称的 v9 形态未作为本页通用代码。
客户端模式操作已取得全集,服务器模式把筛选和排序交给服务端。只取得一页再在该页过滤,会漏掉其他页匹配;服务器分页与客户端排序混用,也无法代表全集顺序。选择状态按业务稳定 ID 保存,不能用当前行号代表长期对象。
分页返回行与总数时需要声明观察条件;并发写入可能让两次查询看到不同数据。服务端排序使用允许列集合和确定的追加顺序,避免相等排序值使页面漂移。
虚拟化改变了哪些可见条件
虚拟列表只挂载可见窗口及余量,降低 DOM 数量。总数据、虚拟项身份、估计或实测尺寸、滚动容器共同决定定位。高度变化、图片载入和字体变化会影响测量,滚动到一个索引也需要处理尚未取得的数据。
虚拟化不等于分页,也不减少服务器返回的全部数据。浏览器查找、打印、屏幕阅读器和键盘导航面对未挂载内容,需要补充方案。采用阈值取决于元素复杂度与真实性能,不沿用归档“几千行”等统一门槛。TanStack Virtual
TanStack Virtual v3 的固定行高接线需要一个滚动容器、总高度占位和各行位置。下面 rows 已在内存,id 稳定;高为 40px 的行使用单行文本,避免内容撑出估计高度:
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
export function RecordList({ rows }: { rows: { id: string; title: string }[] }) {
const parent = useRef<HTMLDivElement>(null);
const virtual = useVirtualizer({
count: rows.length,
getScrollElement: () => parent.current,
estimateSize: () => 40,
getItemKey: index => rows[index]!.id,
overscan: 4,
});
return (
<div ref={parent} style={{ height: 320, overflow: 'auto' }}>
<div style={{ height: virtual.getTotalSize(), position: 'relative' }}>
{virtual.getVirtualItems().map(item => {
const row = rows[item.index];
if (!row) return null;
return <div key={item.key} style={{
position: 'absolute', top: 0, left: 0, width: '100%',
height: item.size, transform: `translateY(${item.start}px)`,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}}>{row.title}</div>;
})}
</div>
</div>
);
}固定高度是该示例的输入规则。动态高度改为测量对应 DOM,数据增长还需自己的分页与滚动恢复;复杂列表需要结构语义和焦点管理,片段不提供可访问交互保证。没有执行真实性能或浏览器检查。TanStack Virtual 固定尺寸示例
拖拽怎样保持数据规则
拖拽库读取输入、计算碰撞并报告目标,业务层决定是否允许移动和怎样更新顺序。dnd-kit 的传感器、可排序项和拖拽覆盖层服务不同对象;稳定 ID 应贯穿源记录、拖拽对象、React key 和保存结果。
键盘应能完成相同核心操作,动作有可辨反馈;列表移位后焦点仍应能接回对象。拖拽只是一个输入方式,提供按钮等替代可以支持精确操作与部分用户需求。
保存排序先取得当前位置或版本,再提交目标,处理拒绝与其他客户端并发。乐观移位失败后依据版本恢复,避免覆盖新变化。跨列表移动还需说明容量、权限和无法放入时的结果。
在 dnd-kit 的 React @dnd-kit/core/@dnd-kit/sortable 这组旧接口中,DndContext 提供拖拽事件,SortableContext 提供同顺序的 items,useSortable 的 ref/attributes/listeners 接到拖拽柄,transform/transition 接到可排序行。PointerSensor 的激活距离可以减少误触;KeyboardSensor 配合 sortableKeyboardCoordinates 提供可排序键盘输入。2026-10-05 官方将此文档置于 Legacy 路线;新接口按对应文档接线,不混用两组 API。
拖拽完成回调与“上移/下移”按钮可以共同调用这个按 ID 移位函数:
export function moveBefore<T extends { id: string }>(
rows: readonly T[], movingId: string, targetId: string,
): T[] {
if (movingId === targetId) return [...rows];
const moving = rows.find(row => row.id === movingId);
if (!moving || !rows.some(row => row.id === targetId)) return [...rows];
const remaining = rows.filter(row => row.id !== movingId);
const target = remaining.findIndex(row => row.id === targetId);
return [...remaining.slice(0, target), moving, ...remaining.slice(target)];
}该规则定义为放到目标之前,不同于“交换”或按数组索引移动;将事件中的 active/over 身份解析成字符串后应用,over 为空时不改变。服务保存完整允许对象集合或明确排序操作,并以当前版本检查冲突,不能信任客户端夹带其他对象 ID。示例未运行拖拽交互。dnd-kit Sortable 接线
最后更新于