Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Hooks 最佳实践? #20

Open
jappp opened this issue Dec 14, 2023 · 0 comments
Open

React Hooks 最佳实践? #20

jappp opened this issue Dec 14, 2023 · 0 comments

Comments

@jappp
Copy link
Owner

jappp commented Dec 14, 2023

一、起因

事情的起因来源于这篇文章:React Hooks 使用误区,驳官方文档,作者是React Hooks 库 ahooks 的维护人,他主要提出了以下几个观点:

  1. 不是所有的依赖都必须放到依赖数组中
  2. deps 参数不能缓解闭包问题
  3. 尽量不要用 useCallback
  4. useMemo 建议适当使用
  5. useState 的正确使用姿势

论点2、3、4、5大家基本都赞同,主要争论点在 useEffect 的使用上,也就是论点1:不是所有的依赖都必须放到依赖数组中。

作者举了个简单的例子:当 props.count 和 count 变化时,上报当前所有数据。

function Demo(props) {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');
  const [a, setA] = useState('');
 
  useEffect(() => {
    monitor(props.count, count, text, a);
  }, [props.count, count]);
 
  return (
    <div>
      <button
        onClick={() => setCount(c => c + 1)}
      >
        click
      </button>
      <input value={text} onChange={e => setText(e.target.value)} />
      <input value={a} onChange={e => setA(e.target.value)} />
    </div>
  )
}

这时候 useEffect 是不符合 React 官方建议的,text 和 a 变量没有放到依赖数组中,eslint 产生警告:
image

但如果按照规范将依赖项都放入deps中,当 text 或者 a 变化时,也触发了函数执行,不符合业务需求。
此时陷入了困境,当满足 useEffect 使用规范时,业务需求就不能满足了。当满足业务需求时,useEffect 就不规范了。
作者给出的建议是:

  • 不要使用eslint-plugin-react-hooks插件,或者可以选择性忽略该插件的警告。
  • 只有一种情况,需要把变量放到 deps 数组中,那就是当该变量变化时,需要触发 useEffect 函数执行。而不是因为 useEffect 中用到了这个变量!

正反两方可大致分为:

学院派

以 React 设计思想为准,代码一定要符合框架本身的设计思路,依赖必须全部放到deps中,要想满足业务需求使用 useRef 来桥接。

还有人认为 useEffect 不适合当 watch 使用,尽量不要使用 useEffect,而是提倡前置处理effect,在事件回调中处理相应逻辑。

例如在 state-change → render → effect 流程中, 将 effect 要执行的内容写在 state-change 中。

function useRefState(defaultValue) {
    const [state, setState] = useState(defaultValue)
    const ref = useRef(defaultValue)
     
    useEffect(()=>{
      ref.current = state
     }, [state])
 
   return [state, setState, ref]
}

实用派

同意作者的用法,以业务需求优先,但为了避免新人犯错,eslint 提示不能关闭。

二、个人看法

个人基本认同文章作者的观点,这是因为在一些业务场景下,个人和作者的解决方式大致相同,但是并不认为这就是 hooks 的最佳实践。个人觉得现在的场景下,不存在所谓的最佳实践,useEffect 以及闭包问题本来就是 function component 一直以来存在的问题。react 自身的定位只是一个基础框架,在复杂的业务场景下,还是需要具体问题具体分析。

具体实践的话,个人推荐使用 useEffect 但最小化 deps ,useEffect 中的 deps 应该保证最小可用原则,只放引起 effect 的变量——也就是 effect 的原因。一定别放无关变量和函数变量。作者提出尽量少用 useCallback,其实不用 useCallback 就是等价于不用函数作为依赖。

无关变量:指effect中会读取,但并不是引起 effect 的原因的变量。

假设一下,我们使用无关变量会有什么坏处:

  • 不符合语义,应该是引发 effect 的依赖变了时,在 effect 中读取无关变量。而不是仅仅是为了解决闭包获取无关变量的最新值就要重新执行一遍 effect。
  • 可能导致bug,类似与作者的监控上报需求,text 改变时并不需要上报,但实际 text 作为依赖一改变就会产生上报,可能导致产生bug。
  • 可读性差,deps 多的结果一定会导致代码可读性差,你需要去判断到底是哪个依赖是需要真正引起 effect。

你可以不把所有使用的值都加入 deps,但是一定要清楚 eslint warning 的原因:有可能因为闭包问题,导致访问的不是最新值而与预期不符。至于闭包问题怎么解决?useRefState 欢迎你。

函数变量

为什么不推荐函数变量呢?引用React Hooks(二): useCallback 之痛的例子

function Child(props){
 console.log('rerender:')
 const [result,setResult] = useState('')
 const { fetchData } = props;
 useEffect(() => {
    fetchData().then(result => {
      setResult(result);
    })
  },[fetchData])
 return (
    <div>query:{props.query}</div>
    <div>result:{result}</div>
  )
}
export function Parent(){
 const [query,setQuery] = useState('react');
 const fetchData = () => {
   const url = '[https://hn.algolia.com/api/v1/search?query='](https://hn.algolia.com/api/v1/search?query=%27) + query
   return fetch(url).then(x => x.text())
 }
 return (
    <div>
    <input onChange={e => setQuery(e.target.value)} value={query} />
    <Child fetchData={fetchData} query={query}/>
    </div>
  )
}

