React Context API 并避免重新渲染

Posted

技术标签:

【中文标题】React Context API 并避免重新渲染【英文标题】:React Context API and avoiding re-renders 【发布时间】:2018-12-21 09:03:20 【问题描述】:

我已经在底部进行了更新

有没有办法维护一个单一的根状态(如 Redux),其中多个 Context API Consumer 工作在他们自己的 Provider 值部分,而不会在每个孤立的更改上触发重新渲染?

已经read through this related question 并尝试了一些变体来测试那里提供的一些见解,但我仍然对如何避免重新渲染感到困惑。

完整代码如下,在线:https://codesandbox.io/s/504qzw02nl

问题在于,根据 devtools,每个组件都会看到“更新”(重新渲染),即使 SectionB 是唯一看到任何渲染更改的组件,即使 b 是唯一的部分改变的状态树。我已经用功能组件和PureComponent 尝试过这个并看到相同的渲染抖动。

因为没有任何东西作为道具传递(在组件级别),所以我看不出如何检测或防止这种情况。在这种情况下,我将整个应用程序状态传递给提供程序,但我也尝试传递状态树的片段并看到同样的问题。很明显,我做错了什么。

import React,  Component, createContext  from 'react';

const defaultState = 
    a:  x: 1, y: 2, z: 3 ,
    b:  x: 4, y: 5, z: 6 ,
    incrementBX: () =>  
;

let Context = createContext(defaultState);

class App extends Component 
    constructor(...args) 
        super(...args);

        this.state = 
            ...defaultState,
            incrementBX: this.incrementBX.bind(this)
        
    

    incrementBX() 
        let  b  = this.state;
        let newB =  ...b, x: b.x + 1 ;
        this.setState( b: newB );
    

    render() 
        return (
            <Context.Provider value=this.state>
                <SectionA />
                <SectionB />
                <SectionC />
            </Context.Provider>
        );
    


export default App;

class SectionA extends Component 
    render() 
        return (<Context.Consumer>
            ( a ) => <div>a.x</div>
        </Context.Consumer>);
    


class SectionB extends Component 
    render() 
        return (<Context.Consumer>
            ( b ) => <div>b.x</div>
        </Context.Consumer>);
    


class SectionC extends Component 
    render() 
        return (<Context.Consumer>
            ( incrementBX ) => <button onClick=incrementBX>Increment a x</button>
        </Context.Consumer>);
    


编辑:我知道可能有a bug in the way react-devtools 检测或显示重新渲染。我以一种显示问题的方式expanded on my code above。我现在无法判断我所做的是否真的会导致重新渲染。根据我从 Dan Abramov 那里读到的内容,我认为我正确地使用了 Provider 和 Consumer,但我无法确定这是否属实。我欢迎任何见解。

【问题讨论】:

【参考方案1】:

有一些方法可以避免重新渲染,也可以让你的状态管理“类似于 redux”。我会告诉你我是怎么做的,它远不是一个 redux,因为 redux 提供了很多实现起来并不简单的功能,比如从任何 action 或 combineReducers 将 action 调度到任何 reducer 的能力等等还有很多。

创建你的减速器

export const initialState = 
  ...
;

export const reducer = (state, action) => 
  ...
;

创建您的 ContextProvider 组件

export const AppContext = React.createContext(someDefaultValue)

export function ContextProvider(props) 

  const [state, dispatch] = useReducer(reducer, initialState)

  const context = 
    someValue: state.someValue,
    someOtherValue: state.someOtherValue,
    setSomeValue: input => dispatch('something'),
  

  return (
    <AppContext.Provider value=context>
      props.children
    </AppContext.Provider>
  );

在您的应用程序的顶层或您想要的地方使用您的 ContextProvider

function App(props) 
  ...
  return(
    <AppContext>
      ...
    </AppContext>
  )

将组件写成纯函数式组件

这样它们只会在这些特定依赖项更新为新值时重新渲染

const MyComponent = React.memo((
    somePropFromContext,
    setSomePropFromContext,
    otherPropFromContext, 
    someRegularPropNotFromContext,  
) => 
    ... // regular component logic
    return(
        ... // regular component return
    )
);

具有从上下文中选择道具的功能(如redux map...)

function select()
  const  someValue, otherValue, setSomeValue  = useContext(AppContext);
  return 
    somePropFromContext: someValue,
    setSomePropFromContext: setSomeValue,
    otherPropFromContext: otherValue,
  

编写一个 connectToContext HOC

function connectToContext(WrappedComponent, select)
  return function(props)
    const selectors = select();
    return <WrappedComponent ...selectors ...props/>
  

把它们放在一起

import connectToContext from ...
import AppContext from ...

const MyComponent = React.memo(...
  ...
)

function select()
  ...


export default connectToContext(MyComponent, select)

用法

<MyComponent someRegularPropNotFromContext=something />

//inside MyComponent:
...
  <button onClick=input => setSomeValueFromContext(input)>...
...

我在其他 *** 问题上所做的演示

Demo on codesandbox

避免了重新渲染

MyComponent 将仅在上下文中的具体道具更新为新值时重新渲染,否则它将保留在那里。 select 中的代码将在每次上下文更新任何值时运行,但它什么也不做,而且很便宜。

其他解决方案

我建议检查一下Preventing rerenders with React.memo and useContext hook.

【讨论】:

