react-afc - библиотека для более простого (чем в простом react) уменьшения количества ненужных ререндеров дочерних компонентов.

Задачи и применение

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

пример

Функционал компонента не несёт конкретного смысла. Просто пример.

import { useState } from 'react'
import Title from 'title-lib'
import HardCalcHeader from './header'
import NameInput from './name-input'
import AgeInput from './age-input'

function App() {
  const [name, setName] = useState('')
  const [age, setAge] = useState(1)

  const onChangeName = value => setName(value)
  const onChangeAge = value => setAge(value)
  const closeWindow = () => window.close()

  const titleArgs = {
    color: 'blue',
    size: 20
  }
  
  return (
    <>
      <Title text='Amazing app' args={titleArgs} />
      <HardCalcHeader onExit={closeWindow} />
        
      <NameInput value={name} onChange={onChangeName} />
      <AgeInput value={age} onChange={onChangeAge} />
    </>
  )
}

При изменении имени происходит перерисовка Title, NameInput, AgeInput, а также HardCalcHeader, что приводит к зависанию приложения.

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

пример с использованием хуков
import { useState, useCallback, useMemo } from 'react'
import Title from 'title-lib'
import HardCalcHeader from './header'
import NameInput from './name-input'
import AgeInput from './age-input'

function App() {
  const [name, setName] = useState('')
  const [age, setAge] = useState(1)

  const onChangeName = useCallback(value => setName(value), [])
  const onChangeAge = useCallback(value => setAge(value), [])
  const closeWindow = useCallback(() => window.close(), [])

  const titleArgs = useMemo(() => ({
    color: 'blue',
    size: 20
  }), [])
  
  return (
    <>
      <Title text='Amazing app' args={titleArgs} />
      <HardCalcHeader onExit={closeWindow} />
        
      <NameInput value={name} onChange={onChangeName} />
      <AgeInput value={age} onChange={onChangeAge} />
    </>
  )
}

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

сравнение
import { afc } from 'react-afc'

// обычный компонент
function CommonComponent(props) {
  // вызывается каждый рендер
  // ...react-хуки
  return <p>обычный компонент</p>
}

