【译】如何写出高性能的React代码

475 阅读7分钟

原文地址

React和性能是个有趣的议题,并且在很长一段时间内引来了极大的争论。这篇文章中对一些场景给出了一般性的性能优化的建议。

本文中的案例线给出了一些我们常写的案例,然后重构这些常用的代码使用性能优化的思想,并从每一步中提出一些通用的建议,并在最后给出了一定的对比结果。

谈及React和性能优化,90%的场景都是由于React“大量重复渲染”而引起的,

本文将不注重具体页面的实现方案,将一些对一些长期快速增长和大型的应用中的不要做的必须做的一些React优化手段进行总结:

1. Page页面优化(父组件优化相关)

1.1 初始代码

因为React的更新是基于组件的参数状态变更时变更的。在Page组件中当setSelectedCountrysetSavedCountry这两个方法被调用状态会变化,导致页面发生重新渲染。CountriesListSelectedCountry这两个组件也会因为props的变化而发生重新渲染。

export const Page = ({ countries }: { countries: Country[] }) => {
  const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
  const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);

  return (
    <>
      <h1>Country settings</h1>
      <div css={contentCss}>
        <CountriesList
          countries={countries}
          onCountryChanged={(c) => setSelectedCountry(c)}
          savedCountry={savedCountry}
        />
        <SelectedCountry
          country={selectedCountry}
          onCountrySaved={() => setSavedCountry(selectedCountry)}
        />
      </div>
    </>
  );
};

1.2 性能优化写法

优化规则一:使用useCallback来减少不必要的渲染

export const Page = ({ countries }: { countries: Country[] }) => {
  // ... same as before

  const onCountryChanged = useCallback((c) => setSelectedCountry(c), []);
  const onCountrySaved = useCallback(() => setSavedCountry(selectedCountry), []);

  return (
    <>
      ...
        <CountriesList
          onCountryChanged={onCountryChange}
        />
        <SelectedCountry
          onCountrySaved={onCountrySaved}
        />
      ...
    </>
  );
};

此时我们发现这个优化并没有生效,其原因是,当父组件重新渲染,子组件不过是否props更新了,都会重新进行渲染。所以其实我们看似把一些props通过useCallbackuseMemo包了一下,其实是在骗自己,没什么用

const CountriesList = () => {
  console.log("Re-render!!!!!");
  return <div>countries list, always re-renders</div>;
};

export const Page = ({ countries }: { countries: Country[] }) => {
  const [counter, setCounter] = useState<number>(1);

  return (
    <>
      <h1>Country settings</h1>
      <button onClick={() => setCounter(counter + 1)}>
        Click here to re-render Countries list (open the console) {counter}
      </button>
      <CountriesList />
    </>
  );
};

规则一

方法一:如果我们想把一个组件的子组件抽出来,这个时候我们需要通过行内元素并使用useMemo包一层,不然其实还是会因为父组件的重新渲染导致子组件被重新渲染的,因为这些钩子只有当依赖变化的时候,对应的组件才会被更新~

方法二:函数组件,通过React.memo包一层,这样会自动做props的浅比较。

import { useState, useMemo, memo } from "react";
import { Country } from "./types";

const CountriesList = memo(({ counter }: any) => {
  console.log("a1 -> Re-render!!!!!");
  return <div>{counter}</div>;
});

export const Page = ({ countries }: { countries: Country[] }) => {
  const [counter, setCounter] = useState<number>(1);
  const [count2, setCounter2] = useState<number>(2);

  const Countries = useMemo(() => {
    console.log("Re-render!!!!!");

    return (
      <>
        <span>{count2}</span>
        <CountriesList />
      </>
    );
  }, [count2]);

  return (
    <>
      <h1>Country settings</h1>
      <button onClick={() => setCounter(counter + 1)}>
        Click here to re-render Countries list (open the console) {counter}
      </button>
      <button onClick={() => setCounter2(count2 + 1)}>+1</button>
      <CountriesList counter={count2} />
      {Countries}
    </>
  );
};

规则二

当你的父组件管理状态,找到对应的render tree,在memo中去除掉不依赖的状态,实现最小的re-render

2 区域列表优化(列表页优化)

2.1 初始代码

type CountriesListProps = {
  countries: Country[];
  onCountryChanged: (country: Country) => void;
  savedCountry: Country;
};

export const CountriesList = ({
  countries,
  onCountryChanged,
  savedCountry
}: CountriesListProps) => {
  const Item = ({ country }: { country: Country }) => {
    // different className based on whether this item is "saved" or not
    const className = savedCountry.id === country.id ? "country-item saved" : "country-item";

    // when the item is clicked - trigger the callback from props with the correct country in the arguments
    const onItemClick = () => onCountryChanged(country);
    return (
      <button className={className} onClick={onItemClick}>
        <img src={country.flagUrl} />
        <span>{country.name}</span>
      </button>
    );
  };

  return (
    <div>
      {countries.map((country) => (
        <Item country={country} key={country.id} />
      ))}
    </div>
  );
};

上述代码的问题是,如果我们把Item写在CountryList这个组件里面,那么每次父组件渲染的时候,这个组件都会被挂载一次,因为每次都会重新执行这个Item

理论上useEffect当没有依赖的时候,只会在挂载阶段执行一次,但是由于这种每次都会创建一个新的组件出来,如果这里有250个country的条目的话,每次父组件重新渲染的时候会重新挂载250个新的组件,开销非常的大。

2.2 性能优化写法

