React 重新渲染:PureComponents 与 Hooks 函数组件

6,594 阅读15分钟

“我正在参加「掘金·启航计划」”。 本文源于翻译 PureComponents vs Functional Components with hooks,由「KooFE前端团队」 完成翻译,微信搜索 ikoofe 可关注公众号。

你是否认为,在逝去的流金岁月里,一切都是那么美好?你是否也认为,在过去的 React 中,我们不需要关心重新渲染的问题:PureComponent 或者 shouldComponentUpdate 可以帮助我们处理重新渲染。

当阅读关于 React 重新渲染的文章或评论时,总会涌现出这样一种观点:由于 Hook 和函数组件让我们现在陷入了重新渲染的泥潭中。这让我感觉十分困惑,我并不记得在 “过去那些流金岁月” 里,在这方面做的有多么美好。是我错过了什么吗?是函数组件真的使重新渲染变得更加糟糕了吗?难道是要我们迁移回类组件和 PureComponent 吗?

因此,之前在 React re-render 指南中介绍了 React 重新渲染的各种场景,本文则是将围绕这些问题展开讨论:搞清楚 PureComponent 和它所解决的问题,了解它是否可以在 Hook 和函数组件的世界中被替换,并介绍 React 重新渲染(re-render)一个有趣(但有点无用)的怪异行为。

PureComponent, shouldComponentUpdate: 它们解决了哪些问题?

首先,我们需要明确什么是 PureComponent,以及为什么我们需要 shouldComponentUpdate。

父组件引起的不必要的重新渲染

父组件的重新渲染是导致组件自身重新渲染的一个原因。如果我们改变 Parent 组件的状态,那么 Parent 组件就会进行重新渲染,进而导致 Child 组件也发生重新渲染:

const Child = () => <div>render something here</div>;

const Parent = () => {
  const [counter, setCounter] = useState(1);

  return (
    <>
      <button onClick={() => setCounter(counter + 1)}>Click me</button>
      <!-- child will re-render when "counter" changes-->
      <Child />
    </>
  )
}

同样的行为也会发生在 class 组件身上,Parent 组件的状态发生变化,引起 Child 组件重新渲染:

class Child extends React.Component {
  render() {
    return <div>render something here</div>
  }
}

class Parent extends React.Component {
  super() {
    this.state = { counter: 1 }
  }

  render() {
    return <>
      <button onClick={() => this.setState({ counter: this.state.counter + 1 })}>Click me</button>
      <!-- child will re-render when state here changes -->
      <Child />
    </>
  }
}

同样对于 class 组件来说,太多的重新渲染,也会引起性能问题。为了能够阻止重新渲染,React 在 class 组件中提供了 shouldComponentUpdate 方法,这个方法会在组件重新渲染之前触发。如果它返回 true,组件的生命周期继续进行,并完成重新渲染;如果返回 false,则不再进行重新渲染。所以,如果我们想要在 Child 组件中,阻止父组件诱发的重新渲染,我们只需在 shouldComponentUpdate 中返回 false:

class Child extends React.Component {
  shouldComponentUpdate() {
    // now child component won't ever re-render
    return false;
  }

  render() {
    return <div>render something here</div>
  }
}

但是,如果我们想要传递一些 props 给 Child 组件呢?在这种情况下,如果 props 发生了变化,我们实际上需要这个组件能够更新(比如,重新渲染)。为了能够解决这个问题,shouldComponentUpdate 为我们提供了 nextProps 参数,而且我们可以通过 this.props 获取当前的 props:

class Child extends React.Component {
  shouldComponentUpdate(nextProps) {
    // now if "someprop" changes, the component will re-render
    if (nextProps.someprop !== this.props.someprop) return true;

    // and won't re-render if anything else changes
    return false;
  }

  render() {
    return <div>{this.props.someprop}</div>
  }
}

现在,只要 someprop 发生变化,Child 就会重新渲染它自己。如果我们再添加一些 state 呢?有趣的是,shouldComponentUpdate 也会在 state 变化之前被调用。因此,这个方法实际上是非常危险的:如果我们没有谨慎的使用它,就会导致组件错误的行为。比如,在 state 变化时不会更新自己:

