useCallback 之痛

useCallback问题缘由

先回顾下hook之前组件的写法

class组件

export class ClassProfilePage extends React.Component<any,any>{
    showMessage = ()=>{
        alert('Fllowed'+ this.props.user)
    }
    handleClick = ()=>{
        setTimeout(this.showMessage, 3000);
    }
    render(){
        return <button onClick={this,handleClick}>Fllow</button>
    }
}

functional组件

export function FunctionProfilePage(props){
    const showMessage = () => {
        alert('Followed' + props.user)
    }
    const handleClick = () =>{
        setTimeout(showMessage, 3000);
    }
    return (
        <button onClick={handleClick}>Follow</button>
    )
}

点击按钮,同时将user由A切换到B时,class组件显示的是B而function组件显示的是A,这两个行为难以说谁更加合理

import React,{useState} from 'react'
import ReactDom from 'react-dom'
import {FunctionProfilePage,ClassProfilePage} from './profile'
import "./styles.css"

function App(){
    const [state,setState] = useState(1)
    return (
        <div className="App">
          <button onClick={()=>{
              setState(x=>x+x)
          }}>double</button>
          <div>state:{state}</div>
          <FunctionProfilePage user={state}/> //点击始终显示的是快照值
          <ClassProfilePage user={state}/>//点击始终显示的是最新值
        </div>
    )
}
const rootElement = document.getElementById("root")
ReactDom.render(<App />,rootElement)

当你的应用里同时存在Functional组件和class组件时,你就面临着UI的不一致性,虽然react官方说function组件是为了保障UI的一致性,但这是建立在所有组件都是functional组件,事实上这假设几乎不成立,如果你都采用class组件也可能保证UI的一致性(都显示最新值),一旦你页面里混用了class组件和functional 组件(使用useref暂存状态也视为class组件),就存在的UI不一致性的可能

快照 or 最新值

所以function和class最大区别只在于默认情况不同,两者可以相互转换,快照合理还是最新值合理,这完全取决于你的业务场景,不能一概而论

事实上在class里也可以拿到快照值,在function里也可以拿到最新值

class里通过触发异步之前保存快照即可

export class ClassProfilePage extends React.Component<any,any>{
    showMessage = (message)=>{
        alert('Followed'+message)
    }
    handleClick = () => {
        const message = this.props.user //在触发异步函数之前保存快照
        setTimeout(()=> showMessage(message),3000)
    }
    render(){
        return <button onClick={this.handleClick}>Follow</button>
    }
}

function里通过ref 容器存取最新值

 export function FunctionProfilePage(props){
     const ref = useRef("")
     useEffect(()=>{
         ref.current = props.user
     })
     const showMessage = () => {
         console.log('ref:',ref);
         alert('Followed' + props.user + ','+ ref.current)
     }
     const handleClick = () => {
         setTimeout(showMessage,3000)
     }
     return <button onClick={handleClick}>function Follow</button>
 }

其实就是个经典的函数闭包问题

在异步函数执行前可以对闭包访问的自由变量进行快照捕获:实现快照功能
在异步函数执行中可以通过ref读取最新的值

for(var i = 0;i<10;i++){
    setTimeout(() => {
        console.log('val:',i); 
    }); //拿到的是最新值
}

for(var i = 0;i<10;i++){
   setTimeout(((val)=>console.log('val',val)).bind(null,i)) //拿到的是快照
}

const ref = {current:null}
for(var i =0;i<10,i++){
    ref.current = i
    setTimeout(((val)=>console.log('val:',ref.current)).bind(null,ref)) //拿到的是最新的值
}
for(var i =0; i<10;i++){ //拿到的是快照
    let t = i;
    setTimeout(()=>{
        console.log('t:',t);
    })
}
重渲染机制

虽然functional和class组件在快照处理方式不一致,但是两者的重渲染机制,并没有大的区别

class重渲染触发条件,此处暂时不考虑采用shouldComponentUpdate和pureComponent优化

this.setState : 无条件重渲染,不进行新旧比较
this.forceUpdate: 无条件重渲染,不进行新旧比较
父组件render带动子组件render: 无条件,和props是否更新无关
祖先组件context变动: 不做props变动假设
我们发现react默认的重渲染机制压根没有对props做任何假设,性能优化完全交给框架去做,react-redux 基于shouldComponent, mobx-react 基于this.forceUpdatehooks 来做一些性能优化

带来的问题

我们发现即使不用hooks本身functional组件和class组件表现就存在较大差异,由于hook目前只能在function组件里使用,这导致了一些本来是functional组件编程思维的问题反映到了hooks上。

