React 源码漂流(三)之 PureComponent

1,861 阅读8分钟

一、PureComponent

PureComponent 最早在 React v15.3 版本中发布,主要是为了优化 React 应用而产生。

class Counter extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

在这段代码中, React.PureComponent 会浅比较 props.color 或 state.count 是否改变,来决定是否重新渲染组件。

  • 实现

    React.PureComponentReact.Component 类似,都是定义一个组件类。不同是 React.Component 没有实现 shouldComponentUpdate(),而 React.PureComponent 通过 props 和 state 的 浅比较 实现了。

  • 使用场景

    React.Component 的 props 和 state 均为基本类型,使用 React.PureComponent 会节省应用的性能

  • 可能出现的问题及解决方案

    当props 或 state 为 复杂的数据结构 (例如:嵌套对象和数组)时,因为 React.PureComponent 仅仅是 浅比较 ,可能会渲染出 错误的结果 。这时有 两种解决方案

    • 知道 有深度数据结构更新时,可以直接调用 forceUpdate 强制更新
    • 考虑使用  immutable objects (不可突变的对象),实现快速的比较对象
  • 注意

    React.PureComponent 中的 shouldComponentUpdate() 将跳过所有子组件树的 prop 更新(具体原因参考 Hooks 与 React 生命周期:即:更新阶段,由父至子去判断是否需要重新渲染),所以使用 React.PureComponent 的组件,它的所有 子组件也必须都为 React.PureComponent

二、PureComponent 与 Stateless Functional Component

对于 React 开发人员来说,知道何时在代码中使用 Component,**PureComponent ** 和 Stateless Functional Component 非常重要。

首先,让我们看一下无状态组件。

无状态组件

输入输出数据完全由 props 决定,而且不会产生任何副作用。

const Button = props =>
  <button onClick={props.onClick}>
    {props.text}
  </button>

无状态组件可以通过减少继承 Component 而来的生命周期函数而达到性能优化的效果。从本质上来说,无状态组件就是一个单纯的 render 函数,所以无状态组件的缺点也是显而易见的。因为它没有 shouldComponentUpdate 生命周期函数,所以每次 state 更新,它都会重新绘制 render 函数。

React 16.8 之后,React 引入 Hooks 。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

何时使用 PureComponent

PureComponent 提高了性能,因为它减少了应用程序中的渲染操作次数,这对于复杂的 UI 来说是一个巨大的胜利,因此建议尽可能使用。此外,还有一些情况需要使用 Component 的生命周期方法,在这种情况下,我们不能使用无状态组件。

何时使用无状态组件?

无状态组件易于实施且快速实施。它们适用于非常小的 UI 视图,其中重新渲染成本无关紧要。它们提供更清晰的代码和更少的文件来处理。

三、PureComponent 与 React.memo

React.memo 为高阶组件。它实现的效果与 React.PureComponent 相似,不同的是:

  • React.memo 用于函数组件
  • React.PureComponent 适用于 class 组件
  • React.PureComponent 只是浅比较 propsstateReact.memo 也是浅比较,但它可以自定义比较函数

React.memo

function MyComponent(props) {
  /* 使用 props 渲染 */
}

// 比较函数
function areEqual(prevProps, nextProps) {
  /*
  如果把 nextProps 传入 render 方法的返回结果与
  将 prevProps 传入 render 方法的返回结果一致则返回 true,
  否则返回 false
  返回 true,复用最近一次渲染
  返回 false,重新渲染
  */
}

export default React.memo(MyComponent, areEqual);
  • React.memo 通过记忆组件渲染结果的方式实现 ,提高组件的性能
  • 只会对 props 浅比较,如果相同,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。
  • 可以将自定义的比较函数作为第二个参数,实现自定义比较
  • 此方法仅作为性能优化的方式而存在。但请不要依赖它来“阻止”渲染,这会产生 bug。
  • 与 class 组件中 shouldComponentUpdate() 方法不同的是,如果 props 相等,areEqual会返回 true;如果 props 不相等,则返回 false。这与 shouldComponentUpdate 方法的返回值相反。

四、使用 PureComponent 常见误区

误区一:在渲染方法中创建函数

如果你在 render 方法里创建函数,那么使用 props 会抵消使用 React.PureComponent 带来的优势。因为每次渲染运行时,都会分配一个新函数,如果你有子组件,即使数据没有改变,它们也会重新渲染,因为浅比较 props 的时候总会得到 false

例如:

// FriendsItem 在父组件引用样式
<FriendsItem
  key={friend.id}
  name={friend.name}
  id={friend.id}
  onDeleteClick={() => this.deleteFriends(friend.id)}
/> 
// 在父组件中绑定
// 父组件在 props 中传递了一个箭头函数。箭头函数在每次 render 时都会重新分配(和使用 bind 的方式相同)

其中,FriendsItemPureComponent

// 其中 FriendsItem 为 PureComponent
class FriendsItem extends React.PureComponent {
  render() {
    const { name, onDeleteClick } = this.props
    console.log(`FriendsItem:${name} 渲染`)
    return (
      <div>
        <span>{name}</span>
        <button onClick={onDeleteClick}>删除</button>
      </div>
    )   
  }
}
// 每次点击删除操作时,未删除的 FriendsItem 都将被重新渲染

点击查看在线实例

这种在 FriendsItem 直接调用 () => this.deleteFriends(friend.id),看起来操作更简单,逻辑更清晰,但它有一个有一个最大的弊端,甚至打破了像 shouldComponentUpdatePureComponent 这样的性能优化。

这是因为:父组件在 render 声明了一个函数onDeleteClick,每次父组件渲染都会重新生成新的函数。因此,每次父组件重新渲染,都会给每个子组件 FriendsItem 传递不同的 props,导致每个子组件都会重新渲染, 即使 FriendsItemPureComponent。 

避免在 render 方法里创建函数并使用它。它会打破了像 shouldComponentUpdate 和 PureComponent 这样的性能优化。

要解决这个问题,只需要将原本在父组件上的绑定放到子组件上即可。FriendsItem 将始终具有相同的 props,并且永远不会导致不必要的重新渲染。

// FriendsItem 在父组件引用样式
<FriendsItem 
  key={friend.id} 
  id={friend.id} 
  name={friend.name} 
  onClick={this.deleteFriends} 
/>

FriendsItem:

class FriendsItem extends React.PureComponent {
  onDeleteClick = () => {
    this.props.onClick(this.props.id)
  } // 在子组件中绑定
  render() {
    const { name } = this.props
    console.log(`FriendsItem:${name} 渲染`)
    return (
      <div>
        <span>{name}</span>
        <button onClick={this.onDeleteClick}>删除</button>
      </div>
    )   
  }
}
// 每次点击删除操作时,FriendsItem 都不会被重新渲染

点击查看在线实例

通过此更改,当单击删除操作时,其他 FriendsItem 都不会被重新渲染了 👍

误区二:在渲染方法中派生 state

考虑一个文章列表,您的个人资料组件将从中显示用户最喜欢的 10 个作品。

render() {
  const { posts } = this.props
  // 在渲染函数中生成 topTen,并渲染
  const topTen = [...posts].sort((a, b) => 
    b.likes - a.likes).slice(0, 9)
  return //...
}
// 这会导致组件每次重新渲染,都会生成新的 topTen,导致不必要的渲染

topTen每次组件重新渲染时都会有一个全新的引用,即使 posts 没有更改,派生 state 也是相同的。

这个时候,我们应该将 topTen 的判断逻辑提取到 render 函数之外,通过缓存派生 state 来解决此问题。

例如,在组件的状态中设置派生 state,并仅在 posts 已更新时更新。

componentWillMount() {
  this.setTopTenPosts(this.props.posts)
}
componentWillReceiveProps(nextProps) {
  if (this.props.posts !== nextProps.posts) {
    this.setTopTenPosts(nextProps.posts)
  }
}
// 每次 posts 更新时,更新派生 state,而不是在渲染函数中重新生成
setTopTenPosts(posts) {
  this.setState({
    topTen: [...posts].sort((a, b) => b.likes - a.likes).slice(0, 9)
  })
}

总结

在使用 PureComponent 时,请注意:

  • 突变一般是不好的,但在使用 PureComponent 时,问题会更加复杂。
  • 不要在渲染方法中创建新函数、对象或数组,这不导致项目性能显著降低。

五、PureComponent 源码解析

// 新建了空方法ComponentDummy ,ComponentDummy 的原型 指向 Component 的原型;
function ComponentDummy() {}
ComponentDummy.prototype = Component.prototype;

/**
 * Convenience component with default shallow equality check for sCU.
 */
function PureComponent(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  this.refs = emptyObject;
  this.updater = updater || ReactNoopUpdateQueue;
} // 解析同 React.Component,详细请看上一章

/**
 * 实现 React.PureComponent 对 React.Component 的原型继承
 */
/**
 * 用 ComponentDummy 的原因是为了不直接实例化一个 Component 实例,可以减少一些内存使用
 *
 * 因为,我们这里只需要继承 React.Component 的 原型,直接 PureComponent.prototype = new Component() 的话
 * 会继承包括 constructor 在内的其他 Component 属性方法,但是 PureComponent 已经有自己的 constructor 了,
 * 再继承的话,造成不必要的内存消耗
 * 所以会新建ComponentDummy,只继承Component的原型,不包括constructor,以此来节省内存。
 */
const pureComponentPrototype = (PureComponent.prototype = new ComponentDummy());

// 修复 pureComponentPrototype 构造函数指向
pureComponentPrototype.constructor = PureComponent;

// Avoid an extra prototype jump for these methods.
// 虽然上面两句已经让PureComponent继承了Component
// 但多加一个 Object.assign(),能有效的避免多一次原型链查找
Object.assign(pureComponentPrototype, Component.prototype);

// 唯一的区别,原型上添加了 isPureReactComponent 属性去表示该 Component 是 PureComponent
// 在后续组件渲染的时候,react-dom 会去判断 isPureReactComponent 这个属性,来确定是否浅比较 props、status 实现更新 
/** 在 ReactFiberClassComponent.js 中,有对 isPureReactComponent 的判断
 if (ctor.prototype && ctor.prototype.isPureReactComponent) {
    return (
      !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
    );
  }
 */
pureComponentPrototype.isPureReactComponent = true;

这里只是 PureComponent 的声明创建,至于如何实现 shouldComponentUpdate() ,核心代码在:

// ReactFiberClassComponent.js
function checkShouldComponentUpdate(
  workInProgress,
  ctor,
  oldProps,
  newProps,
  oldState,
  newState,
  nextContext,
) {
  // ...
  if (ctor.prototype && ctor.prototype.isPureReactComponent) {
    // 如果是纯组件,比较新老 props、state
    // 返回 true,重新渲染,
    // 即 shallowEqual props 返回 false,或 shallowEqual state 返回 false
    return (
      !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
    );
  } 
  return true;
}

shallowEqual.js

/**
 * 通过遍历对象上的键并返回 false 来执行相等性
 * 在参数列表中,当任意键对应的值不严格相等时,返回 false。
 * 当所有键的值严格相等时,返回 true。
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  // 通过 Object.is 判断 objA、objB 是否相等
  if (is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }
    
  // 参数列表
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);
    
  // 参数列表长度不相同
  if (keysA.length !== keysB.length) {
    return false;
  }

  // 比较参数列表每一个参数,但仅比较一层
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}

附:Object.is(来自MDN)

Object.is() 判断两个值是否相同

这种相等性判断逻辑和传统的 == 运算不同,== 运算符会对它两边的操作数做隐式类型转换(如果它们类型不同),然后才进行相等性比较,(所以才会有类似 "" == false 等于 true 的现象),但 Object.is 不会做这种类型转换。

这与 === 运算符的判定方式也不一样。=== 运算符(和== 运算符)将数字值 -0+0 视为相等,并认为 Number.NaN 不等于 NaN

如果下列任何一项成立,则两个值相同:

  • 两个值都是 undefined
  • 两个值都是 null
  • 两个值都是 true 或者都是 false
  • 两个值是由相同个数的字符按照相同的顺序组成的字符串
  • 两个值指向同一个对象
  • 两个值都是数字并且
    • 都是正零 +0
    • 都是负零 -0
    • 都是 NaN
    • 都是除零和 NaN 外的其它同一个数字

系列文章

想看更过系列文章,点击前往 github 博客主页

走在最后,欢迎关注:前端瓶子君,每日更新

前端瓶子君