受控/非受控组件分析

基础概念

日常开发中一定会碰到表单处理的需求,比如输入框、下拉框、单选框、上传等,既然是组件,不管是ui组件还是自定义组件,优秀或者说完善的组件一定得是同时支持受控和非受控的,那么何为受控和非受控组件呢?

改变一个组件的值,只能通过两种方式

用户去改变组件的value或者代码去改变组件的value

如果不能通过代码去改变组件的value, 那么这个组件的value只能通过用户的行为去改变,那么这个组件就不受我们的代码控制,那么它就是一个非受控组件,反之能通过代码改变组件的value值,组件受我们代码控制,那么它就是一个受控组件。

非受控模式下,代码可以组件设置默认值defaulValue,但是代码设置完默认值后就不能控制value,能改变value的只能是用户行为,代码只能通过监听onChange事件获取value或者获取dom实例来获取value值。

注意:defaultValue和value不一样,defaultValue是value的初始值,用户后面改变的是value的值

受控模式下,代码一旦给组件设置了value,用户就不能再去通过行为改变它,只能通过代码监听onChange事件拿到value重新赋值去改变.

圈起来,这句话要考:value能通过用户控制就是非受控、通过打码控制就是受控

受控示例

一个典型的受控代码片段

ts 复制代码
import { Input } from 'antd'
import { ChangeEvent, useState } from 'react'

export default function Demos() {
  const [text, setText] = useState('')
  const inputHandler = (e: ChangeEvent<HTMLInputElement>) => {
    // 通过监听Input的onChange去重新赋值来改变value,用户无法控制输入框的值
    setText(e.target.value)
  }

  return <Input value={text} onChange={inputHandler} />
}

非受控示例

一个典型的非受控代码片段

ts 复制代码
import { Input, InputRef } from 'antd'
import { useRef } from 'react'

export default function Demos() {
  const inputRef = useRef<InputRef>(null)

  setTimeout(() => {
    // 通过ref获取dom元素来获取value
    console.log(inputRef.current?.input?.value)
  }, 4000)

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 通过监听onChange来获取value
    console.log(e.target.value)
  }

  return <Input ref={inputRef} onChange={onChange} />
}

通过以上使用,我们可以发现,antd的Input组件同时支持了受控和非受控模式,那么我们能不能也自己封装一个同时支持受控和非受控模式的组件呢?

自定义同时支持受控和非受控模式的Radio组件

完整封装如下

ts 复制代码
import { useSettingStore } from '@/stores'
import { cn } from '@/utils'
import { useMemo, useState } from 'react'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyRadioProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控单选组件
export default function MyRadio(props: MyRadioProps) {
  const { colorPrimary } = useSettingStore()
  const { value, defaultValue, options, onChange } = props

  // 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

  const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

  // 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

  // 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            { 'text-white': mergedValue === item.value },
          )}
          style={{
            backgroundColor: mergedValue === item.value ? colorPrimary : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}
ts 复制代码
// 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

通过判断props中是否有value属性来判断到底是受控还是非受控

ts 复制代码
const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

保存一个内部状态,来存储非受控模式时的值

js 复制代码
// 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

组件最终显示的值,受控时显示父组件传入的value值,非受控时显示组件内部存储的值

ts 复制代码
// 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

组件值改变时,如果是非受控,更新组件内部的值,并触发onChange事件回调(受控和非受控时都可以传onChange事件)

组件使用

ts 复制代码
<Card title="自定义单选组件非受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={8}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义单选组件受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={radio1}
      onChange={(value) => setRadio1(value)}
    />
</Card>

useMergeState封装

以上代码成功的实现了自定义组件同时支持受控和非受控,但是逻辑太分散,是否可以将处理逻辑再次封装呢?那么我们就来封装一个自定义hook来统一处理受控和非受控的逻辑

ts 复制代码
import { getTypeOf } from '@/utils'
import {
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from 'react'

// 参数属性
export type MergeStateProps<T> = {
  value?: T
  defaultValue?: T
  onChange?: (value: T) => void
  [props: string]: any
}

// 配置属性
export type MergeStateOption<T> = {
  defaultValue?: T // 默认值
  defaultValuePropName?: string // 默认值属性名
  valuePropName?: string // 值属性名
  trigger?: string // 触发
}

/**
 * @description 合并状态hook
 */
function useMergeState<T = any>(
  props: MergeStateProps<T> = {},
  options: MergeStateOption<T> = {},
): [T, (v: SetStateAction<T>, ...args: any[]) => void] {
  const {
    defaultValue,
    defaultValuePropName = 'defaultValue',
    valuePropName = 'value',
    trigger = 'onChange',
  } = options

  const value = props[valuePropName] // 获取当前值
  const isControlled = Reflect.has(props, valuePropName) // 是否受控

  // 初始值
  const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
  }, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
  ])

  const [state, setState] = useState(initialValue) // 保存内部状态

  // 可控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

  // 设置值
  const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
  )

  return [state, handleSetState]
}