这似乎是一个不错的方法,但考虑使用 useMemo 有很多组件,我认为这将是性能开销。对吗? 如果你有复杂和大的组件,useMemo 可以节省大量的计算时间,但对于简单的情况,是的,让重新渲染更容易,而不会提高性能。【参考方案2】:

我对如何从React.Context 中受益进行了概念验证,但要避免重新渲染使用上下文对象的子项。该解决方案使用React.useRefCustomEvent。每当您更改 countlang 时,只会更新使用特定属性的组件。

在下方查看,或尝试CodeSandbox

index.tsx

import * as React from 'react'
import render from 'react-dom'
import CountProvider, useDispatch, useState from './count-context'

function useConsume(prop: 'lang' | 'count') 
  const contextState = useState()
  const [state, setState] = React.useState(contextState[prop])

  const listener = (e: CustomEvent) => 
    if (e.detail && prop in e.detail) 
      setState(e.detail[prop])
    
  

  React.useEffect(() => 
    document.addEventListener('update', listener)
    return () => 
      document.removeEventListener('update', listener)
    
  , [state])

  return state


function CountDisplay() 
  const count = useConsume('count')
  console.log('CountDisplay()', count)

  return (
    <div>
      `The current count is $count`
      <br />
    </div>
  )


function LangDisplay() 
  const lang = useConsume('lang')

  console.log('LangDisplay()', lang)

  return <div>`The lang count is $lang`</div>


function Counter() 
  const dispatch = useDispatch()
  return (
    <button onClick=() => dispatch(type: 'increment')>
      Increment count
    </button>
  )


function ChangeLang() 
  const dispatch = useDispatch()
  return <button onClick=() => dispatch(type: 'switch')>Switch</button>


function App() 
  return (
    <CountProvider>
      <CountDisplay />
      <LangDisplay />
      <Counter />
      <ChangeLang />
    </CountProvider>
  )


const rootElement = document.getElementById('root')
render(<App />, rootElement)

count-context.tsx

import * as React from 'react'

type Action = type: 'increment' | type: 'decrement' | type: 'switch'
type Dispatch = (action: Action) => void
type State = count: number; lang: string
type CountProviderProps = children: React.ReactNode

const CountStateContext = React.createContext<State | undefined>(undefined)

const CountDispatchContext = React.createContext<Dispatch | undefined>(
  undefined,
)

function countReducer(state: State, action: Action) 
  switch (action.type) 
    case 'increment': 
      return ...state, count: state.count + 1
    
    case 'switch': 
      return ...state, lang: state.lang === 'en' ? 'ro' : 'en'
    
    default: 
      throw new Error(`Unhandled action type: $action.type`)
    
  


function CountProvider(children: CountProviderProps) 
  const [state, dispatch] = React.useReducer(countReducer, 
    count: 0,
    lang: 'en',
  )
  const stateRef = React.useRef(state)

  React.useEffect(() => 
    const customEvent = new CustomEvent('update', 
      detail: count: state.count,
    )
    document.dispatchEvent(customEvent)
  , [state.count])

  React.useEffect(() => 
    const customEvent = new CustomEvent('update', 
      detail: lang: state.lang,
    )
    document.dispatchEvent(customEvent)
  , [state.lang])

  return (
    <CountStateContext.Provider value=stateRef.current>
      <CountDispatchContext.Provider value=dispatch>
        children
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  )


function useState() 
  const context = React.useContext(CountStateContext)
  if (context === undefined) 
    throw new Error('useCount must be used within a CountProvider')
  
  return context


function useDispatch() 
  const context = React.useContext(CountDispatchContext)
  if (context === undefined) 
    throw new Error('useDispatch must be used within a AccountProvider')
  
  return context


export CountProvider, useState, useDispatch

【讨论】:

【参考方案3】:

据我了解,上下文 API 并不是为了避免重新渲染,而是更像 Redux。如果您希望避免重新渲染,可以查看PureComponent 或生命周期挂钩shouldComponentUpdate

这是一个很棒的 link 来提高性能,您也可以将其应用于上下文 API

【讨论】:

我已经看过shouldComponentUpdate,但这似乎是一个死胡同。 Provider 的值通过 Consumer 的 HoF 参数传播,而 shouldComponentUpdate 需要访问组件 props。所以我还需要在两个地方都通过它们。阅读了 Dan Abramov 关于 Context 的推文后,我的感觉是我的方法(或我的代码)实际上以我看不到的方式被破坏了。 我不明白,这如何回答这个问题? React 上下文更改将触发重新渲染,而与 shouldComponentUpdate 无关(如此处所示 reactjs.org/docs/context.html#contextprovider) propagation from Provider to its descendant consumers (including .contextType and useContext) is not subject to the shouldComponentUpdate method, so the consumer is updated even when an ancestor component skips an update. 根据文档,所以这不是正确答案,reactjs.org/docs/context.html#contextprovider

以上是关于React Context API 并避免重新渲染的主要内容,如果未能解决你的问题,请参考以下文章

React Context API 似乎要重新渲染每个组件

React Context API似乎重新渲染每个组件

React (Native) Context API 导致 Stack Navigator (React Navigation 5) 在状态更新后重新渲染

React Context 中的函数和重新渲染

(p)React 避免在提交时不必要的重新渲染

React Context Provider 所有子级重新渲染