【翻译】如何编写高性能的React代码:规则、模式、注意事项与禁忌

13 阅读17分钟

原文链接:www.developerway.com/posts/how-t…

作者:Nadia Makarevich

性能与React!这个话题如此有趣,却充满争议观点,众多最佳实践在短短半年内就可能彻底翻转。我们还能在此给出任何确定性结论或普遍性建议吗?

性能专家通常奉行"过早优化是万恶之源"和"先测量再优化"的准则,这大致可理解为"别修没坏的东西",确实难以反驳。但我还是要试着辩驳一下😉

我欣赏 React 的地方在于,它让实现复杂的 UI 交互变得异常简单。我不喜欢 React 的地方在于,它同样让犯下严重错误变得异常容易——这些错误往往不会立即显现。好消息是,预防这些错误同样简单,你完全可以立即编写出绝大多数情况下都高效的代码,从而大幅减少排查性能问题所需的时间和精力,因为这类问题本身就会少得多。简而言之,在 React 与性能优化领域,"过早优化"反而能成为有益之事,值得所有人践行 😉。关键在于掌握几个需警惕的模式,方能实现真正有效的优化。

因此,这正是我在这篇文章中想要证明的内容😊。我将通过逐步实现一个"真实场景"的应用来完成论证:首先采用"常规"方式,使用那些随处可见且你自己也多次用过的模式;随后在每个步骤中进行以性能为导向的重构,并从每个步骤中提炼出适用于大多数应用的通用规则;最后对比最终结果。

开始吧!

我们将为某在线商店(此前在《React开发者的高级TypeScript教程》系列中介绍过)编写"设置"页面之一。用户可在该页面从国家列表中选择目标国家,查看该国所有可用信息(如货币、配送方式等),并保存为首选国家。页面大致如下所示:

左侧将显示国家列表,包含"已保存"和"已选中"状态。点击列表项时,右侧列将展示详细信息。点击"保存"按钮后,"已选中"国家将转为"已保存"状态,并采用不同项颜色标识。

对了,当然要支持深色模式——毕竟都2022年了!

此外,考虑到90%的React性能问题可归结为"过多重渲染",本文将重点探讨如何减少重渲染。(其余10%问题包括:"渲染负载过重"和"需深入排查的异常情况"。)

首先构建应用程序结构

首先,让我们审视设计方案,划定虚拟边界,并勾勒出未来应用程序的结构框架及所需实现的组件:

  • 一个根级别的“页面”组件,用于处理“提交”逻辑和国家选择逻辑
  • 一个“国家列表”组件,用于渲染所有国家列表,未来将支持筛选和排序功能
  • “条目”组件,用于在“国家列表”中渲染单个国家信息
  • 一个“选定国家”组件,用于展示选定国家的详细信息并包含“保存”按钮

当然,这并非实现该页面的唯一方式——这正是React的魅力所在,也是其诅咒所在:万物皆可千万种方式实现,且不存在绝对的对错之分。但某些模式在快速增长或已具规模的应用中,终究会演变为"切忌如此"或"必备法则"。

让我们一起探索这些模式吧 😊

实现页面组件

现在终于到了动手编码的时候。让我们从“根”开始,实现页面组件。

首先:我们需要一个带有样式的包装器,用于渲染页面标题、"国家列表"和"选定国家"组件。

第二:我们的页面应从某个来源获取国家列表,并将其传递给 CountriesList 组件以便渲染。

第三:页面需理解"选中"国家概念,该状态将从 CountriesList 组件接收并传递至 SelectedCountry 组件。

最后:页面需理解"已保存"国家概念,该状态将从 SelectedCountry 组件接收并传递至 CountriesList 组件(未来还将发送至后端)。

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>
    </>
  );
};

这就是“页面”组件的完整实现,它是最基础的React组件,随处可见,实现过程中绝对没有任何问题。除了一个细节。好奇吗?你能发现吗?

重构页面组件——注重性能优化

我想现在大家都知道,当状态或 props 发生变化时,React 会重新渲染组件。在我们的 Page 组件中,当调用 setSelectedCountrysetSavedCountry 时,它会重新渲染。如果 Page 组件中的 countries 数组(props)发生变化,它也会重新渲染。同样的情况也适用于 CountriesListSelectedCountry 组件——当它们的任何 props 发生变化时,它们都会重新渲染。

