Hooks 用法简单总结

Hooks 用法简单总结

useCallback

useCallback 保证依赖其回调的 FC 组件正常的调度次数:

function Child(props: {value:string, onChange:(val:string)=>void}){
     const {value, onChange} = props
     console.log('child fc running')
     return <input value={value} onChange={e=>onChange(e.target.value)}/>
}

function Compo(){
    const [a, setA] = useState('')
    const [b, setB] = useState('')
    return (<>
        <input value={a} onChange={(e)=>setA(e.target.value)}/>
        <Child value={b} onChange={e=>{setB(e)}}/>
    </>)
}

这里没有使用 useCallback 或其他方案,后果就是 “child fc running” 无论在 a 还是 b 变化时,都会打印一遍

注意,这里严谨一点,不直接打印 “render”,因为 FC 不是 class api 的 render function,它本身不处理 render

如何解决?有以下方案

memo + useCallback

const MemoChild = memo(Child)

function Compo(){
   // ...
   return (<>
      <MemoChild value={b} onChange={useCallback((e)=>{
           setB(e)
      // 当前组建的 setter 依赖可以不填
      },[setB])} />
   </>)
}

这种做法是因为回调是函数,直接写的回调每次 Compo FC 运行都会生成新的值,导致 props 判断为新值,引发 Child 渲染

注!必须 memo 配合 useCallback 一起用
强调!保证回调不变性是 Hooks 开发主线,以下手段也从此出发

useMemo

可以用 useMemo 控制整个带参数节点(即控制具体节点调度)

function Compo(){
   // ...
   return (<>
      {useMemo(()=> <MemoedChild value={b} onChange={e=>setB(e)}/>,[b,setB])}
   </>)
}

这种方式最不过脑,思维负担低,但是同 memo + useCallback 存在相同问题 —— 破坏 jsx 可读性 —— 且问题更加严重(因为存在依赖数组)

直接以 setState 作为参数

function Compo(){
   // ...
   return (<>
      <MemoedChild value={b} onChange={setB}/>
   </>)
}

这种方式更简单,但是对库有要求,现在很多库为了易用性,不会封装更多的事件,而是直接暴露 DOMEvent,比如 antd onChange 大多都是返回 ChangeEvent

useReducer dispatch

同 setState,reducer 只是对 state 的带事件过滤器的方案,setState 类型为:

Dispatch<setStateAction> 与 useReducer 的类型相同

函数式变更或 ref 转存变更

目的是将 useCallback 的依赖数组降为空:

useCallback(()=>{
   // 死循环
   setState(state+1)
},[state])

// 函数式变更
useCallback(()=>{
   setState(state=>state+1)
},[])

// ref 转存变更
const stateValue = useRef(state)
useEffect(()=>{
  stateValue.current = state
})

useCallback(()=>{
   setState(stateValue.current+1)
   // 组件内依赖数组中 ref 可不填,跨组件则必须填入,同 setState
},[stateValue])

空依赖数组的 useCallback 回调,可以在组件间任意传递

采用 ref 转存变更不符合 React 函数式开发的思想(ref 是变量),react 更多提倡数据行为分离的建模,

但是作为 DDD 拥趸,个人认为实体建模能力还是需要交给变量和带行为对象,值对象加事件调度器虽然开发和消息通讯很方便,但是对建模的完整性也是一种破坏,这种方式更多是作为领域边界,更多对外而不是对内
况且,Typescript 并非 resonml 的类型系统,没有模式识别,没有逆变校验类型,useReducer 的体验很糟糕,需要将所有 action 类型遍历
当然,作为使用者,dispatch 事件集线器和 凑空依赖回调 的方式,都可以试试,毕竟两者其实等价,关键还是要保证回调不变性

useContext

useContext 中的变化都会导致 FC 重新运行,memo 对此无效,因而反对使用 memo 解决调度问题

useContext 能够将 setState 或者 dispatch 直接暴露,使得所有使用 setState 和 dispatch 的组件都有明确的调度源

可以将 setState 或 dispatch 分开注入:

const [state, dispatch] = useReducer(/* ... */)

<StateContext.Provider value={state}>
   <DispatchContext.Provider value={dispatch}>
      // ...
   </DispatchContext.Provider>
</StateContext.Provider>

这样你在单独注入 dispatch 时,FC 不会重新运行

const dispatch = useContext(DispatchContext)

但是分开注入特别影响组件划分,你可能会为了所谓的性能问题,而打乱组件划分的方式,比如下意识将用户操作的组件单独封装

此时 useMemo 方案就派上了用场:

const {a, b, setA, setB} = useContext(SomeContext)
return useMemo(()=><input value={a} onChange={e=>setA(e.target.value)}/>,[a, setA])

数据驱动

所谓数据驱动,即认为流程的发起由数据的变化产生,比如接受一个 obj 的自定义 hooks

