【React】从 react 的一个误解说说 re-renders

1,604 阅读10分钟

前言

我在外网闲逛,看了一篇文章,作者在里面讲到了他认为当前 react 社区对 react 在重渲染(re-render)方面存在两个误解:

  • 误解1 - 当一个 react 组件因为状态更新而导致重渲染的时候,会导致整个 react 应用的重新渲染;
  • 误解2 - 一个 react 组件的重渲染有可能是单纯因为 props 的值发生了变化。

正文

那好,现在来聊一聊我是怎么理解这两个误解的。

对于误解1,我有点嗤之以鼻。我觉得作者的结论应该是下错了。react 社区应该不会有这么低级的误解。任何一个 react 新手,即使不用了解 react 的核心原理,用脑子想一想也不会有这样的认知吧。我们可以设想一下,假如某个处于最底层的叶子节点组件的 state 发生了改变,如果这会导致应用层级的整颗 react 树上的所有组件都会重渲染,那 react 的实现也简单粗暴,小学生也能实现啊,这还是数一数二的前端框架吗?我觉得,这里面的逻辑,任何一个有编程经验的人一想就通。所以,我觉得 react 社区应该不会有这么低级的误解。

对于误解2,我咋一看,第一反应是:“不应该是这样吗?难道我一直以来的理解都是错的?”。后面经过编码验证,我才发现,原来我真的是错的,作者是对的。我还真的有这样的误解啊。

也许,你会很奇怪,作为一个 react 老手,鲨叔你怎么会有这个误解呢?要回答这个问题,首先,我们要了解真相是什么。真相是:“react 组件的 props 值的变化不会触发该组件的重渲染”。对,你没看错,就是这种表述。你肯定会接着追问,那到底什么情况下会导致一个组件的重渲染呢?答案是:“有两种情况:

  • 情况1 - react 组件的 state 发生了变化,那么就会引起它自身的重渲染
  • 情况2 - 当父组件发生了重渲染,在不进行任何优化的情况下,以父组件为根节点的组件树上的所有的子孙组件都会发生渲染。

第一种情况是人尽皆知的啦。而第二种情况呼应的就是本文中提到的误解2。那么到了这里,我基本上可以回答上面的那个问题了 :

作为一个 react 老手,鲨叔你怎么没有解开这个误解呢?

因为两种认知是存在交叉重叠的部分。在绝大多数情况下,我们会将父组件的 state 间接或者直接通过 props 来传递给子组件的。那么,这种情况下,将子组件的重渲染的源头理解为 state 的变化或者 props 的变化,并不会影响我们理解整个组件树的渲染流程。这就是两种认知的交叉重叠部分。因为秉持着这种误解来理解,定位和解决 react 组件在渲染方面的问题过程中,并不曾遇到过矛盾点,所以这种误解得以在我的脑海里面匿藏了这么久。

下面,我们用代码来验证一个误解2真的是一个误解 - 也就是说,我们要证明:“react 组件的 props 值的变化不会触发该组件的重渲染”。其实,这个证明也很简单,我只需要把由父组件的 state 转化成 props 的这种情况排除掉,看看一个纯粹的 prop 值的变化是否触发子组件重渲染即可。二话不说,我们来看看代码:

import React, { useEffect } from 'react'
import ReactDOM from 'react-dom'

const Child = ({randomNum})=> (<div>子组件 - 随机数是:${randomNum}</div>)

let randomNum = Math.random()
const Parent = ()=> {
    useEffect(()=> {
        const id = setInterval(()=> {
            randomNum = Math.random()
        }, 1000)
        
        return ()=> { clearInterval(id) }
    },[])
    
    return (
        <>
            <div>父组件</div>
            <Child randomNum={randomNum} />
        </>
    )
}

ReactDOM.render(<Parent />, document.body)

如果 props 的变化会触发组件的重渲染的话,那么界面应该是会如期更新 - 每隔 1 秒中去显示一个不同的随机数。

但是,事实上是怎样呢?事实上,随着时间的流逝,界面并没有更新。这从而证明我们的理解真的是错的。误解2 还真的是一个误解。

