陌上人如玉
公子世无双

11.14 手写表单库

11.14 手写表单库

一句话:表单库的核心不是「收集输入值」,而是「一套细粒度的订阅模型 + 一个校验状态机」——难点集中在四处:受控/非受控统一、字段级订阅(而不是整表单重渲染)、异步校验的竞态、校验时机与提交状态机。

一、先定清楚要提供什么

// ✅ 目标 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>;
}

六、面试延伸

  1. 「手写一个表单库,核心是什么?」
    三块:① 值管理——用「非受控 + register」(值留在 DOM 里,只在需要时读)而不是「受控 + useState」,避免「每次输入都重渲染整表单」;② 订阅模型——字段级监听表(Map<path, Set<listener>>),配 useSyncExternalStore 实现「改 A 字段只有 A 的订阅者重渲染」(20 个字段的表单,输入 10 个字符从 200 次渲染降到 10 次);③ 状态机——校验状态机(含异步竞态处理:用「代计数器」丢弃过期结果)与提交状态机(idle → validating → submitting → success/error,含防重复提交与服务端字段错误映射)。

  2. 「为什么用非受控 + register,而不是受控 + useState?」
    两个理由:① 性能——受控写法下每次输入都触发「持有 values 的组件」重渲染,字段一多就很明显;非受控把值放在 DOM 里,只在「校验/提交」时读取;② 集成——非受控更容易与「原生表单行为」(如原生 form.reset()、浏览器自动填充)以及「第三方组件」(日期选择器、富文本)配合。代价是「程序化改值需要手动同步 DOM」(用 ref 设 el.value),以及「实时联动/派生值」需要额外的 watch 机制。⭐ 所以实践中的正确做法是「非受控为主 + 提供受控逃生口」。

  3. 「表单的性能瓶颈在哪?」
    在「重渲染范围」。如果用「一个 useState 装整个 values + 受控输入」,那么每敲一个字符都会让持表单的组件重渲染,并连带它所有的子组件(包括 20 个字段与 20 个错误提示)。解法三层:① 非受控(输入不进入 React 状态);② 字段级订阅(错误提示只订阅自己的字段);③ 只复制路径上的层(setPath 的不可变更新不深拷贝整个 values)。⭐ 这三条分别对应「减少参与渲染的组件数」「减少每次渲染的工作量」「减少更新的开销」。

  4. 「异步校验有什么坑?」
    三个:① 竞态——快速输入时「先发的慢请求后返回」,会把过期结果覆盖掉正确结果(✅ 用「代计数器」:const epoch = ++counter; ... if (epoch !== counter) return;);② 请求风暴——每个字符都触发一次校验(✅ debounce 300ms + 「只在值真的变化时校验」);③ 卸载后写状态——组件卸载后回调仍然 setState(✅ 在 reset/卸载时 epoch++ 作废在途校验)。⭐ 另外还要注意「异步校验期间用户点了提交」——提交前必须等待校验完成(所以提交状态机里有 validating 阶段)。

  5. 「校验时机怎么设计?」
    用「模式(mode)」抽象:① onSubmit——只在提交时校验(性能最好,但用户要在提交后才知道错);② onBlur——失焦时校验(⭐ 最常用,既能及时反馈又不会「边打字边报错」);③ onChange——每次变化就校验(反馈最快,但「刚开始输入就报错」体验差);④ all——两者都触发。配套的关键细节是「显示时机」:错误信息应该在「字段被 touch 过」之后才显示(否则「一打开表单就满屏红字」);并且「提交失败时要把所有有错误的字段标记为 touched」,否则用户看不到错误在哪。

  6. 「嵌套字段(address.city)怎么处理?」
    三个工具函数:① 解析路径(把 items[0].name 统一成 ['items', 0, 'name'],注意数字段要转成 number);② 读取(沿路径 reduce);③ ⭐ 不可变写入——只复制路径上的每一层({ ...node, [key]: next },数组用 slice()),不做深拷贝。为什么不能深拷贝:大表单每次全量克隆会带来显著开销(而且会丢掉「未变化的引用」,破坏 memo 的效果)。另一个要点是「父路径错误的聚合」——errors.address.city 的错误在「显示为 address 分组的错误」时要能取到。

  7. 「数组字段(增删排序)有什么难点?」
    核心是「稳定标识」。如果路径用 items[${index}] 而 key 也用 index,那么「删除中间一项」会导致「后面的项整体前移」→ 输入框的值与状态错位(React 复用 DOM 节点,而它绑定的 defaultValue/注册路径都变了)。解法:① 每项带稳定 id(uuid),key 用 id;② 路径用 items.findIndex(i => i.id === id) 计算出的 index,或者直接用 id 做路径(items.byId.${id}.name)——后者更彻底(避免「用 index 做路径」的一切问题)。③ 排序/移动时也要保证「值跟着项走」。

  8. 「如果让你『提交后把服务端错误显示到字段上』,怎么设计?」
    让提交函数可以抛出携带字段错误的错误对象: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」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.14 手写表单库

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