开篇

「单向数据流」是react推崇的范式,数据流「自顶向下」,一个很明确的概念就是当我们的父组件发生重渲染的时候,会连带着子组件的重渲染。
比如如下的组件,当父组件 setNumber 之后,虽然说我们的子组件自身的状态没有任何的变化,但是由于父组件的 re-render ,子组件中会随着render次数打印这句console。

child render

  1. import { memo, useState } from "react";
  2. import "./styles.css";
  3. const Child = ({ number }) => {
  4. const [word, setWord] = useState("bibi");
  5. console.log("child render");
  6. return (
  7. <div>
  8. this is child with word: {word}
  9. <br />
  10. <button onClick={() => setWord("dandan")}>change word</button>
  11. </div>
  12. );
  13. };
  14. export default function App() {
  15. const [number, setNumber] = useState(0);
  16. return (
  17. <div className="App">
  18. <h1>this is father with number: {number}</h1>
  19. <br />
  20. <button onClick={() => setNumber((n) => n + 1)}>change number</button>
  21. <Child />
  22. </div>
  23. );
  24. }

针对这个情况,解决办法也很简单,对于 FunctionComponent 来说,我们有 React.memo 来帮助我们做类似 ClassComponent shouldComponentUpdate 的事情

  1. import { memo, useState } from "react";
  2. import "./styles.css";
  3. ++const Child = memo(({ number }) => {
  4. const [word, setWord] = useState("bibi");
  5. console.log("child render");
  6. return (
  7. //...
  8. );
  9. ++});
  10. export default function App() {
  11. const [number, setNumber] = useState(0);
  12. return (
  13. //...
  14. );
  15. }

你可以在这里看到对应的demo

带上context玩玩

可以看一下这里的demo

明确一个概念,我们常说的「当context的value发生了变化,导致了我们的组件树发生了重渲染」

这里的「value发生了变化」指的是value实实在在的发生了更改:

  1. 如果value是一个对象,对象的引用发生了变化
  2. 如果value引用不变,对象内部的某个值发生了变化

当我们在父组件中引入了一个context对象,并且在子组件中消费了context,不管我们是在context中提供了修改函数或者是useReduce之类的dispatch函数,最终都会修改了这个value变量。

调度dispatchA -> 引起stateA immutable更新 -> 更新value -> 引用地址变化发生context重渲染

听起来很make sense对不对~

绝大多数人对「context」都是上述概念。似乎「context中value」的变化是因为我的 dispatch 产生的。

听起来也没错,但是很不幸不光如此。如果我们尝试在context中使用一个静态类型数据,比如一个简单的基本类型

  1. <AppContext.Provider value={0}>
  2. <Child />
  3. </AppContext.Provider>

当我们的父组件发生re-render的时候,Child组件不由得也会发生重渲染。这个时候context的value没有发生改变

真遗憾

所以明确一下结论,能让context发生重渲染的机制有两种:

  1. dispatch一个update产生的re-render
  2. context所在组件产生的re-render,也会导致context发生re-render,听起来有点拗口(狗头)

其中2所产生的重渲染不一定是自身state的重渲染,很可能也有外部因素比如props而导致的重渲染。

当我们聊题目的时候,我们在聊什么?

绝大多数场景,当我们看到一个命题比如「context带来的无意义重渲染」,我们往往在聊的都是通过dispatch产生的重渲染。

那么context发生的重渲染和正常父子组件的重渲染有没有啥区别?
回到开篇的例子,再看看上文context的例子,或许第二个例子中多了各种reduce和dispatch,但是从根本意义上而言,真没啥区别。

根本原因是,他们都是由于某一层重渲染导致了自身的重渲染。但唯一的区别可能是,context所在的层次太过于高,甚至往往是最高。

在这么层级如此之高的情况下发生重渲染,渲染成本是十分昂贵的。

回到题目,我们的处理方式是?

