React Hooks的useCallback导致子组件重新渲染。

26

我正在尝试使用新的Hooks从类组件转换为函数组件。但是,使用useCallback时,与类组件中的类函数不同,我会得到不必要的子渲染。

下面有两个相对简单的片段。第一个是我的示例,以类的形式编写,第二个是我的示例以函数组件形式重新编写。目标是使函数组件的行为与类组件相同。

类组件测试用例

class Block extends React.PureComponent {
  render() {
    console.log("Rendering block: ", this.props.color);

    return (
        <div onClick={this.props.onBlockClick}
          style = {
            {
              width: '200px',
              height: '100px',
              marginTop: '12px',
              backgroundColor: this.props.color,
              textAlign: 'center'
            }
          }>
          {this.props.text}
         </div>
    );
  }
};

class Example extends React.Component {
  state = {
    count: 0
  }
  
  
  onClick = () => {
    console.log("I've been clicked when count was: ", this.state.count);
  }
  
  updateCount = () => {
    this.setState({ count: this.state.count + 1});
  };
  
  render() {
    console.log("Rendering Example. Count: ", this.state.count);
    
    return (
      <div style={{ display: 'flex', 'flexDirection': 'row'}}>
        <Block onBlockClick={this.onClick} text={'Click me to log the count!'} color={'orange'}/>
        <Block onBlockClick={this.updateCount} text={'Click me to add to the count'} color={'red'}/>
      </div>
    );
  }
};

ReactDOM.render(<Example/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

Functional component test-case

const Block = React.memo((props) => {
  console.log("Rendering block: ", props.color);
  
  return (
      <div onClick={props.onBlockClick}
        style = {
          {
            width: '200px',
            height: '100px',
            marginTop: '12px',
            backgroundColor: props.color,
            textAlign: 'center'
          }
        }>
        {props.text}
       </div>
  );
});

const Example = () => {
  const [ count, setCount ] = React.useState(0);
  console.log("Rendering Example. Count: ", count);
  
  const onClickWithout = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count);
  }, []);
  
  const onClickWith = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count);
  }, [ count ]);
  
  const updateCount = React.useCallback(() => {
    setCount(count + 1);
  }, [ count ]);
  
  return (
    <div style={{ display: 'flex', 'flexDirection': 'row'}}>
      <Block onBlockClick={onClickWithout} text={'Click me to log with empty array as input'} color={'orange'}/>
      <Block onBlockClick={onClickWith} text={'Click me to log with count as input'} color={'cyan'}/>
      <Block onBlockClick={updateCount} text={'Click me to add to the count'} color={'red'}/>
    </div>
  );
};

ReactDOM.render(<Example/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

在第一个示例(类组件)中,我可以通过红色块更新计数,而不会重新渲染任何一个块,并且我可以通过橙色块自由地记录当前计数。
在第二个示例(函数式组件)中,通过红块更新计数将触发红色和青色块的重新渲染。这是因为useCallback会创建它的函数的新实例,因为计数已经改变,导致块得到一个新的onClick prop并重新渲染。橙色块不会重新渲染,因为用于橙色onClick的useCallback不依赖于计数值。这很好,但当单击它时,橙色块不会显示计数的实际值。
我认为使用useCallback的目的是让子元素不会得到相同函数的新实例并避免不必要的重新渲染,但是如果回调函数使用单个变量,则似乎仍然会发生这种情况,这在我的经验中经常发生,如果不是总是发生。
那么,如何在函数式组件中创建此onClick函数而不使子元素重新渲染?这是否可能?
更新(解决方案): 使用下面Ryan Cogswell的答案,我创建了一个自定义hook以轻松创建类似类的函数。
const useMemoizedCallback = (callback, inputs = []) => {
    // Instance var to hold the actual callback.
    const callbackRef = React.useRef(callback);
    
    // The memoized callback that won't change and calls the changed callbackRef.
    const memoizedCallback = React.useCallback((...args) => {
      return callbackRef.current(...args);
    }, []);

    // The callback that is constantly updated according to the inputs.
    const updatedCallback = React.useCallback(callback, inputs);

    // The effect updates the callbackRef depending on the inputs.
    React.useEffect(() => {
        callbackRef.current = updatedCallback;
    }, inputs);

    // Return the memoized callback.
    return memoizedCallback;
};

我可以很容易地在函数组件中使用它,就像这样简单地将 onClick 传递给子元素。它不会再重新渲染子元素,但仍然利用更新后的变量。

const onClick = useMemoizedCallback(() => {
    console.log("NEW I've been clicked when count was: ", count);
}, [count]);
2个回答

32

useCallback 可以避免由于父组件中的一些变化而导致不必要的子组件重新渲染,这些变化并不属于回调函数的依赖项。若要避免在回调函数依赖项发生变化时导致子组件重新渲染,需要使用 ref。 Ref 是钩子的实例变量等效物。

下面使用 onClickRefonClickMemoized 委托到一个能够知道当前状态值的函数版本(通过 useEffect 设置的当前 onClick)。

我还将updateCount更改为使用函数式更新语法,以此避免其对count的依赖关系。

const Block = React.memo(props => {
  console.log("Rendering block: ", props.color);

  return (
    <div
      onClick={props.onBlockClick}
      style={{
        width: "200px",
        height: "100px",
        marginTop: "12px",
        backgroundColor: props.color,
        textAlign: "center"
      }}
    >
      {props.text}
    </div>
  );
});

const Example = () => {
  const [count, setCount] = React.useState(0);
  console.log("Rendering Example. Count: ", count);

  const onClick = () => {
    console.log("I've been clicked when count was: ", count);
  };
  const onClickRef = React.useRef(onClick);
  React.useEffect(
    () => {
      // By leaving off the dependency array parameter, it means that
      // this effect will execute after every committed render, so
      // onClickRef.current will stay up-to-date.
      onClickRef.current = onClick;
    }
  );

  const onClickMemoized = React.useCallback(() => {
    onClickRef.current();
  }, []);

  const updateCount = React.useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []);

  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <Block
        onBlockClick={onClickMemoized}
        text={"Click me to log with empty array as input"}
        color={"orange"}
      />
      <Block
        onBlockClick={updateCount}
        text={"Click me to add to the count"}
        color={"red"}
      />
    </div>
  );
};

