写一个防抖组件: 在受控/非受控间切换

282 阅读2分钟

更新

切换受控状态写了个hook 在这里
拿这个配合普通受控组件就行 根本不需要那么麻烦 之前是陷入误区了

需求

主题工具项目,要求可以编辑input内容,防抖地将变更同步到展示的组件中。此外切换主题时,input的值和展示的组件都要和主题保持一致。
简化需求如下
0bc469c705e2368d74a12ae9c92fd1ff213b6c10637c1db7a1dedf980206c1e0QzpcVXNlcnNcMTk3NDJcQXBwRGF0YVxSb2FtaW5nXERpbmdUYWxrXDE2MTI0NjIwNDlfdjJcSW1hZ2VGaWxlc1wxNzE5OTc2NTExMTIxXzhEQzY0RDBFLTkzNDAtNDNmMS1CRTc2LTdCRjEwNkQzMTQ1OS5wbmc=.png
这要求输入框在防抖时间内非受控,在之外的时间受控,且不能丢失焦点。

简单实现

下面这段代码简单地完成了需求,实现了input组件在受控/非受控间的切换

export default function App() {
	const initText = '初始内容'
	const [text, setText] = useState(initText)
	return (
		<div className='flex flex-col gap-4 m-4 w-1/4 items-start'>
			<Button onClick={() => setText(initText)} type='primary'>重置文字内容</Button>
			<span><span className='mr-4'>文字内容:</span>{text}</span>
			<span>
				防抖input:
				<DebounceInput value={text} onChange={setText}></DebounceInput>
			</span>
		</div>
	)
}

function DebounceInput({ value, onChange }: { value: string, onChange: (val: string) => void }) {
	const [text, setText] = useState(value)
	const debounceText = useDebounceValue(text) // 取得一个状态的防抖版本
	const isFirstMountedRef = useIsFirstMounted() // 判断是否第一次渲染
	useEffect(() => {
		if (!isFirstMountedRef.current) {
			onChange(debounceText)
		}
	}, [debounceText, onChange, isFirstMountedRef])
	useEffect(() => {
		if (!isFirstMountedRef.current) {
			setText(value)
		}
	}, [value, onChange, isFirstMountedRef])
	return (
		<span className='flex whitespace-nowrap gap-4'>
			防抖input
			<Input value={text} onChange={e => setText(e.target.value)}></Input>
		</span>
	)
}

/** 取得一个状态的防抖版本 */
function useDebounceValue(value: string) {
	const [debounceValue, setDebounceValue] = useState(value)
	useEffect(() => {
		const timer = setTimeout(() => setDebounceValue(value), 200)
		return () => {
			clearTimeout(timer)
		}
	}, [value])
	return debounceValue
}

/** 判断一个组件是否第一次渲染 */
function useIsFirstMounted() {
	const isFirstMountedRef = useRef(true)
	useEffect(() => {
		if (isFirstMountedRef.current) {
			return () => {
				isFirstMountedRef.current = false
			}
		}
	})
	return isFirstMountedRef
}

优化

你可能不需要 Effect – React 中文文档
受文档启发,我发现DebounceInput响应value的变化更新状态是不合理的,做出以下优化

  • 从DebounceInput内抽离非受控组件DebounceInputInner,仅从接受初始值和变化函数
  • DebounceInput监听输入框的值,与value不相等时,以value为初始值重建DebounceInputInner,使它的表现如同受控组件

最终结果


export default function App() {
    // 同上
}

function DebounceInput({ value, onChange }: { value: string, onChange: (val: string) => void }) {
	const prevValueRef = useRef(value)
	const onInnerChange = useCallback<(val: string) => void>(val => {
		prevValueRef.current = val
		onChange(val)
	}, [onChange])
	const keyRef = useRef<number>()
	if (prevValueRef.current !== value) {
		keyRef.current = Math.random()
	}
	return <DebounceInputInner key={keyRef.current} initValue={value} onChange={onInnerChange}></DebounceInputInner>
}


function DebounceInputInner({ initValue, onChange }: { initValue: string, onChange: (val: string) => void }) {
  const [value, setValue] = useState(initValue)
  const debounceValue = useDebounceValue(value)
  const isFirstMountedRef = useRef(true)
  const prevOnChangeRef = useRef(onChange)
  useEffect(() => {
    if (isFirstMountedRef.current) {
      isFirstMountedRef.current = false
    } else {
      if(prevOnChangeRef.current !== onChange){
          prevOnChangeRef.current = onChange
      } else {
          // 组件第一次渲染和onChange变化引发effect时都不调用onChange
          onChange(debounceValue)
      }
    }
  }, [debounceValue, onChange])
  return <Input defaultValue={initValue} onChange={e => setValue(e.target.value)}></Input>
}


/** 取得一个值的防抖版本 */
function useDebounceValue(value: string) {
    // 同上
}

不足

优化后的组件虽然消除了冗余的状态变更,却具有一些新的不足

  • 组件在构建时使用了多个useRef,在理解和改造上有一定阻碍。
  • 组件可以正常输入修改,但如果value因为外部原因变化(例如重置按钮修改value值,这不是来自DebounceInput提交的修改),则会导致输入框重建,丢失焦点。