export default useMergeState
ts 复制代码
const value = props[valuePropName] // 获取当前value值
const isControlled = Reflect.has(props, valuePropName) // 是否受控

获取当前的value,并判断是否是受控模式

ts 复制代码
  // 初始值
const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
}, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
])

const [state, setState] = useState(initialValue) // 保存内部状态

设置内部状态的值 1、如果是受控,则返回value的值 2、如果不是受控,则返回传入配置中定义的defaultValue的属性名对应的值 3、否则返回传入的defaultValue值

这里为什么需要传valuePropName这个属性呢,因为Switch/CheckBox组件没有value属性,只有checked属性,是为了兼容

ts 复制代码
// 受控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

受控的情况下,外部传入值时,更新内部状态

ts 复制代码
// 设置值
const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
)

返回组件的第二个返回值,设置值的方法 1、首先判断使用该hook第二个返回值时传入的参数是不是一个函数,是的话先执行 2、非受控时才去更新内部状态,受控时不用更新,直接由父组件改变value 3、触发事件回调

使用上述hook再封装一个同时支持受控和非受控的自定义组件

ts 复制代码
import useMergeState from '@/hooks/useMergeState'
import { useThemeToken } from '@/hooks/useThemeToken'
import { cn } from '@/utils'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyCheckboxProps = {
  value?: number[]
  defaultValue?: number[]
  onChange?: (value: number[]) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控多选组件
export default function MyCheckbox(props: MyCheckboxProps) {
  const { colorPrimary } = useThemeToken()

  const { options } = props
  const [selectedValue, setSelectedValue] = useMergeState<number[]>(props)

  // 选择的回调
  const onSelect = (value: number) => () => {
    let res = [
      ...(Array.isArray(selectedValue) ? selectedValue : [selectedValue]),
    ]
    if (Array.isArray(selectedValue) && selectedValue?.includes(value)) {
      res = selectedValue.filter((item) => item !== value)
    } else {
      res.push(value)
    }
    setSelectedValue(res)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            {
              'bg-[#1890ff] text-white':
                Array.isArray(selectedValue) &&
                selectedValue?.includes(item.value),
            },
          )}
          style={{
            backgroundColor: selectedValue?.includes(item.value)
              ? colorPrimary
              : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}

组件使用

ts 复制代码
<Card title="自定义多选组件非受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 12 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={[4, 6, 9]}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义多选组件受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 16 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={checkbox1}
      onChange={(value) => setCheckbox1(value)}
    />
</Card>

useControllableValue的使用

以上的封装,都是开发者自定义的,强大的ahooks怎么可能没有想到这种需求呢,所以ahooks也提供了useControllableValue这个hook

基本使用

ts 复制代码
import React, { useState } from 'react';
import { useControllableValue } from 'ahooks';

const ControllableComponent = (props: any) => {
  const [state, setState] = useControllableValue<string>(props);

  return <input value={state} onChange={(e) => setState(e.target.value)} style={{ width: 300 }} />;
};

const Parent = () => {
  const [state, setState] = useState<string>('');
  const clear = () => {
    setState('');
  };

  return (
    <>
      <ControllableComponent value={state} onChange={setState} />
      <button type="button" onClick={clear} style={{ marginLeft: 8 }}>
        Clear
      </button>
    </>
  );
};

使用useControllableValue封装一个自定义时间选择组件

typescript 复制代码
import { cn, dateFormat } from '@/utils'
import { useControllableValue } from 'ahooks'
import dayjs from 'dayjs'
import WhiteSpace from '../whiteSpace'

// 自定义时间选择组件的属性
export type TimeProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  timeNum?: number
}

// 非受控/受控时间选择组件
export default function MyTime(props: TimeProps) {
  const { timeNum = 10 } = props
  const [value, setValue] = useControllableValue(props)

  // 时间选择的回调
  const onSelectTime = (time: number) => () => {
    setValue(time)
  }

  return (
    <div>
      <div>当前时间:{dateFormat(value, 'YYYY-MM-DD HH:mm:ss')}</div>
      <WhiteSpace />
      {Array.from({ length: timeNum }).map((_, index) => {
        const time = dayjs()
          .subtract(index + 1, 'days')
          .startOf('day')
          .valueOf()

        return (
          <div
            onClick={onSelectTime(time)}
            key={index}
            className={cn({ 'text-red-500': time === value })}
          >
            {dateFormat(time, 'YYYY-MM-DD HH:mm:ss')}
          </div>
        )
      })}
    </div>
  )
}

用useControllableValue结合antd的DatePicker组件二次封装一个时间选择组件

ts 复制代码
import { useControllableValue } from 'ahooks'
import { DatePicker, DatePickerProps } from 'antd'
import dayjs from 'dayjs'
import { useMemo } from 'react'

const defaultShortcuts = [
  {
    label: '今天',
    value: dayjs(),
  },
  {
    label: '昨天',
    value: dayjs().subtract(1, 'day'),
  },
  {
    label: '三天前',
    value: dayjs().subtract(3, 'days'),
  },
  {
    label: '一周前',
    value: dayjs().subtract(1, 'week'),
  },
  {
    label: '15天前',
    value: dayjs().subtract(15, 'days'),
  },
  {
    label: '一个月前',
    value: dayjs().subtract(1, 'month'),
  },
]

// 时间选择器组件的属性
export interface MyDatePickerProps extends DatePickerProps {
  shortcuts?: number[] // 快捷选项
  shortcutsMap?: Record<number, string> // 快捷选项的映射
  shortcutsRender?: (shortcuts?: number[]) => DatePickerProps['presets']
  showPresets?: boolean // 是否显示快捷选项
}

// 非受控/受控时间选择器组件
export default function MyDatePicker(props: MyDatePickerProps) {
  const {
    shortcuts,
    shortcutsMap,
    showPresets = true,
    shortcutsRender,
    ...rests
  } = props

  const [values, setValues] =
    useControllableValue<DatePickerProps['value']>(props)

  const presets = useMemo(() => {
    if (!showPresets) return undefined
    if (shortcutsRender && shortcuts?.length) {
      return shortcutsRender(shortcuts)
    }
    if (shortcuts?.length) {
      return shortcuts.map((shortcut) => {
        return {
          label: shortcutsMap?.[shortcut] || `近${shortcut}天`,
          value: dayjs().subtract(shortcut, 'days'),
        }
      })
    }
    return defaultShortcuts
  }, [shortcuts, shortcutsMap, showPresets, shortcutsRender])

  return (
    <DatePicker
      presets={presets}
      {...rests}
      value={values}
      onChange={setValues}
    />
  )
}

用useControllableValue结合antd的Upload组件二次封装一个图片上传组件

ts 复制代码
import type { UploadProps } from 'antd/es/upload/interface'
import { ButtonProps } from 'antd/lib'

export type ImgsValueType = string[] | string // 上传的值类型

// 上传参数类型
export interface IImgsUploadProps
  extends Omit<UploadProps, 'onChange' | 'value' | 'defaultValue'> {
  validate?: boolean // 是否需要验证接收类型和文件大小
  validateSize?: boolean // 是否需要验证图片的宽高
  limitWidth?: number // 验证图片的宽
  limitHeight?: number // 验证图片的高
  size?: number // 限制的尺寸,以M为单位
  successText?: string // 上传成功的提示文字
  failedText?: string // 上传失败的提示文字
  uploadText?: string // 上传按钮文字
  uploadStyles?: React.CSSProperties // 上传按钮的样式
  imgsStyles?: React.CSSProperties // 图片的样式
  imgList?: string[] // 已上传的图片列表
  preview?: boolean // 图片是否可预览
  count?: number // 图片总数限制
  tips?: string // 提示tips
  tipStyle?: React.CSSProperties // 提示tips的样式
  plusSizeTip?: string // 超过尺寸大小的提示语
  errorAcceptTip?: string // 上传格式不正确的提示语
  compress?: boolean // 是否压缩图片
  quality?: number // 压缩比例
  value?: ImgsValueType // 值
  defaultValue?: ImgsValueType // 默认值
  width?: number // 图片展示的宽
  height?: number // 图片展示的高
  multi?: boolean // 是否上传多张图片
  uploadBtn?: React.ReactNode // 自定义上传按钮
  uploadBtnProps?: ButtonProps // 上传按钮属性
  showImgs?: boolean // 是否显示已上传的图片
  fileValidateTip?: string // 文件校验不通过的提示语
  fileValidate?: (file: File) => Promise<boolean> // 文件校验
  onChange?: (data: ImgsValueType) => void // 改变的回调
  remove?: (url: string) => void // 移除已上传图片的回调
  onUploaded?: (url: string, fileInfo?: any, ...restParams: any) => void // 单张上传成功后接收结果
}


import { compressPic } from '@/utils'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons'
import { useControllableValue } from 'ahooks'
import { Button, Image as IM, message, Spin, Upload } from 'antd'
import type { UploadProps } from 'antd/es/upload/interface'
import { useState } from 'react'
import styles from './imgsUpload.module.less'
import type { IImgsUploadProps } from './typings'

// 允许上传的图片类型
export const ACCEPTIMG = '.jpg, .jpeg, .png, .gif, .webp, .ico, .bmp'

// 图片列表(多张、单张)上传通用组件
const ImgsUpload: React.FC<IImgsUploadProps> = (props: IImgsUploadProps) => {
  const {
    validate = true,
    validateSize = false,
    limitWidth = 1080,
    limitHeight = 1920,
    size,
    successText = '上传成功',
    failedText = '上传失败',
    plusSizeTip,
    errorAcceptTip,
    compress = false,
    quality = 0.6,
    accept,
    uploadStyles = {},
    imgsStyles = {},
    preview = true,
    multi = false,
    count = multi ? 5 : 1,
    tips,
    disabled,
    tipStyle = {},
    width = 80,
    height = 80,
    uploadText,
    uploadBtn,
    uploadBtnProps,
    showImgs = true,
    fileValidateTip,
    value,
    fileValidate,
    onChange,
    remove,
    onUploaded,
    ...restProps
  } = props || {}

  console.log(value, onChange)

  const accepts = accept ?? ACCEPTIMG // 接收类型
  const limit = size ?? 5 // 限制大小
  const [spin, setSpin] = useState<boolean>(false) // 上传中
  const [imgs, setImgs] = useControllableValue(props, {
    defaultValue: multi ? [] : '',
  }) // 已上传的图片列表

  // 上传的回调
  const handleChange = (info: any) => {
    if (info.file.status === 'uploading') {
      setSpin(true)
    }
    if (info.file.status === 'done' && info.file?.response?.msg === 'success') {
      setSpin(false)
      const result = info.file?.response?.result[0]
      if (!!result && !result?.endsWith('.bin')) {
        message.success(successText)

        // 多张图片
        if (multi) {
          setImgs((pre: any) => {
            if ((pre || []).length < count) {
              return [...(pre || []), result]
            }
            return pre
          })
        } else {
          // 单张图片
          setImgs(result)
        }

        // 上传成功的回调
        onUploaded?.(result, info.file)
      } else {
        message.error(failedText)
      }
    }
    if (info.file.status === 'done' && info.file?.response?.msg !== 'success') {
      setSpin(false)
      message.error(info.file?.response?.msg || failedText)
    }
    if (info.file.status === 'error') {
      setSpin(false)
      message.error(failedText)
    }
  }

  // 获取上传图片的原始宽高
  const getImgWidthHeight = (
    file: File,
  ): Promise<{ width: number; height: number }> => {
    return new Promise((resolve) => {
      const img = new Image()
      img.crossOrigin = 'anonymous' // 跨域
      img.src = URL.createObjectURL(file)
      img.onload = function () {
        resolve({ width: img.width, height: img.height })
      }
      img.onerror = function () {
        resolve({ width: 0, height: 0 })
      }
    })
  }

  // 上传之前的回调
  const beforeUpload = async (file: File) => {
    if (validateSize) {
      const widthHeight = await getImgWidthHeight(file)
      const { width, height } = widthHeight
      if (width !== limitWidth || height !== limitHeight) {
        message.warning(`图片的大小应该为${limitWidth} * ${limitHeight}`)
        return false
      }
    }
    if (validate) {
      const file_typename = file.name.substring(file.name.lastIndexOf('.'))
      const isRightfile = accepts.includes(file_typename?.toLowerCase())
      // 检验格式
      if (!isRightfile) {
        message.warning(errorAcceptTip || `请上传${accepts}格式的图片`)
      }
      const isLt = file.size / 1024 / 1024 <= limit
      if (!isLt) {
        message.warning(plusSizeTip || `图片大小不超过${limit}M`)
      }

      // 自定义文件校验
      if (fileValidate) {
        const pass = await fileValidate(file)
        if (pass === false) {
          if (fileValidateTip) {
            message.warning(fileValidateTip)
          }
          return false
        }
      }

      // 如果要压缩
      if (isRightfile && isLt && compress) {
        return compressPic(file, quality)
      }
      return isRightfile && isLt
    }
    return true
  }

  // 上传参数
  const uploadProps: UploadProps = {
    showUploadList: false,
    action: `${import.meta.env.VITE_UPLOAD_BASE_URL}/admin/file/upload`,
    accept: accepts,
    disabled: !!spin || disabled,
    multiple: true,
    onChange: handleChange,
    beforeUpload,
  }

  // 移除图片
  const removeImg = (url: string) => {
    if (multi) {
      setImgs((pre: any) => {
        const newImgs = (pre || []).filter((p: string) => p !== url)
        return newImgs
      })
    } else {
      setImgs('')
    }

    // 移除图片的回调
    remove?.(url)
  }

  return (
    <Spin spinning={!!spin}>
      <div className={styles.upload}>
        {showImgs && imgs ? (
          <div className={styles.imgs}>
            {((multi ? imgs : [imgs]) as string[])?.map((url) => (
              <div key={url} className={styles.imgItem}>
                <IM
                  src={`${import.meta.env.VITE_ASSET_BASE_URL}/${url}`}
                  width={width}
                  height={height}
                  style={imgsStyles}
                  preview={preview}
                />
                <CloseOutlined
                  onClick={() => {
                    if (disabled) {
                      return
                    }
                    removeImg(url)
                  }}
                />
              </div>
            ))}
          </div>
        ) : null}
        {(!multi && !imgs) || !imgs || imgs?.length < count ? (
          <Upload
            disabled={disabled}
            {...uploadProps}
            {...restProps}
            style={uploadStyles}
          >
            {spin ? (
              <LoadingOutlined />
            ) : (
              uploadBtn || (
                <Button type="primary" {...uploadBtnProps}>
                  {uploadText || '请选择上传图片'}
                </Button>
              )
            )}
          </Upload>
        ) : null}
      </div>
      {tips ? (
        <div className="pt-2" style={tipStyle}>
          {tips}
        </div>
      ) : null}
    </Spin>
  )
}

export default ImgsUpload

使用useControllableValue来封装同时支持受控和非受控组件,非常的快捷方便,强烈推荐

useControllableValue源码

ts 复制代码
function useControllableValue<T = any>(
  props: StandardProps<T>,
): [T, (v: SetStateAction<T>) => void];
function useControllableValue<T = any>(
  props?: Props,
  options?: Options<T>,
): [T, (v: SetStateAction<T>, ...args: any[]) => void];
function useControllableValue<T = any>(props: Props = {}, options: Options<T> = {}) {
  const {
    defaultValue, // 默认值,会被 props.defaultValue 和 props.value 覆盖
    defaultValuePropName = 'defaultValue', // 默认值的属性名
    valuePropName = 'value', // 值的属性名
    trigger = 'onChange', // 修改值时,触发的函数
  } = options;
  // 外部(父级)传递进来的 props 值
  const value = props[valuePropName] as T;
  // 是否受控:判断 valuePropName(默认即表示value属性),有该属性代表受控
  const isControlled = props.hasOwnProperty(valuePropName);

  // 首次默认值
  const initialValue = useMemo(() => {
    // 受控:则由外部的props接管控制 state
    if (isControlled) {
      return value;
    }
    // 外部有传递 defaultValue,则优先取外部的默认值
    if (props.hasOwnProperty(defaultValuePropName)) {
      return props[defaultValuePropName];
    }
    // 优先级最低,组件内部的默认值
    return defaultValue;
  }, []);

  const stateRef = useRef(initialValue);
  // 受控组件:如果 props 有 value 字段,则由父级接管控制 state
  if (isControlled) {
    stateRef.current = value;
  }

  // update:调用该函数会强制组件重新渲染
  const update = useUpdate();

  function setState(v: SetStateAction<T>, ...args: any[]) {
    const r = isFunction(v) ? v(stateRef.current) : v;

    // 非受控
    if (!isControlled) {
      stateRef.current = r;
      update(); // 更新状态
    }
    // 只要 props 中有 onChange(trigger 默认值未 onChange)字段,则在 state 变化时,就会触发 onChange 函数
    if (props[trigger]) {
      props[trigger](r, ...args);
    }
  }

  // 返回 [状态值, 修改 state 的函数]
  return [stateRef.current, useMemoizedFn(setState)] as const;
}

总结

以上就是对于受控和非受控的总结,文章中部分代码可能有错误之处,还望指正,不喜勿喷哦

相关推荐
_杨瀚博2 小时前
VUE中使用AXIOS包装API代理
前端
张有志2 小时前
基于 Body 滚动的虚拟滚动组件技术实现
前端·react.js
b***74882 小时前
前端正在进入“超级融合时代”:从单一技术栈到体验、架构与智能的全维度进化
前端·架构
白杨SEO营销2 小时前
白杨SEO:看“20步:从0-1做项目的笨办法”来学习如何选一个项目做及经验分享
前端·学习
AY呀2 小时前
# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心
前端·javascript·面试
用户434662153132 小时前
无废话之 useState、useRef、useReducer 的使用场景与选择指南
前端
GinoWi2 小时前
HTML标签 - 表格标签
前端
码是生活2 小时前
老板:能不能别手动复制路由了?我:写个脚本自动扫描
前端·node.js
小皮虾2 小时前
护航隐私!小程序纯前端“证件加水印”:OffscreenCanvas 全屏平铺实战
前端·javascript·微信小程序