【译】缓存 React 中事件监听来提升性能

1,226 阅读9分钟

原文链接:Cache your React event listeners to improve performance.
github 的地址 欢迎 star!

前言

在 JavaScript 中对象和函数是怎么被引用好像不被人重视的,但它却直接影响了 React 的性能。假设你分别创造了两个完全相同的函数,它们还是不相等的。如下:

const functionOne = function() { alert('Hello world!'); };
const functionTwo = function() { alert('Hello world!'); };
functionOne === functionTwo; // false

如果你把一个早已经存在的函数赋值给一个变量,比较它们时,你又会发现:

const functionThree = function() { alert('Hello world!'); };
const functionFour = functionThree;
functionThree === functionFour; // true

对象也是同样的情况。(记住 JavaScript 中函数即对象)

const object1 = {};
const object2 = {};
const object3 = object1;
object1 === object2; // false
object1 === object3; // true

如果你其他语言编程经验,你应该熟悉指针的。每次你创建一个对象,计算机都会分配一些内存储存它。当我声明 object1 = {},会在内存分配空间 object1 的变量。object1 又指向了储存 {} 那块空间的地址。当我又声明了 object2 = {},又会在内存中开辟另一个空间存储这个新的 {},将 object2 的变量指向了那块空间的地址。所以 object1object2 指向的地址是不匹配的,这也就是为什么两个变量比较不相等的原因。尽管两个变量指向的地址的内容的键-值是一致的,但它们代表的地址指针是不一样的。

当我进行赋值 object3 = object1,其实我是把 object3object1 指向了内存中同一块空间的地址。 它不是一个新的对象。你可以这样验证:


const object1 = { x: true };
const object3 = object1;
object3.x = false;
object1.x; // false

这个例子中,在内存中创建一个对象,object1 指向了那个对象的地址。把 object1 赋值给 object3 的时候,object3 也指向了同一个对象的地址。当改变 object3 的时候,改变了它指向的内存空间的对象的键-值, 那么其它所有引用到这个内存空间对象的地方都会发生改变。故 object1 也就会发生相同的变化。

对于初级开发者,这是一个常见的错误,需要尽量深入的去了解它(本文没有深入涉及,可以看看《JavaScript高级程序设计》);这篇文章主要是针对 React 性能进行讨论的,可能有很多经验的开发者都没有考虑过引用类型变量对 React 性能的影响。

你会疑惑变量引用会影响 React 吗? React 是一个性能很高,减少渲染时间的智能的库:如果组件的 state 和 props 没有改变,那么 render 的输出也不会改变。当然,所有的值都相等,根本不需要改变。假设没有值改变, render 必须返回相同的输出,因此没有必要花费时间重新执行。这也是 React 快速的原因,它仅仅在需要的时候才 render。

React 确定组件 props 和 state 的值前后是否相等,用了 JavaScript 中简单比较 == 的操作符进行的。 React 比较它们是否相等不是对对象进行浅(shallow )比较或者深(deep)比较。浅比较用来描述比较对象的每个键值对的术语,通俗点,一般而言是对对象,遍历它的枚举属性,依次用Object.is()对对象每个键对应的值进行比较,全部相等才判断为相等。深比较是更进一步,如果这个对象的键值对的值是一个对象,则继续对那个值进行严格的相等验证(继续用 Object.is()对那个对象的每一个键的值判断),直到没有对象为止,全部深层次的比较。React 不是如此,它是比较 props 和 state 的引用是否改变。(注意 React 中的 PureComponent 是对 props 和 state 进行的浅比较)。

假如你改变了组件的 props,从{ x: 1}变到另外一个对象 { x: 1}, React 是会重新 render,因为两个对象在内存中的地址不一样。假如你把组件 props 从 object1(上面例子中)变成 boject3, React 是不会重新 render 的,因为两个对象是同一个的引用。

在 JavaScript 中,函数也是这种特性(函数即对象)。假如 React 组件 接受了一个功能相同但内存地址不同的函数,它也会重新 render。如果 React 接受相同功能的函数引用,它就不会重新 render。

不幸的是,这是我在 code review 中遇到的常见场景:

class SomeComponent extends React.PureComponent {

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={() => alert('!')} />
      </div>
    );
  }
}

这是一个非常简单的组件。它是一个按钮,当点击的时候,它会 alert('!')instructions 属性告诉你是否应该点击它,这是由 SomeComponent 的 prop 中的 do 来控制的。

每次当 SomeComponent 重新 render (例如 do 从 true 变成 false),Button 组件也会重新 render。onClick 的事件尽管都是一样的,但每次 render 调用都会重新创建。每次 render,一个新的函数在内存中储存,当这个新的内存地址的引用传递给 Button 组件的时候,Button 组件就会重新渲染,尽管它的输出没有什么改变。

如何修复

如果函数没有依赖于你的组件(没有用 this),你可以在组件的外面的定义函数。你所有组件的实例都将会共享相同的一份函数的引用,假定那个函数在所有用例中功能都相同。

const createAlertBox = () => alert('!');

class SomeComponent extends React.PureComponent {

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={createAlertBox} />
      </div>
    );
  }
}

与先前的例子相反的,每次 render,createAlertBox 都是指向了内存中相同的地址,Button 组件绝不会重新 render。

虽然 Button 可能是很小的,渲染很快的组件,(你感受不出来),但是当你在更大的,复杂的组件上看到这些内嵌的函数定义时,你能真实地感受到性能的影响。这是一个非常棒的又简单的实践:不要再 render 的方法里面去定义这些函数。

如果函数依赖于你的组件,你不在组件外部定义它,但你可以把组件的方法作为事件处理函数:

class SomeComponent extends React.PureComponent {

  createAlertBox = () => {
    alert(this.props.message);
  };

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={this.createAlertBox} />
      </div>
    );
  }
}

当然,每个 SomeComponent组件的实例的弹出框是不同的,这是无法避免的,每一个 SomeComponentButton 组件的点击事件监听器必须要唯一的(不能互相干扰)。通过调用 createAlertBox的方法,你不用关心 SomeComponent是否重新 render,props 的 message 是否改变,Button 组件都不会重新渲染,因为它永远指向是组件实例的那个方法,这样能减少不必要渲染,提高你应用的性能。

但是如果我的函数是动态生成的,怎么处理呢?

(进阶)的修复

作者笔记:作者不假思索的写下下面的例子,来反复引用内存中相同的函数。这些例子旨在让你更容易地理解引用。作者建议你们阅读文章这一部分内容来理解引用,更希望你们在评论处给出你自己的理解。一些读者慷慨地给出了更好的实现,其中考虑到了缓存失效和 React 中内置的内存管理器。

在单个组件的动态事件处理中,这是一种很常见不唯一的用法,像对一个数组遍历:

class SomeComponent extends React.PureComponent {
  render() {
    return (
      <ul>
        {this.props.list.map(listItem =>
          <li key={listItem.text}>
            <Button onClick={() => alert(listItem.text)} />
          </li>
        )}
      </ul>
    );
  }
}

在这个例子中,你创建了 SomeComponent,声明了动态的数量不固定的 Button,创建了动态的事件监听器,每个事件监听函数都是唯一不同的。怎么解决这个难题呢?

进行记忆,或者更简单的说法,缓存。对于每一个唯一的值,创建并缓存函数;对于那个唯一值的所有将来的引用,都返回以前缓存的那个函数。

下面展示了我如何实现上面的方法:

class SomeComponent extends React.PureComponent {

  // Each instance of SomeComponent has a cache of click handlers
  // that are unique to it.
  clickHandlers = {};

  // Generate and/or return a click handler,
  // given a unique identifier.
  getClickHandler(key) {

    // If no click handler exists for this unique identifier, create one.
    if (!Object.prototype.hasOwnProperty.call(this.clickHandlers, key)) {
      this.clickHandlers[key] = () => alert(key);
    }
    return this.clickHandlers[key];
  }

  render() {
    return (
      <ul>
        {this.props.list.map(listItem =>
          <li key={listItem.text}>
            <Button onClick={this.getClickHandler(listItem.text)} />
          </li>
        )}
      </ul>
    );
  }
}

list 数组里面的每一个目标值都是通过 getClickHandler 的方法调用。这个方法在第一次用参数调用它时,就会创建一个函数对应那个值,然后返回那个创建的函数。所有将来对那个函数的调用都不用再创建新的函数,相反地,它将会返回先前在内存中创建的函数的引用。

结果,重新渲染 SomeComponent 将不会导致 Button 的重新渲染。

当它们不只由一个变量决定时,你需要发挥自己的聪明才智,给每一个事件处理生成一个唯一标志。当然,它并不比简单地为返回的每个 JSX 对象生成唯一的 key 难多少。

使用索引 index 作为唯一标志符是需要警告的:如果这个列表 list 改变顺序或者删除某一项你将会得到错误的结果。当数组从 [ 'soda', 'pizza' ] 变为 [ 'pizza' ], 你缓存了你的事件监听器像这样 listeners[0] = () => alert('soda'),你会发现,当你点击索引是0的 pizza 的 Button时,弹出来是 soda。 这也是 React 建议不要将数组的索引作为 key 的原因。

最后?

如果你喜欢这篇文章,请点一下赞哦。如果你有任何问题或者更好的建议,请在评论区留言。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢!

评论区

  1. 网友一:和 memorize-decorator 库一起使用是非常棒的

  1. 网友2:这种说法听过很多次,但性能提升从来没有被量化。应该需要具体的例子衡量优化前后的影响。

我的观点

在这个问题不构成性能的主要因素时,可以直接用闭包(或者bind)的方式来解决动态事件监听问题(可以不做优化);影响性能的时候才进行缓存。这篇文章主要是认识一般的 React 组件更新是直接比较 props 和 state 的引用。而 PureComponent 组件则是对 props 和 state 分别前后进行浅比较。这才是我想表达的。

参考

  1. 你真的了解浅比较么?