返回博客

策略注册表里调 Hook 被 ESLint 封杀——PascalCase 子组件是唯一解

搭了一套字段渲染器的策略注册表,每种字段类型有自己的 handler 对象,暴露一个 renderEditor 函数。前几个类型是纯渲染,没有问题。

轮到一个带异步字典查询和条件级联的字段类型时,renderEditor 里自然地加了 useStateuseMemo 和自定义 usePresetDict——ESLint 当场红线:

React Hook "useState" is called in function "renderEditor" which is neither a
React function component nor a custom React Hook function.

第一反应是加 // eslint-disable-next-line,但这条路走不通——不是规则太严格,而是规则在提示真实 bug 风险。

现象

export const presetEnumHandler = {
  renderEditor: (props: FieldRendererProps) => {
    const [open, setOpen] = useState(false)          // ❌ ESLint 报错
    const dict = usePresetDict(props.col.presetKey)  // ❌ 同上
    if (props.col.cascade) {
      return renderCascadePath(props)                // ❌ 小写 helper 里也有 hook
    }
    return <Select open={open} onOpenChange={setOpen} options={dict.options} />
  }
}

把 hook 移入一个小写 helper renderCascadePath 也不行,ESLint 同样拒绝。

根因

react-hooks/rules-of-hooks 只允许在两种函数里调用 hook:

  1. React 函数组件:函数名以大写字母开头(ESLint 插件内部用 /^[A-Z]/ 匹配)
  2. 自定义 hook:函数名以 use 开头

注册表 handler 里的 renderEditor 是小写绑定的箭头函数,两条都不满足。辅助函数 renderCascadePath 也是小写,同样不被接受。

坑:加 eslint-disable 只是掩盖了真实 bug

eslint-disable react-hooks/rules-of-hooks 能让报错消失,但不能修复底层问题:非组件函数里的 hook 无法和 React 的 Fiber 树正确关联,在条件分支、remount、stale closure 场景下行为不可预期。这条规则存在的意义正是提前暴露这类风险,禁用它等于把定时炸弹藏进代码里。

正解

每个需要调用 hook 的渲染分支,提取成独立的 PascalCase 函数组件。handler 的 renderEditor 退化为一行 JSX 工厂,什么都不做,只负责实例化。

// 每个逻辑分支拥有自己的组件 —— ESLint 全部通过
function CascadePath({ col, value, onChange, row }: FieldRendererProps) {
  const dict = usePresetDict(col.presetKey!)                // ✅ 组件里调 hook
  const parentValue = row?.[col.parentFieldKey!]
  const filtered = useMemo(
    () => dict.options.filter(o => o.parent === parentValue),
    [dict.options, parentValue]
  )
  return <Select options={filtered} value={value} onChange={onChange} />
}
 
function PlainPath({ col, value, onChange }: FieldRendererProps) {
  const dict = usePresetDict(col.presetKey!)                // ✅
  const [open, setOpen] = useState(false)                   // ✅
  return (
    <Select
      open={open}
      onOpenChange={setOpen}
      options={dict.options}
      value={value}
      onChange={onChange}
    />
  )
}
 
// 分发组件:条件渲染 OK,条件调 hook 不 OK
function PresetEnumEditor(props: FieldRendererProps) {
  if (props.col.cascade) return <CascadePath {...props} />
  return <PlainPath {...props} />
}
 
// handler 只剩一行
export const presetEnumHandler: FieldRenderer = {
  name: 'preset_enum',
  match: (col) => col.type === 'preset_enum',
  renderEditor: (props) => <PresetEnumEditor {...props} />,  // ✅
}
正解:renderEditor 实例化 PascalCase 组件,hook 全部下沉进去

把每个 hook 调用点都移入 PascalCase 函数组件,ESLint 满意,React DevTools 也能正确建立组件树,re-render 和 stale closure 行为完全符合预期。分发组件内部可以做条件渲染if (x) return <A />),但不能做条件 hook 调用if (x) const v = useState())——这两者是两个不同的问题。

何时不需要子组件

如果 renderEditor 是纯渲染(无 state、无 effect、无 context、无异步查询),保持行内箭头函数完全没问题:

export const stringHandler: FieldRenderer = {
  name: 'string',
  match: (col) => col.type === 'string',
  renderEditor: ({ value, onChange }) => (
    <Input value={String(value ?? '')} onChange={e => onChange(e.target.value)} />
  ),
}

只在「确实需要 hook」时才拆子组件,不要过度设计。

一句话外卖

React 靠函数名首字母大写识别组件——注册表 handler 的 renderEditor 永远是小写,hook 只能活在 PascalCase 子组件里,别试图用 eslint-disable 绕过这道门。