深入了解 React Context

2,034 阅读4分钟

React Context

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。

考虑到不熟悉 Context 的同学,这里贴上常用的两个 API

API

React.createContext

const MyContext = React.createContext(defaultValue);

创建一个 Context 对象。当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中离自身最近的那个匹配的 Provider 中读取到当前的 context 值。

Context.Provider

<MyContext.Provider value={/* 某个值 */}>

每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化。

Provider 接收一个 value 属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。多个 Provider 也可以嵌套使用,里层的会覆盖外层的数据。

useContext

const store = useContext(MyContext)

接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定。

当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染。(重要!!!)

了解了 API 后,我们来看一个简单的例子。

示例

index.js

const MyContext = React.createContext(null);

function reducer(state, action) {
  switch (action.type) {
    case 'countAdd': {
      return {
        ...state,
        count: state.count + 1
      }
    }
    case 'numAdd': {
      return {
        ...state,
        num: state.num + 1
      }
    }
    default: return state;
  }
}

const MyProvider = ({ children }) => {
  const [store, dispatch] = useReducer(reducer, { count: 0, num: 0 })
  const value = useMemo(() => ({
    store, dispatch
  }), [store])
  return <MyContext.Provider value={value}>{children}</MyContext.Provider>
};

export default () => {
  return (
    <MyProvider>
      <Child />
      <Child2 />
      <Child3 />
    </MyProvider>
  );
}

Child1.js

export default () => {
    const { state, dispatch } = React.useContext(MyContext);
    console.log('re-render Child1')
    return (
      <>
        <div>count is: {state.count}</div>
        <button onClick={() => dispatch({ type: 'countAdd' });}> AddCount </button>
      </>
    )
}

Child2.js

export default () => {
    const { state, dispatch } = React.useContext(MyContext);
    console.log('re-render Child2')
    return (
      <>
        <div>num is: {state.num}</div>
        <button onClick={() => dispatch({ type: 'numAdd' });}> AddNum </button>
      </>
    )
}

Child3.js

export default () => {
  console.log('re-render Child3')
  return <div>Child3</div>
}

点击 AddCount 按钮,输出:

re-render Child1
re-render Child2

点击 AddNum 按钮,输出:

re-render Child1
re-render Child2

我们可以发现,Context.Provider 下的所有消费组件,在value变化后,都会重新渲染。

优化

针对子组件做函数记忆

React.memo

我们如下修改所有的 Child 组件

export default React.memo(() => {
    const { state, dispatch } = React.useContext(MyContext);
    console.log('re-render Child1')
    return (
      <>
        <div>count is: {state.count}</div>
        <button onClick={() => dispatch({ type: 'countAdd' });}> AddCount </button>
      </>
    )
})

点击 AddCount 后发现,依然打印出
re-render Child1
re-render Child2
???

我们重新认识下高阶组件 React.memo

React.memo 默认情况下仅仅对传入的 props 做浅比较,如果是内部自身状态更新(useState, useContext等),依然会重新渲染,在上面的例子中,useContext 返回的 state 一直在变化,导致就算被 memo 包裹的组件依然触发更新了。

useMemo

我们如下修改所有的 Child 组件

export default () => {
    const { state, dispatch } = React.useContext(MyContext);
    return useMemo(() => {
      console.log('re-render Child1')
      return (
          <>
            <div>count is: {state.count}</div>
            <button onClick={() => dispatch({ type: 'countAdd' });}> Add </button>
          </>
      )
    }, [state.count, dispatch])
}

点击 countAdd 后发现,只打印出了
re-render Child1

点击 numAdd 后发现,只打印出了
re-render Child2

useMemo 可以做更细粒度的缓存,我们可以在依赖数组里来管理组件是否更新

思考? 有没有一种办法,不用 useMemo 也可以做到按需渲染。就像 react-redux 里的 useSelector

动手实现 useSelector

我们先想一下,在上面的例子中,触发子组件渲染的根本原因是什么?

没错就是因为 value 的值一直在变化,那我们要想个办法让子组件感知不到 value 的变化,同时在 value 变化的时候触发消费了 value 的子组件的更新。

使用 发布订阅 实现

1、使用 useMemo 缓存 value
2、在 Context.Provider 中创建一个订阅回掉数组,在其内部监听 state,如果变化则依次执行订阅回调 3、在子组件初始化时,订阅更新函数,这个函数内部获取前后两次的 state 做对比,不一样则强制更新组件,并返回最新的状态。

Context.Provider

const MyProvider = ({children}) => {
  const [state, dispatch] = useReducer(reducer, initState);
  
  // ref state
  const stateRef = useRef(null);
  stateRef.current = state;

  // ref 订阅回调数组
  const subscribersRef = useRef([]);

  // state 变化,执行回调
  useEffect(() => {
    subscribersRef.current.forEach(sub => sub());
  }, [state]);

  // 缓存 value, 利用 ref 拿到最新的 state, subscribe 状态
  const value = useMemo(
    () => ({
      dispatch,
      subscribe: cb => {
        subscribersRef.current.push(cb);
        return () => {
          subscribersRef.current = subscribersRef.current.filter(item => item !== cb);
        };
      },
      getState: () => stateRef.current
    }),
    []
  )

  return <MyContext.Provider children={children} value={value} />;
}

useSelector

export const useSelector = selector => {
  // 强制更新
  const [, forceRender] = useReducer(v => v + 1, 0);
  const store = useContext(MyContext);

  // 获取当前使用的 state
  const selectedStateRef = useRef(null)
  selectedStateRef.current = selector(store.getState());

  // 对比更新回调
  const checkForUpdates = useCallback(() => {
    const newState = selector(store.getState());
    if (newState !== selectedStateRef.current) forceRender({});
  }, [store]);
  
  // 订阅 state
  useEffect(() => {
    const subscription = store.subscribe(checkForUpdates);
    return () => subscription();
  }, [store, checkForUpdates]);
  
  // 返回需要的 state
  return selectedStateRef.current;
}

useDispatch

export const useDispatch = () => {
  const store = useContext(MyContext);
  return store.dispatch
}

我们用自定义的API,改写下刚开始的例子

index.js

export default () => {
    return (
      <MyProvider>
        <Child />
        <Child2 />
        <Child3 />
      </Provider>
    );
}

Child1.js

export default () => {
    const dispatch = useDispatch();
    const count = useSelector(state => state.count);
    console.log('re-render Child1')
    return (
      <>
        <div>count is: {count}</div>
        <button onClick={() => dispatch({ type: 'countAdd' });}> AddCount </button>
      </>
    )
};

Child2.js

export default () => {
    const dispatch = useDispatch();
    const num = useSelector(state => state.num);
    console.log('re-render Child2')
    return (
      <>
        <div>num is: {state.num}</div>
        <button onClick={() => dispatch({ type: 'numAdd' });}> AddNum </button>
      </>
    )
}

Child3.js

export default () => {
    console.log('re-render Child3')
    return <div>Child3</div>
}

点击addCount: 只打印了 re-render Child1
点击AddNum: 只打印了 re-render Child2

以上通过对 Context 使用中的思考,我们简单的实现了 useSelector,实现了 Context 子组件的按需渲染。

总结

在使用Context API 的时候,要避免不必要的渲染,可以使用 useMemo 做细粒度更新,也可以使用 useSelector 实现按需渲染。

这里可以看到本文的示例!在线demo