Pull to refresh
1946.18

React.js: размышления об управлении состоянием и повторном рендеринге

Reading time14 min
Views16K



Привет, друзья!


В этой статье я хочу поговорить с вами об управлении состоянием и повторном рендеринге в React.js.


Что такое состояние и зачем им управлять?


Состояние/state можно определить как любые данные, которые влияют на рендеринг/rendering компонентов. Состояние хранится в реактивных переменных/reactive variables ](в терминологии RxJS).


Управление состоянием/state management — это механизм, позволяющий создавать реактивные переменные, фиксировать изменения их значений и уведомлять об этом "заинтересованные" компоненты. Как правило, такой механизм реализуется с помощью паттерна Издатель-Подписчик/Publisher-Subscriber/Pub-Sub.


Реакция компонентов на изменение состояния часто, но далеко не всегда завершается добавлением/модификацией/удалением HTML-элементов в/из DOM. Как известно, операции, связанные с манипулированием DOM, являются очень медленными. Это обуславливает необходимость грамотного управления состоянием приложений.


Состояние бывает 2 видов:


  • локальное/local: для определения такого состояние используется хук useState;
  • распределенное/shared: для создания такого состояния используется хук useContext (часто в сочетании с хуком useReducer) или библиотеки типа Redux.

В свою очередь, распределенное состояние условно также можно разделить на 2 вида:


  • совместно размещенное/co-located: такое состояние является общим для группы автономных, т.е. не находящихся в отношениях предок-потомок/parent-child, компонентов (useContext/useReducer; в качестве примера библиотеки, реализующей такой подход, можно назвать Recoil);
  • глобальное/global — такое состояние является общим для всего приложения (Redux).

Отличная иллюстрация того, чем следует руководствоваться при определении вида состояния:





Существует еще один подход к управлению состоянием, предназначенный для приложений, где большая часть состояния хранится на сервере. Такой подход предлагает, в частности, библиотека React Query. Управление распределенным состоянием клиента в этом случае, как правило, реализуется за счет кеширования. Условно данный подход можно назвать смешанным/mixed.


Чуть позже мы рассмотрим паттерн, позволяющий управлять состоянием приложения наиболее простым и эффективным способом.


Что такое рендеринг и почему он происходит?


Рендеринг — это вычисление компонентов (их структуры, дочерних компонентов, свойств и других данных), часто завершающаяся добавлением/модификацией/удалением HTML-элементов в/из DOM.


Выполнение кода функции (компонента) не всегда завершается отрисовкой/перерисовкой компонента в DOM. Для определения необходимости в манипулировании DOM React использует множество эвристических техник: объект виртуальный DOM/virtual DOM или, если быть точнее, волокно/fiber, алгоритм согласование DOM/DOM diffing (опять же, в настоящее время речь идет о согласовании волокон), атрибут ключ/key и т.д.


Рендеринг бывает 2 видов:


  • первоначальный/initial: происходит при инициализации приложения;
  • повторный/rerendering: происходит при определенных условиях (см. ниже).

Для управления первоначальным рендерингом предназначены такие вещи как утилита lazy и компонент верхнего уровня Suspense, позволяющие выполнять разделение кода/code splitting, т.е. загружать (в этом React помогает Webpack) и выполнять только тот JS-код, который используется приложением в данный момент, а также условный рендеринг/conditional rendering компонентов, когда рендерятся только те компоненты, которые "соответствуют" текущему состоянию приложения.


Повторный рендеринг происходит в следующих случаях:


  • изменение состояния компонента;
  • изменение значений пропов/props, передаваемых компоненту;
  • повторный рендеринг родительского компонента.

Отличное визуальное руководство по повторному рендерингу в React.


Потребление контекста/context consuming также приводит к повторному рендерингу компонента. Это происходит при изменении передаваемых через контекст значений, которые используются (потребляются) компонентом. Поскольку чаще всего через контекст передается объект, это происходит почти всегда.


Для управления повторным рендерингом предназначены такие вещи как:


  • хук useCallback, позволяющий запомнить (мемоизировать/memoize) вычисление дорогостоящих с точки зрения производительности функций (обработчиков);
  • хук useMemo, который позволяет мемоизировать вычисление объектов (в том числе, передаваемых в качестве пропов другим компонентам) и вложенных элементов, таких как списки;
  • утилита memo, позволяющая мемоизировать компоненты за счет поверхностного/shallow сравнения передаваемых им пропов.

