使用 React 钩子 useContext 避免不必要的重新渲染

Posted

技术标签:

【中文标题】使用 React 钩子 useContext 避免不必要的重新渲染【英文标题】:Avoid unnecessary re-renders with React hook useContext 【发布时间】:2020-07-26 17:32:09 【问题描述】:

我想找到一种在使用useContext 时避免不必要的重新渲染的方法。我创建了一个玩具沙箱,你可以在这里找到它:https://codesandbox.io/s/eager-mclean-oupkx 来演示这个问题。如果你打开开发控制台,你会看到console.log 每秒都在发生。

在这个例子中,我有一个简单地每 1 秒递增一个计数器的上下文。然后我有一个消耗它的组件。我不希望计数器的每个刻度都导致组件重新渲染。例如,我可能只想每 10 个滴答声更新我的组件,或者我可能想要一些其他组件随着每个滴答声更新。我尝试创建一个钩子来包装useContext,然后返回一个静态值,希望这样可以防止重新渲染,但无济于事。

我能想到的解决此问题的最佳方法是将我的 Component 包装在一个 HOC 中,它通过 useContext 消耗 count,然后将值作为道具传递给 Component .这似乎是一种有点迂回的方式来完成应该很简单的事情。

有没有更好的方法来做到这一点?

【问题讨论】:

感觉就像你试图通过这个超级简化的演示来破坏系统。 React 钩子运行每个渲染,并且功能组件在状态或道具更新时重新渲染。我在这里同意,但如果您不希望组件在每次更新上下文时重新渲染,那么将其分解并作为道具传递。 memo 的反应将是帮助尝试减少无关渲染的 HOC(尽管根据定义,它们根本不是无关的,因为它们完全按照设计进行渲染!!) 【参考方案1】:

通过自定义钩子直接或间接调用useContext的组件将在每次提供者的值发生变化时重新渲染。

你可以从容器中调用useContext,让容器渲染一个纯组件,或者让容器调用一个用useMemo记忆的函数式组件。

在下面的示例中,计数每 100 毫秒更改一次,但因为 useMyContext 返回 Math.floor(count / 10),组件将仅每秒渲染一次。容器将每 100 毫秒“渲染”一次,但它们会在 10 次中返回相同的 jsx 9。

const 
  useEffect,
  useMemo,
  useState,
  useContext,
  memo,
 = React;
const MyContext = React.createContext( count: 0 );
function useMyContext() 
  const  count  = useContext(MyContext);

  return Math.floor(count / 10);


// simple context that increments a timer
const Context = ( children ) => 
  const [count, setCount] = useState(0);
  useEffect(() => 
    const i = setInterval(() => 
      setCount((c) => c + 1);
    , [100]);
    return () => clearInterval(i);
  , []);

  const value = useMemo(() => ( count ), [count]);
  return (
    <MyContext.Provider value=value>
      children
    </MyContext.Provider>
  );
;
//pure component
const MemoComponent = memo(function Component( count ) 
  console.log('in memo', count);
  return <div>MemoComponent count</div>;
);
//functional component
function FunctionComponent( count ) 
  console.log('in function', count);
  return <div>Function Component count</div>;


// container that will run every time context changes
const ComponentContainer1 = () => 
  const count = useMyContext();
  return <MemoComponent count=count />;
;
// second container that will run every time context changes
const ComponentContainer2 = () => 
  const count = useMyContext();
  //using useMemo to not re render functional component
  return useMemo(() => FunctionComponent( count ), [
    count,
  ]);
;

function App() 
  console.log('App rendered only once');
  return (
    <Context>
      <ComponentContainer1 />
      <ComponentContainer2 />
    </Context>
  );


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


<div id="root"></div>

使用 React-redux useSelector,只有当传递给 useSelector 的函数返回不同于上次运行时的内容时,才会重新渲染调用 useSelector 的组件,并且传递给 useSelector 的所有函数都将在 redux 存储更改时运行。所以在这里你不必拆分容器和展示来防止重新渲染。

如果父组件重新渲染并传递不同的 props,或者父组件重新渲染并且组件是功能组件(功能组件总是重新渲染),组件也会重新渲染。但是由于在代码示例中父级(App)从不重新渲染,我们可以将容器定义为功能组件(无需包装在 React.memo 中)。

以下是如何创建类似于 react redux connect 的连接组件的示例,但它会连接到上下文:

const 
  useMemo,
  useState,
  useContext,
  useRef,
  useCallback,
 = React;
const  createSelector  = Reselect;
const MyContext = React.createContext( count: 0 );
//react-redux connect like function to connect component to context
const connect = (context) => (mapContextToProps) => 
  const select = (selector, state, props) => 
    if (selector.current !== mapContextToProps) 
      return selector.current(state, props);
    
    const result = mapContextToProps(state, props);
    if (typeof result === 'function') 
      selector.current = result;
      return select(selector, state, props);
    
    return result;
  ;
  return (Component) => (props) => 
    const selector = useRef(mapContextToProps);
    const contextValue = useContext(context);
    const contextProps = select(
      selector,
      contextValue,
      props
    );
    return useMemo(() => 
      const combinedProps = 
        ...props,
        ...contextProps,
      ;
      return (
        <React.Fragment>
          <Component ...combinedProps />
        </React.Fragment>
      );
    , [contextProps, props]);
  ;
;
//connect function that connects to MyContext
const connectMyContext = connect(MyContext);
// simple context that increments a timer
const Context = ( children ) => 
  const [count, setCount] = useState(0);
  const increment = useCallback(
    () => setCount((c) => c + 1),
    []
  );
  return (
    <MyContext.Provider value= count, increment >
      children
    </MyContext.Provider>
  );
;
//functional component
function FunctionComponent( count ) 
  console.log('in function', count);
  return <div>Function Component count</div>;

//selectors
const selectCount = (state) => state.count;
const selectIncrement = (state) => state.increment;
const selectCountDiveded = createSelector(
  selectCount,
  (_, divisor) => divisor,
  (count,  divisor ) => Math.floor(count / divisor)
);
const createSelectConnectedContext = () =>
  createSelector(selectCountDiveded, (count) => (
    count,
  ));
//connected component
const ConnectedComponent = connectMyContext(
  createSelectConnectedContext
)(FunctionComponent);
//app is also connected but won't re render when count changes
//  it only gets increment and that never changes
const App = connectMyContext(
  createSelector(selectIncrement, (increment) => (
    increment,
  ))
)(function App( increment ) 
  const [divisor, setDivisor] = useState(0.5);
  return (
    <div>
      <button onClick=increment>increment</button>
      <button onClick=() => setDivisor((d) => d * 2)>
        double divisor
      </button>
      <ConnectedComponent divisor=divisor />
      <ConnectedComponent divisor=divisor * 2 />
      <ConnectedComponent divisor=divisor * 4 />
    </div>
  );
);

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


<div id="root"></div>

【讨论】:

以上是关于使用 React 钩子 useContext 避免不必要的重新渲染的主要内容,如果未能解决你的问题,请参考以下文章

在使用数据库数据更新上下文后,React 'useContext' 钩子不会重新渲染

如何将 React 钩子(useContext、useEffect)与 Apollo 反应钩子(useQuery)结合起来

React TS useContext useReducer 钩子

你如何在 React 中调用 useContext 而不破坏钩子规则?

如何测试依赖于 useContext 钩子的反应组件?

在一个 React 组件中使用多个“useContext”