一起读博客系列 > 我的React应用太慢了怎么办?

 

引子

近来总是有种很强烈的危机感,好像脱离了校园进入公司以后,技术实力不升反降。为了督促自己每天学些新东西,保持自己的竞争力,故而开设了一个“一起读博客”的系列,希望能从其他优秀的开发者那里学到经验,持续提高自己。本篇博客就是对该博客的整理和讲解。

尽管本文是对原博客的整理,但我不是复读机,我写的内容也不是单纯地把原文从英文翻译成中文,我加入了很多自己的思考以及独特的解读。同时,原文中有些东西我可能不会提到,原文并未提到但我觉得很重要或者值得思考的东西我也会在本文中做额外补充。

1. State Colocation

在使用state时,一个重要的原则是,尽量把state放在最需要它的那个层次。也就是说,如果一个state可以放在子组件里,那就不要放在父组件里。为什么呢?因为当一个prop或者state发生了变化的时候,React会重新渲染所有同一层的组件。而当同一层的组件里有渲染耗时较长的组件,那么React应用就会变得很慢。

一个具体的例子如下,原博客里有可交互的代码,以下代码是我选取的一个比较重要的片段:

<div>
  <button onClick={handleClick}>Pokemons</button>
  <Menu
    id="simple-menu"
    anchorEl={anchorEl}
    keepMounted
    open={Boolean(anchorEl)}
    onClose={handleClose}
  >
    {pokemons.map(pokemon => {
      return <MenuItem onClick={handleClose}>{pokemon}</MenuItem>;
    })}
  </Menu>
  <br />
  Sitewide search:
  <input
    type="text"
    value={inputValue}
    onChange={event => setInputValue(event.target.value)}
  />
</div>

我们可以看到,input组件和Menu组件处在同一个层次。当我们改变了input组件中的inputValue的时候,input组件将会被重新渲染。由于和input组件处于同一层次,Menu组件也会被重新渲染 (即便传给Menu组件的prop没有发生任何变化)。我们可以注意到,Menu组件每次渲染时需要读取一个数组,假如这个数组非常大,这种不必要的渲染开销就会严重的降低React应用的性能!

那么正确的做法是什么呢?

我们把input这部分包裹在一个新的组件里,这样,相对于修改前,它的state就处于子组件中,与Menu组件不再是同一层次,也就避免了同层次组件重新渲染的问题。

代码如下:

<div>
  <button onClick={handleClick}>Pokemons</button>
  <Menu
    id="simple-menu"
    anchorEl={anchorEl}
    keepMounted
    open={Boolean(anchorEl)}
    onClose={handleClose}
  >
    {pokemons.map(pokemon => {
      return <MenuItem onClick={handleClose}>{pokemon}</MenuItem>;
    })}
  </Menu>

  <SearchInput />
</div>

<SearchInput />这个新组件包含了之前input组件的全部内容,同时又避免了不必要的渲染造成的性能问题。

2. Fixing Slow Renders

作者指出这部分的优化仅针对于基于Hook的函数式组件。

function SomeComponent() {
  // Everything in here including the jsx being returned is considered a part of the render function.
  const [someState, setSomeState] = React.useState('');
  return <div>{someState}</div>;
}

“在一个函数式组件里,不仅仅是JSX部分,其余所有代码也都是渲染函数的一部分。”这句话其实就是在说,当React重新渲染你的函数式组件的时候,不仅仅是JSX部分要重新执行,函数体内的其他代码也会被重新执行。这样就会出现一个问题,假设在返回JSX之前,我们有一行代码进行了非常耗时的运算,但这个运算的结果是不变的,那我们每次渲染都会在这行代码上浪费时间且毫无意义,因为运算结果不变或者几乎不变。

我们最直接的解决办法,就是将这行代码的结果存起来,这样,等第二次执行它的时候,如果依赖项没变,则不需要重新计算,我们就可以直接调用缓存里的结果,节约时间提升渲染的性能。

巧了!React里恰好就提供了这样的一个hook函数,React.useMemo()。它可以在依赖项不变的时候帮我们缓存运算结果。

注意,依赖项不变才会缓存结果,这是因为,依赖项一旦变化,我们就知道这个计算需要重新执行,以前的结果需要被丢弃掉。这个道理谁都懂,但是JS有一个陷阱。我们知道,在JS语言中,{} === {}返回的是false,不仅如此,[] === []返回的也是false。可这又能说明什么呢?我给大家举个例子大家就能懂了。

没有”隐患”的代码

// numOfChildren = 2
const familyCosts = React.useMemo(() => calculate(numOfChildren), [numOfChildren]);

代码如上,假设我们要缓存一个家庭全部开销的执行结果,依赖项是孩子的数量。也就是说,如果孩子的数量发生了变化,那么家庭开销就需要重新被计算。假设该家庭积极响应国家政策,在开放三胎的第一时间就又生了一个,此时numOfChildren发生了变化,家庭开销就会重新计算;与此相对,如果孩子的数量没有发生变化,我们假设原来这个家庭有2个孩子,那么numOfChildren每次都是2,这个函数就永远不会被重新执行。