Для мониторинга повторного рендеринга предназначены такие вещи как:



Чуть позже мы рассмотрим примеры использования этих инструментов.


Как сделать хорошо?


Разработаем простое React/TypeScript-приложение и научимся управлять его состоянием, а также контролировать рендеринг его компонентов.


Песочница

Создаем шаблон приложения с помощью create-react-app:


yarn create react-app my-app --template typescript
# or
npx create-react-app ...

Наше приложение будет состоять из 2 компонентов: RandomString и RandomHexColor. Каждый из этих компонентов будет состоять из 3 компонентов: Container, Button и компонент, объединяющий их в единое целое.


Нам потребуется утилита для создания хранилища состояния (createStore), а также хук для вывода в консоль сообщений о рендеринге компонентов, начиная со второго рендеринга (useLogAfterFirstRender).


Структура директории src будет следующей:


- components
 - RandomHexColor
   - Button.tsx
   - Container.tsx
   - index.tsx
 - RandomString
   - Button.tsx
   - Container.tsx
   - index.tsx
 - index.ts
- hooks
 - useLogAfterFirstRender.ts
- types
 - index.d.ts
- utils
 - createStore.tsx
- App.scss
- App.tsx
- index.tsx

Состояние будет общим (распределенным) для компонентов RandomString и RandomHexColor. Имеет смысл начать с разработки утилиты для создания хранилища.


Вот чем я руководствовался при реализации данной утилиты:


  • утилита должна принимать объект (хранилище/store) с состоянием (state) и сеттерами (setters) — методами для изменения частей состояния/state slices или всего состояния;
  • сеттеры должны мемоизироваться во избежание повторного вычисления, но при этом всегда иметь доступ к актуальному состоянию за счет декорирования;
  • провайдеры состояния и сеттеров должны быть автономными, чтобы повторное вычисление состояния не приводило к повторному рендерингу компонентов, потребляющих сеттеры;
  • утилита должна возвращать провайдер и хуки для извлечения состояния и сеттеров.

Начнем с определения типов для утилиты (types/index.d.ts) и дочерних компонентов:


// тип для состояния, передаваемого утилите
export type State = { [k: string]: any }
// тип для начальных сеттеров
export type InitialSetters = {
 [k: string]: (s: State, ...args: any[]) => void | Partial<State>
}
// тип для проксированных сеттеров
export type ProxySetters = { [k: string]: (v: any) => void }
// тип для хранилища
export type Store = { state: State; setters: InitialSetters }
// тип для дочерних компонентов
export type Children = { children: React.ReactNode }

Сигнатура объекта хранилища, передаваемого утилите:


const store = {
 state: {
   stateSlice: value
 },
 setters: {
   setter: (state, args) => stateSlice
 }
}

Приступаем к реализации утилиты.


Импортируем хуки и типы:


import React, { createContext, useContext, useMemo, useState } from 'react'
import { State, InitialSetters, ProxySetters, Store, Children } from 'types'

Определяем утилиту для проксирования сеттеров:


const createSetters = (
 setters: InitialSetters,
 setState: React.Dispatch<(prevState: State) => State>
) => {
 const _setters = {} as ProxySetters

 for (const key in setters) {
   _setters[key] = (...args) => {
     setState((state) => {
       const newState = setters[key](state, ...args)

       return { ...state, ...newState }
     })
   }
 }

 return _setters
}

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


Определяем утилиту для создания хранилища:


export function createStore(store: Store) {
 // разделяем контексты и провайдеры (ниже)
 const StateContext = createContext<State>(store.state)
 const SetterContext = createContext<ProxySetters>(store.setters)

 const Provider = ({ children }: Children) => {
   const [state, setState] = useState(store.state)
   // это позволит избежать повторного рендеринга компонентов, потребляющих сеттеры (кнопок)
   const setters = useMemo(() => createSetters(store.setters, setState), [])

   return (
     <StateContext.Provider value={state}>
       <SetterContext.Provider value={setters}>
         {children}
       </SetterContext.Provider>
     </StateContext.Provider>
   )
 }

 const useStore = () => useContext(StateContext)
 const useSetter = () => useContext(SetterContext)

 return [Provider, useStore, useSetter] as const
}

Используем данную утилиту для создания хранилища в App.tsx.


Импортируем стили, тип, утилиту и компоненты:


import './App.scss'
import { Store } from 'types'
import { createStore } from 'utils/createStore'
import { RandomString, RandomHexColor } from 'components'

Создаем хранилище:


const store: Store = {
 state: {
   randomString: '',
   randomHexColor: ''
 },
 setters: {
   setRandomString: (_, randomString) => ({ randomString }),
   setRandomHexColor: (_, randomHexColor) => ({ randomHexColor })
 }
}

Наше хранилище содержит 2 реактивные переменные randomString и randomHexColor и 2 метода для изменения значений этих переменных: setRandomString и setRandomHexColor, соответственно.


Создаем хранилище, экспортируем хуки и оборачиваем компоненты в провайдер:


// поскольку утилита возвращает массив,
// мы вольны именовать провайдер и хуки как угодно,
// что способствует совместному размещению состояний,
// позволяя избежать путаницы в провайдерах/хуках
export const [Provider, useStore, useSetter] = createStore(store)

function App() {
 return (
   <Provider>
     <RandomString />
     <RandomHexColor />
   </Provider>
 )
}

export default App

Стили (`App.scss`)
// шрифт
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');

// переменные
$primary: #0275d8;
$success: #5cb85c;
$warning: #f0ad4e;
$light: #f7f7f7;
$dark: #292b2c;

// миксин для сброса стилей
@mixin reset($font-family, $font-size, $color) {
 margin: 0;
 padding: 0;
 box-sizing: border-box;
 @if $font-family {
   font-family: $font-family;
 }
 @if $font-size {
   font-size: $font-size;
 }
 @if $color {
   color: $color;
 }
}

// миксин для центрирования
@mixin flex-center($column: false) {
 display: flex;
 justify-content: center;
 align-items: center;

 @if $column {
   & {
     flex-direction: column;
   }
 }
}

*,
*::before,
*::after {
 // применяем миксин
 @include reset('Montserrat', 1rem, $dark);
}

#root {
 min-height: 100vh;
 @include flex-center(true);

 // свойство gap не работает в Firefox, поэтому приходится делать так
 div + div {
   margin-top: 1rem;
 }

 .random-string,
 .random-color {
   @include flex-center(true);
 }

 .random-color {
   div {
     width: 120px;
     height: 120px;
     border-radius: 4px;
     box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
   }
 }

 button {
   margin: 0.5rem;
   padding: 0.5rem 1rem;
   border: none;
   outline: none;
   border-radius: 4px;
   background-color: $primary;
   color: $light;
   box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
   cursor: pointer;
   user-select: none;

   &:active {
     box-shadow: none;
   }
 }

 .color-button {
   background-color: $success;
 }

 .reload-button {
   background-color: $warning;
   color: $dark;
 }
}

Не забываем установить sass: yarn add -D sass или npm i -D sass.


Хук для вывода в консоль сообщений о рендеринге (hooks/useLogAfterFirstRender.ts):


import { useEffect, useRef } from 'react'

export const useLogAfterFirstRender = (message: string) => {
 const firstRender = useRef(true)

 useEffect(() => {
   firstRender.current = false
 }, [])

 if (!firstRender.current) {
   console.log(message)
 }
}

Теперь займемся компонентами.


Контейнер для случайной строки (RandomString/Container.tsx):


import { useStore } from 'App'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

export const Container = () => {
 // извлекаем значение переменной `randomString`
 const { randomString } = useStore()

 useLogAfterFirstRender('Random string container')

 return <p>{randomString || 'qwerty'}</p>
}

Кнопка для установки значения строки (RandomString/Button.tsx):


import { useCallback } from 'react'
import { useSetter } from 'App'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

// функция для генерации случайной строки
const getRandomString = () => Math.random().toString(36).slice(2)

export const Button = () => {
 // извлекаем сеттер для изменения значения переменной `randomString`
 const { setRandomString } = useSetter()

 useLogAfterFirstRender('Random string button')

 // мемоизируем обработчик нажатия кнопки
 const onClick = useCallback(() => {
   const randomString = getRandomString()
   // используем сеттер
   setRandomString(randomString)
 }, [setRandomString])

 return <button onClick={onClick}>Set random string</button>
}

Компонент RandomString (RandomString/index.tsx):


import { Container } from './Container'
import { Button } from './Button'

export const RandomString = () => (
 <div className='random-string'>
   <Container />
   <Button />
 </div>
)

Компонент RandomHexColor:


