[译]如何编写高性能的 React 代码

本文作者系360奇舞团前端开发工程师
本文为翻译,原文标题:How to write performant React code: rules, patterns, do's and don'ts,原文作者:Nadia Makarevich,原文地址:www.developerway.com/posts/how-t...

写在开头

关于 React 性能!是一个非常有趣且有争议性的话题,在短时间内(6个月)的最佳实践也可能是不准确的, 在这里我们可以一起探讨出一些明确的观点以及提出一些的建议。

通常,性能专家都主张"过早优化是万恶之源"和"测量优先"的原则。大致可以理解为"不要修复没有问题的代码程序",这个观点很难反驳。但我还是要反驳一下。

我喜欢 React 的地方在于它使得实现复杂的 UI 交互变得非常容易。我不喜欢 React 的地方在于它也让人们很容易写出"错误 "的代码,而开发者却意识不到。好消息是,我们可以非常轻松地避免这些错误,并编写具备良好性能的代码,从而显著减少分析性能问题所需花费的时间和精力,因为会出现更少有关性能的问题。基本上,在涉及 React 代码性能时,"过早优化 "实际上可能是件好事,我们每个人都应该这样做。你需要知道一些最佳实践,以便更好的进行优化。

所以,这正是我想在本文中证明的。我将逐步实现一个"真实"的应用,首先按照"正常"的方式实现,且你自己肯定多次使用过的代码来完成。然后,在考虑性能的情况下对每个组件进行重构,并从每个组件中提取出一条可以应用于大多数应用的最佳实践。

让我们开始吧

我们将为在线商城编写一个"设置"页面。在这个页面上,用户可以从列表中选择一个国家,查看该国家的所有可用信息(如货币、快递方式等),还可以将选中的国家保存。页面大致如下所示:

image.png

左边有一个国家列表,带有"选择"和"保存"状态。当点击列表中的项目时,在右侧列中显示详细信息。当点击"保存"按钮时,可以将选择的国家保存起来,且当前选中的国家会被高亮显示。

并且我们希望使用深色模式!

此外,考虑到90%的情况下React性能问题可以总结为re-render次数过多,本文我们将主要专注于减少这个问题。

构建应用程序

首先,让我们来看一下设计图,并确定我们未来应用的结构以及需要实现的组件:

  • "Page"根组件,在这里我们处理提交逻辑和国家选择逻辑
  • "CountriesList"组件,用于以列表形式渲染所有的国家,并且将来可以处理过滤和排序等功能
  • "Item"组件,在"国家列表"中渲染每个国家
  • "Selected"组件,用于展示所选国家的详细信息并包含"保存"按钮

image.png

当然这不是实现此页面的唯一方式,但这就是 React 的魅力所在:可以用无数种方式来实现,这些方式也没有对错之分。但在长期快速增长或已经很大的应用程序中,有一些代码绝对可以被称为"永远不要这样做"或"必须这样做"。

让我们看看能否一起找出它们。

Page 组件

现在,是时候开始编写代码了。让我们从"根组件"开始实现页面。

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

2、页面应该从某个地方接收到国家列表,并将其传递给CountriesList组件以便进行渲染。

3、页面应该有一个"已选择"的国家状态,在CountriesList组件中接收并传递给SelectedCountry 组件。

4、页面应该有一个"已保存"的国家状态,在SelectedCountry组件中接收并传递给CountriesList组件。

ini 复制代码
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>
    </>
  );
};

这是 Page 组件的完整实现,它是你可以在任何地方看到的最基本的 React 组件,而且这个实现中绝对没有任何"错误"代码。但有一个地方可以优化,你可以看出来吗?

重构 Page 组件(性能考虑)

大家应该都知道,组件的状态或属性发生变化时,React 会re-render。在我们的 Page 组件中,当调用setSelectedCountrysetSavedCountry时,组件会re-render。如果 Page 组件中的 countries 数组(props)发生变化,也同样会re-render。对于 CountriesListSelectedCountry组件也是如此,当它们的任何 props 发生变化时,组件将re-render

此外,任何使用过一段时间 React 的人都知道 JavaScript 的相等比较,即 React 对 props 进行严格的相等比较,并且内联函数在每次渲染中都会重新创建。这导致了一个非常普遍(而且绝对错误)的观点,即为了减少CountriesListSelectedCountry组件的重新渲染,我们需要通过将内联函数包装在 useCallback 中来摆脱在每次渲染时重新创建内联函数。甚至 React 文档也提到了使用useCallback来"防止不必要的渲染"!看看这段代码是否熟悉:

javascript 复制代码
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 组件re-render的原因:当父组件re-render时。无论 props 如何,如果 Page re-renderCountriesList 也同样会re-render,即使它根本没有任何 props。

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

typescript 复制代码
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没有任何 props,我们也会看到它重新渲染了。

最后,我们得出了本文的第一条规则:

规则1 :如果你想将内联函数中的 props 提取到useCallback中的原因是为了避免子组件re-render,那么不要这样做,它不会起作用。

现在,有其他几种处理上述情况的方法,我们将使用最简单的方法来处理此特定场合:使用 useMemo hook 。它所做的就是"缓存"传递给它的任何函数的结果,并且仅当useMemo依赖项发生变更时才重新执行。如果我们只是将渲染后的CountriesList提取到一个变量const list = <ComponentList />,然后对其使用useMemo,在只有当useMemo依赖项发生变更时ComponentList组件才会re-render

typescript 复制代码
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}
    </>
  );
};

在这种情况下CountriesList永远不会re-render,因为它没有任何依赖项。这打破了"父组件re-render,所有子组件也将re-render"的规律。

最重要的是要注意useMemo的依赖列表。如果它依赖于导致父组件re-render的完全相同的东西(上例中的counter),那么它将在每次re-render时重新执行一次,实际上没什么用。例如,在下面这个简化的示例中,如果我们将counter作为 list 的依赖项传递进去,那么每次状态改变都会导致useMemo重新执行,并使CountriesList re-render

javascript 复制代码
const list = useMemo(() => {
  return (
    <>
      {counter}
      <CountriesList />
    </>
  );
}, [counter]);

以上的优化,如何将它应用到我们的页面组件上呢?仔细看一下我们页面的实现方式:

ini 复制代码
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>
    </>
  );
};

我们可以看到:

CountriesList组件中,selectedCountry状态从未被使用。

SelectedCountry组件中,savedCountry状态从未被使用。

image.png

这意味着当selectedCountry状态改变时,CountriesList组件是不需要re-render的!而且savedCountry状态和SelectedCountry组件也是同样的情况。我们可以将它们都使用useMemo包裹,以防止它们不必要的re-render

ini 复制代码
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 :如果组件使用了状态,找到那些不依赖于其他状态的组件,并对它们进行memo缓存,以最小化re-render次数。

CountriesList 组件

我们的Page组件已经准备好了,是时候写其他子组件了。

首先,让我们来实现:CountriesList。我们已经知道,这个组件应该接受国家列表,并在选择一个国家时触发onCountryChanged回调函数,并根据设计要求将保存的国家以不同颜色进行高亮显示。

让我们开始吧:

ini 复制代码
type CountriesListProps = {
  countries: Country[];
  onCountryChanged: (country: Country) => void;
  savedCountry: Country;
};
export const CountriesList = ({
  countries,
  onCountryChanged,
  savedCountry
}: CountriesListProps) => {
  const Item = ({ country }: { country: Country }) => {
    const className = savedCountry.id === country.id ? "country-item saved" : "country-item";

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

这是一个比较简单的组件,但两个值得注意的地方:

1、我们根据接收到的 props 生成 Item (它取决于 onCountryChangedsavedCountry

2、循环渲染出所有国家的 Item

上例代码并没有什么"错误"之处,我们在很多项目中都见到过类似的代码。

重构 CountriesList 组件

再来回顾一下我们对于 React 渲染的了解,这一次是关于一个组件(Item组件)在另一个组件渲染期间被创建会发生什么。答案是:很糟糕。

从 React 的角度来看,这个 Item 只是一个函数,在每次渲染时都会重新创建,并且返回一个新的值。

所以它将在每次渲染时重新创建该函数,也就是说它将仅仅比较前一个组件状态和当前状态,就像正常重新渲染时发生的那样。它将删除先前生成的组件,包括其DOM树,然后从页面中移除它,并且每当父级组件re-render时都会重新生新的组件并挂载。

一个简化版本的,类似于以下内容:

javascript 复制代码
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次 re-renders微不足道。在正常情况下,只有当组件完成挂载和第一次渲染后,才会触发具有空依赖数组的useEffect,而且仅触发一次。之后,在 React 中的重新渲染过程,组件并不是从头开始创建,而是在需要时进行更新(这就是 React 如此快的原因)。但在这种情况下并非如此-请查看此codesandbox,在打开控制台后点击"re-render"按钮,查看每次点击时发生的250个渲染和挂载。

解决方法很明显且简单:我们只需将Item组件移到render函数之外即可。

javascript 复制代码
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} />
      ))}
    </>
  );
};

作为一个额外的好处,这样重构有助于保持不同组件之间的健康边界,并使代码更清晰、更简洁。当我们将这个改进应用到我们之前的代码中时,这一点尤其明显。 之前:

javascript 复制代码
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>
  );
};

现在:

ini 复制代码
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>
  );
};

现在,我们摆脱了每次父组件re-render时重新挂载 Item 组件的问题。

最后,我们得出了本文的第三条规则:

规则3、 永远不要在另一个组件的渲染函数中创建新的组件。

实现 SelectedCountry 组件

这将是文章中最短、最无聊的部分,因为没有什么可展示的内容:它只是一个接受属性和回调函数,并渲染一些字符串的组件。

javascript 复制代码
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 是最佳的解决方案之一。

首先创建 ThemeContext

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


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

context provider 和切换按钮添加到页面组件中。

javascript 复制代码
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>
  )
}

然后使用 context hook 为我们设置颜色属性。

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

这个实现中没有任何不合适的地方,这是一种非常常见的模式,尤其适用于主题设计。

重构主题

在我们能够找出上述实现有什么问题之前,让我们来回顾一下,为什么一个 React 组件会re-render,这通常是:如果一个组件使用了context consumer,那么每当context provider的值发生变化时,它都会re-render

还记得我们简化的例子吗?我们对渲染结果使用useMemo以避免re-render

typescript 复制代码
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}
    </>
  );
};

页面组件将在每次点击按钮时re-render,因为它在每次点击时更新状态。但是CountriesList使用了 useMemo,并且与该状态无关,所以它不会re-render,所以 Item 组件也不会re-render

现在,如果我们在这里添加ThemeContext provider会发生什么?

Page 组件:

javascript 复制代码
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 组件:

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

如果它们只是普通的组件和hooks,什么都不会发生,Item 不是 Page 的子组件,CountriesList因为使用useMemo而不会re-render,所以 Item 也不会。除非使用了Provider-Customer,所以每当Provider的value值发生变化时,所有Consumer都将re-render。由于我们传递的value一直都是新对象,Item 将在每次计数器改变后进行不必要的re-renderContext绕过了我们的 memo 缓存,使其几乎无用。

修复方法如你可能已经猜到的那样,就是确保Provider中的值更改得尽量少。在我们的例子中,我们只需要对其使用useMemo包裹:

javascript 复制代码
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>
  );
};

现在计数器将正常工作,而不会导致所有组件re-render

为了防止不必要的re-render,我们可以将其应用到我们的页面组件上:

javascript 复制代码
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 、在使用Context时,确保如果 value 属性不是数字、字符串或布尔值,则始终使用 memo缓存。

总结

最后,我们的应用终于完成了!整个实现都可以在codesandbox中找到。如果你使用的是最新款的MacBook,请降低CPU速度,以体验普通用户所经历的,并尝试在列表中选择不同国家。即使减少了6倍的CPU速度,它仍然非常快!🎉

现在,我想很多人都有一个迫切想问的问题:"Nadia,React 本身就已经非常快了。你做出来的那些'优化'对于只有250个国家的简单列表来说并没有太大影响吧?是不是你夸大它重要性了"。

是啊,在我刚开始写这篇文章时,我也是这么认为的。但后来我以"非高效"的方式实现了该应用程序。我甚至不需要降低CPU速度就能看到选择项目之间存在延迟 😱 。将其降低6倍,它可能是世界上最慢、甚至无法正常工作(与"高性能"应用相比) 的简单列表之一。而且,在那里面我甚至没有做任何明显的恶意操作!

让我们回顾下什么时候 React 组件会re-render:

当组件的状态改变时

当父组件re-render

当一个组件使用Context,并且Provider的值发生变化时

而我们从中总结出来了4个规则:

1、如果你想将内联函数中的 props 提取到useCallback中的原因是为了避免子组件re-render,那么不要这样做,它不会起作用。

2、如果组件使用了状态,找到那些不依赖于其他状态的组件,并对它们进行memo缓存以最小化re-render次数。

3、永远不要在另一个组件的渲染函数中创建新的组件。

4、在使用Context时,确保如果 value 属性不是数字、字符串或布尔值,则始终使用 memo缓存。

希望这些规则能够帮助您从一开始就编写出性能更高的应用,并使客户更加满意,他们再也不必体验缓慢的产品了。

useCallback 的困境

在真正结束这篇文章之前,我觉得有一个谜题需要解决:为什么 useCallback 对于减少re-render是无用的,而 React 文档却明确表示"useCallback将回调传递给依赖引用相等的子组件,以防止不必要渲染时很有用"?

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

这里适用两种情况。

第一种:接收到回调函数的组件被包裹在React.memo中,并且该回调函数作为其依赖项。大体是这样的:

ini 复制代码
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" />
}

或者这样

ini 复制代码
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" />
}

第二中:如果接收回调的组件在像useMemo、useCallback或useEffect这样的hooks中将此回调作为依赖项。

javascript 复制代码
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" />
}

这些都不能简单地概括为"做"或"不做",它只能用于解决具体组件的确切性能问题。

感谢您的阅读,希望您觉得有用!下次再见。

相关推荐
霸气小男2 分钟前
react + antDesign封装图片预览组件(支持多张图片)
前端·react.js
小白小白从不日白15 分钟前
react 组件通讯
前端·react.js
小白小白从不日白3 小时前
react hooks--useReducer
前端·javascript·react.js
volodyan3 小时前
electron react离线使用monaco-editor
javascript·react.js·electron
等下吃什么?15 小时前
NEXT.js 创建postgres数据库-关联github项目-连接数据库-在项目初始化数据库的数据
react.js
小白小白从不日白17 小时前
react 高阶组件
前端·javascript·react.js
黑狼传说19 小时前
前端项目优化:极致最优 vs 相对最优 —— 深入探索与实践
前端·性能优化
奶糖 肥晨21 小时前
react是什么?
前端·react.js·前端框架
Lill_bin1 天前
Lua编程语言简介与应用
开发语言·数据库·缓存·设计模式·性能优化·lua
B.-2 天前
Remix 学习 - @remix-run/react 中主要的 hooks
前端·javascript·学习·react.js·web