浅析 React 跨组件类表单场景的数据流思路

2,078 阅读16分钟

引言

在一切开始之前,我们先了解一下什么是表单?什么又是类表单场景?

在我看来,表单就是一个具备统一状态管理的容器,该容器内部可以放置相关表单项(以 form 元素举例, input、radio、checkbox 等表单控件就是一个个表单项),并且表单项之间可以实现联动。同时,表单又可以聚合内部所有表单项的值与状态,并通过相关方法统一管理它们

而类表单场景顾名思义,就是符合以上使用需求的场景。就拿 React 来说,一个简单的类表单场景实现为:

一个父组件,通过 props 分别向两个子组件下发对应的状态值state与状态回调函数onChange,在子组件中则可以使用state并绑定触发相应state的 change 事件,最后通过调用onChange方法完成数据回流,交给父组件统一管理所有的state

类表单场景简单实现.jpeg

下面是基本的代码实现:

import React, { useState } from 'react'

interface ChildProps {
  value: string
  onChange: (input: string) => void
}

const Child: React.FC<ChildProps> = ({ value, onChange }) => {
  return (
    <div>
      <h3>{value}</h3>
      <input
        onChange={(e) => {
          onChange(e.target.value)
        }}
      />
    </div>
  )
}

const Parent: React.FC = () => {
  const [state, setState] = useState(() => ({
    input1: '',
    input2: ''
  }))
  const onChange = (input: string, key: string) => {
    setState((prevState) => ({
      ...prevState,
      [key]: input
    }))
  }

  return (
    <div>
      <div>
        <h2>Input1</h2>
        <Child value={state.input1} onChange={(v) => onChange(v, 'input1')} />
      </div>
      <div>
        <h2>Input2</h2>
        <Child value={state.input2} onChange={(v) => onChange(v, 'input2')} />
      </div>
    </div>
  )
}

const Demo: React.FC = () => {
  return <Parent />
}

export default Demo

上面的代码就是 React 中一个简单的类表单场景实现。当然,在实际使用过程中我们需要考虑更多,比如如何更优雅的绑定表单项的值与回调函数,跨组件的表单数据传输以及不同表单项之间的数据联动等

回到标题上,基于上述这些问题,我们可以思考一下相关的解决方案,React 社区中又是如何处理上述这些问题的?

下文我们会基于 React 社区中类表单场景的发展历程,解析此种场景的数据流实现思路。

实现思路

阶段一:如何实现一个跨组件的类表单状态流机制

统一的数据下发

我们的首要目标是先实现一个具备管理与收集表单项的类表单容器,那么如何在 React 中跨组件管理状态呢?

答案是使用Context,在Context中保存所有表单项的值与相关更新回调,并在对应表单项中获取并使用,就可以实现跨组件层级的数据传输。

创建Context

export type Store = Record<string, any>

export interface FieldMeta {
  name: string
  value: any
}

export interface FormContextValue {
  // Context 内保存的每一个表单项
  fieldsStore: Store
  // Context 内下发如何修改表单项值的方法
  setFields: (fields: FieldMeta[]) => void
}

export const FormContext = React.createContext<FormContextValue | null>(null)

// 使用 Context
export function useFormContext() {
  const ctx = useContext(FormContext)
  if (!ctx) {
    throw new Error('FormContext must be used under Form')
  }
  return ctx
}

创建表单容器,并在表单容器中使用:

export interface FormProps {
  // 注意这里我们可以传入默认值,也只允许传入默认值
  initialValues?: Store
  // 表单项改变的方法
  onFieldsChange?: (options: {
    // 改变的表单项
    changedFields: FieldMeta[]
    fieldsStore: Store
  }) => void
  children?: React.ReactNode
}