function useRequest<T extends {[key:string]:any}>(params: T){
    useEffect(()=>{  
        // 方便用变量判断析构
        let end = false
        fetch('xxxx', {body: JSON.stringify(params)}).then(/* 也会变更数据 */)
        return ()=>{
            end = true
        }
    },[params])   
}

如果你想要跳过首次 effect 执行,则传入类型为 T|null 的结构,null 代表跳过不执行

由数据变化产生行为就是 React 本身的逻辑,因此此种方式实现较为完善,比如 end 的生命周期判断,如果不用此种方式,则需要一个 ref 指示销毁,这么做有较大隐患

这里说的生命周期只有两个,创建和析构,并非之前 MVVM 的更新周期,而是组合生命周期,指示组合关系

React 配合事件驱动的流式模型并不能很好地进行开发,因为 Hooks 本身是低粒度的 csp 模式(可以控制每个节点渲染),直接操作 DOM 或经过简单代理操作 DOM 的框架更适合事件驱动模型(事件驱动要求时机准确性),比如 lit,angular 等

数据驱动需要非常关注数据变化的问题:

复杂数据结构,对象,数组,Set,Map,函数,每次都是产生新的实例,在没有唯一标识的情况下,无法进行判等,因此:

useEffect(()=>{
},[{},[],Set(),Map(),function(){}])

以上结构在没有经过 useMemo 和 useCallback 处理后,填入依赖数组会直接破坏调度逻辑

注意,这不是性能优化,将对象判等作为性能优化的话,问题会很大,因为 useEffect 中大概率是请求,视图变化,甚至是一些敏感业务操作
其他地方出问题无所谓,但是这里真不能看成性能优化,否则意识上你就已经很难处理某些不知名 bug 了

调度标识

几种思维方式在其他事件驱动编程平台被广泛使用

  1. 时间上的数组

将 [state, setState] 或 [state, dispatch] 中的 state 作为时间上的数组看待,很多不理解的问题可以很快看清,比如 最新值问题,因为 state 是一段一段的离散数组,而非时序上连续的变量

2. 子弹图

将 state 作为时间上数组之后,就可以出现描述时序和调度逻辑的子弹图,这同样是 Rx 的思维模式,但是与 Rx 这样的 actor 模型不同,React csp 主要关注数据随时间变化,即特定时间前后数据,而非 Rx 关注数据流的组合关系(子弹图中的横向思维和纵向思维)

比如在特定时刻,useEffect 几个依赖,判断当前依赖情况,进而进行相关操作,等于是将子弹图进行纵向切割

而 Rx 关注不同数据变化的流之间的关系,是将子弹图横向切割

Actor 和 csp 可以相互转化,只有思维的根本性不同,Rx 写成 csp 就是无限tap,map,subscribe(别笑,笑就说明你是这么用的),而 React 写成 actor 就是无数自定义 Hooks 嵌套

3. 事件队列

由于 useEffect 是按照 React 调度异步进行(18-是 promise,18+ 是 batchUpdate 或 idle),因此这期间分发的事件(比如极少情况下的同步事件分发,以外的交错出现的 raf 事件分发,或文件流事件等),可以用缓存数组(或 Map)构造队列,相应的调度中清空队列即可

事件队列也能实现诸如 concatAll, debounce(useEffect 自带 debounce 逻辑,找找怎么弄?)等逻辑

4. 调度探针(锁)

锁其实就是调度探针,但是在 JS 环境中,我们一般不提锁,因为锁是平台自带的,由运行环境控制(调度线程)

不过,只要是异步,都需要区分先后,IO 也需要区分何时输入何时输出,因此,我们称标识通讯状态的数据为 —— 调度探针

比如:

// 标识可以附着在 state 上
const [state, setState] = useState({childRunnable: false, value: ''})
// 也可以采用 ref
const childRunnable = useRef(false)
// 父组件变更时,禁止子组件
const parentChange = useCallback(()=>{
   setState(res=>({childRunnable:false, value: res+'1'}))
},[])
// 子组件略过父组件的变化
useEffect(()=>{
   if(!state.childRunnable) returnÏ
},[state])

上文中的数据驱动传入 params = null,也是调度探针

所谓调度,即区分先后顺序,确定相应时序,管理单播多播等
调度是业务很重要的组成部分,一般建模为泳道(Lane)流程图(bpmn)

动画

因为 hooks 有每个特定节点的调度控制权,因此动画非常好做可以多多尝试:

const changeDispatchNode = useCallback(()=>{
   setDisplayNode(currentNode)
   // 配合 css 变更 transition
},[currentNode])
useEffect(()=>{
    setTimeout(changeDisplayNode,1000)
},[changeDisplayNode])

这就是个非常简单的,让节点延迟消失的动画,可以跟动画库说拜拜了

编辑于 2021-10-22 09:57