此外,任何接触过React的人都了解JavaScript的相等性比较机制:React对props采用严格相等性比较,而内联函数每次渲染都会生成新值。这导致了一个非常普遍(但绝对错误)的误解:为减少 CountriesListSelectedCountry 组件的重新渲染,我们需要通过 useCallback 包裹内联函数来避免每次渲染时重新创建。甚至 React 官方文档也把 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}
        />
      ...
    </>
  );
};

你知道最有趣的部分是什么吗?它实际上根本不起作用。因为它忽略了 React 组件重新渲染的第三个原因:当父组件重新渲染时。无论 props 如何,只要 Page 重新渲染,CountriesList 就会重新渲染——即使它根本没有 props。

我们可以将 Page 的示例简化为这样:

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 />
    </>
  );
};

每次点击按钮时,我们都会看到 CountriesList 被重新渲染,即使它根本没有接收任何propsCodesandbox代码在此

这最终让我们得以确立本文的第一条规则:

规则 #1:若你将内联函数提取到 props 中使用 useCallback 的唯一目的只是为了避免子组件重新渲染——请不要这么做。这根本行不通。

现在,处理上述情况有几种方法,我将在此特定场景中采用最简单的一种:useMemo 钩子。其核心机制是"缓存"传入函数的计算结果,仅在依赖项发生变化时刷新缓存。若将渲染后的 CountriesList 提取为常量const list = <ComponentList />; 并应用 useMemoComponentList 组件便仅在useMemo依赖项变更时重新渲染。

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


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


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

在这种情况下永远不会发生,因为它没有任何依赖项。这种模式基本上让我能够打破“父组件重新渲染——无论如何都重新渲染所有子组件”的循环,从而掌控局面。完整示例请参见codesandbox

最需要注意的是 useMemo 的依赖项列表。若其依赖项与触发父组件重渲染的条件完全一致,则每次父组件重渲染时它都会刷新缓存,从而失去实际意义。例如,若在此简化示例中将 counter 值作为依赖项传递给 list 变量(注意:甚至不是传递给缓存组件的 props!),这将导致 useMemo 在每次状态变更时刷新自身,进而使 CountriesList 再次重渲染。

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

请参阅 codesandbox 示例

好的,这些都很棒,但具体如何应用到我们未简化的 Page 组件呢?如果我们再次仔细观察它的实现方式

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>
    </>
  );
};

我们将看到:

  • selectedCountry 状态在 CountriesList 组件中从未被使用
  • savedCountry 状态在 SelectedCountry 组件中从未被使用

这意味着当 selectedCountry 状态改变时,CountriesList 组件完全无需重新渲染!savedCountry 状态与 SelectedCountry 组件的情况亦是如此。我只需将二者提取为变量并进行缓存,即可避免不必要的重新渲染:

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


  const list = useMemo(() => {
    return (
      <CountriesList
        countries={countries}
        onCountryChanged={(c) => setSelectedCountry(c)}
        savedCountry={savedCountry}
      />
    );
  }, [savedCountry, countries]);


  const selected = useMemo(() => {
    return (
      <SelectedCountry
        country={selectedCountry}
        onCountrySaved={() => setSavedCountry(selectedCountry)}
      />
    );
  }, [selectedCountry]);


  return (
    <>
      <h1>Country settings</h1>
      <div css={contentCss}>
        {list}
        {selected}
      </div>
    </>
  );
};

最后,这让我们得以将本文的第二条规则正式化:

规则 #2: 若组件管理状态,请找出渲染树中不依赖于变更状态的部分,并为其添加缓存以最大限度减少重渲染。

实现国家列表

现在,我们的Page组件已经准备就绪且完美无缺,是时候完善其子组件了。首先,让我们实现这个复杂的组件:CountriesList。我们已经知道,该组件需要接受国家列表,当列表中选中某个国家时触发 onCountryChanged 回调,并根据设计要求将 savedCountry 高亮显示为不同颜色。那么就从最简单的方案开始吧:

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>
  );
};

再次强调,这是最简单的组件了,实际上只做了两件事:

  • 根据接收到的 props 生成 Item(取决于 onCountryChangedsavedCountry 两个条件)
  • 通过循环为所有国家渲染该 Item

同样地,这些做法本身并无不妥,我几乎在所有地方都见过这种模式。

重构国家列表组件——兼顾性能考量