为了解决上面的问题,针对Item这个组件而言,可以将其抽离到CountryList外面,这样组件在父组件更新的时候会重新渲染,但不会重新挂载。

规则三

永远不要创建一个新的组件,在另外一个组件的render函数中,如果需要组件抽离需要放到对应组件的外部。

在真实的应用场景中,我们需要保持组件的健康边界,保持代码的干净和简洁。 可以优化为下面的代码:

const Item = ({ country }: { country: Country }) => {
    useEffect(() => {
      console.log("Mounted!");
    }, []);
    console.log("Render");
    return <div>{country.name}</div>;
  };

const CountriesList = ({ countries }: { countries: Country[] }) => {

  return (
    <>
      {countries.map((country) => (
        <Item country={country} />
      ))}
    </>
  );
};

2.3 实现已选择的国家

这部分代码是全文最无聊的地方,因为没什么东西可以讲的

const SelectedCountry = ({ country, onSaveCountry }: { country: Country; onSaveCountry: () => void }) => {
  return (
    <>
      <ul>
        <li>Country: {country.name}</li>
        ... // whatever country's information we're going to render
      </ul>
      <button onClick={onSaveCountry} type="button">Save</button>
    </>
  );
};

3. 最后一步:主题

3.1 基础写法

这里主要是讲对于context的优化,使用一个主题色,黑夜模式的例子。

首先创建一个Context的上下文,其具体内容如下:

type Mode = 'light' | 'dark';
type Theme = { mode: Mode };
const ThemeContext = React.createContext<Theme>({ mode: 'light' });

const useTheme = () => {
  return useContext(ThemeContext);
};

接下来在页面中使用主题色,具体代码的实现如下:

// Page.tsx
export const Page = ({ countries }: { countries: Country[] }) => {
  // same as before
  const [mode, setMode] = useState<Mode>("light");

  return (
    <ThemeContext.Provider value={{ mode }}>
      <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
      // the rest is the same as before
    </ThemeContext.Provider>
  )
}

// Item.tsx
const Item = ({ country }: { country: Country }) => {
    const { mode } = useTheme();
    const className = `country-item ${mode === "dark" ? "dark" : ""}`;
    // the rest is the same
}

上述对于主题色的使用和Context注入是很通常的写法,并没有什么特殊要讲的,接下来我们开始对其进行重构

3.2 使用性能优化思想重构

在上述的这个实现中,有四个地方可能会导致React组件的重新渲染,通常来说不能被我们忽略的一点是:

if a component uses context consumer, it will be re-rendered every time the context provider’s value is changed.

如果一个组件是Context的消费者,那么每次Context生产者的值变了的时候,都会导致组件的重新渲染

现在代码的现状

const Item = ({ country }: { country: Country }) => {
  console.log("render");
  return <div>{country.name}</div>;
};

const CountriesList = ({ countries }: { countries: Country[] }) => {
  return (
    <>
      {countries.map((country) => (
        <Item country={country} />
      ))}
    </>
  );
};

export const Page = ({ countries }: { countries: Country[] }) => {
  const [counter, setCounter] = useState<number>(1);

  const list = useMemo(() => <CountriesList countries={countries} />, [
    countries
  ]);

  return (
    <>
      <h1>Country settings</h1>
      <button onClick={() => setCounter(counter + 1)}>
        Click here to re-render Countries list (open the console) {counter}
      </button>
      {list}
    </>
  );
};

如果在没有加入Context的情况下,由于CountryList被memo了,所以其不会更新,而Item组件是CountriesList的子组件,所以也不会被更新。加入Context之后,这种生产消费的模式,导致当生产者变化后所有消费者都会被重新渲染。当生产者传入一个新的对象,这个Item组件每次都会被不必要的更新,使用Context绕过了这种memorisation的机制。

引入对应的Context后的Item,无论是否使用,组件都会被重新渲染一次,当生产者的值变了,就会被重新渲染

const Item = ({ country }: { country: Country }) => {
  const theme = useTheme();
  console.log("render");
  // 这里theme只是被引入,没有使用,但是仍然每次theme改动后都会被重新渲染
  return <div>{country.name}</div>;
};

规则四

当使用Context的时候如果参数类型不是string/number/boolean的时候,变量需要memorisation,目的是当其他状态变化的时候(非context中状态变化场景),这个时候不会让引用Context的组件重新更新

export const Page = ({ countries }: { countries: Country[] }) => {
  // same as before
  const [mode, setMode] = useState<Mode>("light");

  // memoising the object!
  const theme = useMemo(() => ({ mode }), [mode]);

  return (
    <ThemeContext.Provider value={theme}>
      <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
      // the rest is the same as before
    </ThemeContext.Provider>
  )
}

4. 总结

作者给出的最终可用版本代码:re-renders-final-good - CodeSandbox

回顾何时React会进行重复渲染:

  1. state或props改变的时候
  2. 父组件重新渲染的时候
  3. 组件使用Context且Context值变化的时候

对应上述的问题,我们应该使用上述的四条规则:

  1. 如果抽离了一个组件出去,这个时候想通过useCallback来实现非重复渲染,不要这么做,他不会生效的,正确的做法是使用useMemo包一层,让其只有在依赖改变的时候才重新渲染
  2. 当组件自己管理自己状态的时候,找到渲染树中不依赖状态的部分使用memorisation,尽可能减少重复渲染的次数
  3. 永远不要在一个render函数中创建一个新的组件
  4. 在创建Context,非number/string/boolean时候要用memorisation把对应的状态包一层,不然的话会重复渲染