hooks的使用引入了两条强假设,导致了编程思维的巨大变动

只能在functional组件里使用: 导致我们需要处理最新值的问题
副作用(包括rerender和effect)基于新旧值的reference equality : 强制我们使用immutable进行编程
上述两条带来了很大的心智负担

Stale closure 与 infinite loop

这两个问题是硬币的两面,通常为了解决一个问题,可能导致另外一个问题

一个最简单的case就是一个组件依赖了父组件的callback,同时内部useffect依赖了这个callback

如下是一个典型的搜索场景

export function Parent(){
    const [query,setQuery] = useState('react')
    const fetchData=()=>{
        const url = 'https://hn.algolia.com/api/v1/search?query='+query
        return fetch(url).thne(x=>x.text())
    }
    return (
        <div>
          <input onChange={e=>setQuery(e.target.value)} value={query}/>
          <Child fetchData={fetchData} query={query}/>
        </div>
    )
}

上述代码存在的一个问题就是,每次Parent重渲染都会生成一个新的fetchData,因为fetchData是Child的useEffect的dep,每次fetchData变动都会导致子组件重新触发effect,一方面这会导致性能问题,假如effect不是幂等的这也会导致业务问题(如果在effect里上报埋点怎么办)

解决思路1:
不再useEffect里监听fetchData: 导致stale closure 问题 和页面UI不一致

  useEffect(() => {
    fetchData().then(result => {
      setResult(result);
    })
  },[]) // 去掉fetchData依赖

此时一方面父组件query更新,但是子组件的搜索并未更新但是子组件的query显示却更新了,这导致了子组件的UI不一致

解决思路2:
在思路1的基础上加强刷token

//child
 useEffect(()=>{
     fetchData().then(result=>{
         setResult(result)
     })
 },[refreshToken]) 

 //parent
 <Child fetchData={fetchData} query={query} refreshToken={query}/>

问题:

如果子组件的effect较多,需要建立refreshToken和effect的映射关系
触发eslint-hook的warning,进一步的可能触发eslint-hook的auto fix功能,导致bug
fetchData仍然可能获取的是旧的闭包?
为了更好的语义化和避免eslint的报错,可以自定义封装useDep来解决

useDepChange(()=>{
    fetchData().then(result=>{
        setResult(result)
    },[fetchData])
},[queryToken]) //只在dep变动的时候触发 约等于componentWillReceiveProps了

实际上是放弃了eslint-hook的 exhaustive检查,可能会导致忘记添加某些依赖,需要写代码时非常仔细了

解决思路3:

useCallback包裹fetchData, 这实际上是把effect强刷的控制逻辑从callee转移到了caller

const fetchData = useCallback(()=>{
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query
    return fetch(url).then(x=>x.text())
},[query])
//child
useEffect(()=>{
    fetchData().then(result=>{
        setResult(result)
    })
},[fetchData])

问题:

如果child的useEffect里依赖了较多的callback,需要所有的callback都需要进行useCallback包装,一旦有一个没用useCallback包装,就前功尽弃
props的不可控制,Parent的fetchData很可能是从其他组件里获取的,自己并没有控制fetchData不可变的权限,这导致千里之外的一个祖先组件改变了fetchData,导致Child最近疯狂刷新effect,这就需要将callback做层层useCallback处理才能避免该问题
官方说useCallback不能做语义保障,而且存在cache busting的风险
组件API的设计:我们发现此时设计组件时需要关心传进来的组件是否是可变的了,但是在接口上并不会反馈这种依赖

<Button onClick={clickHandler} />  // onClick改变会触发Button的effect吗? 
解决思路4:

使用useEventCallback作为逃生舱,这也是官方文档给出的一种用法useEventCallback

//child
useEventCallback(()=>{
    fetchData().then(result=>{
        setResult(result)
    })
},[fetchData])
function useEventCallback(fn,dependencies){
    const ref = useRef(()=>{
        throw new Error('Cannot call an event handler while rendering')
    })
    useEffect(()=>{
        ref.current = fn
    },[fn,...dependencies])

    return useCallback(()=>{
        const fn = ref.current
        return fn()
    },[ref])
}

这仍然存在问题,

在这里插入图片描述

解决思路5:

拥抱mutable,实际上这种做法就是放弃react的快照功能(变相放弃了concurrent mode ),达到类似vue3的编码风格

实际上我们发现hook + mobx === vue3, vue3后期的api实际上能用mobx + hook进行模拟

问题就是: 可能放弃了concurrent mode (concurrent mode更加关注的是UX,对于一般业务开发效率和可维护性可能更加重要)

import {ref,onMounted,onUnmounted} from 'vue'
export function useMousePosition(){
    const x = ref(0)
    const y = ref(0)
    function update(e){
        x.value= e.pageX
        y.value = e.pageY
    }
    onMounted(()=>{
        window.addEventListener('mousemove',update)
    })

    onUnmounted(()=>{
        window.removeEventListener('mousemove',update)
    })
    return {x,y}
}

<template>
  <div>
    x:{{pos.x}}
    y:{{pos.y}}
  </div>
</template>
export default{
    setup(){
        const {x,y} = useMousePosition()
        return pos
    }
}

调用者约定:

父组件传递给子组件的callback: 永远获取到的是父组件的最新state (通过useObservable|useRef)
被调用者约定
被调用者约定

不要把callback作为useEffect的依赖:因为我们已经限定了callback永远是最新的,实际上避免了陈旧闭包问题,所以不需要把callback作为depdency

代码里禁止直接使用useEffect:只能使用自定义封装的hook,(因为useEffect会触发eslint-hook的warning,每次都禁止不好,且useEffect没有那么语义化)如可以使用如下hook
useMount: 只在mount触发(更新不触发)
useUpdateEffect: 只在更新时触发(mount不触发)
useDepChange: dep改变时触发,功能和useEffect类似,不会触发wanring

//parent.js
export observer(function VueParent(){
    const [state] = useState(observable({
        query:'reqct'
    }))
    const fetchData = () =>{
        const url = 'https://hn.algolia.com/api/v1/search?query=' + state.query
        return fetch(url).then(x=>x.text())
    }
    return (
        <div>
          <input onChange={e=>state.query = e.target.value} value={state.query}/>
          <Child fetchData={fetchData} query={state.query}/>
        </div>
    )
})

//child.js
export function observable(VueChild(props){
    const [result,setResult] = useState('')
    useMount(()=>{
        props.fetchData().then(result=>{
            setResult(result)
        })
    })
    useUpdateEffect(()=>{
        props.fetchData().then(result=>{
            setResult(result)
        })
    },[props.query])

    return (
        <div>
          <div>query:{props.query}</div>
          <div>result:{result}</div>
        </div>
    )
})
解决思路6

useReducer 这也是官方推荐的较为正统的做法

我们仔细看看我们的代码,parent里的fetchData为什么每次都改变,因为我们父组件每次render都会生成新的函数,为什每次都会生成新的函数,我们依赖了query导致没法提取到组件外,除了使用useCallback我们还可以将fetchData的逻辑移动至useReducer里。因为useReducer返回的dispatch永远是不变的,我们只需要将dispatch传递给子组件即可,然而react的useReducer并没有内置对异步的处理,所以需要我们自行封装处理,幸好有一些社区封装可以直接拿来使用,比如zustand, 这也是我目前觉得较好的方案,尤其是callback依赖了多个状态的时候。https://codesandbox.io/s/github/hardfist/hooks-problem/tree/master/

function Child(props){
    const [result,setResult] = useState("")
    const {fetchData} = props
    useEffect(()=>{
        console.log('trigger effect');
        fetchData().then(result=>{
            setResult(result)
        })
    },[props.query,fetchData])
    return(
        <>
         <div>query:{props.query}</div>
         <div>result:{result}</div>
        </>
    )
}

const [useStore] = create((set,get)=>({
    query:'react',
    setQuery(query){
        set(state=>({
            ...state,
            query
        }))
    },
    fetchData:async() => {
        const url = "https://hn.algolia.com/api/v1/search?query=" + get().query;
        const x = await (await fetch(url)).text()
        return x
    }
}))
export function Parent(){
    const store = useStore()
    const forceUpdata = useForceUpdata()
    console.log('parent render');
    useEffect(()=>{
        setInterval(() => {
            forceUpdata({})
        }, 1000);
    },[forceUpdata])
    return(
        <div>
          <input onChange={e=>store.setQuery(e.target.value)} value={store.query}/>
          <Child fetchData={store.fetchData} query={store.query}/>
        </div>
    )
}
解决思路7:

这也是我觉得可能的最佳解法了,核心问题还是在于js语言对于并发|immutable|函数式编程的羸弱支持如(thread local object | mutable, immutable 标记| algebraic effects 支持),导致react官方强行在框架层面对语言设施进行各种hack,引起了各种违反直觉的东西,换一门语言做react可能是更好的方案(如reasonml)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值