class Child extends React.Component {
  constructor(props) {
    super(props);
    this.state = { somestate: 'nothing' }
  }

  shouldComponentUpdate(nextProps) {
    // re-render component if and only if "someprop" changes
    if (nextProps.someprop !== this.props.someprop) return true;
    return false;
  }

  render() {
    return (
      <div>
        <!-- click on a button should update state -->
        <!-- but it won't re-render because of shouldComponentUpdate -->
        <button onClick={() => this.setState({ somestate: 'updated' })}>Click me</button>
        {this.state.somestate}
        {this.props.someprop}
      </div>
    )
  }
}

除了 props 之外,现在 Child 组件还有一些 state,当 button 被点击时 state 被更新。但是在上面的代码中,button 虽然被点击,但并未引起 Child 组件重新渲染。因为 shouldComponentUpdate 方法中,并没有 state 的相关条件判断,用户在页面上也不会看到 state 更新在页面上。

为了解决这个问题,我们需要在 shouldComponentUpdate 添加 state 对比,React 为我们提供了第二个参数 nextState:

shouldComponentUpdate(nextProps, nextState) {
    // re-render component if "someprop" changes
    if (nextProps.someprop !== this.props.someprop) return true;

    // re-render component if "somestate" changes
    if (nextState.somestate !== this.state.somestate) return true;
    return false;
  }

正如你想象的那样,为每一个 props 和 state 手动编写这些代码是一个灾难。所以很多情况下,代码像下面这个样子:

shouldComponentUpdate(nextProps, nextState) {
    // re-render component if any of the prop change
    if (!isEqual(nextProps, this.prop)) return true;

    // re-render component if "somestate" changes
    if (!isEqual(nextState, this.state)) return true;

    return false;
  }

因为这是一种非常常见的场景,React 在 Component 之外为我们提供了 PureComponent,来为我们完成这种对比逻辑。这样,如果我们想要 Child 组件阻止那些非必要的重新渲染,只需要继承 PureComponent 即可:

// extend PureComponent rather than normal Component
// now child component won't re-render unnecessary
class PureChild extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = { somestate: 'nothing' }
  }

  render() {
    return (
      <div>
        <button onClick={() => this.setState({ somestate: 'updated' })}>Click me</button>
        {this.state.somestate}
        {this.props.someprop}
      </div>
    )
  }
}

现在,如果我们在 Parent 组件中使用上面的 Child 组件,父组件的 state 发生变化也不会让它重新渲染。而且 Child 组件的 state 也能像预期那样工作:

class Parent extends React.Component {
  super() {
    this.state = { counter: 1 }
  }

  render() {
    return <>
      <button onClick={() => this.setState({ counter: this.state.counter + 1 })}>Click me</button>
      <!-- child will NOT re-render when state here changes -->
      <PureChild someprop="something" />
    </>
  }
}

state 引起的不必要的重新渲染

正如上面提到的,shouldComponentUpdate 既可以阻止 props 变化引起的重新渲染,也可阻止 state 变化引起的重新渲染。这是因为,它会在组件每一次重新渲染之前被触发:无论这个重新渲染是来自于父组件还是它自身的 state 变化导致。更糟糕的是,在每次调用 this.setState 方法时都会触发重新渲染,无论 state 实际上是否真正发生变化。

class Parent extends React.Component {
  super() {
    this.state = { counter: 1 }
  }

  render() {
    <!-- every click of the button will cause this component to re-render -->
    <!-- even though actual state doesn't change -->
    return <>
      <button onClick={() => this.setState({ counter: 1 })}>Click me</button>
    </>
  }
}

将这个组件继承 React.PureComponent,当 button 按钮被点击时,便不再触发重新渲染。正是由于这种行为,在过去的“流金岁月”中,我们在 React 中设置 state 时,推荐的做法是在实际需要时设置 state。这也是为什么我们应该在 shouldComponentUpdate 中明确检查 state 是否已更改,以及为什么 PureComponent 已经为我们实现了它。如果没有这些,在实际应用中就可能会因为不必要的状态更新导致性能问题。