把函数变量做 deps 的原因很简单,就是一个 effect 用到了一个父子组件乃至整个组件树内通用的逻辑,这段通用逻辑被封装成了方法 fetchData,于是你的 effect 中就直接调用它。eslint 会提醒你将函数加入 deps,这时候为了避免每次渲染时重新定义函数,需要使用 useCallback 来包裹函数。

这样useCallback包裹一层麻烦不说,还有隐式依赖的问题:我子组件使用祖先传来的 callback 时,根本不知道 callback 依赖了什么东西,我必须向上找看看到底依赖了什么东西,才知道它到底什么时候会变,才敢放心地书写子组件中的 effect 逻辑。

怎么解决呢?可以主动把函数和状态抽离开来,有点类似于不依赖于 state 的全局函数,这个思路 Dan 在 useEffect 完整指南 中也有提到。
image

这样一是减少了频繁使用 useCallback 带来的代码可读性降低,二是在子组件中触发 effect 的逻辑一目了然了。

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

函数依赖 + useCallback 的场景应该化整为零,拆解成 state 依赖 + "纯"工具函数,在需要使用的地方自行组合使用,以此收获更简明易维护的代码。

总结:虽然上面提到了一些 useEffect 的推崇实践,并不代表推荐滥用 useEffect,事实上有时候个人会尽量避免使用 useEffect,因为副作用本就不该是一个高频应用的场景。在正确的地方正确的使用 useEffect,能帮我们避免很多未知的bug。如何正确使用?看看官方的建议:you-might-not-need-an-effect

三、useEvent

上面说了这么多心智问题,官方也感觉React API 的设计还有很多可完善的空间,useEvent 就此诞生,useEvent主要解决一个问题:如何同时保持函数引用不变与访问到最新状态。

看个例子:

function Chat() {  
    const [text, setText] = useState('');   
    // ✅ Always the same function (even if `text` changes)  
    const onClick = useEvent(() => {    sendMessage(text);  });   
    return <SendButton onClick={onClick} />;
}

onClick 既能保持引用不变,又能在每次触发时访问到最新的 text 值。

没有 useEvent 之前怎么实现呢?

function App() {
  const [count, setCount] = useState(0)
  const countRef = React.useRef()
  countRef.current = count
 
  const sayCount = useCallback(() => {
    console.log(countRef.current)
  }, [])
 
  return <Child onClick={sayCount} />
}

这种写法明显不推荐,原因有二:

  1. 每个值都要加一个配套 Ref,非常冗余。
  2. 在函数内直接同步更新 ref 不是一个好主意,但写在 useEffect 里又太麻烦。

再强调一遍:useEvent 会将一个函数「持久化」,同时可以保证函数内部的变量引用永远是最新的。

利用 useEvent 解决文章开始的问题:

function Demo(props) {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');
  const [a, setA] = useState('');
   
  // ✅ Stable identity
  const monitorEvent = useEvent((propsCount, count) => {
    monitor(propsCount, count, text, a);
  });
 
  useEffect(() => {
    monitorEvent(props.count, count);
  }, [props.count, count]); // ✅ Re-runs only on props.count and count
 
  return (...)
}

useEvent实现原理:

// (!) Approximate behavior
 
function useEvent(handler) {
  const handlerRef = useRef(null);
 
  // In a real implementation, this would run before layout effects
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });
 
  return useCallback((...args) => {
    // In a real implementation, this would throw if called during render
    const fn = handlerRef.current;
    return fn(...args);
  }, []);
}

上面的代码是官方提供的一个示例代码,需要重点注意这句注释 In a real implementation, this would run before layout effects,翻译过来就是 “在真实的实现中,这里用的 Hooks 执行时机在 useLayoutEffect之前”。

这里一定是不能用 useLayoutEffect来更新 ref的,因为子组件的 useLayoutEffect比父组件的执行更早,如果这样用的话,子组件的 useLayoutEffect中访问到的 ref一定是旧的。

所以官方为了实现 useEvent,一定是要加一个在 useLayoutEffect之前执行的 Hooks 的,并且这个 Hooks 应该不会开放给普通用户使用的。

另外 React 要求不要在 render 中直接调用 useEvent返回的函数,原理也是一样的,在 render 中访问的函数一定是旧的,因为 useLayoutEffect还没执行。

出师未捷身先死:

useEvent 原计划解决两个问题,1、渲染优化,2、useEffect 重新触发问题。但是官方发现没办法一下子做两件事,于是 useEvent RFC 被废弃了。

官方计划后续发布一个不同的、范围更小的RFC来取代这个useEvent RFC,当然名称也会有所改变。再见useEvent

参考阅读

useEffect 完整指南

escape-hatches

useEvent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant