好得很程序员自学网

<tfoot draggable='sEl'></tfoot>

如何编写高性能的 React 代码:规则、模式、注意事项

首先会以常规的模式来实现组件,然后再考虑性能的情况下重构每个步骤,并从每个步骤中提取一个通用规则,这些规则可以应用于大多数应用程序。然后比较最后的结果。

下面将编写一个[国家设置]页面,用户可以从列表中选择国家,查看该国家的信息,可以保存国家:

可以看到,左侧有一个国家列表,带有[已保存]和[已选择]状态,当点击列表中的选项时,右侧会显示该国家的详细信息。当按下保存按钮时,选定的国家会变成已保存,已保存的选项的背景会变成蓝色。

1. 构建应用程序

首先,根据设计图,我们要考虑应用的结构以及要实现哪些组件:

页面组件:在其中处理提交逻辑和国家选择逻辑;

国家列表组件:将呈现列表中的所有国家,并进行过滤和排序等操作;

国家选项组件:将所有国家呈现在国家列表组件中;

选定国家组件:将呈现选定国家的详细信息,并具有保存按钮。

当然,这不是实现这个页面的唯一方式,实现方式仅供参考。下面就来看看如何实现这些组件。

2. 实现页面组件

下面终于要开始写代码了,下面就从根开始,实现Page组件,步骤如下:

需要组件包含页面标题、国家列表和选定国家组件; 将页面参数中的国家列表数据传递给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 >    )  ;   }  ;    

3. 重构页面组件(考虑性能)

在 React 中,当组件的 state 或者 props 发生变化时,组件会重新渲染。在 Page 组件中,当 setSelectedCountry 或者 setSavedCountry 被调用时,组件就会重新渲染。当组件的国家列表数据(props)发生变化时,组件也会重新渲染。CountriesList 和 SelectedCountry 组件也是如此,当它们的 props 发生变化时,都会重新渲染。

我们知道,React 会对 props 进行严格相等的比较,并且内联函数每次都会创建新值。这就导致了一个错误的的观念:为了减少 CountriesList 和SelectedCountry 组件的重新渲染,需要通过在 useCallback 中包装内联函数来避免在每次渲染中重新创建内联函数。

export const Page  =   (  {  countries  }  :   {  countries :  Country [  ]   }  )   =>   {  const onCountryChanged  =  useCallback (  ( c )   =>  setSelectedCountry ( c )  ,   [  ]  )  ;  const onCountrySaved  =  useCallback (  (  )   =>  setSavedCountry ( selectedCountry )  ,   [  ]  )  ;  return  (   <>  ...  < CountriesList
          onCountryChanged =  { onCountryChange }   />   < SelectedCountry
          onCountrySaved =  { onCountrySaved }   />  ...   )  ;   }  ;  

而实际上,这样并不会起作用。因为它没有考虑到:如果父组件 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  />    )  ;   }  ;     

当每次点击按钮时,即使没有任何 props,都会看到 CountriesList 组件被重新渲染。由此,总结出第一条规则:「如果想把 props 中的内联函数提取到 useCallback 中,以此来避免子组件的重新渲染,请不要这样做,它不起作用。」

现在,有几种方法可以处理上述情况,最简单的一种就是使用 useMemo,它本质上就是缓存传递给它的函数的结果。并且仅在 useMemo 的依赖项发生变化时才会重新执行。这就就将 CountriesList 组件使用 useMemo 包裹,只有当 useMemo 依赖项发生变化时,才会重新渲染 ComponentList 组件:

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

当然,在这个简化的例子中是不行的,因为它没有任何依赖项。那我们该如何简化 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 >    )  ;   }  ;    

可以看到:

在 CountriesList 组件中不会使到 selectedCountry 状态; 在 SelectedCountry 组件中不会使用到 savedCountry 状态;

这意味着当 selectedCountry 状态发生变化时,CountriesList 组件不需要重新渲染。savedCountry 状态发生变化时,SelectedCountry 组件也不需要重新渲染。可以使用 useMemo 来包裹它们,以防止不必要的重新渲染:

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

由此总结出第二条规则:「如果组件需要管理状态,就找出渲染树中不依赖于已更改状态的部分,并将其使用 useMemo 包裹,以减少其不必要的重新渲染。」

4. 实现国家列表组件

Page 页面已经完美实现了,是时候编写它的子组件了。首先来实现比较复杂的 CountriesList 组件。这个组件一个接收国家列表数据,当在列表中选中一个国家时,会触发 onCountryChanged 回调,并应以不同的颜色突出显示保存的国家:

type CountriesListProps  =   {  countries :  Country [  ]  ;  onCountryChanged :   ( country :  Country )   =>  void ;  savedCountry :  Country ;   }  ;  export const CountriesList  =   (  {  countries ,  onCountryChanged ,  savedCountry  }  :  CountriesListProps )   =>   {  const Item  =   (  {  country  }  :   {  country :  Country  }  )   =>   {   //  根据国家选项是否已选中来切换不同的className
    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 >   )  ;   }  ;     

这里只做了两件事:

根据接收到的 props 来生成 Item 组件,它依赖于onCountryChanged和savedCountry;

遍历 props 中的国家数组,来渲染国家列表。

5. 重构国家列表组件(考虑性能)

如果在一个组件渲染期间创建了另一个组件(如上面的 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 }   />   )  )  }    )  ;   }  ;   

从性能角度来看,与完全重新创建组件相比,正常的重新渲染的性能会好很多。在正常情况下,带有空依赖项数组的 useEffect 只会在组件完成装载和第一次渲染后触发一次。之后,React 中的轻量级重新渲染过程就开始了,组件不是从头开始创建的,而是只在需要时更新。这里假如有100个国家,当点击按钮时,就会输出100次 Render 和100次 Mounted,Item 组件会重新装载和渲染100次。

解决这个问题最直接的办法就是将 Item 组件移到渲染函数外:

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

这样在点击按钮时,Item组件就会重现渲染100次,只输出100次 Render,而不会重新装载组件。保持了不同组件之间的边界,并使代码更简洁。下面就来看看国家列表组件在修改前后的变化。

修改前:

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

修改后:

type ItemProps  =   {  country :  Country ;  savedCountry :  Country ;  onItemClick :   (  )   =>  void ;   }  ;  const Item  =   (  {  country ,  savedCountry ,  onItemClick  }  :  ItemProps )   =>   {   //  ...  }  ;  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 组件,由此可以总结出第三条规则:「不要在一个组件的渲染内创建新的组件。」

6. 实现选定国家组件

这个组件比较简单,就是接收一个属性和一个回调函数,并呈现国家信息:

const SelectedCountry  =   (  {  country ,  onSaveCountry  }  :   {  country :  Country ;  onSaveCountry :   (  )   =>  void  }  )   =>   {  return  (   <>   < ul >   < li > Country :   { country .name  }   li >  ...  //  要渲染的国家信息   ul >   < button onClick =  { onSaveCountry }  type =  "button"  > Save  button >    )  ;   }  ;     

7. 实现页面主题

最后来实现页面的黑暗模式和明亮模式的切换。考虑到当前主题会在很多组件中使用,如果通过 props 传递就会非常麻烦。因此 Context 是比较合适的解决方案。

首先,创建主题 context:

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

添加 context provider ,以及切换主题的按钮:

为国家选项根据主题上色:

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

这是一种实现页面主题的最常见的方式。

8. 重构主题(考虑性能)

在发现上面组件的问题之前,先来看看导致组件重新渲染的另一个原因:如果一个组件使用 context,当 provider 提供的值发生变化时,该组件就会重新渲染。

再来看看简化的例子,这里记录了渲染结果以避免重新渲染:

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

每次点击按钮时,页面状态发生变化,页面组件会重新渲染。但 CountriesList 组件使用useMemo缓存了,会独立于该状态,因此不会重新渲染,因此 Item 组件也不会重新渲染。

如果现在添加Theme context,Provider 在 Page 组件中:

export const Page  =   (  {  countries  }  :   {  countries :  Country [  ]   }  )   =>   {   //  ...
  const list  =  useMemo (  (  )   =>   < CountriesList countries =  { countries }   />  ,   [  countries  ]  )  ;  return  (   < ThemeContext .Provider  value =  {  {  mode  }  }  >   //  ...   ThemeContext .Provider  >   )  ;   }  ;   

context 在 Item 组件中:

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

如果它们只是普通的组件和Hook,那什么都不会发生—— Item 不是 Page 组件的子级,CountriesList 组件因为被缓存而不会重新渲染,所以 Item 组件也不会。但是,本例是提供者-使用者的模式,因此每次提供者提供的值发生变化时,所有使用者都将重新渲染。由于一直在向该值传递新对象,因此每个计数器上都会重新呈现不必要的项。Context 基本绕过了useMemo,使它毫无用处。

解决方法就是确保 provider 中的值不会发生超出需要的变化。这里只需要把它记下来:

export const Page  =   (  {  countries  }  :   {  countries :  Country [  ]   }  )   =>   {   //  ...
  const theme  =  useMemo (  (  )   =>   (  {  mode  }  )  ,   [ mode ]  )  ;  return  (   < ThemeContext .Provider  value =  { theme }  >   //  ...   ThemeContext .Provider  >   )  ;   }  ;   

现在计数器就不会再导致所有 Items 重新渲染。下面就将这个解决方案应用于主题组件,以防止不必要的重新渲染:

export const Page  =   (  {  countries  }  :   {  countries :  Country [  ]   }  )   =>   {   //  ...
  const  [ mode ,  setMode ]   =  useState < Mode >  (  "light"  )  ;  const theme  =  useMemo (  (  )   =>   (  {  mode  }  )  ,   [ mode ]  )  ;  return  (   < ThemeContext .Provider  value =  { theme }  >   < button onClick =  {  (  )   =>  setMode ( mode  ===   'light'   ?   'dark'   :   'light'  )  }  > Toggle theme  button >   //  ...   ThemeContext .Provider  >   )   }    

根据这个结果就可以得出第四条规则:「在使用 context 时,如果 value 属性不是数字、字符串或布尔值,请使用 useMemo 来缓存它。」

9. 总结

导致 React 组件重新渲染的时机主要有以下三种:

当state或props发生变化时; 当父组件重现渲染时; 当组件使用 context,并且 provider 的值发生变化时。

避免不必要的重新渲染的规则如下:

如果想把 props 中的内联函数提取到 useCallback 中,以此来避免子组件的重新渲染,不要这样做,它不起作用。 如果组件需要管理状态,就找出渲染树中不依赖于已更改状态的部分,并将其使用 useMemo 包裹,以减少其不必要的重新渲染。 不要在一个组件的渲染内创建新的组件; 在使用 context 时,如果value属性不是数字、字符串或布尔值,请使用useMemo来缓存它。

这些规则将有助于从开始就编写高性能的 React 应用程序。

作者:NADIA MAKAREVICH

译者:CUGGZ

原文:https://www.developerway.com/posts/how-to-write-performant-react-code

查看更多关于如何编写高性能的 React 代码:规则、模式、注意事项的详细内容...

  阅读:35次