第一部分内容的总结:PureComponent 或 shouldComponentUpdate 主要用于解决由不必要的重新渲染引起的性能问题,这些重新渲染是由组件的 state 更新或父组件重新渲染导致。

PureComponent/shouldComponentUpdate vs functional components & hooks

回到当下,state 和 父组件引起的更新行为是怎样的呢?

父组件引起的不必要的重新渲染:React.memo

众所周知,父组件引起的重新渲染一直都是存在的,它们的行为和 class 组件完全一致:如果一个父组件发生了重新渲染,它的子组件也会发生重新渲染。但是在函数组件中,我们不能使用 shouldComponentUpdate 或 PureComponent 去解决这些问题。

但是,我们可以使用 React.memo,它是 React 提供的一个高阶组件。在 props 方面,它的功能和 PureComponent 完全一致:当某个子组件被 React.memo 包裹时,该子组件只在 props 发生变化时才进行重新渲染,而父组件的重新渲染并不会触发该子组件重新渲染。

如果我们以函数组件的方式来实现上面的 Child 组件,并且要像 PureComponent 那样做性能优化,代码如下所示:

const Child = ({ someprop }) => {
  const [something, setSomething] = useState('nothing');

  render() {
    return (
      <div>
        <button onClick={() => setSomething('updated')}>Click me</button>
        {somestate}
        {someprop}
      </div>
    )
  }
}

// Wrapping Child in React.memo - almost the same as extending PureComponent
export const PureChild = React.memo(Child);

当 Parent 组件的 state 发生变化时,PureChild 并不进行重新渲染,这一点与基于 PureComponent 实现的PureChild 一样。

const Parent = () => {
  const [counter, setCounter] = useState(1);

  return (
    <>
      <button onClick={() => setCounter(counter + 1)}>Click me</button>
      <!-- won't re-render because of counter change -->
      <PureChild someprop="123" />
    </>
  )
}

函数类型 props:React.memo 的对比函数

现在假如 PureChild 接收了 onClick 回调函数,如果我们以箭头函数的形式传递给组件,那么会发生什么?

<PureChild someprop="123" onClick={() => doSomething()} />

无论是以 React.memo 还是 PureComponent 实现的组件都会被破坏:onClick 是一个函数(不是基础类型),每次 Parent 重新渲染的,它也会被重新创建。也就是说每次 Parent 重新渲染,PureChild 都会认为 onClick 已经改变,PureChild 也会重新渲染。这两种性能优化方案均对此失效。

函数组件对此有一定的优势。基于 PureComponent 实现的 PureChild 对此无能为力:要么父级组件正确的传递函数;要么放弃使用 PureComponent,使用 shouldComponentUpdate 手动重新实现 props 和 state 比较,并且将 onClick 排除在比较之外。

使用 React.memo 就会变得相对简单:我们只需将比较函数作为它的第二个参数:

// exclude onClick from comparison
const areEqual = (prevProps, nextProps) => prevProps.someprop === nextProps.someprop;

export const PureChild = React.memo(Child, areEqual);

本质上,React.memo 集 PureComponent 和 shouldComponentUpdate 两者的能力为一体。使用起来特别方便。

另一个便利之处在于,我们不用像 shouldComponentUpdate 那样再做 state 对比。React.memo 和它的对比函数处理 props 即可,Child 组件的 state 不受影响。

函数类型 props:记忆化

尽管上面提到的对比函数看起来很好,坦白来讲,我不会在真正的开发中使用它(当然我也不会去使用 shouldComponentUpdate)。特别是团队里还有其他开发人员。它很容易把事情搞砸了,添加一个 prop 却没有更新这些函数,这会引起容易错过和难于理解的 bug,不得不花费更多的精力修复它们。

这也是 PureComponent 带来便利的地方。在过去,我们会用什么来代替创建内联函数?答案是将回调函数绑定到类的实例上:

class Parent extends React.Component {
  onChildClick = () => {
    // do something here
  }

  render() {
    return <PureChild someprop="something" onClick={this.onChildClick} />
  }
}

这样,回调函数只创建一次,无论 state 如何变化,在 Parent 所有重新渲染中,回调函数保持不变,并且也不会影响 PureComponent 的 props 浅比较。

