
一句话:表单库的核心不是「收集输入值」,而是「一套细粒度的订阅模型 + 一个校验状态机」——难点集中在四处:受控/非受控统一、字段级订阅(而不是整表单重渲染)、异步校验的竞态、校验时机与提交状态机。
一、先定清楚要提供什么
// ✅ 目标 API(借鉴 react-hook-form 的「非受控 + 注册」与 Zustand 的「细粒度订阅」)
const form = useForm({
defaultValues: { email: '', password: '', address: { city: '' } },
mode: 'onBlur', // 校验时机:onChange | onBlur | onSubmit | all
resolver: async (values) => { // ⭐ 表单级校验(可以是异步)
const errors: Record<string, string> = {};
if (!values.email.includes('@')) errors.email = '邮箱格式不正确';
if (values.password.length < 8) errors.password = '密码至少 8 位';
return errors;
},
});
function LoginForm() {
return (
<form onSubmit={form.handleSubmit(async (values) => {
await api.login(values);
})}>
{/* ⭐ register 返回「可直接展开到 input 上的 props」(非受控,不引起重渲染) */}
<input {...form.register('email')} placeholder="邮箱" />
<FieldError form={form} name="email" />
<input type="password" {...form.register('password')} placeholder="密码" />
<FieldError form={form} name="password" />
{/* ⭐ 嵌套字段:路径写法 */}
<input {...form.register('address.city')} placeholder="城市" />
<button disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? '登录中…' : '登录'}
</button>
{form.formState.submitError && <p role="alert">{form.formState.submitError}</p>}
</form>
);
}
⭐⭐ 四个「设计决定」(每个都决定了整套架构):
① 【非受控 + 注册(register)而不是「受控 + useState」】
受控写法:`value={values.email} onChange={...}` → ⭐ 每次输入都触发「父组件重渲染」。
非受控写法:`<input {...register('email')} />` → 值保存在 **DOM 里**,
库里只在「需要时」(校验/提交)读取。
→ ⭐ 好处是「**输入不引起 React 重渲染**」(性能),
而且「与原生表单/第三方组件(日期选择器)更好集成」。
✅ 我采用「非受控为主 + 支持受控」的混合模型。
② 【细粒度订阅(字段级)】
错误提示组件只订阅「它关心的那个字段」→
改 A 字段时 B 字段的提示组件不重渲染。
实现上需要「字段级监听表」,而不是「一个全局 listeners 集合」。
③ 【校验是一个「状态机」而不是「一个函数」】
`idle → validating → valid/invalid`,
且「异步校验必须处理竞态」(旧请求的结果要丢弃)。
⭐ 不处理竞态的后果:快速输入时「先发的慢请求后返回」,
把「正确的结果」覆盖成「过期结果」。
④ 【提交也是一个「状态机」】
`idle → validating → submitting → success/error`,
且要处理「提交中禁用重复提交」「提交后的服务端错误映射到字段」。
二、完整实现(1):与 React 解耦的内核(约 130 行)
// mini-form/core.ts —— 表单内核(不依赖 React,可单测)
export type FieldPath = string; // 'email' | 'address.city' | 'items[0].name'
export type Errors<V> = Partial<Record<FieldPath, string>>;
export type ValidateMode = 'onChange' | 'onBlur' | 'onSubmit' | 'all';
export interface FormState<V> {
values: V;
errors: Errors<V>;
touched: Record<FieldPath, boolean>;
dirty: Record<FieldPath, boolean>;
isSubmitting: boolean;
isValidating: boolean;
submitCount: number;
submitError: string | null;
}
export interface FormOptions<V> {
defaultValues: V;
mode?: ValidateMode;
resolver?: (values: V) => Errors<V> | Promise<Errors<V>>;
}
/** ⭐ 数组/嵌套路径的读写工具(这是「嵌套字段」的基础) */
function parsePath(path: string): Array<string | number> {
return path
.replace(/\[(\d+)\]/g, '.$1') // items[0].name → items.0.name
.split('.')
.filter(Boolean)
.map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg));
}
export function getPath(obj: unknown, path: string): unknown {
return parsePath(path).reduce<unknown>((acc, key) => {
if (acc === null || acc === undefined) return undefined;
return (acc as Record<string | number, unknown>)[key];
}, obj);
}
/** ⭐ 不可变写入(底层是「复制路径上的每一层」,这是嵌套字段更新的正确方式) */
export function setPath<T>(obj: T, path: string, value: unknown): T {
const keys = parsePath(path);
if (keys.length === 0) return value as T;
const clone = (node: unknown, depth: number): unknown => {
const key = keys[depth]!;
if (depth === keys.length - 1) {
if (Array.isArray(node)) {
const arr = node.slice();
arr[key as number] = value;
return arr;
}
return { ...(node as object), [key]: value };
}
const child = (node as Record<string | number, unknown>)?.[key];
const nextChild = clone(child, depth + 1);
if (Array.isArray(node)) {
const arr = node.slice();
arr[key as number] = nextChild;
return arr;
}
return { ...(node as object), [key]: nextChild };
};
return clone(obj, 0) as T;
}
/** ============ 内核 ============ */
export function createFormCore<V extends object>(options: FormOptions<V>) {
const mode = options.mode ?? 'onBlur';
let state: FormState<V> = {
values: structuredCloneSafe(options.defaultValues),
errors: {},
touched: {},
dirty: {},
isSubmitting: false,
isValidating: false,
submitCount: 0,
submitError: null,
};
/** ⭐ 版本号:让 React 侧的选择器能「跳过没变的部分」 */
let version = 0;
/** 全局监听器(用于表单级订阅,如「是否有错误」「是否 dirty」) */
const globalListeners = new Set<() => void>();
/** ⭐⭐ 字段级监听表:这是「细粒度重渲染」的关键 */
const fieldListeners = new Map<FieldPath, Set<() => void>>();
/** 异步校验的「代」计数器(处理竞态) */
let validationEpoch = 0;
const notify = (changedFields: FieldPath[], all = false) => {
version++;
if (all) {
[...globalListeners].forEach((l) => l());
fieldListeners.forEach((set) => [...set].forEach((l) => l()));
return;
}
// ⭐ 只通知「受影响的字段」+ 全局(全局监听器一般很少)
const affected = new Set<FieldPath>(changedFields);
if (changedFields.length) {
// 字段前缀也要通知(如 items 变了,items.0.name 的订阅者要看是否受影响)
for (const key of fieldListeners.keys()) {
for (const f of changedFields) {
if (key === f || key.startsWith(`${f}.`) || f.startsWith(`${key}.`)) {
affected.add(key);
break;
}
}
}
}
affected.forEach((f) => fieldListeners.get(f)?.forEach((l) => l()));
[...globalListeners].forEach((l) => l());
};
const getState = () => state;
const getVersion = () => version;
const subscribeAll = (listener: () => void) => {
globalListeners.add(listener);
return () => { globalListeners.delete(listener); };
};
const subscribeField = (path: FieldPath, listener: () => void) => {
let set = fieldListeners.get(path);
if (!set) { set = new Set(); fieldListeners.set(path, set); }
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) fieldListeners.delete(path);
};
};
/** ============ ① 读取/写入字段值 ============ */
const getValue = (path: FieldPath) => getPath(state.values, path) as unknown;
const setValue = (path: FieldPath, value: unknown) => {
const prev = getValue(path);
if (Object.is(prev, value)) return; // ⭐ 短路:值没变就不做事
state = {
...state,
values: setPath(state.values, path, value),
dirty: { ...state.dirty, [path]: !isEqualWithDefaults(path, value) },
};
// ⭐ 按 mode 决定「是否立即校验」
if (mode === 'onChange' || mode === 'all') {
void runFieldValidation(path, /* silent */ false);
}
notify([path]);
};
const isEqualWithDefaults = (path: FieldPath, value: unknown) => {
const def = getPath(options.defaultValues, path);
return Object.is(def, value);
};
/** ============ ② 校验(含异步 + 竞态处理 + debounce) ============ */
const debounceTimers = new Map<FieldPath, ReturnType<typeof setTimeout>>();
async function runFieldValidation(path: FieldPath, silent: boolean) {
// ⭐ 如果配了 resolver,就整体算一遍,然后只取这个字段的错误
if (options.resolver) {
await runFullValidation();
// 取完之后只通知「这个字段」(避免其它字段的组件也重渲染)
notify([path]);
return;
}
// 没有 resolver → 没有校验
if (!silent) notify([path]);
}
/** ⭐⭐ 表单级校验(异步 + 竞态处理) */
async function runFullValidation(): Promise<boolean> {
if (!options.resolver) return true;
const epoch = ++validationEpoch; // ⭐ 记下「这一代」
state = { ...state, isValidating: true };
notify([], true);
let errors: Errors<V> = {};
try {
errors = await options.resolver(state.values);
} catch (err) {
// ⭐ resolver 抛错 → 变成「表单级错误」,而不是「字段错误」
errors = { __form: err instanceof Error ? err.message : '校验失败' } as Errors<V>;
}
// ⭐⭐ 竞态处理:如果在这期间又发起了新的校验,就丢弃这次结果
if (epoch !== validationEpoch) return false;
state = { ...state, errors, isValidating: false };
notify([], true);
return Object.keys(errors).length === 0;
}
/** ⭐ 带 debounce 的字段校验(用于 onChange/异步校验频繁触发) */
function scheduleValidation(path: FieldPath, delayMs = 300) {
const existing = debounceTimers.get(path);
if (existing) clearTimeout(existing);
debounceTimers.set(
path,
setTimeout(() => {
debounceTimers.delete(path);
void runFieldValidation(path, false);
}, delayMs)
);
}
/** ============ ③ 交互回调 ============ */
const setTouched = (path: FieldPath) => {
if (state.touched[path]) return;
state = { ...state, touched: { ...state.touched, [path]: true } };
notify([path]);
};
const handleBlur = (path: FieldPath) => {
setTouched(path);
if (mode === 'onBlur' || mode === 'all') void runFieldValidation(path, false);
};
const reset = (nextValues?: V) => {
validationEpoch++; // ⭐ 作废在途校验
debounceTimers.forEach((t) => clearTimeout(t));
debounceTimers.clear();
state = {
values: structuredCloneSafe(nextValues ?? options.defaultValues),
errors: {},
touched: {},
dirty: {},
isSubmitting: false,
isValidating: false,
submitCount: 0,
submitError: null,
};
notify([], true);
};
/** ============ ④ 提交状态机 ============ */
type SubmitHandler = (values: V, ctx: { setFieldError: (path: FieldPath, msg: string) => void; setSubmitError: (msg: string | null) => void }) => Promise<void> | void;
async function handleSubmit(onValid: SubmitHandler) {
if (state.isSubmitting) return; // ⭐ 防重复提交
state = { ...state, isSubmitting: true, submitCount: state.submitCount + 1, submitError: null };
notify([], true);
// ⭐ ① 提交前先整体校验
const ok = await runFullValidation();
if (!ok) {
state = { ...state, isSubmitting: false };
notify([], true);
return;
}
try {
await onValid(state.values, {
setFieldError: (path, msg) => {
state = { ...state, errors: { ...state.errors, [path]: msg } };
notify([path]);
},
setSubmitError: (msg) => {
state = { ...state, submitError: msg };
notify([], true);
},
});
state = { ...state, isSubmitting: false };
} catch (err) {
state = {
...state,
isSubmitting: false,
submitError: err instanceof Error ? err.message : '提交失败',
};
}
notify([], true);
}
return {
getState, getVersion, subscribeAll, subscribeField,
getValue, setValue, setTouched, handleBlur, scheduleValidation,
runFullValidation, handleSubmit, reset,
};
}
/** 结构化克隆(带降级,处理「不可克隆值」时退回浅拷贝) */
function structuredCloneSafe<T>(value: T): T {
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value)) as T;
}
}
三、完整实现(2):React 集成与字段级订阅(约 90 行)
// mini-form/react.tsx
import { useRef, useCallback, useMemo, useSyncExternalStore, useEffect } from 'react';
import { createFormCore } from './core';
/** ⭐ 字段级 Hook:只订阅「一个字段」的状态(这是性能的关键) */
function useFieldState<V extends object>(core: ReturnType<typeof createFormCore<V>>, path: string) {
const cacheRef = useRef<{ hasValue: boolean; value: FieldSnapshot; version: number }>({
hasValue: false, value: null as unknown as FieldSnapshot, version: -1,
});
const getSnapshot = useCallback((): FieldSnapshot => {
const state = core.getState();
const version = core.getVersion();
const cache = cacheRef.current;
if (cache.hasValue && cache.version === version) return cache.value; // ⭐ 版本没变 → 复用
const next: FieldSnapshot = {
value: core.getValue(path),
error: state.errors[path as never] as string | undefined,
touched: Boolean(state.touched[path]),
dirty: Boolean(state.dirty[path]),
isValidating: state.isValidating,
};
cache.version = version;
// ⭐⭐ 浅比较:内容相同就【复用旧引用】,否则 React 会认为「一直在变」→ 无限重渲染
if (cache.hasValue && shallowEqual(cache.value, next)) return cache.value;
cache.hasValue = true;
cache.value = next;
return next;
}, [core, path]);
return useSyncExternalStore(
(cb) => core.subscribeField(path, cb),
getSnapshot,
getSnapshot
);
}
interface FieldSnapshot {
value: unknown;
error: string | undefined;
touched: boolean;
dirty: boolean;
isValidating: boolean;
}
function shallowEqual(a: FieldSnapshot, b: FieldSnapshot) {
return (
Object.is(a.value, b.value) &&
a.error === b.error &&
a.touched === b.touched &&
a.dirty === b.dirty &&
a.isValidating === b.isValidating
);
}
/** ⭐ useForm:返回「注册函数 + 表单状态 + 提交/重置」 */
export function useForm<V extends object>(options: Parameters<typeof createFormCore<V>>[0]) {
const coreRef = useRef<ReturnType<typeof createFormCore<V>> | null>(null);
if (coreRef.current === null) coreRef.current = createFormCore<V>(options);
const core = coreRef.current;
/** ⭐ register:把「非受控输入」接到内核上 */
const register = useCallback(
(path: string) => {
const isCheckboxOrRadio = (el: HTMLElement) =>
el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio');
return {
name: path,
defaultValue: (core.getValue(path) as string | number | undefined) ?? '',
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const target = e.target;
let value: unknown;
if (target instanceof HTMLInputElement) {
if (target.type === 'checkbox') value = target.checked;
else if (target.type === 'number' || target.type === 'range') value = target.value === '' ? '' : Number(target.value);
else if (target.type === 'file') value = target.files;
else value = target.value;
} else {
value = target.value;
}
core.setValue(path, value);
},
onBlur: () => core.handleBlur(path),
// ⭐ 提交时让浏览器把「name 属性」用于原生校验的开关
ref: undefined,
};
},
[core]
);
/** ⭐ 表单级状态(只订阅「聚合信息」,不订阅具体字段值) */
const formState = useSyncExternalStore(
core.subscribeAll,
() => {
const s = core.getState();
return {
isSubmitting: s.isSubmitting,
isValidating: s.isValidating,
submitCount: s.submitCount,
submitError: s.submitError,
isDirty: Object.values(s.dirty).some(Boolean),
isValid: Object.keys(s.errors).length === 0,
};
},
() => ({
isSubmitting: false, isValidating: false, submitCount: 0,
submitError: null, isDirty: false, isValid: true,
})
);
const handleSubmit = useCallback(
(onValid: (values: V) => Promise<void> | void) => (e?: React.FormEvent) => {
e?.preventDefault();
void core.handleSubmit(async (values, ctx) => {
try {
await onValid(values);
} catch (err) {
// ⭐ 把服务端返回的「字段错误」映射回字段上(这是很实用的能力)
const fieldErrors = (err as { fieldErrors?: Record<string, string> })?.fieldErrors;
if (fieldErrors) {
Object.entries(fieldErrors).forEach(([k, msg]) => ctx.setFieldError(k, msg));
} else {
ctx.setSubmitError(err instanceof Error ? err.message : '提交失败');
}
}
});
},
[core]
);
return useMemo(
() => ({
register,
handleSubmit,
formState,
/** ⭐ 观察字段(用于「受控组件」「联动」「watch」) */
useField: (path: string) => useFieldState(core, path),
setValue: core.setValue,
getValue: core.getValue,
setError: (path: string, msg: string) => {
core.getState().errors[path as never] = msg as never;
},
reset: core.reset,
/** ⭐ 提交前手动触发校验 */
validate: core.runFullValidation,
}),
[core, register, handleSubmit, formState]
);
}
⭐⭐⭐ 为什么「字段级订阅」能带来数量级差异(一个具体账):
假设表单有 20 个字段,每个字段下面有一个错误提示组件。
【做法 A:整表单订阅】(`const { values } = useForm()`)
输入任一字符 → state 变化 → 表单组件重渲染 →
20 个错误提示组件**全部**重渲染(每个都执行浅比较 + 可能的渲染逻辑)
→ 输入 10 个字符 = 200 次组件渲染
【做法 B:字段级订阅】(`useField('email')`)
输入 email 的字符 → 只有 email 的订阅者重渲染
→ 输入 10 个字符 = 10 次组件渲染(⭐ 差 20 倍)
⭐ 这就是「细粒度订阅」的价值——它与 Zustand 的选择器、Jotai 的原子是**同一个思想**
(见 11.13)。
四、使用示例(含联动与数组字段)
/** ============ 一个完整可跑的注册表单 ============ */
export function SignupForm() {
const form = useForm({
defaultValues: { email: '', password: '', confirm: '', age: 18, agree: false },
mode: 'onBlur',
resolver: async (values) => {
const errors: Record<string, string> = {};
if (!values.email.includes('@')) errors.email = '邮箱格式不正确';
if (values.password.length < 8) errors.password = '密码至少 8 位';
// ⭐ 「跨字段校验」必须放在表单级 resolver(字段级拿不到别的字段)
if (values.confirm !== values.password) errors.confirm = '两次输入不一致';
if (!values.agree) errors.agree = '请先同意条款';
return errors;
},
});
// ⭐ 联动:观察 age,决定是否显示「监护人」字段
const age = form.useField('age');
return (
<form onSubmit={form.handleSubmit(async (values) => {
await new Promise((r) => setTimeout(r, 600));
// ⭐ 模拟服务端把错误映射回字段
if (values.email === 'taken@example.com') {
throw Object.assign(new Error('提交失败'), { fieldErrors: { email: '该邮箱已被注册' } });
}
})} style={{ display: 'grid', gap: 12, maxWidth: 360 }}>
<Field form={form} name="email" label="邮箱" />
<Field form={form} name="password" label="密码" type="password" />
<Field form={form} name="confirm" label="确认密码" type="password" />
<label>
年龄:
<input type="number" {...form.register('age')} style={{ width: 80 }} />
</label>
{Number(age.value) < 18 && <Field form={form} name="guardian" label="监护人姓名" />}
<label style={{ display: 'flex', gap: 8 }}>
<input type="checkbox" {...form.register('agree')} /> 我已同意条款
</label>
<FieldError form={form} name="agree" />
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? '提交中…' : '注册'}
</button>
{form.formState.isValidating && <p style={{ color: '#888' }}>校验中…</p>}
{form.formState.submitError && <p role="alert" style={{ color: '#c00' }}>{form.formState.submitError}</p>}
</form>
);
}
/** ⭐ 两个配套组件:字段(含 label/输入/错误)与纯错误提示 */
function Field({ form, name, label, type = 'text' }: {
form: ReturnType<typeof useForm>;
name: string;
label: string;
type?: string;
}) {
const field = form.useField(name); // ⭐ 只订阅这一个字段
return (
<label style={{ display: 'grid', gap: 4 }}>
<span>{label}</span>
<input
type={type}
{...form.register(name)}
aria-invalid={field.error ? true : undefined}
aria-describedby={field.error ? `${name}-error` : undefined}
/>
<FieldError form={form} name={name} />
</label>
);
}
function FieldError({ form, name }: { form: ReturnType<typeof useForm>; name: string }) {
const field = form.useField(name);
// ⭐ 只在「触碰过」之后才显示错误(避免「一打开表单就满屏红字」)
if (!field.error || !field.touched) return null;
return <span id={`${name}-error`} role="alert" style={{ color: '#c00', fontSize: 12 }}>{field.error}</span>;
}
⭐⭐ 这段代码演示了四个「表单库必须解决的真实问题」:
① 【跨字段校验(confirm === password)】
字段级校验器拿不到别的字段 → 必须放在「表单级 resolver」里。
⭐ 而且它要在「任一相关字段变化时重跑」,所以 `onBlur`/`onChange` 要触发**整体校验**。
② 【联动显示(age < 18 时显示 guardian)】
用 `form.useField('age')` 订阅单个字段 → 只有 age 变化时这个组件重渲染。
③ 【错误显示时机(touched)】
如果「一打开就显示所有校验错误」,用户会看到满屏红字(体验极差)。
⭐ 所以「显示错误」的条件是 `error && touched`;
`onSubmit` 时要把**所有字段**标记为 touched(否则提交失败后看不到错误)。
④ 【服务端错误映射回字段】
`throw { fieldErrors: { email: '已被注册' } }` → 库把它映射到 `errors.email`。
⭐ 这是「客户端校验通过、服务端仍有错」的必备能力。
// ✅ ③ 的补充:onSubmit 时把所有字段标记为 touched
async function handleSubmit(onValid: SubmitHandler) {
// ⭐ 提交前把所有「有错误的字段」标记为 touched,
// 否则用户提交失败后会「看不到错误在哪里」
const errors = ... // 校验后的 errors
Object.keys(errors).forEach((path) => { state.touched[path] = true; });
notify([], true);
// ...
}
五、本篇特有的坑
// ① 用「受控 + useState」写每个字段(每次输入就重渲染整表单)
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} /> // ⚠️ 20 个字段 = 20 次 setState
// ✅ 非受控 register(值在 DOM 里),或字段级订阅
// ② `useSyncExternalStore` 的 getSnapshot 返回新对象(无限重渲染)
() => ({ value: core.getValue(path), error: ... }) // ❌ 每次新对象
// ⭐ React 会报 "The result of getSnapshot should be cached"
// ✅ 缓存 + 浅比较(见实现里的 cacheRef + shallowEqual)
// ③ 异步校验不处理竞态(快速输入时「旧结果覆盖新结果」)
const errors = await validate(value); setErrors(errors); // ❌
// ✅ 用「代计数器」:`if (epoch !== currentEpoch) return;`
// ④ 异步校验不 debounce(每个字符都发一次请求)
onChange → 立刻 validateAsync(value) // ❌ 请求风暴
// ✅ debounce 300ms + 「只在值真的变了时校验」
// ⑤ 只在 onSubmit 校验,但「提交失败后不把字段标记为 touched」
// ⚠️ 用户看到按钮没反应(错误存在但没显示)
// ✅ 提交失败时把所有「有错误的字段」标为 touched
// ⑥ 「显示错误」的条件漏了 touched(一打开满屏红字)
{field.error && <span>{field.error}</span>} // ⚠️
// ✅ {field.error && field.touched && <span>{field.error}</span>}
// ⑦ 嵌套字段用「深拷贝整个 values」更新(大表单性能差)
setValues(JSON.parse(JSON.stringify(values))) // ❌ 每次全量克隆
// ✅ 只复制「路径上的每一层」(见 setPath 的实现)
// ⑧ 数组字段用 `index` 当「稳定标识」(删除中间项时状态错位)
items.map((item, i) => <input key={i} {...register(`items[${i}].name`)} />) // ⚠️
// ✅ 用「稳定 id」(uuid)+ `items[index]` 由 index 定位,但「key 用 id」
// ⚠️ 更好:用 `useFieldArray` 这类「以 id 为路径」的实现
// ⑨ 忘了「number 输入框的空字符串」
Number('') === 0 // ⚠️ 空输入变成 0
// ✅ `value === '' ? '' : Number(value)`(保留「未填」的语义)
// ⑩ checkbox/radio 的取值写错
value={target.value} // ⚠️ checkbox 要看 checked
// ✅ `target.type === 'checkbox' ? target.checked : target.value`
// ⑪ 忘记「⌘+Enter / Enter 提交」(多行输入框里 Enter 不应提交)
// ✅ 在 form 上用 onSubmit(原生行为已覆盖「input 里按 Enter 提交」),
// 并显式处理 textarea(Enter 换行,⌘/Ctrl+Enter 提交)
// ⑫ 「提交中」没有禁用按钮(用户连点两次 → 重复下单)
<button onClick={submit}> // ❌
// ✅ disabled={isSubmitting} + 在 handleSubmit 里 `if (isSubmitting) return`
// ⑬ 提交时不清「上次的 submitError」(失败后重试仍显示旧错误)
// ✅ 提交开始时 `submitError = null`
// ⑭ 组件卸载后「异步校验回调」仍然 setState
// ⚠️ 在卸载后写状态 → 警告 / 内存泄漏
// ✅ 用「代计数器」作废在途校验(reset 时也 epoch++)
// ⑮ 把「校验函数」写在组件里(每次渲染重新定义 → 每次都触发校验)
const resolver = (v) => {...} // ⚠️ 内联 → 引用不稳
// ✅ 提到组件外,或用 useMemo/useCallback 稳定引用
// ⑯ `defaultValue` 与后续的 `setValue` 冲突
// ⚠️ 非受控输入只在「首次挂载」时读 defaultValue;
// 之后用 `setValue` 改值**不会**更新 DOM 里的输入框
// ✅ 「程序化改值」需要用「受控」或「ref 直接改 el.value」
// (这是「非受控」的固有代价——react-hook-form 用 `setValue` 时也会同步 DOM)
// ⑰ 嵌套字段的「父路径错误」(如 `address` 有错但错误挂在 `address.city`)
// ⚠️ 显示错误时要「向上取」:`errors.address.city` 的提示要能出现在合理的位置
// ✅ 提供「取某路径及其子路径的所有错误」的工具函数
// ⑱ SSR 下的 `useSyncExternalStore` 第三参忘了给
// ⚠️ 服务端渲染会报错
// ✅ 传 `getSnapshot`(或用「基于 defaultValues 的快照」)
// ⑲ 忘记「字段卸载时的清理」(fieldListeners 泄漏)
// ⚠️ 组件卸载后监听器还在 Set 里 → 内存泄漏 + 无效通知
// ✅ `subscribeField` 返回的函数里 delete(并「set 空了就删 key」)
// ⑳ 把「校验」与「格式化」混在一起
// ⚠️ 如「手机号自动加空格」既改了值又触发校验 → 混乱
// ✅ 分开:「格式化」改 DOM 显示,「校验」只读规范化后的值
// ⑯ 的完整处理(程序化改值 + 同步 DOM)
function setValueAndSyncDom(path: string, value: unknown, el: HTMLInputElement | null) {
core.setValue(path, value);
if (el) {
// ⭐ 非受控输入的 DOM 值要手动同步
if (el.type === 'checkbox') el.checked = Boolean(value);
else el.value = String(value ?? '');
}
}
// 使用:给 register 增加 ref 收集
<input {...form.register('email')} ref={(el) => { inputRefs.current.email = el; }} />
// 需要程序化改值时:
setValueAndSyncDom('email', 'default@x.com', inputRefs.current.email ?? null);
// ⑰ 的完整处理(取「某路径及其子路径」的错误)
export function getErrorsUnder(errors: Record<string, string>, path: string): string[] {
return Object.entries(errors)
.filter(([key]) => key === path || key.startsWith(`${path}.`) || key.startsWith(`${path}[`))
.map(([, msg]) => msg);
}
// 或在渲染时直接「聚合父路径的错误」:
function FieldGroupError({ form, name }: { form: ReturnType<typeof useForm>; name: string }) {
const field = form.useField(name);
const subErrors = getErrorsUnder(form.formState.errors as never, name);
const messages = [...(field.error ? [field.error] : []), ...subErrors];
if (messages.length === 0) return null;
return <ul>{messages.map((m) => <li key={m} role="alert">{m}</li>)}</ul>;
}
六、面试延伸
- 「手写一个表单库,核心是什么?」
三块:① 值管理——用「非受控 +register」(值留在 DOM 里,只在需要时读)而不是「受控 +useState」,避免「每次输入都重渲染整表单」;② 订阅模型——字段级监听表(Map<path, Set<listener>>),配useSyncExternalStore实现「改 A 字段只有 A 的订阅者重渲染」(20 个字段的表单,输入 10 个字符从 200 次渲染降到 10 次);③ 状态机——校验状态机(含异步竞态处理:用「代计数器」丢弃过期结果)与提交状态机(idle → validating → submitting → success/error,含防重复提交与服务端字段错误映射)。 -
「为什么用非受控 + register,而不是受控 + useState?」
两个理由:① 性能——受控写法下每次输入都触发「持有 values 的组件」重渲染,字段一多就很明显;非受控把值放在 DOM 里,只在「校验/提交」时读取;② 集成——非受控更容易与「原生表单行为」(如原生form.reset()、浏览器自动填充)以及「第三方组件」(日期选择器、富文本)配合。代价是「程序化改值需要手动同步 DOM」(用ref设el.value),以及「实时联动/派生值」需要额外的watch机制。⭐ 所以实践中的正确做法是「非受控为主 + 提供受控逃生口」。 -
「表单的性能瓶颈在哪?」
在「重渲染范围」。如果用「一个useState装整个 values + 受控输入」,那么每敲一个字符都会让持表单的组件重渲染,并连带它所有的子组件(包括 20 个字段与 20 个错误提示)。解法三层:① 非受控(输入不进入 React 状态);② 字段级订阅(错误提示只订阅自己的字段);③ 只复制路径上的层(setPath的不可变更新不深拷贝整个 values)。⭐ 这三条分别对应「减少参与渲染的组件数」「减少每次渲染的工作量」「减少更新的开销」。 -
「异步校验有什么坑?」
三个:① 竞态——快速输入时「先发的慢请求后返回」,会把过期结果覆盖掉正确结果(✅ 用「代计数器」:const epoch = ++counter; ... if (epoch !== counter) return;);② 请求风暴——每个字符都触发一次校验(✅ debounce 300ms + 「只在值真的变化时校验」);③ 卸载后写状态——组件卸载后回调仍然setState(✅ 在reset/卸载时epoch++作废在途校验)。⭐ 另外还要注意「异步校验期间用户点了提交」——提交前必须等待校验完成(所以提交状态机里有validating阶段)。 -
「校验时机怎么设计?」
用「模式(mode)」抽象:①onSubmit——只在提交时校验(性能最好,但用户要在提交后才知道错);②onBlur——失焦时校验(⭐ 最常用,既能及时反馈又不会「边打字边报错」);③onChange——每次变化就校验(反馈最快,但「刚开始输入就报错」体验差);④all——两者都触发。配套的关键细节是「显示时机」:错误信息应该在「字段被 touch 过」之后才显示(否则「一打开表单就满屏红字」);并且「提交失败时要把所有有错误的字段标记为 touched」,否则用户看不到错误在哪。 -
「嵌套字段(
address.city)怎么处理?」
三个工具函数:① 解析路径(把items[0].name统一成['items', 0, 'name'],注意数字段要转成number);② 读取(沿路径 reduce);③ ⭐ 不可变写入——只复制路径上的每一层({ ...node, [key]: next },数组用slice()),不做深拷贝。为什么不能深拷贝:大表单每次全量克隆会带来显著开销(而且会丢掉「未变化的引用」,破坏memo的效果)。另一个要点是「父路径错误的聚合」——errors.address.city的错误在「显示为address分组的错误」时要能取到。 -
「数组字段(增删排序)有什么难点?」
核心是「稳定标识」。如果路径用items[${index}]而key也用 index,那么「删除中间一项」会导致「后面的项整体前移」→ 输入框的值与状态错位(React 复用 DOM 节点,而它绑定的defaultValue/注册路径都变了)。解法:① 每项带稳定id(uuid),key用id;② 路径用items.findIndex(i => i.id === id)计算出的 index,或者直接用 id 做路径(items.byId.${id}.name)——后者更彻底(避免「用 index 做路径」的一切问题)。③ 排序/移动时也要保证「值跟着项走」。 -
「如果让你『提交后把服务端错误显示到字段上』,怎么设计?」
让提交函数可以抛出携带字段错误的错误对象:throw Object.assign(new Error('提交失败'), { fieldErrors: { email: '已被注册' } })。库在handleSubmit里捕获它,把fieldErrors写进errors(按字段),把「非字段错误」写进submitError(表单级)。两个配套细节:① 写字段错误时也要把这些字段标记为 touched(否则仍然不显示);② 还要支持「字段级自定义校验(setError)+ 清除(clearErrors)」,因为「用户一改密码,上次的『邮箱已注册』错误就该清掉」。
一句话速记
表单库 = 「非受控 +
register(值留在 DOM,输入不引起重渲染)+ 字段级订阅表(Map<path, Set<listener>>+useSyncExternalStore,只让变化字段的提示组件重渲染)+ 两个状态机(校验 / 提交)」;⭐ 三个最关键的实现细节:getSnapshot必须缓存 + 浅比较(否则无限重渲染)、异步校验必须用「代计数器」处理竞态(否则旧结果覆盖新结果)并 debounce、嵌套字段的写入只复制路径上的层(不深拷贝);校验时机用 mode 抽象(onBlur最常用),显示时机必须「error && touched」(否则满屏红字),提交失败要把有错的字段标为 touched;数组字段的难点是「稳定标识」(key用 id,避免用 index 做路径);服务端错误通过throw { fieldErrors }映射回字段;非受控的固有代价是「程序化改值要手动同步 DOM」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!