是时候重新温习React的渲染机制了。这次要探讨:当某个组件(如上文的 Item 组件)在其他组件渲染过程中被创建时会发生什么?简而言之——后果并不理想。在React看来,这个 Item 组件只是每次渲染时都会被重新创建的函数,每次渲染都会返回新的结果。因此每次渲染时,系统都会从头开始重建该函数的结果——即像常规重渲染那样比较前一帧组件状态与当前状态。它会丢弃先前生成的组件(包括其DOM树),从页面移除后,在父组件每次重渲染时都生成并挂载一个带有全新DOM树的组件。

若简化国家示例来演示此效果,其过程大致如下:

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


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

这是React中所有操作中最耗费资源的。从性能角度看,10次"常规"重渲染根本无法与全新创建组件的完整重挂载相提并论。通常情况下,使用空依赖数组的 useEffect 只会触发一次——即组件完成挂载并完成首次渲染之后。此后React将启动轻量级重渲染机制,组件无需从头创建,仅在必要时更新(这正是React高速运行的核心原理)。但当前场景例外——请查看此codesandbox示例,打开控制台点击"re-render"按钮,每次点击都会触发250次渲染与挂载操作。

解决方案显而易见且简单:只需将 Item 组件移出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} />
      ))}
    </>
  );
};

现在在我们的简化codesandbox中,父组件每次重新渲染时都不会触发挂载操作。

额外好处是,此类重构有助于保持不同组件间的健康边界,使代码更简洁明了。当我们将这项改进应用到"真实"应用时,效果将尤为显著。改进前:

export const CountriesList = ({
  countries,
  onCountryChanged,
  savedCountry
}: CountriesListProps) => {


  // only "country" in props
  const Item = ({ country }: { country: Country }) => {
    // ... same code
  };


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

之后:

type ItemProps = {
  country: Country;
  savedCountry: Country;
  onItemClick: () => void;
};


// turned out savedCountry and onItemClick were also used
// but it was not obvious at all in the previous implementation
const Item = ({ country, savedCountry, onItemClick }: ItemProps) => {
  // ... same code
};


export const CountriesList = ({
  countries,
  onCountryChanged,
  savedCountry
}: CountriesListProps) => {
  return (
    <div>
      {countries.map((country) => (
        <Item
          country={country}
          key={country.id}
          savedCountry={savedCountry}
          onItemClick={() => onCountryChanged(country)}
        />
      ))}
    </div>
  );
};

现在,既然我们已经解决了父组件每次重新渲染时都需要重新挂载 Item 组件的问题,就可以提炼出本文的第三条规则:

规则 #3:切勿在其他组件的render函数内部创建新组件。

实现选定国家组件

下一步:实现“选定国家”组件,这将是本文最短且最枯燥的部分,因为该组件实际上没什么可展示的:它只是一个接受属性与回调函数的组件,并渲染几行字符串:

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>
    </>
  );
};

🤷🏽‍♀️ 就这样!它只是为了让演示代码沙盒更有趣而已 🙂

最终润色:主题化

现在进入最后一步:深色模式!谁会不喜欢呢?考虑到当前主题应在多数组件中可用,若通过 props 在各处传递主题将是一场噩梦,因此 React Context 自然成为最佳解决方案。

首先创建主题上下文:

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


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

向页面组件添加上下文提供者及切换按钮:

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>
  )
}

然后使用上下文钩子为按钮应用相应的主题配色:

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

再次强调,这种实现方式并无不当之处,尤其在主题设计中属于非常常见的模式。

重构主题设计——兼顾性能考量。

在我们找出上述实现的问题之前,先来探讨React组件可能被重新渲染的第四个原因——这个原因常被忽略:若组件使用上下文消费者,每当上下文提供者的值发生变化时,该组件就会被重新渲染。

还记得我们简化的示例吗?当时通过缓存渲染结果来避免重复渲染。

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}
    </>
  );
};

每次点击按钮时,Page 组件都会重新渲染,因为每次点击都会更新状态。但 CountriesList 组件经过备忘处理且独立于该状态,因此不会重新渲染,Item 组件也不会重新渲染。请参见此处的codesandbox

那么,如果在此处添加主题上下文会发生什么?在 Page 组件中添加Provider:

export const Page = ({ countries }: { countries: Country[] }) => {
  // everything else stays the same


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


  return (
    <ThemeContext.Provider value={{ mode }}>
      // same
    </ThemeContext.Provider>
  );
};

以及 Item 组件中的 Context

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