ReactDOM.render(<Example />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

当然,hooks 的美妙之处在于你可以将这种有状态的逻辑因素提取到一个自定义 hook 中:

import React from "react";
import ReactDOM from "react-dom";

const Block = React.memo(props => {
  console.log("Rendering block: ", props.color);

  return (
    <div
      onClick={props.onBlockClick}
      style={{
        width: "200px",
        height: "100px",
        marginTop: "12px",
        backgroundColor: props.color,
        textAlign: "center"
      }}
    >
      {props.text}
    </div>
  );
});

const useCount = () => {
  const [count, setCount] = React.useState(0);

  const logCount = () => {
    console.log("I've been clicked when count was: ", count);
  };
  const logCountRef = React.useRef(logCount);
  React.useEffect(() => {
    // By leaving off the dependency array parameter, it means that
    // this effect will execute after every committed render, so
    // logCountRef.current will stay up-to-date.
    logCountRef.current = logCount;
  });

  const logCountMemoized = React.useCallback(() => {
    logCountRef.current();
  }, []);

  const updateCount = React.useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []);
  return { count, logCount: logCountMemoized, updateCount };
};
const Example = () => {
  const { count, logCount, updateCount } = useCount();
  console.log("Rendering Example. Count: ", count);

  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <Block
        onBlockClick={logCount}
        text={"Click me to log with empty array as input"}
        color={"orange"}
      />
      <Block
        onBlockClick={updateCount}
        text={"Click me to add to the count"}
        color={"red"}
      />
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<Example />, rootElement);

Edit useCallback and useRef


是的,这就是钩子的美妙之处。回答已更新。 - Ryan Cogswell
看起来很漂亮,但沙盒有一个错误,不过我肯定可以用这个工作! - ApplePearPerson
1
我忘记保存我的沙盒的最新版本了 -- 链接已更新。 - Ryan Cogswell
1
@mowwwalker 因为渲染不应该有任何副作用(例如设置参考),因为渲染可能尚未提交; 而效果只有在渲染已提交到DOM时才会执行。 - Ryan Cogswell
1
@mowwwalker 我在很多地方都看到过这个问题的解答,但是我没有一个好的参考资料可以给你。关于准备StrictMode和并发渲染的文章会涉及到它。我在Twitter上关注了几位React团队成员,并在那里看到了一些相关的讨论(例如,在Twitter上搜索“@sebmarkbage ref”)。 - Ryan Cogswell
显示剩余6条评论

1

在当前的代码中,只需进行最小的更改即可实现此功能。

  • useCallback中删除deps参数
  • 当状态发生变化时,更新ref值
  • useCallback块中,不再使用状态的值,而是使用ref中的值,因为已经删除了deps,状态值将不会更新。

const {useState} = React

const Block = React.memo((props) => {
  console.log("Rendering block: ", props.color);
  
  return (
      <div onClick={props.onBlockClick}
        style = {
          {
            width: '200px',
            height: '100px',
            marginTop: '12px',
            backgroundColor: props.color,
            textAlign: 'center'
          }
        }>
        {props.text}
       </div>
  );
});

const Example = () => {
  const [ count, setCount ] = useState(0);
  
  const countRef = React.useRef(count);
  console.log("Rendering Example. Count: ", count);
  
  const onClickWithout = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count, countRef.current);
  }, []);
  
  const onClickWith = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count, countRef.current);
  }, [ ]);
  
  const updateCount = React.useCallback(() => {
    setCount(count => { 
      countRef.current = count+1
      return count + 1 
    });
  }, [ ]);
  
  return (
    <div style={{ display: 'flex', 'flexDirection': 'row'}}>
      <Block onBlockClick={onClickWithout} text={'Click me to log with empty array as input'} color={'orange'}/>
      <Block onBlockClick={onClickWith} text={'Click me to log with count as input'} color={'cyan'}/>
      <Block onBlockClick={updateCount} text={'Click me to add to the count'} color={'red'}/>
    </div>
  );
};


ReactDOM.render( <Example /> , document.getElementById("react"));
//ReactDOM.render( < Example / > , document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接