export const Form: React.FC<FormProps> = ({
  initialValues,
  onFieldsChange,
  children,
}) => {
  const [fieldsStore, setFieldsStore] = useState<Store>(
    () => initialValues || {}
  )
  // 改变的 fields
  const [changedFields, setChangedFields] = useState<FieldMeta[]>([])

  const onFieldsChangeRef = useRef(onFieldsChange)
  onFieldsChangeRef.current = onFieldsChange
  const ctx: FormContextValue = useMemo(() => {
    return {
      fieldsStore,
      // 修改表单项值
      setFields(fields) {
        const newStore = {
          ...fieldsStore,
          ...fields.reduce((acc, next) => {
            acc[next.name] = next.value
            return acc
          }, {} as Store),
        }
        setFieldsStore(newStore)
        setChangedFields(fields)
      },
    }
  }, [fieldsStore])

  // 这里要在 useEffect 也就是刷新 state 后再调用,否则如果在 onFieldsChangeRef 修改值会覆盖掉上次修改
  useEffect(() => {
    onFieldsChangeRef.current?.({
      changedFields,
      fieldsStore,
    })
  }, [fieldsStore, changedFields])
  return <FormContext.Provider value={ctx}>{children}</FormContext.Provider>
}

此时,就可以在Form内部调用useFormContext获取到所有表单项的值与相关更新回调了。

隔离内部状态

表单容器内部的状态收集最好交由其容器内部自行控制,外界无法直接通过受控组件的方式控制表单容器。

这样做的好处是能确保数据流向单一,通过表单容器的自动收集值,可以更好地实现各种状态联动效果(比如修改值时同时要触发值的校验等)。

同时由于将容器与外界隔离,内部的任何操作都不会对外界其他组件造成影响,可以减少不必要的麻烦。

比如有时我们需要在点击提交按钮后再去拿表单值,否则用以前的,如果变成外部传入值,需要额外再维护一套状态。

那么,既然把表单容器做成了非受控组件,对于外界来说又该怎样获取与修改内部值呢?

我们只需将表单容器内获取和修改内部值的相关 API 外抛出即可。同时,配合之前定义的onFieldsChange方法,就可以精确地对表单项的改变做出对应反馈。

React中,要让外部使用组件内的方法有两种方式:

  • 一种是使用Class Component创建组件,外部直接使用ref获取内部实例来调用。
  • 一种是使用Function Component创建组件,外部需要将一个ref变量通过props传入组件,组件内部则使用useImperativeHandle将相关方法挂载到ref.current中,此时外部就可以使用ref.current.xxx来调用挂载的方法了。

因为我们使用的是Function Component,所以选择第二种方式,也就是ref + useImperativeHandle的方案。

但传统直接使用此方式会让我们的调用流程略显麻烦,每个方法都需要通过ref.curent?.xxx(需要多加一层ref.current,并且ref.current可能没有被赋值)这样的形式来调用。为了简化调用流程,我们定义一个useForm方法来让外界能够直接使用到表单容器内部的 API,使用方式如下:

const App: React.FC = () => {
  const form = useForm()

  return (
    <Form form={form}>
      <button
        onClick={() => {
          // 直接调用 form 的方法
          form.setFields([{ name: 'foo', value: 'foo' }])
        }}
      >
        Set Fields
      </button>
      <button
        onClick={() => {
          console.log(form.getFields())
        }}
      >
        Get Fields
      </button>
    </Form>
  )
}

整体实现则是对useRef使用方式的进一步封装,相关useForm的定义如下:

// 对外暴露的值
export interface FormAction {
  // 修改字段值
  setFields: (fields: FieldMeta[]) => void
  // 获取字段值
  getFields: (names?: string[]) => any[]
}
// 对内使用的值
export interface InternalFormAction extends FormAction {
  __INTERNAL__: React.MutableRefObject<FormAction | null>
}

function throwError(): never {
  throw new Error(
    'Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?'
  )
}

export function useForm(): FormAction {
  // 在容器内部需要绑定的值,不将其暴露给用户
  const __INTERNAL__ = useRef<FormAction | null>(null)
  return {
    __INTERNAL__,
    // 额外封装一层方法调用
    getFields(names) {
      const action = __INTERNAL__.current
      if (!action) {
        throwError()
      }
      return action.getFields(names)
    },
    setFields(fields) {
      const action = __INTERNAL__.current
      if (!action) {
        throwError()
      }
      action.setFields(fields)
    }
  } as InternalFormAction
}