上文的概念甚至可以继续收拢成「怎么避免父组件发生重渲染导致的自身渲染」

基于这个想法出发,一些正常的方案我们就能够想得到了。

泛memo化

可以指的是React.memo,也可以指useMemo包裹起来的组件/值,以方便我们达到scu的功能。直接上Dan的例子

React.memo

  1. function Button() {
  2. let appContextValue = useContext(AppContext);
  3. let theme = appContextValue.theme;
  4. return <ThemedButton theme={theme} />
  5. }
  6. // 对 ThemedButton 使用 Memo,只「响应」theme 的变化
  7. const ThemedButton = memo(({ theme }) => {
  8. return <ExpensiveTree className={theme} />;
  9. });

useMemo

  1. function Button() {
  2. let appContextValue = useContext(AppContext);
  3. let theme = appContextValue.theme; // 相当于自己实现的 selector
  4. return useMemo(() => {
  5. return <ExpensiveTree className={theme} />;
  6. }, [theme])
  7. }

props.children

通过children来实现memo效果其实也是黑科技之一。
我们都知道react在运行时都是调用的React.createElement,只要我们保持传进去的props.children的引用不变,一样可以实现类似memo的效果。

  1. const Container = (props) => {
  2. //...
  3. return (
  4. <Context.Provider value={value}>
  5. {props.children}
  6. </Context.Provider>
  7. )
  8. }
  9. const Demo = () => {
  10. return (
  11. <div>
  12. <Container>
  13. <Count />
  14. <SetCount />
  15. <Pure />
  16. </Container>
  17. </div>
  18. )
  19. }

粒度比较粗,局限性比较大:

  1. 实际工作中除非是特别简单的场景。否则次次都需要多包一个额外的父容器带来的成本较高。
  2. 由于「1」的原因,永远需要向其他同事解释这东西是用来干嘛的。或者次次写注释。
  3. 且不好阅读。

    拆分context

    拆!我就摁拆!

这当然是一个解决方案之一,对不同上文背景的context进行拆分,这样就很容易能做到「组件按需选用订阅自己的 Contexts data」。
实际上拆分context也是官方提出的「最佳」解决方案之一。既然一个context很可能处于较高的位置,那么是不是所有的context都必须处于这个位置?

合理的决定context value的位置,从而让我们原本整个组件树从上而下重渲染变成个别子树的重渲染。

理论上只要context拆的足够细致,value的划分足够合理,我们完全可以规避各种所谓不必要的重渲染。

  • value是不是必须处于这一层context?他们之间不共用的是否可以再拆一下?
  • context会有多变和不多变之分么?一些很少变动的context我们是不是可以放到外层?频繁变动的放到内层?

将Context拆分到最小的粒度,是最好的实现。但是也自然带来一个问题:当context非常多的时候,外层的Provider会嵌套很多层。因此,在日常开发中,开发人员还是会考虑将context合并。永远也做不到代码和性能的兼得。

看起来麻烦,写起来更麻烦。js这叼语言灵活的飞起,每个人的编程范式都不一样,图方便一把梭完事他不香吗?

在项目中喜欢在根组件塞一个大对象的人不在少数。(including me

所以这一套当然是好的,但是必然要求开发者需要有良好的编程习惯,以及严格的自我约束。

社区方案

能不能用更细粒度的实现按需订阅?
use-context-selector

了解了上面分析,其实我们也不难猜测出,或自己设计并实现一个 Context selectors:使用同一份引用地址维护 state 和 selectors,在修改 state 时,保证改引用地址不变,因此自动 bailout 掉整颗组件树的无脑渲染,我们通过比对分析出变化 state 所对应的组件,调用 forceUpdate 去触发必要组件的 re-rendering。

熟悉的vue感觉。。

推荐阅读

详细也可以见这一篇 如何优雅地处理使用 React Context 导致的不必要渲染问题?
程墨的经典回答 避免React Context导致的重复渲染
codesandbox举例