// afc компонент
const AdvancedComponent = afc(props => {
  // вызывается один раз за весь жизненный цикл компонента
  // afc-хуки

  return () => {
    // render-функция, вызывается каждый рендер
    // ...react-хуки (только по необходимости)
    return <p>afc</p>
  }
}

Данное изменение позволяет нам во многих случаях не использовать useCallback и useMemo, а также не думать о зависимостях.

пример с использованием библиотеки
import { afc, useState } from 'react-afc'
import Title from 'title-lib'
import HardCalcHeader from './header'
import NameInput from './name-input'
import AgeInput from './age-input'

const App = afc(() => {
  const [getName, setName] = useState('')
  const [getAge, setAge] = useState(1)

  const onChangeName = value => setName(value)
  const onChangeAge = value => setAge(value)
  const closeWindow = () => window.close()

  const titleArgs = {
    color: 'blue',
    size: 20
  }
  
  return () => (
    <>
      <Title text='Amazing app' args={titleArgs} />
      <HardCalcHeader onExit={closeWindow} />
        
      <NameInput value={getName()} onChange={onChangeName} />
      <AgeInput value={getAge()} onChange={onChangeAge} />
    </>
  )
})

Работает аналогично примеру с хуками, никаких лишних перерисовок.

Побочным эффектом является то, что мы больше не нуждаемся в использовании useRef для передачи данных между рендерами.

пример
import { useRef } from 'react'
import { afc } from 'react-afc'

// обычный компонент
function CommonComponent() {
  const renderCount = useRef(0)
  renderCount.current++

  return (
    <p>
      Рендер вызван {renderCount.current} раз
    </p>
  )
}

// afc компонент
const AdvancedComponent = afc(() => {
  let renderCount = 0

  return () => {
    renderCount++
    return (
      <p>
        Рендер вызван {renderCount} раз
      </p>
    )
  }
})

Примечание: в библиотеке имеются аналоги для useState, useRef, useMemo, useEffect, memo. Их применение узкоспециализировано, читайте доку.
Пример работы можете найти на codesandbox.

Принцип работы

При первом рендере библиотека вызывает переданную в afc функцию, определяет какие хуки используются и сохраняет возвращённую рендер-функцию.

При последующих рендерах обновляются свойства в объекте пропсов (если они изменились), выполняются определённые ранее react-хуки и вызывается рендер-функция.

Принцип прост как пробка и требует незначительных вычислений только при первом рендере.

Комментарии (27)


  1. markelov69
    01.01.2024 12:30
    -3

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


    1. VerZsuT Автор
      01.01.2024 12:30

      Можно. Эта библиотека не заменяет (и даже не пытается делать этого) такие инструменты как MobX. Лишь выступает как небольшое добавление функционала к оригинальному react.


    1. DarthVictor
      01.01.2024 12:30

      MobX не будет делать частичного обновления компонента, пока вы его сами не разобьёте на несколько, а тут как я понимаю, пытаются именно в частичное обновление. Это корректнее вроде бы с @preact/signals-react сравнивать. Но я пока не уверен.


      1. VerZsuT Автор
        01.01.2024 12:30
        +1

        @preact/signals это про управление состоянием.
        Тут же единственная фича - инициализатор (или же "конструктор", как угодно), плюс функции, позволяющие в нём использовать аналогичный react-хукам функционал.
        Уменьшение ререндеров следует из этой фичи. Все колбеки и переменные из инициализатора не вызывают ререндер, так как ссылка на них не меняется в течение всего жизненного цикла.


        В обычном функциональном компоненте ссылка на любую функцию будет новая каждый рендер. Также все переменные будут созданы заново. При передаче в компонент они вызовут его ререндер (даже компонента в memo, исключение - строки и числа, они сравниваются по значению).
        Именно по этой причине нет смысла оборачивать в memo компонент, принимающий children,так как это массив и он имеет разную ссылку при каждом рендере.

        Это довольно назойливая проблема реакта, которая в один момент может заставить фризить всё приложение и придётся использовать useMemo / useCallback / useRef.


  1. 19Zb84
    01.01.2024 12:30
    -2

    Какой смысл бороться с тем, что в основе библиотеки лежит ? На мой взгляд это очередной костыль.

    Сделать хлеборезку, а потом апгрейживать ее до утюга.

    Ну так тоже можно.


    1. VerZsuT Автор
      01.01.2024 12:30
      +1

      Если сами разработчики react предоставили инструменты для того чтобы бороться с ререндерами, то почему бы не упростить их использование?
      Если взять UI либу (Antd к примеру, а уж теб более MIU), то ой как встанет вопрос о сокращении лишних отрисовок, ибо даже 20-30 дополнительных отрисовок сложных компонентов будут фризить всё приложение.


      1. 19Zb84
        01.01.2024 12:30
        +1

        Где много отрисовок, лучше реакт вообще не использовать. Если бы разработчики предоставили метод, они бы его как контекст в библиотеку добавили.

        А так, от этой проблемы только костыли. Ее невозможно в реакте решить. По крайней мере сейчас.



  1. Dartess
    01.01.2024 12:30
    +2

    Звучит всё неплохо.

    Из того что бросилось в глаза - изменение сигнатуры useState вместе с сохранением нейминга. Я думаю это будет путать при чтении, все привыкли что useState это value+setter, а не getter+setter. Можно сказать - ну так это же другой useState, но при чтении на месте использования не очевидно, откуда был импорт.

    Также на это имя могут быть завязаны фишки IDE (например парное переименование в jetbrains) или например какие-то правила линтеров.

    Я бы посоветовал посмотреть в сторону имени useSignal, сигналы как раз дают ту же сигнатуру, getter+setter.


    1. VerZsuT Автор
      01.01.2024 12:30

      Хорошее замечание. Обязательно подумаю над переименованием useState в useSignal


  1. nin-jin
    01.01.2024 12:30
    +3

    // afc компонент
    const AdvancedComponent = afc(() => {
      let renderCount = 0
    
      return () => {
        renderCount++
        return (
          <p>
            Рендер вызван {renderCount} раз
          </p>
        )
      }
    })
    // Afc компонент
    class AdvancedComponent extends Afc {
      renderCount = 0
    
      render() {
        this.renderCount++
        return (
          <p>
            Рендер вызван {this.renderCount} раз
          </p>
        )
      }
      
    })

    Oh, wait..


    1. VerZsuT Автор
      01.01.2024 12:30

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


      1. nin-jin
        01.01.2024 12:30

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


        1. VerZsuT Автор
          01.01.2024 12:30

          Гибкость в более простом создании переиспользуемой логики.
          Для классов есть только путь HOC или заморочкой с наследованием/передачей this, что в больших объёмах понижает производительность, да и читается хуже чем обычный хук в функциональщине.

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


          1. nin-jin
            01.01.2024 12:30

            const App = afc(() => {
              const [getName, setName] = useState('')
              const [getAge, setAge] = useState(1)
            
              const onChangeName = value => setName(value)
              const onChangeAge = value => setAge(value)
              const closeWindow = () => window.close()
            
              const titleArgs = {
                color: 'blue',
                size: 20
              }
              
              return () => (
                <>
                  <Title text='Amazing app' args={titleArgs} />
                  <HardCalcHeader onExit={closeWindow} />
                    
                  <NameInput value={getName()} onChange={onChangeName} />
                  <AgeInput value={getAge()} onChange={onChangeAge} />
                </>
              )
            })
            export class App extends Afc {
              @mem name = ''
              @mem age = 1
            
              @act onChangeName = value => { this.name = value }
              @act onChangeAge = value => { this.age = value }
              @act closeWindow = ()=> { window.close() }
            
              titleArgs = {
                color: 'blue',
                size: 20
              }
              
              render () {
                return <>
                  <Title text='Amazing app' args={this.titleArgs} />
                  <HardCalcHeader onExit={this.closeWindow} />
                    
                  <NameInput value={getName()} onChange={this.onChangeName} />
                  <AgeInput value={getAge()} onChange={this.onChangeAge} />
                </>
              }
            }

            Ой, как useState прекрасно читается-то. И с this такие проблемы, ух. А уж переиспользование классов - это вообще что-то из области фантастики, да.


            1. VerZsuT Автор
              01.01.2024 12:30

              Как без HOC добавить классу общий функционал (применяемый в нескольких компонентах), использующий состояние или метод жизненного цикла?
              Ещё и полностью типизированный ;)


              1. nin-jin
                01.01.2024 12:30

                Ну вот у вас без HOF и не получилось это сделать. Вы покажите хоть один пример, где эмуляция классов через функции была бы проще/гибче собственно классов.


              1. strannik_k
                01.01.2024 12:30
                +2

                Для добавления объекту новой функциональности есть 3 подхода - смешивание с другим объектом, обертка другим объектом, замена объектов/функций в полях-ссылках объекта на другие объекты/функции.

                Вариации 3-го подхода:
                Можно вынести метод в функцию вне класса и вызывать ее в соответствующем методе жизненного цикла в нужных компонентах.

                Можно завести в компонентах массив ссылок на подобные функции, тем самым еще и позволяя менять функциональность компонента, чего нет в подходе с хуками.

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

                Реализацию такого подхода я описывал в https://habr.com/ru/articles/545064


  1. Rushelex
    01.01.2024 12:30
    +2

    Мне кажется, когда встаёт вопрос про уменьшение ререндеров путём кардинального обратно-не-совместимого изменения API компонентов, проще совсем уйти от React в сторону библиотек, которые из коробки работают с ререндерами хорошо, например, Preact, Solid или Svelte. Потому что если ваше приложение написано на react-afc и вы используете другие библиотеки для React, то количество ререндеров библиотечных компонентов всё равно останется таким же и это будет бутылочным горлышком, пока создатели библиотек не перепишут их на этот же react-afc или что-то аналогичное.


    1. VerZsuT Автор
      01.01.2024 12:30

      Библиотечные компоненты как правило оптимизированы и сами по себе не вызывают проблем (если их правильно использовать).
      Есть совместимое API у библиотеки, можно писать хуки для обычных и для afc компонентов одновременно, при этом само написание остаётся идентичным в 90% случаев.
      С точки зрения использования компонента разницы нет, те же пропсы, callback'и и всё остальное. Вся работа идёт внутри, а снаружи то же самое API компонента (afc возвращает React.FC).


  1. donatello2005
    01.01.2024 12:30

    В статье не хватает сравнения с memo(). Как понимаю, afc выполняет схожую работу, но после прочтения я, если честно, не понял существенных различий между ними. Возможно, afc глубже работает с хуками?


    1. VerZsuT Автор
      01.01.2024 12:30

      memo работает с пропсами компонента, то есть позволяет не ререндерить сам компонент если входные данные идентичны.
      afc же добавляет инициализатор компонента, позволяя при изменении состояния не ререндерить дочерние компоненты который в этом не нуждаются.
      Также есть afcMemo, который добавляет и то и другое (afc оборачивается в memo)


  1. DarthVictor
    01.01.2024 12:30

    Я правильно понимаю, что в отличие @preact/signals-react, afc не требует интеграции в сборку?


    1. VerZsuT Автор
      01.01.2024 12:30

      Вся библиотека react-afc это лишь обёртка над обычным react.
      Даже сама функция afc лишь создаёт функциональный компонент с некоторым функционалом, который в качестве представления возвращает результат render-функции.

      То есть да, никакая интеграция не требуется.


  1. Themezv
    01.01.2024 12:30

    1. VerZsuT Автор
      01.01.2024 12:30

      Прошло почти два года и пока не стандарт.
      Надеюсь они делают что-то большее чем просто автодобавление даже аналогов useMemo и useCallback. Одно дело когда функция статична и никогда не вызовет ререндер, даже если в ней используется состояние. А другое когда функция на каждое изменение будет всё равно создаваться заново (использование useCallback с зависимостью в виде состояния).


    1. VerZsuT Автор
      01.01.2024 12:30

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