在函数组件中,不再有类的实例,一切都是函数,所以我们不能再给它绑定任何东西。但是,我们有其他方法去处理回调函数的引用,这取决于你的应用场景以及 Child 的不必要渲染引起的性能问题有多严重。

1. useCallback

最简单的方式是使用 useCallback,基本可以满足 99% 的应用场景。用 useCallback 包裹 onClick 函数,当 useCallback 的依赖不更新时,回调函数会被保持。

const Parent = () => {
  const onChildClick = () => {
    // do something here
  }

  // dependencies array is empty, so onChildClickMemo won't change during Parent re-renders
  const onChildClickMemo = useCallback(onChildClick, []);

  return <PureChild someprop="something" onClick={onChildClickMemo} />
}

如果 onClick 要访问 Parent 的 state,会发生什么呢?在类组件中实现是非常简单,我们能在回调函数中访问到整个 state:

class Parent extends React.Component {
  onChildClick = () => {
    // check that count is not too big before updating it
    if (this.state.counter > 100) return;
    // do something
  }

  render() {
    return <PureChild someprop="something" onClick={this.onChildClick} />
  }
}

在函数组件中也是很简单:我们只需在 useCallback 的依赖中添加 state 即可:

const Parent = () => {
  const onChildClick = () => {
    if (counter > 100) return;
    // do something
  }

  // depends on somestate now, function reference will change when state change
  const onChildClickMemo = useCallback(onChildClick, [counter]);

  return <PureChild someprop="something" onClick={onChildClickMemo} />
}

useCallback 依赖于 counter,当 counter 发生变化时,它会返回不同的函数。也就是说,PureChild 要进行重新渲染,尽管它并没有显示的依赖于 counter。这是典型的不必要的重新渲染。会有什么影响吗?在大多数情况下,这不会有什么不同,性能也会很好。在继续进一步优化之前,始终要评估出实际影响。

在非常极端的情况下,如果对性能确实有一定影响,我们至少还有两种方法绕过此限制。

2. setState

如果在回调函数中,根据条件判断来设置 state,可以使用一种被称为 “更新函数” 的模式,并将条件判断放在该函数中。通常,如果我们的代码是这样的:

const onChildClick = () => {
  // check "counter" state
  if (counter > 100) return;
  // change "counter" state - the same state as above
  setCounter(counter + 1);
}

那么,我们可以把上面的改写为:

const onChildClick = () => {
  // don't depend on state anymore, checking the condition inside
  setCounter((counter) => {
    // return the same counter - no state updates
    if (counter > 100) return counter;

    // actually updating the counter
    return counter + 1;
  });
}

这样,onChildClick 不需要依赖 counter, useCallback 也不需要依赖 state。

3. 将 state 放在 Ref 中

在这种方法里,回调函数中绝对不需要对比 state,也绝对不会触发 PureChild 组件重新渲染,我们可以把任何需要的 state 以“镜像”的形式放到 ref 对象中。

Ref 对象只是一个在重新渲染之间保留的可变对象,非常类似于状态,但是:

  • 它是可变的
  • 它在更新时不触发重新渲染

我们可以用它来储存一些不会在 render 函数中使用的数据。因此,在我们的回调的情况下,会是这样的:

const Parent = () => {
  const [counter, setCounter] = useState(1);
  // creating a ref that will store our "mirrored" counter
  const mirrorStateRef = useRef(null);

  useEffect(() => {
    // updating ref value when the counter changes
    mirrorStateRef.current = counter;
  }, [counter])

  const onChildClick = () => {
    // accessing needed value through ref, not statej - only in callback! never during render!
    if (mirrorStateRef.current > 100) return;
    // do something here
  }

  // doesn't depend on state anymore, so the function will be preserved through the entire lifecycle
  const onChildClickMemo = useCallback(onChildClick, []);

  return <PureChild someprop="something" onClick={onChildClickMemo} />
}

首先,创建一个 ref 对象。然后,在 useEffect 里用 state 更新这个对象。ref 是可变的,所以更新它不会触发重新渲染,所以它是安全的。最后,在回调函数中使用 ref 取得相关数据。这样,就能在 memoized 回调函数中,获得 state 的值,而不需要依赖 state。

注意:我从未在生产应用程序中使用过此技巧。这只不过是一种思考练习。如果您发现自己实际正在使用此技巧来解决实际性能问题,那么您的应用程序架构可能有问题,有更简单的方法来解决这些问题。在 React re-renders 指南中,介绍了一些防止重新渲染的方法,我们也可以使用这些模式。

Array 和 Object 类型 Props:记忆化

对于 PureComponent 和 React.memo 组件,如果 props 接收的是数组和对象类型,那会有些棘手。把这些值直接传递组件会破坏性能,因为在每次重新渲染时这些值会被重新创建。

<!-- will re-render on every parent re-render -->
<PureChild someArray={[1,2,3]} />

在这两种情况中,处理它们的方法完全相同:数组的引用会被保持在重新渲染时。也就是使用记忆化技术去阻止它们被重新创建。在过去,是通过第三方库来完成这些处理,比如 memoize. 如今,我们依然可以使用它们,或者使用 React 自带的 useMemo:

// memoize the value
const someArray = useMemo(() => ([1,2,3]), [])
<!-- now it won't re-render -->
<PureChild someArray={someArray} />

state 引起的不必要的重新渲染

除了防止父级组件重新渲染引起的不必要的重新渲染之外,PureComponent 还可以防止状态更新中不必要的重新渲染。现在我们没有 PureComponent 了,我们如何阻止这些?

还有一点是关于函数组件:我们不必再考虑这个问题了!在函数组件中,不实际更改状态的状态更新不会触发重新渲染。这段代码将是完全安全的,不需要从重新渲染的角度进行任何优化:

const Parent = () => {
  const [state, setState] = useState(0);

  return (
    <>
      <!-- we don't actually change state after setting it to 1 when we click on the button -->
      <!-- but it's okay, there won't be any unnecessary re-renders-->
      <button onClick={() => setState(1)}>Click me</button>
    </>
  )
}

这种行为被称为“跳过 state 更新”,useState Hook 自身就支持该特性。

跳过 state 更新的怪异行为

有趣的事实:如果你怀疑上面的代码示例和 React 文档, 并且亲自去验证它如何运行的,然后把 console.log 放在了 render 函数中,最终会得到一个意想不到的结果:

const Parent = () => {
  const [state, setState] = useState(0);

  console.log('Log parent re-renders');

  return (
    <>
      <button onClick={() => setState(1)}>Click me</button>
    </>
  )
}

你会发现,第一次点击 button 时,触发了 console.log,这是符合预期的,因为状态从 0 变化为 1。但是第二次点击 button,状态从 1 变化为 1,却再次触发了 console.log。而第三次和之后的点击就不会再触发 console.log。

事实上,这是一个特性,而不是 bug:React 这样处理的原因是,只有再次渲染之后才能确保在各种场景中都能够安全跳过。“跳过”在这里的真正含义是,如果 State Hook 更新后的 state 与当前的 state 相同时,React 将跳过子组件的渲染并且不会触发 effect 的执行。但是,为了以防万一,React 还是会在第一次触发 Parent 的 render 函数。可以查看相关文档了解更多内容。

总结

以上是本文的所有内容,希望您在比较过去和未来的过程中获得快乐,并在这个过程中学到一些有用的东西。最后梳理一下本文的主要内容:

  • 从 PureComponent 迁移到函数组件,用 React.memo 包裹组件可提供与 PureComponent 相同的重新渲染行为。
  • shouldComponentUpdate 支持的 props 对比逻辑,在 React.memo 中被重写为一个更新函数
  • 在函数组件中不必再关心不必要的状态更新 - React 已经为我们处理好了
  • 当在函数组件中使用纯组件时,把访问 state 的函数传递给 props 会比较复杂,因为函数组件中没有类的实例。但是我们可以通过以下方法替代:
    • useCallback
    • setState 的更新函数
    • ref 引用 state 数据
  • 数组和对象作为“纯”组件 的 props 时,PureComponent 和 React.memo 组件都需要对其做记忆化

微信搜索 ikoofe, 关注公众号「KooFE前端团队」阅读最新技术文章。