Оптимизация производительности React | Включая принципы, методы, демонстрацию, использование инструментов

React.js

ответы на вопросы

Прочитав эту статью, вы найдете ответы на следующие вопросы:

  1. Каков рабочий процесс в React? На каком этапе мы можем оптимизировать производительность?
  2. Какие методы оптимизации производительности мы можем использовать, если в нашем проекте React есть отставание?
  3. Как найти проблемы с производительностью с помощью React Profiler? Какие этапы содержит React Profiler?

контур

Эта статья разделена на три части: во-первых, в ней представлен рабочий процесс React, чтобы читатели могли получить общее представление о процессе обновления компонентов React. Затем перечислите ряд методов оптимизации, кратко изложенных автором, и подготовьте исходный код CodeSandbox для несколько более сложных методов оптимизации, чтобы читатели могли их испытать. Наконец, я делюсь своим опытом использования React Profiler, чтобы помочь читателям быстрее находить узкие места в производительности.

Рабочий процесс реакции

React — это декларативная библиотека пользовательского интерфейса, отвечающая за преобразование состояния в структуру страницы (виртуальную структуру DOM), а затем в реальную структуру DOM для рендеринга браузером. При изменении состояния React сначала выполнит фазу согласования и сразу же перейдет к фазе фиксации после завершения фазы согласования.После завершения фазы фиксации отображается страница, соответствующая новому состоянию.

Фаза согласования React должна сделать две вещи.1. Рассчитайте виртуальную структуру DOM, соответствующую целевому состоянию. 2. Найдите оптимальное решение для обновления для «изменения структуры виртуальной DOM на целевую структуру виртуальной DOM».React следует за обходом дерева виртуального DOM в глубину.После завершения расчета двух вещей в виртуальном DOM он вычисляет следующий виртуальный DOM. Прежде всего, нужно вызвать метод рендеринга компонента класса или самого функционального компонента. Во-вторых, это алгоритм Diff, реализованный внутри React.Алгоритм Diff записывает, как обновляется виртуальный DOM (например, Update, Mount, Unmount), чтобы подготовиться к фазе отправки.

Фаза фиксации React также должна делать две вещи.1. Примените схему обновления, записанную на этапе согласования, к DOM. 2. Вызов методов-ловушек, доступных разработчикам, таких как: componentDidUpdate, useLayoutEffect и т. д.Время выполнения этих двух вещей на этапе фиксации отличается от фазы согласования.На этапе фиксации React сначала выполнит 1, а затем выполнит 2 после завершения 1. Итак, в методе componentDidMount дочернего компонента вы можете сделатьdocument.querySelector('.parentClass'), получить визуализацию родительского компонента.parentClassузел DOM, даже если метод componentDidMount родительского компонента еще не выполнен. Время выполнения useLayoutEffect такое же, как и для componentDidMount, см.онлайн-кодаутентификация.

Поскольку «процесс сравнения» на этапе согласования и «применение решения для обновления к DOM» на этапе отправки являются внутренними реализациями React, возможности оптимизации, которые могут предоставить разработчики, ограничены. В этой статье описан только один метод оптимизации (Элементы списка используют ключевой атрибут) относятся к ним. Большинство методов оптимизации в реальном проектировании сосредоточены на процессе "вычисления целевой виртуальной структуры DOM" на этапе согласования. Этот процесс находится в центре внимания оптимизации. Архитектура Fiber и режим параллелизма внутри React также сокращают трудоемкую блокировку. этого процесса. Для процесса «выполнить функцию-ловушку» на этапе отправки разработчики должны убедиться, что код в функции-ловушке как можно проще, чтобы избежать трудоемкой блокировки. Связанные методы оптимизации см. в этой статье.Избегайте обновления состояния компонента в didMount, didUpdate.

расширить знания

  1. Читателям, незнакомым с жизненным циклом React, рекомендуется объединитьДиаграмма жизненного цикла компонентов ReactПрочтите эту статью. Не забудьте поставить галочку на этом сайте.
  2. Поскольку вы знаете, когда страница будет обновлена, только после понимания цикла событий, рекомендуется использоватьВидео, знакомящее с циклом событий. Псевдокод цикла событий в этом видео показан ниже, он очень нагляден и прост для понимания.

Определить процесс рендеринга

Для удобства описания в данной статьеПроцесс «вычисления целевой виртуальной DOM-структуры» на этапе согласования называется процессом рендеринга.. В настоящее время существует три способа запуска процесса рендеринга компонентов React, а именно: forceUpdate, обновление состояния и рендеринг родительского компонента для запуска процесса рендеринга дочернего компонента.

Советы по оптимизации

В этой статье методы оптимизации разделены на три категории, а именно:

  1. Пропустите ненужные обновления компонентов. Этот тип оптимизации достигается за счет сокращения ненужных обновлений компонентов после изменения состояния компонентов и является основной частью методов оптимизации, описанных в этой статье.
  2. Оптимизация фазы фиксации. Цель этого типа оптимизации — сократить время фазы фиксации, и в этой категории есть только один прием оптимизации.
  3. Общая оптимизация интерфейса. Такого рода оптимизации существуют во всех интерфейсных фреймворках, и в этой статье основное внимание уделяется применению этих методов к компонентам React.

Пропускать ненужные обновления компонентов

Этот тип оптимизации достигается за счет сокращения ненужных обновлений компонентов после изменения состояния компонентов и является основной частью методов оптимизации, описанных в этой статье.

1. PureComponent, React.memo

В рабочем процессе React, если только родительский компонент имеет обновление состояния, даже если все реквизиты, переданные от родительского компонента к дочернему компоненту, не изменены, будет вызван процесс рендеринга дочернего компонента. С точки зрения концепции декларативного дизайна React, если свойства и состояние дочернего компонента не изменились, то структура DOM и генерируемые им побочные эффекты не должны измениться. Когда подкомпонент соответствует концепции декларативного проектирования, процесс рендеринга подкомпонента можно игнорировать. PureComponent и React.memo работают с этим сценарием: PureComponent — поверхностное сравнение свойств и состояний компонентов класса, а React.memo — поверхностное сравнение свойств функциональных компонентов.

2. shouldComponentUpdate

В те дни, когда React впервые стал открытым исходным кодом, неизменяемость данных не была так популярна, как сейчас. В то время архитектура Flux использовала переменную модуля для поддержания состояния и напрямую изменяла значение свойства переменной модуля при обновлении состояния вместо использованияРазвернуть синтаксисСоздайте новую ссылку на объект. Например, когда вы хотите добавить элемент данных в массив, код в это время, скорее всего, будетstate.push(item), вместоconst newState = [...state, item]. См. Дэна Абрамова.Говоря о ReduxДемонстрационный код Flux.

В этом контексте разработчики в то время часто использовали shouldComponentUpdate для глубокого сравнения реквизитов и выполняли процесс рендеринга компонента только при изменении реквизитов. Сегодня из-за популярности неизменяемости данных и функциональных компонентов таких сценариев оптимизации больше нет.

Далее мы представляем еще один сценарий, который можно оптимизировать с помощью shouldComponentUpdate. На начальном этапе проекта разработчики часто передают большой объект в качестве реквизита подкомпонентам для удобства, а подкомпоненты могут использовать все, что захотят использовать позже. Когда «неиспользуемое свойство подкомпонента» в большом объекте обновляется, подкомпонент также запускает процесс рендеринга. В этом сценарии, реализуя метод shouldComponentUpdate дочернего компонента, он будет возвращаться только тогда, когда изменилось «свойство, используемое дочерним компонентом».true, вы можете избежать повторного рендеринга дочерних компонентов.

Но использование shouldComponentUpdate для оптимизации второго сценария имеет два недостатка.

  1. Если компонентов-потомков много, «поиск свойств, используемых всеми компонентами-потомками» потребует много работы, и легко вызвать ошибки из-за пропущенных тестов.
  2. Существуют потенциальные инженерные скрытые опасности. В качестве примера предположим, что структура компонентов выглядит следующим образом.
<A data="{data}">
  {/* B 组件只使用了 data.a 和 data.b */}
  <B data="{data}">
    {/* C 组件只使用了 data.a */}
    <C data="{data}"></C>
  </B>
</A>

Только data.a и data.b сравниваются в shouldComponentUpdate компонента B, и в настоящее время проблем нет. Позже разработчик хочет использовать data.c в компоненте C, предполагая, что data.a и data.c в проекте обновляются вместе, так что проблем не возникает. Но этот код стал хрупким, и если модификация приведет к тому, что data.a и data.c не будут обновляться вместе, то система выйдет из строя. Более того, код в реальном бизнесе часто бывает более сложным, и может быть несколько промежуточных компонентов от B до C. В настоящее время трудно думать о проблеме, вызванной shouldComponentUpdate.

расширить знания

  1. Лучшим решением для второго сценария является использование режима издатель-подписчик, но изменений в коде немного больше, вы можете обратиться к методикам оптимизации в этой статье"Издатель-подписчик пропускает процесс рендеринга промежуточного компонента".
  2. Во втором сценарии между родительским и дочерним компонентами также может быть добавлен промежуточный компонент, который отвечает за выбор атрибутов, которые важны для дочернего компонента, из родительского компонента, а затем передает их дочернему компоненту. По сравнению с методом shouldComponentUpdate он повысит иерархию компонентов, но не будет иметь второго недостатка.
  3. в этой статьеПропустить процесс рендеринга, запущенный изменением функции обратного вызова.Его также можно реализовать с помощью shouldComponentUpdate, поскольку функция обратного вызова не участвует в процессе рендеринга компонента.

3. useMemo, useCallback для достижения стабильного значения Props

Оптимизация PureComponent и React.memo завершается ошибкой, если производное состояние или функция, передаваемая дочернему компоненту, каждый раз является новой ссылкой. Поэтому вам нужно использовать useMemo и useCallback для создания стабильных значений и комбинировать PureComponent или React.memo, чтобы избежать повторного рендеринга дочерних компонентов.

расширить знания

useCallback — это частный случай «возвращаемого значения useMemo является функцией», и это удобный способ, предоставляемый React. существуетКод React Server Hooks, useCallback реализован на основе useMemo. Хотя клиентские хуки React используют другой код,useCallbackЛогика кода иuseMemoЛогика кода осталась прежней.

4. Издатель-подписчик пропускает процесс рендеринга промежуточного компонента

React рекомендует помещать общие данные в общего предка всех «компонентов, которым требуется состояние», но после помещения состояния в общего предка состояние необходимо передавать вниз по уровням, пока оно не будет передано компоненту, который использует состояние.

Каждое обновление состояния будет включать процесс рендеринга промежуточного компонента, но промежуточный компонент не заботится о состоянии, а его процесс рендеринга отвечает только за передачу состояния дочерним компонентам. В этом сценарии состояние может поддерживаться в режиме издатель-подписчик.Только компоненты, которые заботятся о состоянии, могут подписаться на состояние, а промежуточные компоненты больше не требуются для передачи состояния. Когда состояние обновляется, издатель публикует сообщение об обновлении данных, только компонент-подписчик запускает процесс рендеринга, а промежуточный компонент больше не будет выполнять процесс рендеринга.

Эту оптимизацию можно выполнить, если это библиотека модели издатель-подписчик. Например: redux, use-global-state, React.createContext и т. д. Пример ссылки:Шаблон издатель-подписчик пропускает фазу рендеринга промежуточных компонентов., в этом примере для реализации используется React.createContext.

import { useState, useEffect, createContext, useContext } from "react"

const renderCntMap = {}
const renderOnce = name => {
  return (renderCntMap[name] = (renderCntMap[name] || 0) + 1)
}

// 将需要公共访问的部分移动到 Context 中进行优化
// Context.Provider 就是发布者
// Context.Consumer 就是消费者
const ValueCtx = createContext()
const CtxContainer = ({ children }) => {
  const [cnt, setCnt] = useState(0)
  useEffect(() => {
    const timer = window.setInterval(() => {
      setCnt(v => v + 1)
    }, 1000)
    return () => clearInterval(timer)
  }, [setCnt])

  return <ValueCtx.Provider value={cnt}>{children}</ValueCtx.Provider>
}

function CompA({}) {
  const cnt = useContext(ValueCtx)
  // 组件内使用 cnt
  return <div>组件 CompA Render 次数:{renderOnce("CompA")}</div>
}

function CompB({}) {
  const cnt = useContext(ValueCtx)
  // 组件内使用 cnt
  return <div>组件 CompB Render 次数:{renderOnce("CompB")}</div>
}

function CompC({}) {
  return <div>组件 CompC Render 次数:{renderOnce("CompC")}</div>
}

export const PubSubCommunicate = () => {
  return (
    <CtxContainer>
      <div>
        <h1>优化后场景</h1>
        <div>
          将状态提升至最低公共祖先的上层,用 CtxContainer 将其内容包裹。
        </div>
        <div style={{ marginTop: "20px" }}>
          每次 Render 时,只有组件A和组件B会重新 Render 。
        </div>

        <div style={{ marginTop: "40px" }}>
          父组件 Render 次数:{renderOnce("parent")}
        </div>
        <CompA />
        <CompB />
        <CompC />
      </div>
    </CtxContainer>
  )
}

export default PubSubCommunicate

5. Децентрализовать государство и уменьшить влияние государства

Если состояние используется только в определенной части поддерева, то это поддерево можно извлечь как компонент, а состояние переместить внутрь компонента. Как показано в коде ниже, в то время как цвет состояния находится только в<input />и<p />используется, но изменение цвета вызовет<ExpensiveTree />Рендерить снова.

import { useState } from "react"

export default function App() {
  let [color, setColor] = useState("red")
  return (
    <div>
      <input value={color} onChange={e => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  )
}

function ExpensiveTree() {
  let now = performance.now()
  while (performance.now() - now < 100) {
    // Artificial delay -- do nothing for 100ms
  }
  return <p>I am a very slow component tree.</p>
}

Изменяя состояние цвета,<input />и<p />Извлеченный в форму компонента, результат выглядит следующим образом.

export default function App() {
  return (
    <>
      <Form />
      <ExpensiveTree />
    </>
  )
}

function Form() {
  let [color, setColor] = useState("red")
  return (
    <>
      <input value={color} onChange={e => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
    </>
  )
}

После этой настройки изменение цвета не приведет к повторному рендерингу компонентов App и ExpensiveTree.

Если вы расширите описанный выше сценарий, цвет состояния будет использоваться как на верхнем уровне, так и в поддереве приложения-компонента, но<ExpensiveTree />Все еще не заботитесь об этом, как показано ниже.

import { useState } from "react"

export default function App() {
  let [color, setColor] = useState("red")
  return (
    <div style={{ color }}>
      <input value={color} onChange={e => setColor(e.target.value)} />
      <ExpensiveTree />
      <p style={{ color }}>Hello, world!</p>
    </div>
  )
}

В этом сценарии мы по-прежнему извлекаем состояние цвета в новый компонент и предоставляем слот для компоновки.<ExpensiveTree />,Следующее.

import { useState } from "react"

export default function App() {
  return <ColorContainer expensiveTreeNode={<ExpensiveTree />}></ColorContainer>
}

function ColorContainer({ expensiveTreeNode }) {
  let [color, setColor] = useState("red")
  return (
    <div style={{ color }}>
      <input value={color} onChange={e => setColor(e.target.value)} />
      {expensiveTreeNode}
      <p style={{ color }}>Hello, world!</p>
    </div>
  )
}

После этой настройки изменение цвета не приведет к повторному рендерингу компонентов App и ExpensiveTree.

Этот метод оптимизации исходит изbefore-you-memo, Дэн считает, что эта оптимизация более эффективна в сценарии с серверным компонентом, потому что<ExpensiveTree />Может выполняться на стороне сервера.

6. Перечислите элементы, используя ключевой атрибут

Если при рендеринге элемента списка вы не установите неравный ключ свойства для компонента, вы получите следующий сигнал тревоги.

Я считаю, что многие разработчики видели эту тревогу сотни раз, так что же именно оптимизирует ключевой атрибут? Например 🌰, когда ключ не используется, результат рендеринга компонента дважды выглядит следующим образом.

<!-- 前一次 Render 结果 -->
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<!-- 新的 Render 结果 -->
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

На этом этапе алгоритм React Diff будет следовать<li>Сравнивается порядок появления, и получается, что первые два нужно обновить<li>и создайте контент для Виллановыli, всего будет выполнено два обновления DOM и одно создание DOM.

Если вы добавите ключевой атрибут React, результат двух рендеров будет следующим.

<!-- 前一次 Render 结果 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<!-- 新的 Render 结果 -->
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

Алгоритм React Diff сравнит виртуальный DOM со значением ключа 2015 и обнаружит, что виртуальный DOM со значением ключа 2015 не был изменен и не нуждается в обновлении. Точно так же виртуальный DOM с ключом 2016 не нуждается в обновлении. В результате необходимо только создать виртуальный DOM со значением ключа 2014. Использование ключей экономит две операции обновления DOM по сравнению с кодом без ключей.

Если в примере<li>Заменяется пользовательским компонентом, а пользовательский компонент оптимизируется с помощью PureComponent или React.memo. Затем использование ключевого атрибута не только сохраняет обновления DOM, но и позволяет избежать процесса рендеринга компонента.

Реагировать на официальную рекомендациюИдентификатор каждого элемента данных используется в качестве ключа компонента для достижения вышеуказанной цели оптимизации. и устарелоиндекс каждого элементаКак ключ, потому что когда индекс передается в качестве ключа, он вырождается в код, когда ключ не используется. Итак, лучше ли использовать ID, чем использование index во всех сценариях рендеринга списка?

Ответ — нет. В обычном списке подкачки идентификаторы элементов списка на первой и второй странице различаются. Предполагая, что каждая страница отображает три части данных, результаты рендеринга компонентов до и после переключения страниц выглядят следующим образом. .

<!-- 第一页的列表项虚拟 DOM -->
<li key="a">dataA</li>
<li key="b">dataB</li>
<li key="c">dataC</li>

<!-- 切换到第二页后的虚拟 DOM -->
<li key="d">dataD</li>
<li key="e">dataE</li>
<li key="f">dataF</li>

После перехода на вторую страницу из-за всех<li>Значения ключей разные, поэтому алгоритм Diff пометит все узлы DOM на первой странице как удаленные, а затем пометит все узлы DOM на второй странице как новые. Весь процесс обновления требует три удаления DOM и три создания DOM. Если ключ не используется, алгоритм Diff будет только<li>Узел помечается для обновления, и выполняются три обновления DOM. Справочная демонстрацияСписок с разбивкой на страницы без функций добавления, удаления, сортировки, каждый переворот страницы занимает около 140 мс при использовании ключа и всего 70 мс без использования ключа.

Несмотря на приведенные выше сценарии, React официально по-прежнему рекомендует использовать идентификатор в качестве значения ключа для каждого элемента. Этому есть две причины:

  1. При выполнении таких операций, как удаление, вставка и сортировка элементов в списке, более эффективно использовать идентификатор в качестве ключа. Операции перелистывания страниц часто сопровождаются запросами API, а операции DOM занимают гораздо меньше времени, чем запросы API. Использование идентификаторов мало влияет на взаимодействие с пользователем в этом сценарии.
  2. Использование идентификатора в качестве ключа может поддерживать состояние компонента элемента списка, соответствующего идентификатору. Например, у каждого столбца в таблице есть два состояния: обычное состояние и состояние редактирования. Сначала все столбцы находятся в нормальном состоянии. Пользователь щелкает первую строку и первый столбец, чтобы войти в состояние редактирования. Затем пользователь перетаскивает вторую строку, перемещая ее в первую строку таблицы. Если разработчик использует индекс в качестве ключа, состояние первой строки и первого столбца все еще находится в состоянии редактирования, а пользователь действительно хочет отредактировать данные во второй строке, что не соответствует ожиданиям пользователя. . Хотя эту проблему можно решить, сохраняя «находится ли он в состоянии редактирования» в данных элемента данных и используя реквизиты, не более ли ароматно использовать идентификатор в качестве ключа?

7. useMemo возвращает виртуальный DOM

Используя функцию, которую useMemo может кэшировать результаты вычислений, если useMemo возвращает виртуальный DOM компонента, фаза рендеринга компонента будет пропущена, когда зависимость useMemo останется неизменной. Этот подход похож на React.memo, но имеет следующие преимущества перед React.memo:

  1. более удобно. React.memo необходимо обернуть компонент один раз, чтобы сгенерировать новый компонент. А useMemo нужно использовать только там, где есть узкое место в производительности, без изменения компонента.
  2. более гибкий. useMemo не нужно учитывать все реквизиты компонента, а только значения, используемые в текущей сцене, также можно использоватьuseDeepCompareMemoСделайте глубокое сравнение используемых значений.

Пример ссылки:useMemo пропускает процесс рендеринга компонента. В этом примере после обновления состояния родительского компонента дочерний компонент, не использующий useMemo, выполнит процесс рендеринга, а дочерний компонент, использующий useMemo, — нет.

import { useEffect, useMemo, useState } from "react"
import "./styles.css"

const renderCntMap = {}

function Comp({ name }) {
  renderCntMap[name] = (renderCntMap[name] || 0) + 1
  return (
    <div>
      组件「{name}」 Render 次数:{renderCntMap[name]}
    </div>
  )
}

export default function App() {
  const setCnt = useState(0)[1]
  useEffect(() => {
    const timer = window.setInterval(() => {
      setCnt(v => v + 1)
    }, 1000)
    return () => clearInterval(timer)
  }, [setCnt])

  const comp = useMemo(() => {
    return <Comp name="使用 useMemo 作为 children" />
  }, [])

  return (
    <div className="App">
      <Comp name="直接作为 children" />
      {comp}
    </div>
  )
}

8. Пропустить процесс рендеринга, запущенный изменением функции обратного вызова.

Реквизиты для компонентов React можно разделить на две категории. а) Один класс — это атрибуты, влияющие на визуализацию компонента, такие как: данные страницы,getPopupContainerи функция renderProps. б) Другой тип — это функция обратного вызова после компонента Render, например: onClick,onVisibleChange. б) Атрибуты класса не участвуют в процессе рендеринга компонента, потому что б) Атрибуты класса могут быть оптимизированы. Когда b) изменяется свойство класса, повторная визуализация компонента не запускается, но при срабатывании обратного вызова вызывается последняя функция обратного вызова.

Дэн Абрамов вA Complete Guide to useEffectВ статье классная фича, что каждый рендер имеет свой обратный вызов события. Однако эта функция требует, чтобы повторная визуализация компонента запускалась каждый раз, когда изменяется функция обратного вызова, что является компромиссом в процессе оптимизации производительности.

Пример ссылки:Пропустить процесс рендеринга, запущенный изменением функции обратного вызова.. Демо реализовано путем перехвата реквизита подкомпонентов, просто потому, что автор ленив и не хочет его менять.Этот способ реализации также может расширить кругозор читателя. На самом деле эта идея оптимизации должна быть реализована через useMemo/React.memo, и ее легче понять, когда реализуешь через useMemo.

import { Children, cloneElement, memo, useEffect, useRef } from "react"
import { useDeepCompareMemo } from "use-deep-compare"
import omit from "lodash.omit"

let renderCnt = 0

export function SkipNotRenderProps({ children, skips }) {
  if (!skips) {
    // 默认跳过所有回调函数
    skips = prop => prop.startsWith("on")
  }

  const child = Children.only(children)
  const childProps = child.props
  const propsRef = useRef({})
  const nextSkippedPropsRef = useRef({})
  Object.keys(childProps)
    .filter(it => skips(it))
    .forEach(key => {
      // 代理函数只会生成一次,其值始终不变
      nextSkippedPropsRef.current[key] =
        nextSkippedPropsRef.current[key] ||
        function skipNonRenderPropsProxy(...args) {
          propsRef.current[key].apply(this, args)
        }
    })

  useEffect(() => {
    propsRef.current = childProps
  })

  // 这里使用 useMemo 优化技巧
  // 除去回调函数,其他属性改变生成新的 React.Element
  return useShallowCompareMemo(() => {
    return cloneElement(child, {
      ...child.props,
      ...nextSkippedPropsRef.current,
    })
  }, [omit(childProps, Object.keys(nextSkippedPropsRef.current))])
}

// SkipNotRenderPropsComp 组件内容和 Normal 内容一样
export function SkipNotRenderPropsComp({ onClick }) {
  return (
    <div className="case">
      <div className="caseHeader">
        跳过『与 Render 无关的 Props』改变触发的重新 Render
      </div>
      Render 次数为:{++renderCnt}
      <div>
        <button onClick={onClick} style={{ color: "blue" }}>
          点我回调,回调弹出值为 1000(优化成功)
        </button>
      </div>
    </div>
  )
}

export default SkipNotRenderPropsComp

9. Обновление хуков по запросу

Если пользовательский хук предоставляет несколько состояний, а вызывающий объект заботится только об одном состоянии, то другие изменения состояния не должны вызывать повторную визуализацию компонента.

export const useNormalDataHook = () => {
  const [data, setData] = useState({ info: null, count: null })
  useEffect(() => {
    const timer = setInterval(() => {
      setData(data => ({
        ...data,
        count: data.count + 1,
      }))
    }, 1000)

    return () => {
      clearInterval(timer)
    }
  })

  return data
}

Как показано выше,useNormalDataHookПредоставляет вызывающему объекту информацию о двух состояниях и счет. Если вызывающий объект заботится только об информационном поле, то изменение счетчика не должно запускать рендеринг компонента вызывающего объекта.

Обновление по запросу в основном реализуется в два этапа, см.Хуки обновляются по запросу

  1. Сбор зависимостей на основе данных, используемых вызывающей стороной, используемых в демонстрацииObject.definePropertiesвыполнить.
  2. Обновления компонентов запускаются только при изменении зависимостей.

10. Библиотека анимации напрямую изменяет свойства DOM

Эту оптимизацию не следует использовать в бизнесе, но ее все же стоит изучить, и в будущем ее можно применить к библиотекам компонентов. Ссылаться наreact-springРеализация анимации, когда анимация начинается, каждое изменение свойства анимации не приводит к повторному рендерингу компонента, а напрямую изменяет значения связанных свойств в dom.

Пример демонстрации:Онлайн-демонстрация CodeSandbox

import React, { useState } from "react"
import { useSpring, animated as a } from "react-spring"
import "./styles.css"

let renderCnt = 0
export function Card() {
  const [flipped, set] = useState(false)
  const { transform, opacity } = useSpring({
    opacity: flipped ? 1 : 0,
    transform: `perspective(600px) rotateX(${flipped ? 180 : 0}deg)`,
    config: { mass: 5, tension: 500, friction: 80 },
  })

  // 尽管 opacity 和 transform 的值在动画期间一直变化
  // 但是并没有组件的重新 Render
  return (
    <div onClick={() => set(state => !state)}>
      <div style={{ position: "fixed", top: 10, left: 10 }}>
        Render 次数:{++renderCnt}
      </div>
      <a.div
        class="c back"
        style={{ opacity: opacity.interpolate(o => 1 - o), transform }}
      />
      <a.div
        class="c front"
        style={{
          opacity,
          transform: transform.interpolate(t => `${t} rotateX(180deg)`),
        }}
      />
    </div>
  )
}

export default Card

Оптимизация этапа фиксации

Цель этого типа оптимизации — сократить время фазы фиксации, и в этой категории есть только один прием оптимизации.

1. Избегайте обновления состояния компонента в didMount и didUpdate

Эта техника применима не только к didMount, didUpdate, но и к willUnmount, useLayoutEffect и useEffect в особых сценариях (при срабатывании cDU/cDM родительского компонента синхронно будет вызываться useEffect дочернего компонента), в этой статье будет коллективно ссылайтесь на них как на «фиксацию фазового хука».

Рабочий процесс реакцииВторым шагом на этапе фиксации является выполнение перехватчиков фазы фиксации, их выполнение блокирует обновление страницы браузером. Если состояние компонента обновляется в функции ловушки фазы отправки, процесс обновления компонента будет запущен снова, что приведет к удвоению времени.

Как правило, сценарии, в которых состояние компонента обновляется в ловушке фазы фиксации, следующие:

  1. Рассчитайте и обновите производное состояние компонента. В этом сценарии компонент класса должен использоватьgetDerivedStateFromPropsВместо этого хуки-методы функциональные компоненты должны использоватьВыполнить setState при вызове функциивместо этого. После использования двух вышеуказанных методов React завершит новое состояние и производное состояние за одно обновление.
  2. Измените состояние компонента на основе информации DOM. В этом сценарии, если вы не найдете способ не полагаться на информацию DOM, два процесса обновления необходимы, и вы можете использовать только другие методы оптимизации.

исходный код use-swrИспользуется этот метод оптимизации. Когда на интерфейсе есть кешированные данные, use-swr сначала будет использовать кешированные данные интерфейса, а затемrequestIdleCallbackЗатем повторно инициируйте запрос, чтобы получить последние данные. Если use-swr не выполняет эту оптимизацию, она вызовет повторную проверку в useLayoutEffect иУстановите для состояния isValidating значение true, вызывая процесс обновления компонента, что приводит к потере производительности.

Общая оптимизация интерфейса

Такого рода оптимизации существуют во всех интерфейсных фреймворках, и в этой статье основное внимание уделяется применению этих методов к компонентам React.

1. Компоненты монтируются по требованию

Оптимизацию монтирования компонентов по требованию можно разделить на три категории: отложенная загрузка, отложенный рендеринг и виртуальные списки.

ленивая загрузка

В SPA ленивая оптимизация загрузки обычно используется для перехода с одного маршрута на другой. Его также можно использовать для сложных компонентов, которые отображаются после действий пользователя, таких как модуль всплывающего окна, отображаемый после нажатия кнопки (иногда всплывающее окно представляет собой сложную страницу 😌). В этих сценариях преимущества объединения Code Split выше.

Реализация ленивой загрузки осуществляется через динамический импорт Webpack иReact.lazyметод,

Справочный примерlazy-loading. При реализации ленивой оптимизации загрузки следует учитывать не только состояние загрузки, но и отказоустойчивую обработку сбоев загрузки.

import { lazy, Suspense, Component } from "react"
import "./styles.css"

// 对加载失败进行容错处理
class ErrorBoundary extends Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error) {
    return { hasError: true }
  }

  render() {
    if (this.state.hasError) {
      return <h1>这里处理出错场景</h1>
    }

    return this.props.children
  }
}

const Comp = lazy(() => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.5) {
        reject(new Error("模拟网络出错"))
      } else {
        resolve(import("./Component"))
      }
    }, 2000)
  })
})

export default function App() {
  return (
    <div className="App">
      <div style={{ marginBottom: 20 }}>
        实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。
      </div>
      <ErrorBoundary>
        <Suspense fallback="Loading...">
          <Comp />
        </Suspense>
      </ErrorBoundary>
    </div>
  )
}

ленивый рендеринг

Ленивый рендеринг относится к компонентам рендеринга, когда они входят или собираются войти в видимую область. Общие компоненты, такие как Modal/Drawer, визуализируют содержимое компонента, когда свойство visible имеет значение true, что также можно рассматривать как реализацию отложенного рендеринга.

Сценарии использования отложенного рендеринга:

  1. Компоненты появляются на странице несколько раз, а рендеринг компонента занимает время, или компонент содержит запросы к интерфейсу. Если отрисовываются несколько компонентов с запросами, поскольку браузер ограничивает количество одновременных запросов под одним и тем же доменным именем, запросы в других компонентах в видимой области могут блокироваться, что приводит к задержке отображения контента в видимой области.
  2. Компонент, который отображается только после действия пользователя. Это то же самое, что и отложенная загрузка, но при отложенном рендеринге не требуется динамическая загрузка модулей и не требуется учитывать итоговую обработку состояния загрузки и сбоя загрузки, что проще реализовать.

При реализации отложенного рендеринга определяется, появляется ли компонент в видимой области, поreact-visibility-observerконтролировать.

Пример ссылки:ленивый рендеринг

import { useState, useEffect } from "react"
import VisibilityObserver, {
  useVisibilityObserver,
} from "react-visibility-observer"

const VisibilityObserverChildren = ({ callback, children }) => {
  const { isVisible } = useVisibilityObserver()
  useEffect(() => {
    callback(isVisible)
  }, [callback, isVisible])

  return <>{children}</>
}

export const LazyRender = () => {
  const [isRendered, setIsRendered] = useState(false)

  if (!isRendered) {
    return (
      <VisibilityObserver rootMargin={"0px 0px 0px 0px"}>
        <VisibilityObserverChildren
          callback={isVisible => {
            if (isVisible) {
              setIsRendered(true)
            }
          }}
        >
          <span />
        </VisibilityObserverChildren>
      </VisibilityObserver>
    )
  }

  console.log("滚动到可视区域才渲染")
  return <div>我是 LazyRender 组件</div>
}

export default LazyRender

виртуальный список

Виртуальный список — это частный случай отложенного рендеринга. Компоненты виртуального списка:react-windowи реактивно-виртуализированный, оба разработаны одним и тем же автором. react-window — это облегченная версия react-virtualized с более дружественным API и документацией. Поэтому рекомендуется использовать окно реагирования в новых проектах вместо использования более виртуализированного реагирования Star.

Использовать реагирующее окно легко, просто рассчитайте высоту каждого элемента. Высота каждого элемента в приведенном ниже коде составляет 35 пикселей.

Пример ссылки:Официальный пример

import { FixedSizeList as List } from "react-window"
const Row = ({ index, style }) => <div style={style}>Row {index}</div>

const Example = () => (
  <List
    height={150}
    itemCount={1000}
    itemSize={35} // 每项的高度为 35
    width={300}
  >
    {Row}
  </List>
)

Если высота каждого элемента различается, передайте функцию параметру itemSize.

Для этой точки оптимизации автор столкнулся с реальным случаем. В программе найма компании вы можете просмотреть все записи о доставке кандидата через выпадающее меню. Обычно этот список состоит всего из десятков элементов, но позже пользователи сообщили, что «отображение списка доставки после нажатия раскрывающегося меню занимает много времени». Причина этой проблемы в том, что у этого кандидата в нашей системе есть тысячи отправленных материалов, и одновременное отображение тысяч отправленных материалов приводит к зависанию страницы. Поэтому в процессе разработки при столкновении со всеми данными, возвращаемыми интерфейсом, необходимо заранее предотвращать такие баги и использовать оптимизацию виртуального списка.

2. Пакетное обновление

Давайте сначала вспомним распространенный вопрос интервью React, заданный несколько лет назад: является ли setState в компонентах класса React синхронным или асинхронным? Если вы не знакомы с компонентами класса, вы можете понимать setState как второе возвращаемое значение useState.

balabala...

Ответ таков: в обратных вызовах событий и жизненных циклах, управляемых React, setState является асинхронным, тогда как в других случаях setState является синхронным. Основная причина этой проблемы заключается в том, что React обновляет setState партиями в обратном вызове события и жизненном цикле, которым он управляет сам, и немедленно обновляет в другое время. Читатели могут обратиться к онлайн-примеруsetState синхронный или асинхронный, и убедитесь в этом сами.

При пакетном обновлении setState многократное выполнение setState вызовет процесс рендеринга только один раз. Напротив, когда setState обновляется немедленно, процесс рендеринга будет запускаться каждый раз, когда выполняется setState, и это влияет на производительность.

Предположим, есть следующий код компонента, компонент находится вgetData()После возврата результата запроса API два состояния соответственно обновляются. Онлайн-справочник по коду:пакетные обновления.

function NormalComponent() {
  const [list, setList] = useState(null)
  const [info, setInfo] = useState(null)

  useEffect(() => {
    ;(async () => {
      const data = await getData()
      setList(data.list)
      setInfo(data.info)
    })()
  }, [])

  return <div>非批量更新组件时 Render 次数:{renderOnce("normal")}</div>
}

Компонент будет вsetList(data.list)После запуска процесса рендеринга компонента, а затем вsetInfo(data.info)Затем снова запустите процесс рендеринга, что приведет к потере производительности. При возникновении этой проблемы у разработчиков есть два способа реализовать пакетное обновление для решения этой проблемы:

  1. Объединить несколько штатов в один штат. Например, используйтеconst [data, setData] = useState({ list: null, info: null })Замените два списка состояний и информацию.
  2. Используйте официально предоставленный React метод нестабильности_batchedUpdates для инкапсуляции нескольких setStates в обратный вызов нестабильности_batchedUpdates. Модифицированный код выглядит следующим образом.
function BatchedComponent() {
  const [list, setList] = useState(null)
  const [info, setInfo] = useState(null)

  useEffect(() => {
    ;(async () => {
      const data = await getData()
      unstable_batchedUpdates(() => {
        setList(data.list)
        setInfo(data.info)
      })
    })()
  }, [])

  return <div>批量更新组件时 Render 次数:{renderOnce("batched")}</div>
}

расширить знания

  1. Рекомендуемое чтениеПочему setState асинхронный?
  2. Почему интервьюеры не спрашивают, является ли setState в функциональных компонентах синхронным или асинхронным? Поскольку функция, созданная в функциональном компоненте, обращается к состоянию через замыкание, а не к состоянию через this.state, состояние в функции обработки функционального компонента должно быть старым значением, а не новым значением. Можно сказать, что функциональный компонент заблокировал этот вопрос, поэтому интервьюер его не задаст. Может относиться конлайн-пример.
  3. в соответствии софициальная документация, в параллельном режиме React setState будет выполняться в пакетных обновлениях по умолчанию. К тому времени эта оптимизация может уже не понадобиться.

3. Обновляйте по приоритету и своевременно отвечайте пользователям

Приоритетное обновление — это операция, обратная пакетному обновлению. Идея состоит в том, чтобы сначала реагировать на поведение пользователя, а затем выполнять трудоемкие операции.

Распространенный сценарий: на странице появляется модальное окно, и когда пользователь нажимает кнопку «ОК» в модальном окне, код выполняет две операции. а) Закрыть модальный. б) Страница обрабатывает данные, возвращаемые Modal, и отображает их пользователю. Когда для выполнения действия б) требуется 500 мс, пользователь заметно почувствует задержку с момента нажатия кнопки до закрытия модального окна.

Пример ссылки:Онлайн-демонстрация CodeSandbox. В этом примере после того, как пользователь добавит целое число, страница должна скрыть поле ввода, добавить новое добавленное целое число в список целых чисел и отсортировать список перед его отображением. Ниже приведена общая реализация,slowHandleФункция действует как функция обратного вызова для пользователя, чтобы щелкнуть кнопку.

const slowHandle = () => {
  setShowInput(false)
  setNumbers([...numbers, +inputValue].sort((a, b) => a - b))
}

slowHandle()Процесс выполнения занимает много времени, и пользователь явно почувствует, что страница зависла после нажатия на кнопку. Если странице отдан приоритет для скрытия поля ввода, пользователь может немедленно воспринять обновление страницы, и задержки не будет.Смысл реализации приоритетного обновления состоит в том, чтобы переместить трудоемкую задачу к следующей задаче макроса для выполнения, отдавая приоритет реагированию на поведение пользователя.Например, в этом примереsetNumbersПеремещенный в обратный вызов setTimeout, пользователь может видеть, что поле ввода скрыто сразу после нажатия кнопки, и не воспринимает зависание страницы. Оптимизированный код выглядит следующим образом.

const fastHandle = () => {
  // 优先响应用户行为
  setShowInput(false)
  // 将耗时任务移动到下一个宏任务执行
  setTimeout(() => {
    setNumbers([...numbers, +inputValue].sort((a, b) => a - b))
  })
}

4. Оптимизация кеша

Оптимизация кэша часто является самым простым и эффективным способом оптимизации, и useMemo обычно используется в компонентах React для кэширования результатов последнего вычисления. Если зависимости useMemo не изменились, перерасчет не будет запущен. Обычно он используется в сценариях, где «код для вычисления производного состояния» требует очень много времени, например, при просмотре большого списка для получения статистической информации.

расширить знания

  1. React официально не гарантирует, что useMemo будет закэширован, поэтому он все равно может выполнять перерасчет, когда зависимости не меняются. Ссылаться наHow to memoize calculations
  2. useMemo может кэшировать только результаты самого последнего выполнения функции.Если вы хотите кэшировать результаты большего количества выполнений функций, вы можете использоватьmemoizee.

5. Debounce и Throttle оптимизируют часто вызываемые обратные вызовы

В компоненте поиска обратный вызов поиска запускается при изменении содержимого ввода. Когда компонент быстро обрабатывает результаты поиска, пользователь не испытывает задержки ввода. Однако в реальном сценарии страница списка промежуточного и фонового приложений очень сложна, и рендеринг результатов поиска компонентами приведет к зависанию страницы, что, очевидно, повлияет на ввод данных пользователем.

Обычно используется в сценариях поискаuseDebounce+ useEffect для получения данных.

Пример ссылки:debounce-search.

import { useState, useEffect } from "react"
import { useDebounce } from "use-debounce"

export default function App() {
  const [text, setText] = useState("Hello")
  const [debouncedValue] = useDebounce(text, 300)

  useEffect(() => {
    // 根据 debouncedValue 进行搜索
  }, [debouncedValue])

  return (
    <div>
      <input
        defaultValue={"Hello"}
        onChange={e => {
          setText(e.target.value)
        }}
      />
      <p>Actual value: {text}</p>
      <p>Debounce value: {debouncedValue}</p>
    </div>
  )
}

Зачем использовать debounce вместо дросселя в сценариях поиска?

дроссель — это особый сценарий устранения дребезга, дроссель передает параметр maxWait для устранения дребезга, см.useThrottleCallback. В сценарии поиска вам нужно только ответить на последний ввод пользователя, не отвечая на промежуточное значение ввода пользователя.Debounce больше подходит для использования в этом сценарии. И дроссель больше подходит для сценариев, которые должны реагировать на пользователей в режиме реального времени, например, регулировать размер путем перетаскивания или увеличения и уменьшения масштаба путем перетаскивания (например, событие изменения размера окна). В сценариях реагирования в реальном времени на действия пользователя, если время обратного вызова невелико, можно даже использовать requestAnimationFrame вместо дросселя.

React Profiler находит узкое место в процессе рендеринга

React Profiler — это официальный инструмент для проверки производительности, предоставляемый React. В этой статье представлен только опыт автора по его использованию. Пожалуйста, перейдите к подробному руководству.Официальная документация сайта.

Profiler записывает только время, затраченное на процесс рендеринга.

С помощью React Profiler разработчики могут просматривать время, затрачиваемое на процесс рендеринга компонента, но не могут знать, сколько времени занимает этап отправки. Хотя на панели Profiler есть поле Committed at, это поле относится к времени начала записи и не имеет никакого значения. Итак, напомните читателюНе находите узкие места производительности в процессах, не связанных с рендерингом, с помощью профилировщика..

Включите статистику производительности Chrome и React Profiler, поэкспериментировав с React v16. Как показано на рисунке ниже, на панели «Производительность» фаза согласования и фаза фиксации заняли 642 мс и 300 мс соответственно, в то время как на панели «Профилировщик» отображалось только 642 мс, а информация о фазе фиксации отсутствовала.

расширить знания

  1. React удалил функцию статистики пользовательского времени после версии 17. По конкретным причинам см.PR#18417.
  2. На версии v17 автор тоже прошелтестовый кодПроверено, что статистика в Profiler не включает стадию отправки, и заинтересованные читатели могут посмотреть.

Включить «Записать причину обновления компонента»

Щелкните шестеренку на панели, а затем установите флажок «Записывать, почему каждый компонент отображается при профилировании», как показано ниже.

Затем щелкните на виртуальном DOM-узле в панели, и справа отобразится причина повторного рендеринга компонента.

Найдите причину этого процесса рендеринга

Из-за механизма пакетного обновления React создание процесса рендеринга может включать обновления состояния многих компонентов. Итак, как определить, какие обновления состояния компонента вызваны?

В древовидной структуре виртуального DOM в левой части панели Profiler просмотрите каждый компонент, который был отрендерен (не выделен серым цветом) сверху вниз. Если компонент запускает процесс рендеринга из-за изменения состояния или хука, то это именно тот компонент, который мы ищем, как показано ниже.

Суммировать

В этой статье мы впервые освоили рабочий процесс React.Обновление состояния компонента React разделено на этап согласования и этап отправки. Точка оптимизации производительности проекта React в основном сосредоточена на этапе согласования, и оптимизация достигается за счет максимально возможного исключения попадания ненужных компонентов в процесс рендеринга. В реальном проекте, если вы столкнулись с проблемами производительности, сначала определите причину трудоемкости с помощью React Profiler, а затем выберите методы оптимизации в соответствии с конкретной ситуацией.

Выберите методы оптимизации

  1. Если это связано с тем, что в процессе рендеринга есть ненужные обновленные компоненты, выберитеПропускать ненужные обновления компонентовоптимизировать.
  2. Если это связано с тем, что на странице смонтировано слишком много невидимых компонентов, выберителенивая загрузка,ленивый рендерингиливиртуальный списокоптимизировать.
  3. Если состояние обновляется несколько раз из-за нескольких настроек состояния, выберитеМассовое обновлениеилиdebounce, дроссельная оптимизация часто вызываемых обратных вызововоптимизировать.
  4. Если логика рендеринга компонента действительно требует много времени, нам нужно найти трудоемкий код и определить, можно ли его оптимизировать с помощью кэширования. Если да, выберитеоптимизация кеша, иначе выберитеОбновления по приоритету для своевременного ответа пользователям, разберите логику компонента, чтобы быстрее отвечать пользователям.

Эпилог

Автор начал писать эту статью несколько лет назад, а с момента ее публикации прошел месяц, за это время я то и дело добавлял в статью свое понимание React за последние несколько лет, а затем корректировал формулировку и обогащал Примеры Наконец, до четверга Done (четверг - мой крайний срок 😌).

Теперь, когда я приложил столько усилий, будем надеяться, что это станет отличным руководством по оптимизации React.


Если у вас есть вопросы по статье или у вас есть дополнительные советы по оптимизации, добро пожаловать в комментарии.