起因
事情源自这里,一开始写了一个高阶组件用于实现组件受控/非受控的切换,但这个HOC有一些不足 计划改进
原本是这样的
import { ComponentType, useRef } from "react"
type Props<T> = {
defaultValue: T,
onChange: (newValue: T) => void
}
export function withControlledPerformance<P extends Props<any>>(Comp: ComponentType<P>) {
type T = P extends Props<infer V> ? V : never
/**
* 使用与受控组件相同
* value突变时会应用新的value,其余时刻非受控
*/
function CompWithControlledPerformance(props: Omit<Props<T> & { value: T }, 'defaultValue'>) {
const { value, onChange, ...restProps } = props
const prevValueRef = useRef<T | undefined>(value)
const onInnerChange = (val: T) => {
prevValueRef.current = val
onChange(val)
}
const keyRef = useRef<number>()
if (prevValueRef.current !== value) {
keyRef.current = Date.now()
}
return <Comp {...restProps as P} key={keyRef.current} defaultValue={value} onChange={onInnerChange}></Comp>
}
return CompWithControlledPerformance
}
增加isEqual函数 调整变量命名 取消类型断言
type Props<T> = {
defaultValue: T,
onChange: (newValue: T) => void
}
type Value<P> = P extends Props<infer T> ? T : never
type EqualFn<T> = (val1?: T, val2?: T) => boolean
export function withControlledPerformance<P extends Props<any>>(Comp: ComponentType<P>, isEqual?: EqualFn<Value<P>>) {
const equalFn = isEqual ?? Object.is.bind(Object)
type ValueType = Value<P>
type OriginProps = Omit<P, 'defaultValue'>
type PropsForHOC = { value: ValueType }
function CompWithControlledPerformance(props: OriginProps & PropsForHOC) {
const { value, onChange, ...resetProps } = props
const changedValueRef = useRef<ValueType>()
const onInnerChange = useCallback((newVal: ValueType) => {
changedValueRef.current = newVal
onChange(newVal)
}, [onChange])
const keyRef = useRef<number>()
if (!equalFn(value, changedValueRef.current)) {
keyRef.current = Date.now()
}
const compProps: P = {
...restProps,
defaultValue: value,
onChange: onInnerChange
}
return <Comp {...compProps} key={keyRef.current}></Comp>
}
return CompWithControlledPerformance
}
问题
这里出现了一个类型问题 被赋值给compProps的字面量并不是类型P,也不能用as P将字面量指定为P
原因在于HOC生成的新组件中具有参数value 泛型并未规定value应当如何表现
因此当P对value属性具有要求时 用于赋值的字面量就不再满足要求
对类型做出修改
type Props<T> = {
value:never,
defaultValue: T,
onChange: (newValue: T) => void
}
type OriginProps = Omit<P, 'defaultValue'|'value'>
将Props['value']设置为never要求组件允许不传value的情况
经过修改后依旧存在类型问题 查找资料后发现这是ts自身存在的bug
因此将赋值语句改写为
const compProps = {
...restProps,
defaultValue: value,
onChange: onInnerChange
} as P
尽管使用了类型断言 但是相比上一版已经好了不少
最终方案
最终方案当然是————hook
优点在于管理多个值的突变 以及类型推导更好
高阶组件泛型写为<P extends Props<any>>而不是<T,P extends Props<T>>是ts规则所限 具体可以看这篇文章 总之比较麻烦(tsdoc也不好写)
而hook接受参数有限 范围更小 便于ts推导类型
import { useCallback, useRef } from "react"
/**
* 使非受控组件的在value突变时重建,达到类似受控的效果
* @param value 监听突变的值
* @param onChange 变更函数
* @param isEqual 比较函数.默认Object.is
* @example
* const InputWithMutation:FC<{value:string,onChange:(newVal:string)=>void}> = props=>{
* const [key,onInnerChange] = useCompWithMutation(props.value,props.onChange)
* return <input key={key} defaultValue={props.value} onChange={e => onInnerChange(e.target.value)} />
* }
*
* const Comp:FC=()=>{
* const [text,setText] = useState('')
* return (
* <>
* <button onClick={()=>setText('reset')}>reset</button>
* <InputWithMutation value={text} onChange={setText}/>
* </>
* )
* }
*/
export function useCompWithMutation<T>(value: T, onChange: (newVal: T) => void, isEqual?: (val1?: T, val2?: T) => boolean) {
const keyRef = useRef<string>()
const prevValueRef = useRef(value)
const changedValueRef = useRef<T>()
const onInnerChange = useCallback((newVal: T) => {
changedValueRef.current = newVal
onChange(newVal)
}, [onChange])
const equalFn = isEqual ?? Object.is.bind(Object)
if (!equalFn(prevValueRef.current, value) && !equalFn(value, changedValueRef.current)) {
keyRef.current = Date.now().toString()
}
prevValueRef.current = value
return [keyRef.current, onInnerChange] as const
}
尽管也有所不足 但总的来说比HOC便捷许多
更新
又想到看上去非受控的组件并不是非得真的非受控 如果使用受控组件 但是根据value调配它的值 一样可以达到非受控且突变的结果 改进如下
/**
* value突变时组件应用最新的value值,其余时刻表现如同非受控组件.
* @param value 监听突变的值
* @param onChange 变更函数
* @param isEqual 比较函数.默认Object.is
* @example
* const InputWithMutation:FC<{value:string,onChange:(newVal:string)=>void}> = props=>{
* const [value,onChange] = useValueMutation(props.value,props.onChange)
* return <input value={value} onChange={e => onChange(e.target.value)} />
* }
*
* const Comp:FC=()=>{
* const [text,setText] = useState('')
* return (
* <>
* <button onClick={()=>setText('reset')}>{text}</button>
* <InputWithMutation value={text} onChange={debounce(setText,1000)}/>
* </>
* )
* }
*/
export function useValueMutation<T, V>(
value: T,
onChange: (newVal: T) => V,
isEqual: (val1?: T, val2?: T) => boolean = Object.is
) {
const prevValueRef = useRef(value);
const [changedValue, setChangedValue] = useState<T>(value);
const onInnerChange = useCallback(
(newVal: T) => {
setChangedValue(newVal);
return onChange(newVal);
},
[onChange]
);
const equal =
isEqual(prevValueRef.current, value) || isEqual(value, changedValue);
const curValue = equal ? changedValue : value;
prevValueRef.current = value;
return [curValue, onInnerChange] as const;
}
再更新!
已经迭代过三版了 竟然还有问题 还是这么个小小的钩子 属实有点难绷
问题在于如果上级组件因为其他原因更新了 而钩子输入的value毫无变化 则会导致显示changedValue 解决方法是引入一个ref判断是否处于非受控态
export function useValueMutation<T, V>(
value: T,
onChange: (newVal: T) => V,
isEqual: (val1?: T, val2?: T) => boolean = Object.is
) {
const prevValueRef = useRef(value);
const [changedValue, setChangedValue] = useState<T>(value);
const isUncontrolledRef = useRef(false);
const onInnerChange = useCallback(
(newVal: T) => {
isUncontrolledRef.current = true;
setChangedValue(newVal);
return onChange(newVal);
},
[onChange]
);
const equal =
isEqual(prevValueRef.current, value) || isEqual(value, changedValue);
if (!equal) {
isUncontrolledRef.current = false;
}
const curValue = isUncontrolledRef.current ? changedValue : value;
prevValueRef.current = value;
return [curValue, onInnerChange] as const;
}