react hooks个人笔记- 向下传递回调

1,087 阅读6分钟

向下传递回调

背景

也是和 rerender 的机制有关:
hooks 在每次 rerender 的过程中,组件内部的函数都会重新创建,如果这个函数是需要传递给自组件的回调,且子组件在 useEffect 里面依赖了这个回调,那么子组件的这个 useEffect 也会被触发;

而通常说,这是一个非常常见的逻辑,react 对此有做过建议:

我们已经发现大部分人并不喜欢在组件树的每一层手动传递回调。尽管这种写法更明确,但这给人感觉像错综复杂的管道工程一样麻烦。 在大型的组件树中,我们推荐的替代方案是通过 context 用 useReducer 往下传一个 dispatch 函数。

注意 我们推荐 在 context 中向下传递 dispatch 而非在 props 中使用独立的回调。下面的方法仅仅出于文档完整性考虑,以及作为一条出路在此提及。 同时也请注意这种模式在 并行模式 下可能会导致一些问题。我们计划在未来提供一个更加合理的替代方案,但当下最安全的解决方案是,如果回调所依赖的值变化了,总是让回调失效。


但是 ahooks/usePersistFn 钩子提出了函数的持久化概念:

在某些场景中,你可能会需要用 useCallback 记住一个回调,但由于内部函数必须经常重新创建,记忆效果不是很好,导致子组件重复 render。对于超级复杂的子组件,重新渲染会对性能造成影响。通过 usePersistFn,可以保证函数地址永远不会变化。


就 rerender 会重新创建函数这一点而言,其实并没有什么问题,也不需要给每一个函数做持久化,持久化本身也是有开销的,对于没有被依赖的函数,做持久化并无必要。

那么对于这两点捋一下。

首先考虑官方的建议

demo1

官方希望我们直接使用 context + reducer,直接在上下文中传入 dispatcher ,而不是在 props 中使用独立的回调。即:

import React, { useReducer, useContext } from "react";

// context
const GlobalContext = React.createContext();

// recuder
function reducerDemo(state, action) {
  switch (action.type) {
    case "increment":
      return state + 1;
    case "decrement":
      return state - 1;
    default:
      return state;
  }
}

// parent component
function App() {
  const [count, setCount] = useReducer(reducerDemo, 0);

  return (
    <GlobalContext.Provider value={{ setCount }}>
      count: {count} <br />
      <br />
      <Demo />
    </GlobalContext.Provider>
  );
}

// son component
function Demo() {
  const { setCount } = useContext(GlobalContext);

  return (
    <>
      <button onClick={() => setCount({ type: "increment" })}>increment</button>
      <button onClick={() => setCount({ type: "decrement" })}>decrement</button>
    </>
  );
}

export default App;

demo2

上面的写法看起来有非常明显的臃肿的问题,没有复杂逻辑的情况下这么写无疑会增加开发时间。一般我们的写法是这样的:

import React, { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const increment = () => setCount((preCount) => preCount + 1);

  const decrement = () => setCount((preCount) => preCount + 1);

  return (
    <>
      count: {count} <br />
      <br />
      <Demo increment={increment} decrement={decrement} />
    </>
  );
}

function Demo({ increment, decrement }) {
  return (
    <>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
    </>
  );
}

也就是直接在 porps 中传递 callback -- 向下传递回调。目前看这两个 demo 的运行都是没问题的:
image.png

问题 1 依赖变动

这两个的不同点就是行为的传递一个通过 props 一个通过 context,如果子应用有钩子依赖这个行为的话,通过 ptops 传递的行为会有一个问题,现在在两个 demo 的子应用里面加入 useEffect 来监听:

// 官方意见的setCount变化
function Demo() {
  const { setCount } = useContext(GlobalContext);

  useEffect(() => {
    console.log("官方意见的setCount变化");
  }, [setCount]);

  return (
    <>
      <button onClick={() => setCount({ type: "increment" })}>increment</button>
      <button onClick={() => setCount({ type: "decrement" })}>decrement</button>
    </>
  );
}

// props直传的setCount变化
function Demo({ increment, decrement }) {
  useEffect(() => {
    console.log("props直传的setCount变化");
  }, [increment, decrement]);

  return (
    <>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
    </>
  );
}

在官方意见的 demo 里面,button 的点击不会在控制台重复 log,而我们直传的 demo 里面就会有这个问题:
image.png
初始化都打印没有问题,但是下面的直传 demo 中每次点击按钮,都会 log;

解决方案

props 直传回调的这个问题解决方案很多,其中比较常见的就是用 useCallBack 来对函数做一次缓存的操作,现在对 demo 2,也就是直传的组件做一点更改,用 useCallback 钩子包裹住需要在 props 中传递的回调函数 increment 和 decrement:

import React, { useState, useEffect, useCallback } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => setCount(preCount => preCount + 1), []);

  const decrement = useCallback(() => setCount(preCount => preCount + 1), []);

  return (
    <>
      count: {count} <br />
      <br />
      <Demo increment={increment} decrement={decrement} />
    </>
  );
}

function Demo({ increment, decrement }) {
  useEffect(() => {
    console.log("props直传的setCount变化");
  }, [increment, decrement]);

  return (
    <>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
    </>
  );
}

这样,似乎是解决了这个问题,每次点击按钮,出了初始化的时候会 log 一次,以后就再也不会 log 了:
image.png

问题 2 重复渲染

虽然看起来是解决了这个问题,但是:

useCallBack 也是需要收集依赖的(在实际使用中非常常见),如果收集的依赖发生了变动,那么内部函数会重新创建,记忆效果不是很好,导致子组件重复 render。对于超级复杂的子组件,重新渲染会对性能造成影响;

解决方案


对于第二点是啥意思呢?和 purecomponent 对应的 react memo,也就是纯函数,即:相同输入总是对应着相同输出。纯函数组件也就意味着保证相同输入(props) 不变的情况下,输出也是相同的(不会走 rerender)。

现在给这两个 demo 的子组件都加上 React.memo 来做纯函数处理,并且加上渲染次数的统计:

// 官方建议
const Demo = React.memo(() => {
  const { setCount } = useContext(GlobalContext);
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  useEffect(() => {
    console.log("官方意见的setCount变化");
  }, [setCount]);

  return (
    <>
      render count {renderCountRef.current} <br />
      <button onClick={() => setCount({ type: "increment" })}>increment</button>
      <button onClick={() => setCount({ type: "decrement" })}>decrement</button>
    </>
  );
});

// props直传
const Demo = React.memo(({ increment, decrement }) => {
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  useEffect(() => {
    console.log("props直传的setCount变化");
  }, [increment, decrement]);

  return (
    <>
      render count {renderCountRef.current} <br />
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
    </>
  );
});

这样看打印结果:
image.png
这样看使用 props 直传,加上 useCallBack 和 React.memo 能够减少重复渲染的次数。

问题 3 useCallBack 的依赖

现在单独拿出 demo 2,修改下,在 useCallback 中加入依赖:

import React, { useState, useEffect, useCallback, useRef } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    console.log("count", count);
    setCount(preCount => preCount + 1);
  }, [count]);

  return (
    <>
      count: {count} <br />
      <br />
      <Demo increment={increment} />
    </>
  );
}

const Demo = React.memo(({ increment }) => {
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  useEffect(() => {
    console.log("props直传的setCount变化");
  }, [increment]);

  return (
    <>
      render count {renderCountRef.current} <br />
      <button onClick={increment}>increment</button>
    </>
  );
});

这里加了一个很小的需求,在 useCallBack 中 log 一下 count,所以 count 需要被加入 useCallBack 的依赖中。仅仅这一步,就导致了结果的完全不同:
image.png
现在,每一次点击按钮,都会导致 increment 函数被重新创建,而在 props 中传递的 increment 如果被重新创建就会导致子组件的 rerender 和子组件内依赖了 increment 函数的 useEffect 被重复触发。

解决方案

又回到最开始说到的一个概念,函数持久化。本质上来说,increament 这个函数我在创建完成后内部逻辑决定了其实是不需要再次被创建的。

那么 hooks 中实现一个持久化的钩子,需要用 useRef 来保存函数的引用,再利用 useCallBack 来依赖这个不变的引用,就可以保持函数的持久化。参考 ahooks/usePersisFn 的实现方式:

function usePersistFn(fn = () => {}) {
  const ref = useRef(fn);

  ref.current = fn;

  const persistFn = useCallback((...args) => ref.current(...args), [ref]);

  return persistFn;
}

再看一下上一个问题的 demo:

import React, { useState, useEffect, useRef, useCallback } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const increment = usePersistFn(() => {
    console.log("count", count);
    setCount((preCount) => preCount + 1);
  });

  return (
    <>
      count: {count} <br />
      <br />
      <Demo increment={increment} />
    </>
  );
}

const Demo = React.memo(({ increment }) => {
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  useEffect(() => {
    console.log("props直传的setCount变化");
  }, [increment]);

  return (
    <>
      render count {renderCountRef.current} <br />
      <button onClick={increment}>increment</button>
    </>
  );
});

// 持久化 钩子
function usePersistFn(fn = () => {}) {
  const ref = useRef(fn);

  ref.current = fn;

  const persistFn = useCallback((...args) => ref.current(...args), [ref]);

  return persistFn;
}

但是也不需要给每一个函数做持久化,持久化本身也是有开销的,对于没有被依赖的函数,做持久化并无必要。