为什么呢?我们从JS的角度来看:

JS在对传进来的依赖项进行比较之后,JS发现,2 === 2返回了true,说明依赖项没改变,那我就调用缓存;

此时三胎开放了,JS发现,3 === 2返回了false,说明依赖项改变了,那就重新计算。

聪明的小伙伴或许已经发现了什么,没错,当依赖项是一个值的时候,例如number,或者boolean,不会出现什么“诡异”的事情,可如果依赖项是一个object或者一个Array的时候,我们的麻烦就来了,因为尽管你的Array并未发生任何变化,JS也觉得它发生了变化,从而不会调用缓存,而会重复计算!

有”隐患”的代码

// nameOfChildren = [“张三”,“罗翔”]
const familyCosts = React.useMemo(() => calculate(nameOfChildren), [nameOfChildren]);

例如,我们把numOfChildren换成nameOfChildren,不再传入孩子的数量,而传入孩子的姓名列表,如[“张三”,“罗翔”],那么当你的组件再次被渲染的时候,同样的列表传入,你认为依赖项没变,可JS认为依赖项变了,而你说了又不算,JS就会重新计算家庭开销,造成React应用运行缓慢。

3. Fixing Unnecessary Renders using React.memo

原文中的第三部分讲的依然是缓存的问题,提出了两个新的函数。同时,作者也指出了我在本文第二部分所提到的那个JS的“陷阱”,只不过这次的陷阱表现的更加“隐蔽”。

代码如下:

export default function App(props) {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const [inputValue, setInputValue] = React.useState("");

  const handleClick = event => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <button onClick={handleClick}>Pokemons</button>
      <Menu
        id="simple-menu"
        anchorEl={anchorEl}
        keepMounted
        open={Boolean(anchorEl)}
        onClose={handleClose}
      >
        {pokemons.map(pokemon => {
          return <MenuItemMemo onClick={handleClose}>{pokemon}</MenuItemMemo>;
        })}
      </Menu>
      <br />
      Sitewide search:
      <input
        type="text"
        value={inputValue}
        onChange={event => setInputValue(event.target.value)}
      />
    </div>
  );
}

由于业务逻辑的限制,很多时候我们无法采用文中提到的第一种方法,而不得不将state与其他组件放在同一个层次上。此时,提高渲染性能的唯一方法似乎就只有缓存这一种了。(之所以说“似乎”,是因为我们还可以从代码本身的写法进行提升,比如能写成O(n)复杂度的算法,就不要写成O(n*n))

由上面的代码可知,Menu组件由于和input同处一个层级,inputValue的变化会触发Menu组件的再次渲染。而由于业务逻辑的限制,我们不能采取本文提到的第一种方法,那我们怎么办?我们可以将整个Menu组件缓存起来!!!怎么搞?用React.memo()。我们在上面代码的基础上,添加一行代码即可:

const MenuItemMemo = React.memo(MenuItem);

添加完之后的效果,大家可以去原博客里尝试一下。我这里直接说结果:“没有任何效率提升!!!

明明我们已经缓存了整个组件,可为什么还是没有效率提升呢?还记得我在第三部分最开始说的,这次的陷阱更加隐蔽了吗?

让我们一起,按照第二部分的思路来思考这个问题:

首先,性能没有提升,说明缓存并没有起作用。缓存没有起作用,说明JS觉得依赖项持续在发生变化,所以不会调用缓存结果而是重新计算。好的,分析到这里,我们已经快接近事情的真相了。我们接下来就是找到,在应用React.memo()的情况下,Menu组件的依赖项究竟是谁。这里不卖关子了,依赖项就是Menu组件的props,具体来说,是onClose={handleClose}部分。为什么不是其他部分?因为其他部分的值都没有发生变化!尽管它们也是依赖项,但它们在当前的状况里是无关的依赖项。

现在我们只关注handleClose这个函数,看看它在整个函数式组件中的定义部分,代码如下:

const handleClose = () => {
  setAnchorEl(null);
};

我们知道,在两次渲染中,JS觉得上面这个函数的定义发生了变化,所以才不调用缓存而是重新计算。为什么JS会这么认为呢?其实还是一样的道理,在JS的世界里,() => {} === () => {}的返回值依然是false

如果严谨些,其实应该这样写(() => {}) === (() => {})

所以,JS认为我们在不断地重定义这一个函数,所以才没有帮我们调用缓存!而React里恰好就有一个函数可以帮我们缓存函数,这个函数就是React.useCallback()

我们把上面的代码改写成如下代码,性能就终于可以提升了!

const handleClose = React.useCallback(() => {
    setAnchorEl(null);
  }, []);

在原文的最后,作者提醒我们,React.memo()通常都会与React.useMemo()或者React.useCallback()一起使用。这样其实挺麻烦的,要考虑的事情也很多。因此,不到万不得已,不要用这几个函数!