// RandomHexColor/Container.tsx
import { useStore } from 'App'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

export const Container = () => {
 // извлекаем значение переменной `randomHexColor`
 const { randomHexColor } = useStore()

 useLogAfterFirstRender('Random HEX color container')

 // мемоизируем вычисление стилей
 const containerStyles = useMemo(
   () => ({
     backgroundColor: randomHexColor || 'deepskyblue'
   }),
   [randomHexColor]
 )

 return <div style={containerStyles}></div>
}

// RandomHexColor/Button.tsx
import { useCallback } from 'react'
import { useSetter } from 'App'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

// функция для генерации случайного цвета
const getRandomHexColor = () =>
 `#${((Math.random() * 0xffffff) << 0).toString(16)}`

export const Button = () => {
 // извлекаем сеттер для изменения значения переменной `randomHexColor`
 const { setRandomHexColor } = useSetter()

 useLogAfterFirstRender('Random HEX color button')

 const onClick = useCallback(() => {
   const randomHexColor = getRandomHexColor()
   // используем сеттер
   setRandomHexColor(randomHexColor)
 }, [setRandomHexColor])

 return (
   <button onClick={onClick} className='color-button'>Set random HEX color</button>
 )
}

Выполняем повторный экспорт компонентов (components/index.ts):


export { RandomString } from './RandomString'
export { RandomHexColor } from './RandomHexColor'

Посмотрим, как работает наше приложение. Выполняем команду yarn start или npm start:





Нажимаем на кнопку Set random string:





Ожидаемо меняется значение строки.


Но что это? В консоли мы видим сообщения о повторном рендеринге как компонента RandomString, так и компонента RandomHexColor. Но мы же ничего не делали с RandomHexColor. Почему же он подвергся повторному рендерингу?


Обратите внимание:


  • повторный рендеринг не означает перерисовки компонента Random HEX color container, а лишь его повторное вычисление;
  • кнопки повторно не рендерятся. Этим мы обязаны мемоизации сеттеров, выполненной при создании хранилища.

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


Определим отображаемые названия компонентов:


Container.displayName = 'Random string container'
// и т.д.

Переходим во вкладку Profiler инструментов разработчика в браузере.


Настраиваем его: нажимаем на иконку шестеренки View settings и выбираем Highlight updates when components render. на вкладке General и Record why each component rendered while profiling. на вкладке Profiler:








Закрываем панель настроек профилировщика и нажимаем на стрелку Reload and start profiling в левом верхнем углу рядом с иконкой записи:





Нажимаем на кнопку Set random string, видим подсветку повторного рендеринга.


Нажимаем на иконку записи для ее остановки и переключаем представление на Ranked chart:





В правом верхнем углу мы видим текущий рендеринг и общее количество рендерингов (1 / 2). Сейчас мы наблюдаем результаты первоначального рендеринга, о чем говорит сообщение при наведении курсора на любой компонент:





Переключаемся на результаты второго рендеринга и наводим курсор на компонент Random HEX color container:





Сообщение говорит нам о том, что повторный рендеринг данного компонента произошел по причине повторного рендеринга его родительского компонента, т.е. провайдера контекста (Context.Provider) (его мы видим ниже). Это верно, но лишь отчасти.


Устанавливаем пакет why-did-you-render:


yarn add -D @welldone-software/why-did-you-render
# or
npm i ...

Подключаем его в src/index.tsx для режима разработки:


if (process.env.NODE_ENV === 'development') {
 const whyDidYouRender = require('@welldone-software/why-did-you-render')
 whyDidYouRender(React, {
   titleColor: 'green',
   diffNameColor: 'blue',
   logOnDifferentValues: true,
   trackHooks: true
 })
}

Обратите внимание: после перехода CRA на react-scripts v4 (где используется новый способ трансформации JSX в JS) данная утилита и ряд других какое-то время не работали. Если у вас возникнут проблемы, связанные с тем, что wdyr не выводит сообщения в консоль, поищите ответ в этом обсуждении.


Регистрируем компонент RandomHexColor/Container:


Container.whyDidYouRender = true

На всякий случай перезапускаем сервер для разработки.


Снова нажимаем на кнопку Set random string и получаем гораздо более информативное сообщение:





Сначала мы видим, что значение переменной randomString изменилось с "" на "wpkhijfo7xi", затем, что значение, передаваемое через контекст, изменилось с { randomString: '', randomHexColor: '' } на { randomString: 'wpkhijfo7xi', randomHexColor: '' }.