如果它们只是普通的组件和钩子,就不会发生任何情况——Item 不是 Page 组件的子组件,由于备忘录机制,CountriesList 不会重新渲染,因此 Item 也不会。但在此情境下,这是 Provider-consumer 组合,因此每次 Provider 的值改变时,所有消费者都会重新渲染。由于我们持续向value传递新对象,每次计数器更新时 Items 都会不必要地重新渲染。Context机制实质上绕过了我们设置的备忘机制,使其几乎失去作用。详见codesandbox演示

解决方法正如你所料:只需确保Provider中的 value 不会发生不必要的变更。在本例中,我们同样需要为其添加备忘机制:

export const Page = ({ countries }: { countries: Country[] }) => {
  // everything else stays the same


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


  return (
    <ThemeContext.Provider value={theme}>
      // same
    </ThemeContext.Provider>
  );
};

现在计数器将正常工作,而不会导致所有项目重新渲染!

同样的解决方案也适用于我们的非简化版 Page 组件,可有效避免不必要的重新渲染:

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:使用上下文时,若值属性非数字、字符串或布尔值,务必始终进行备忘存储。

整合所有内容

至此,我们的应用程序已完成!完整实现可在该 codesandbox 中查看。若使用最新款MacBook,请尝试限制CPU性能,以普通用户视角体验:在国家列表中切换选项。即便CPU性能降低6倍,运行依然迅如闪电!🎉

此刻想必许多人迫不及待要问:"娜迪亚,React本身就快如闪电, 你做的那些'优化'对仅有250项的简单列表能有多大提升?是不是夸大其词了?"

没错,我刚开始写这篇文章时也这么认为。但随后我用"低效"方式实现了该应用。请在codesandbox中查看。我甚至无需降低CPU性能就能看到选中项时的延迟😱。将性能降低6倍后,它可能成了全球最慢的简单列表——而且还存在焦点错误(而"高性能"版本没有这个问题)。更荒谬的是,我根本没做任何明显恶劣的操作啊!😅

那么让我们回顾React组件何时会重新渲染

当组件状态改变时 当父组件重新渲染时 当组件使用上下文且其提供者的值改变时

我们总结的规则如下:

规则 #1:若你将props中的内联函数提取到 useCallback 中,唯一目的是避免子组件重新渲染——请不要这样做。这行不通。

规则 #2:若组件管理状态,请找出渲染树中不依赖变更状态的部分,通过缓存机制最小化其重新渲染。

规则 #3:切勿在其他组件的渲染函数内创建新组件。

规则 #4:使用上下文时,确保 value 属性若非数字、字符串或布尔值,必须始终进行缓存。

以上就是全部规则!希望这些准则能助你从一开始就编写更高效的应用,让用户告别卡顿体验,收获更愉悦的使用感受。

附赠:useCallback 的困境

在真正结束本文之前,我感觉需要先解开一个谜团:为什么 useCallback 对减少重新渲染毫无用处?既然如此,React文档为何明明白白写着"当向依赖引用相等性来避免不必要渲染的优化子组件传递回调时,[useCallback]非常有用"?🤯

答案就在这句话里:"依赖引用相等性的优化子组件"

这里有两种适用场景。

第一种:接收回调的组件被 React.memo 包裹,且该回调作为其依赖项。基本形式如下:

const MemoisedItem = React.memo(Item);


const List = () => {
  // this HAS TO be memoised, otherwise `React.memo` for the Item is useless
  const onClick = () => {console.log('click!')};


  return <MemoisedItem onClick={onClick} country="Austria" />
}

或者:

const MemoisedItem = React.memo(Item, (prev, next) => prev.onClick !== next.onClick);


const List = () => {
  // this HAS TO be memoised, otherwise `React.memo` for the Item is useless
  const onClick = () => {console.log('click!')};


  return <MemoisedItem onClick={onClick} country="Austria" />
}

其次:如果接收回调的组件在 hooks(如 useMemouseCallbackuseEffect)中将此回调作为依赖项。

const Item = ({ onClick }) => {
  useEffect(() => {
    // some heavy calculation here
    const data = ...
    onClick(data);


  // if onClick is not memoised, this will be triggered on every single render
  }, [onClick])
  return <div>something</div>
}
const List = () => {
  // this HAS TO be memoised, otherwise `useEffect` in Item above
  // will be triggered on every single re-render
  const onClick = () => {console.log('click!')};


  return <Item onClick={onClick} country="Austria" />
}

这些内容都不能简单概括为"该做"或"不该做",只能用于解决特定组件的具体性能问题,在此之前都不可妄下结论。

本文至此终于完成,感谢您阅读至此,希望对您有所帮助!祝您健康,下次见 ✌🏼