如何写一个类型复杂的ts泛型HOC

87 阅读3分钟

起因

事情源自这里,一开始写了一个高阶组件用于实现组件受控/非受控的切换,但这个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;
}