在表单容器Form中也需要做相应修改,将相关 API 绑定在上面的__INTERNAL__中:

export interface FormProps {
  // ...
  // 增加一个 form 属性
  form?: FormAction
}

export const Form: React.FC<FormProps> = ({
  // ...
  form: formProp
}) => {
  // ...
  // 增加默认参数,避免后续使用报错
  const defaultForm = useForm()
  const form = (formProp || defaultForm) as InternalFormAction
  // ...
  useImperativeHandle(
    form.__INTERNAL__,
    () => ({
      getFields(names) {
        if (!names) {
          return [ctx.fieldsStore]
        }
        return names.map((name) => {
          return ctx.fieldsStore[name]
        })
      },
      setFields: ctx.setFields
    }),
    // 这里会依赖之前定义好的 ctx 上的值
    [ctx.fieldsStore, ctx.setFields]
  )
  // ..
}

抽象表单项逻辑

在之前我们已经处理了表单容器的数据下发,现在则需要考虑如何在内部表单控件中使用它们。

如果直接在表单控件内部使用useFormContext获取对应值,那么我们在每个表单控件中都需要进行相同处理,增加了维护成本,同时也无法很好地与第三方库进行集成,这种方式显然并不可取,所以useFormContext我们一般会当作内部 hook,不会对用户暴露。

在这里我们使用文章最开始的方式,为每一个表单控件单独绑定value与对应的onChange事件,也就是将其变为受控组件。

一般情况下,我们会考虑使用一个高阶组件来减少重复的绑定工作量。不过在这里还可以再抽象一下,一个表单项中可能还会有各种其余的配置信息,比如值的校验规则、报错文案等,这些信息应该与表单控件本身无关,我们在使用时也会动态地进行指定,如果使用高阶组件来包裹,写法相对来说不够优雅(antd v3中的Form的写法就经常被吐槽)。

所以最好的方式应该是将每一个表单项的外层逻辑抽象为单独的组件Field,在该组件内部使用React.cloneElement完成对内部真正的表单控件状态绑定。

使用方式如下:

<Form>
  {/* 在内部使用 React.cloneElement 为 input 绑定 value 与 onChange */}
  <Field name="foo">
    <input />
  </Field>
</Form>

下面是对Field组件的实现:

export interface FieldProps {
  children: React.ReactNode
  name: string
}

export function getTargetValue(e: any) {
  if (typeof e === 'object' && e !== null && 'target' in e) {
    return e.target.value
  }
  return e
}

export const Field: React.FC<FieldProps> = ({ children, name }) => {
  const { setFields, fieldsStore } = useFormContext()
  // 实现 value 与 onChange
  const value = fieldsStore[name]
  const onChange = useCallback(
    (e: any) => {
      setFields([{ name, value: getTargetValue(e) }])
    },
    [name, setFields]
  )
  const onChangeRef = useRef(onChange)
  onChangeRef.current = onChange
  // 为了确保 onChange 不会改变地址指向
  const elementOnChange = useCallback((e: any) => {
    return onChangeRef.current(e)
  }, [])
  const element = useMemo(() => {
    if (!React.isValidElement(children)) {
      return children
    }
    // 克隆 children,传入 value 与 onChange
    return React.cloneElement(children as React.ReactElement, {
      onChange: elementOnChange,
      value,
      ...children.props
    })
  }, [children, value, elementOnChange])

  return <>{element}</>
}

处理数据联动

类表单场景一个很大的特点就是需要考虑数据联动问题,一个表单项的更新依赖于另一个表单项的值,那么我们如何在一个表单项中接收到另一个表单项的改变并获取到对应的值呢?

其实由于我们最初的设计模式,在任意一个表单项修改时都会自动触发表单容器Form的重新渲染,所以我们只需要监听是否是对应依赖的值发生改变即可。

因为我们不打算对用户暴露useFormContext,所以这里可以再单独封装一个 hook useWatch来实现指定值的监听。

useWatch的实现很简单:

// 当传入 name 时返回具体的字段值,不传则返回整个 fieldsStore
export function useWatch(name?: string) {
  const { fieldsStore } = useFormContext()
  return name ? fieldsStore[name] : fieldsStore
}

实现数据联动:

const Input: React.FC = () => {
  const foo = useWatch('foo')
  const [value, setValue] = useState(foo + 'bar')
  useEffect(() => {
    setValue(foo + 'bar')
  }, [foo])
  return <input value={value} readOnly />
}

到目前为止,我们已经基本实现了一个跨组件的类表单状态流机制。基于数据的自动收集,可以保证整个表单容器内部的状态更新与反馈,支持绝大多数类表单场景。

局限性

我们在处理数据联动时有提到,在任意一个表单项修改时都会自动触发表单容器Form的重新渲染, 这一特性会导致很多不必要的表单项渲染,在表单字段过多或有拥有大量计算的表单项时会有很大的性能瓶颈。

整体的状态流向大致如下:

阶段一状态流.jpeg

虽然可以使用memo等手段优化局部表单项的二次渲染,但也只是对这种全量刷新方式的一种妥协而已,对于整体表单容器来说依旧是会重新渲染,内部整体走一遍完整的 diff。并且如果在表单控件内部使用useWatch,由于是直接从Context中取值,memo也会直接失去作用。

而早期 React 生态中的大部分表单库基本都是如此实现的,比如Antd v3中的Form引入的rc-form,国外早期爆火的redux-form(在 v6 版本之前)都是使用的全量渲染机制。

后来,社区逐渐意识到了这类问题,于是也就有了阶段二中的优化方案。

阶段二:优化状态流的渲染机制

既然状态值的全量更新会带来性能问题,那么我们可以换一个思路,将状态值的更新逻辑交由其对应的Field自行处理,而表单容器本身只用保存所有的表单项状态,承担中央的调度器的功能:当有表单项的状态改变时由表单容器给对应的Field发送通知,对应的Field接到通知后再调用内部的状态更新方法重新渲染自身,以此达到局部更新的效果

整个流程其实就是一个发布订阅模式,整体的状态流向大致如下:

阶段二状态流.jpeg

创建发布订阅中心

由于此时表单容器只是作为一个中央调度器存在,所以我们可以将相关表单项状态与发布订阅功能单独抽离出来。

我们创建一个类来实现这部分逻辑:

export type SubscribeCallback = (changedFields: FieldMeta[]) => void

export class FormStore {
  // 保存所有表单项的值
  private store: Store = {}
  // 监听器数组
  private observers: SubscribeCallback[] = []

  constructor(initialValues?: Store) {
    initialValues && this.updateStore(initialValues)
  }

  private updateStore(nextStore: Store) {
    this.store = nextStore
  }
  // 当有值改变时,给所有监听器发布通知
  private notify(changedFiles: FieldMeta[]) {
    this.observers.forEach((callback) => {
      callback(changedFiles)
    })
  }
  // 注册监听器
  subscribe(callback: SubscribeCallback) {
    this.observers.push(callback)
    return () => {
      this.observers = this.observers.filter((fn) => fn !== callback)
    }
  }
  getFields(names?: string[]): any[] {
    if (!names) {
      return [this.store]
    }
    return names.map((name) => {
      return this.store[name]
    })
  }

  setFields(fields: FieldMeta[]) {
    const newStore = {
      ...this.store,
      ...fields.reduce((acc, next) => {
        acc[next.name] = next.value
        return acc
      }, {} as Store)
    }
    this.updateStore(newStore)
    // 当 store 更新时发送通知
    this.notify(fields)
  }
}

订阅状态变更

完成发布订阅逻辑的抽离后,下一步则是使用它,现在我们需要重构阶段一中的FormField,使它们与上面的FormStore绑定。

// 下面可以和阶段一中的写法对应着看
// 更改 Context 的存储值,现在只需要向内传入 formStore 实例即可
export interface FormContextValue {
  // // Context 内保存的每一个表单项
  // fieldsStore: Store
  // // Context 内下发如何修改表单项值的方法
  // setFields: (fields: FieldMeta[]) => void
  formStore: FormStore
}

// ...

export const Form: React.FC<FormProps> = ({
  initialValues,
  onFieldsChange,
  form: formProp,
  children
}) => {
  // 不再需要保存 fields 状态
  // const [fieldsStore, setFieldsStore] = useState<Store>(
  //   () => initialValues || {}
  // )
  const onFieldsChangeRef = useRef(onFieldsChange)
  onFieldsChangeRef.current = onFieldsChange
  const defaultForm = useForm()
  const form = (formProp || defaultForm) as InternalFormAction
  // 创建 formStore 实例
  const formStore = useMemo(() => new FormStore(initialValues), [])
  // 改变 context value
  const ctx: FormContextValue = useMemo(() => {
    return {
      formStore
    }
  }, [formStore])

  useImperativeHandle(
    form.__INTERNAL__,
    () => ({
      setFields(fields) {
        formStore.setFields(fields)
      },
      getFields(paths) {
        return formStore.getFields(paths)
      }
    }),
    [formStore]
  )

  // 加入一个监听,为 onFieldsChange 回调服务
  useEffect(() => {
    const unsubscribe = formStore.subscribe((changedFields) => {
      onFieldsChangeRef.current?.({
        changedFields,
        fieldsStore: formStore.getFields()
      })
    })
    return unsubscribe
  }, [formStore])
  return <FormContext.Provider value={ctx}>{children}</FormContext.Provider>
}

export const Field: React.FC<FieldProps> = ({ children, name }) => {
  const { formStore } = useFormContext()
  // 内部自己维护状态
  const [value, setValue] = useState(() => formStore.getFields([name])[0])
  const onChange = useCallback(
    (e: any) => {
      formStore.setFields([{ name, value: getTargetValue(e) }])
    },
    [formStore, name]
  )
  const onChangeRef = useRef(onChange)
  onChangeRef.current = onChange
  const elementOnChange = useCallback((e: any) => {
    return onChangeRef.current(e)
  }, [])
  const element = useMemo(() => {
    if (!React.isValidElement(children)) {
      return children
    }
    return React.cloneElement(children as React.ReactElement, {
      onChange: elementOnChange,
      value,
      ...children.props
    })
  }, [children, elementOnChange, value])

  // 订阅一个监听,当 changedFields 中包含当前的字段时更新 value 值
  useEffect(() => {
    const unsubscribe = formStore.subscribe((changedFields) => {
      const targetField = changedFields.find(
        (changedField) => name === changedField.name
      )
      targetField && setValue(targetField.value)
    })
    return unsubscribe
  }, [formStore, name])
  return <>{element}</>
}

重构数据联动功能

在阶段一中由于是使用的全量更新的方式,我们创建的useWatch实际只起到了过滤值的作用,并没有实现watch本身的价值。而此时由于整体采用发布订阅模式更新值,我们就可以真正地实现watch效果了。

// 其实内部的订阅逻辑和 Field 是类似的
export function useWatch(name?: string) {
  const { formStore } = useFormContext()
  // 内部维护一个状态值,当监听到指定字段值改变时,会更新当前调用 useWatch 的组件
  const [value, setValue] = useState(() =>
    name ? formStore.getFields([name])[0] : formStore.getFields()[0]
  )
  useEffect(() => {
    const unsubscribe = formStore.subscribe((changedFields) => {
      if (name) {
        const targetField = changedFields.find(
          (changedField) => name === changedField.name
        )
        targetField && setValue(targetField.value)
      } else {
        setValue(formStore.getFields()[0])
      }
    })
    return unsubscribe
  }, [formStore, name])

  return value
}

现在,我们已经成功完成了渲染机制的整体升级,使用发布订阅机制将全量的状态更新转移为了局部更新。目前Antd v4中的Form引入的rc-field-form以及redux-form v6以及后续redux-form作者新开发的react-final-form都是基于这种模式实现的,目前社区中使用最为广泛的也是此种模式。

阶段三:特定场景的渲染优化

我们可以发现,不论阶段一还是阶段二,在表单容器内部实际都是通过useState重置值来实现状态更新的,对于具体的表单控件来说,其本身的值是依靠state下发的,意味着它实际上是一个受控组件。

那么我们换一个思路思考,将表单控件本身作为一个非受控组件使用。如果表单控件本身是一个非受控组件,也就意味着我们无法在外界直接拿到它的状态值并管理其状态值的更新。但可以选择从另一条路入手,通过ref获取表单控件实例,直接在ref中拿到和更改对应表单控件的值。这样我们就将状态值的更新限制在了对应的表单控件中,可以做到进一步的渲染优化。

不过需要注意的是,目前只有在纯原生表单场景时上面的优化才能起到作用(一些不使用原生表单控件的类表单场景无法优化),如果想要用在自定义的表单控件中,需要用forwardRef包装拿到外部传入的ref,并将其手动挂载给原生表单控件。

总体来说,此种方式在虽然相对于阶段二有着更加优秀的性能,但也具有相应的限制条件,比如很难和第三方组件库中的搭配使用。

不过对我们来说这个问题其实不难解决,我们可以选择同时兼容这两种渲染方式,在原有的状态流机制新增一个特殊的NativeField组件,用于对原生表单场景做出进一步的优化。

优化原生表单场景

使用NativeField优化原生表单控件的重新渲染。

export type FieldElement =
  | HTMLInputElement
  | HTMLSelectElement
  | HTMLTextAreaElement
  
export const NativeField: React.FC<FieldProps> = ({ children, name }) => {
  const { formStore } = useFormContext()
  // 使用 ref 保存原生表单控件
  const fieldRef = useRef<FieldElement>()
  const onChange = useCallback(
    (e: any) => {
      formStore.setFields([{ name, value: getTargetValue(e) }])
    },
    [formStore, name]
  )
  const onChangeRef = useRef(onChange)
  onChangeRef.current = onChange
  const elementOnChange = useCallback((e: any) => {
    return onChangeRef.current(e)
  }, [])
  const element = useMemo(() => {
    if (!React.isValidElement(children)) {
      return children
    }
    return React.cloneElement(children as React.ReactElement, {
      onChange: elementOnChange,
      ref: fieldRef,
      ...children.props
    })
  }, [children, elementOnChange])

  // 订阅一个监听,当 changedFields 中包含当前的字段时更新 value 值
  useEffect(() => {
    // 设置初始值
    if (fieldRef.current) {
      fieldRef.current.value = formStore.getFields([name])[0]
    }
    const unsubscribe = formStore.subscribe((changedFields) => {
      if (!fieldRef.current) {
        return
      }
      const targetField = changedFields.find(
        (changedField) => name === changedField.name
      )
      // 将 setValue 改为直接修改 ref 的值
      if (targetField) {
        fieldRef.current.value = targetField.value
      }
    })
    return unsubscribe
  }, [formStore, name])
  return <>{element}</>
}

当然,上面只是对使用原生表单控件时的简单处理,目前社区中基于非受控的模式已经诞生出了一个相对成熟的表单库 react-hook-form,其内部也对第三方组件库做了兼容的受控处理,有兴趣的同学可以详细了解一下。

结论

本文讲到了跨组件类表单场景的数据流实现思路,从三种表单数据流实现阶段讲起,一步步深入挖掘了它们的实现原理与相应的局限性。

这三种阶段的产生其实也对应着 React 中类表单场景的发展历程,从最开始的全量状态更新,再到后续使用发布订阅模式在子组件内部进行局部状态更新,最后到针对特殊场景的进一步性能优化。我们可以发现,数据流传输的性能问题一直是这类场景的首要解决问题。

最后需要注意的是,表单场景的设计与实现并不止于此,除了基本的数据流交互外,还要考虑如何对表单进行脏检查以及各种状态判断,如何去除已卸载表单项的数据干扰,如何对表单数据进行校验等。本文则只是从数据流交互为突破口进行了研究,并没有涉及过多额外的概念,不过也基本算得上是对整个表单场景核心部分的剖析了。当然,如果你想更完整地了解到一个表单库是如何从零到一设计并实现的,可以试着参考下述的仓库源码,相信它们能给你提供一个合适的实现思路。

参考