抛开「randomNum 应该成为 state 」正确的解法不说,为了让 <Child />的界面得到更新,我们可以利用 react 的重渲染机制 - 「重渲染 <Parent />, 迫使 <Child /> 进行重渲染」来“解决”这个问题:

import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom'

const Child = ({randomNum})=> (<div>子组件 - 随机数是:${randomNum}</div>)

let randomNum = Math.random()
const Parent = ()=> {
    const [count, setCount] = useState(0)
    useEffect(()=> {
        setInterval(()=> {
          const id = setInterval(()=> {
            randomNum = Math.random()
            setCount(count=> count + 1)
        }, 1000)
        
        return ()=> { clearInterval(id) }
        }, 1000)
    },[])
    
    return (
        <>
            <div>父组件 - count: ${count}</div>
            <Child randomNum={randomNum} />
        </>
    )
}

ReactDOM.render(<Parent />, document.body)

按理说,一个真正的响应式实现应该是这样的 - 只要组件所依赖的 props 或者 state 发生了变化,组件都要进行重渲染。react 为什么不这么实现呢?我们可以去探究一下。

不过话说话来,这个误解还真的呼应了“存在即是合理”的箴言。因为,我们开发者的直觉告诉我们,组件 props 也算是我组件的依赖,既然它发生了改变,你 react 理所当然地应该给我重渲染。好吧,事实狠狠地打了我们的脸。于是乎,我们打破沙锅问到底:“我的 props 值发生了变化,你 react 也不帮我重渲染也就算了,那假如我的 props 值不变的话,我的组件应该不会被重渲染了吧?”

不好意思,react 就喜欢跟你对着干 - 当子组件的 props 没有发生任何变化的时候,只要我父组件重渲染了,你就必须被迫重渲染。

小结

在 react 组件的重渲染机制中,围绕 props 跟 react 组件重渲染之间有两个很违反开发者直觉的实现。

符合我们直觉的认知是:

  • react 组件自身的 props 值发生改变「会导致」组件的重渲染;
  • react 组件自身的 props 值没有发生改变的时候(react 组件依赖只有 props)「不会触发」组件的重渲染。

当前 react 的实现是:

  • react 组件自身的 props 值发生改变「不会导致」组件的重渲染;
  • react 组件自身的 props 值没有发生改变的时候(react 组件依赖只有 props)「也会触发」组件的重渲染(被迫地)。

看来 react 的实现恰恰是跟我们的直觉认知是相反的。我相信,即使是经验丰富的 react 开发者,如果在这一块没有仔细考究过,是很容易产生这样的误解的。

为什么组件自身的 props 值没有变化的时候,react 也要重渲染我的组件?

在理想世界里面,react 组件应该是「纯的」。这里的「纯」跟函数式编程里面的「pure」的概念是一致的。因为,react 组件,从代码的角度来审视这个概念的话,本质上就是一个普通的 javascript 函数。如果一个 react 组件是纯的话,相同的输入(props 值),无论经过多少次的调用,返回的 UI 结果都是一致的。此谓之 react 组件是”纯的“。假如,我们是生活在这样只有「纯 react 组件」的世界的话,我想 react 是可以这么干。但是,事实却是不是。

实际上,我们生活在一个充满了「副作用」的世界。我们很容易写出一个非纯的 react 组件:

function CurrentTime() {
  const now = new Date();
  return (
    <p>It is currently {now.toString()}</p>
  );
}

反复渲染这个组件(也就是以同样的 props 值 - undefined),我们会得不到不同的 UI 结果。

我们再举一个例子 - ref 作为 prop。当 ref 作为组件的 prop 的时候,react 是没办法知道这个值是发生了改变的。因为 ref 的值是一个引用类型。只比较引用的话,两次渲染间, ref 永远是相等的。如果要通过序列化来比较的话,因为 ref 有可能会保存其他的引用类型(尤其是函数),那么事情就变得复杂了。

react 在渲染方面的理念是「UI 一致性至上」。秉持这个理念,react 的做法是,只要父组件重渲染了,那么以它为根节点的组件树上所有的后代组件都必须重渲染,不管你的 props 值是否有变化。

react 之所以这么做,是因为 react 不想去猜你这个组件是否是纯的,也不想过多地去想如果判断 props 值是否发生了变化。react 只想拿到当前 react 组件树的一个快照(react element tree 或者或说 虚拟 DOM 节点树),然后进入 reconcile 环节,最后找出最小的 DOM 更新,一次性保证 react 状态 与 UI 结果的一致性。

只要父组件重渲染了,那么以它为根节点的组件树上所有的后代组件都必须重渲染,不管你的 props 值是否有变化。

react 这么做,优缺点都很明显:

  • 缺点:reactivity 的粒度太粗了,渲染性能较差。
  • 优点:开发者在理解 react 组件更新机制方面,心智负荷很轻。

往事不可追,react 从一诞生就采用这种组件更新模型。你想让它变成跟 vue.js 或者 solid.js 一样的细响应粒度(fine-grained reactivity)更新机制,我估计没那个可能了。

性能优化

正常情况,javascript 的执行速度够快,所以,在 react 全量重渲染的模型下,界面更新速度也没有问题。Don't over-optimize! 这是性能优化方面大家公认的一条知道准则。那么,什么时候,我们需要考虑对 react 组件的重渲染进行优化呢?如果你的界面更新出现了肉眼可见的卡顿,又或者说,公司决策层很在乎某些跟组件重渲染挂钩的指标表现,这个时候,你是可以考虑做性能优化了。

在 react 中,防止 react 组件过渡的重渲染,法宝有三:

  • class component 时代 - shouldComponentUpdata()生命周期函数
  • class component 时代 - extends React.PureComponent {}
  • functional component 时代 - React.memo()

当你确定某个组件是纯组件(比如说,静态组件就是典型的纯组件)或者考虑到它的子孙组件特别地庞大,没必要的重渲染很浪费性能,那么使用上面的方法进行优化也是十分方便,优化成本是可以接受的。

也许,你会说,为什么 react 不能默认地用React.memo()来包裹我们得组件呢?我想很难。因为 react 没办法替我们开发者是去定义组件的 props 值是否发生了变化的。举个例子来说,如果你的某个 props 值是一个链表,那 react 怎样判断这个链表的「值」发生了改变呢?现实情况是,即使我每次传递给该 prop 的值是一个不同的引用,但是我也不把它定义为「值发生了改变」。因为,我会根据我的业务场景来定义这个链表的「值」是什么,比如说我通过实现链表的toString()方法来做定义。而实现toString()方法是跟具体业务是强相关的,react 没办法替我们做主。这就是为什么React.memo()需要开放第二个参数去让开发者去实现值的比较:

function areEqual(prevProps, nextProps) {
  /*
  return true if passing nextProps to render would return
  the same result as passing prevProps to render,
  otherwise return false
  */
}

不过,抛开React.memo()级别的优化,react core team 确实是想尝试主要针对useMemo(),useCallback等 API进行优化,这就是最近 react core team 的大动作之一 - React Forget compiler。它是由国人黄玄来主导开发,目前还在开发中,让我们一起来拭目以待吧。

总结

本文主要在阐述以下几点信息:

  • 「一个 react 组件的重渲染有可能是单纯因为 props 的值发生了变化」真的是一个误解。
  • 正解是:「一个 react 组件的重渲染跟它自己的 props 值的改变没有关系」
  • 原来鲨叔也有这样的误解。之所以有这样的误解,是因为,props 的值发生改变和父组件的重渲染同时发生了。
  • 触发组件重渲染有两种情况:
    • 情况1 - 当 react 组件的 state 发生了变化,那么就会引起它自身的重渲染
    • 情况2 - 当父组件发生了重渲染,在不进行任何优化的情况下,以父组件为根节点的组件树上的所有的子孙组件都会发生渲染。
  • 在被迫渲染的场景下,避免不必要渲染的方法有:
    • shouldComponentUpdata()生命周期函数
    • extends React.PureComponent {}
    • React.memo()

参考资料