Так вот в чем дело! При изменении значения любой реактивной переменной генерируется новый объект.


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


Однако предположим, что повторное вычисление компонента Random HEX color container крайне нежелательно. Допустим, мы хотим, чтобы этот компонент подвергался повторному рендерингу только при изменении значения переменной randomHexColor. Как нам этого добиться? Существует один способ, но он имеет некоторые побочные эффекты.


Данный способ заключается в мемоизации компонента с помощью memo. Но для того, чтобы у нас появились пропы для сравнения, состояние необходимо поднять/lift до ближайшего родительского компонента. В нашем случае таким компонентом является RandomHexColor/index.tsx. Перепишем его следующим образом:


import { useStore } from 'App'
import { Button } from './Button'
import { Container } from './Container'

export const RandomHexColor = () => {
 const { randomHexColor } = useStore()

 return (
   <div className='random-color'>
     {/* передаем переменную `randomHexColor` как проп */}
     <Container randomHexColor={randomHexColor} />
     <Button />
   </div>
 )
}

Отрефакторим компонент RandomHexColor/Container.tsx:


import { memo } from 'react'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

type Props = {
 randomHexColor: string
}

// мемоизируем компонент
export const Container = memo(({ randomHexColor }: Props) => {
 useLogAfterFirstRender('Random HEX color container')

 const containerStyles = useMemo(
   () => ({
     backgroundColor: randomHexColor || 'deepskyblue'
   }),
   [randomHexColor]
 )

 return <div style={containerStyles}></div>
})

Возвращаемся в браузер и нажимаем Set random string:





Компонент Random HEX color container больше не рендерится при изменении значения переменной randomString, но за все приходится платить: теперь повторно рендерится компонент Random HEX color button, поскольку повторному рендерингу подвергается его предок (RandomHexColor). Другими словами, наша оптимизация нивелировала выгоду от предварительной мемоизации сеттеров.


Мемоизируем компонент Random HEX color button с помощью memo:


import { memo, useCallback, useState } from 'react'
import { useSetter } from 'App'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

// мемоизируем компонент
export const Button = memo(() => {
 const { setRandomHexColor } = useSetter()

 useLogAfterFirstRender('Random HEX color button')

 const onClick = useCallback(() => {
   const randomHexColor = getRandomHexColor()
   setRandomHexColor(randomHexColor)
 }, [setRandomHexColor])

 return (
   <button onClick={onClick} className='color-button'>
     Set random HEX color
   </button>
 )
})

Снова нажимаем Set random string:





Теперь при изменении randomString повторно рендерится только Random string container. Мы добились, чего хотели, но еще раз повторю: наши оптимизации являются преждевременными, в них не было совершенно никакой необходимости.


Последний вопрос: что если нам потребуется выполнить принудительный повторный рендеринг компонента, например, Random HEX color button. Эта задача решается за счет обновления локального состояния. Перепишем данный компонент следующим образом:


import { memo, useCallback, useState } from 'react'
import { useSetter } from 'App'
import { useLogAfterFirstRender } from 'hooks/useLogAfterFirstRender'

const getRandomHexColor = () =>
 `#${((Math.random() * 0xffffff) << 0).toString(16)}`

export const Button = memo(() => {
 const { setRandomHexColor } = useSetter()
 // создаем локальное состояние
 const [, setState] = useState({})

 const forceUpdate = useCallback(() => {
   // обновление состояния повлечет за собой повторный рендеринг
   setState({})
 }, [])

 useLogAfterFirstRender('Random HEX color button')

 const onClick = useCallback(() => {
   const randomHexColor = getRandomHexColor()
   setRandomHexColor(randomHexColor)
 }, [setRandomHexColor])

 return (
   <>
     <button onClick={onClick} className='color-button'>
       Set random HEX color
     </button>
     {/* добавляем кнопку для принудительного рендеринга */}
     <button onClick={forceUpdate} className='reload-button'>
       Force update
     </button>
   </>
 )
})

Нажимаем Force update:





Получаем сообщение о рендеринге Random HEX color button.


Пожалуй, это все, чем я хотел поделиться с вами в этой статье.


Надеюсь, вы узнали что-то новое и не зря потратили время.


Благодарю за внимание и happy coding!




Tags:
Hubs:
+5
Comments18

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud