javascript

Как мы боролись с лишними рендерами в react

  • среда, 12 февраля 2025 г. в 00:00:08
https://habr.com/ru/articles/881206/

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

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

Структура страницы
Структура страницы

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

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

Код страницы:

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  const listItems = useMemo(() => {
    return React.Children.map(children, c => {
      const { id, title } = c.props;
      return { id, title };
    });
  }, [children]);

  return (
    <>
      <div>
        {listItems.map(e => {
          return (
            <div key={e.id}>
              {e.title}
              {count[e.id]}
            </div>
          );
        })}
      </div>
      <div>
        {React.Children.map(children, c => {
          const { id } = c.props;
          const clone = React.cloneElement(c, {
            onSetCount: cnt => {
              setCount(cnts => ({ ...cnts, [id]: cnt }));
            }
          });
          return clone;
        })}
      </div>
    </>
  );
};

Все виджеты имеют плюс минус похожую структуру:

//сложный грид или список или график
const Widget = ({ onSetCount }) => {

  // делает запрос данных
  // когда данные доступны, срабатывает эффект ниже  
  
  useEffect(() => {
    onSetCount(data.length);
  }, [onSetCount, data]);
};

Композируем все вместе примерно так:

<Page>
  <Widget id="widget_key" title="Название виджета" />
  ...
</Page>

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

const listItems = useMemo(() => {
  return React.Children.map(children, c => {
    const { id, title } = c.props;
    return { id, title };
  });
}, [children]);
<div>
  {listItems.map(e => {
    return (
      <div key={e.id}>
        {e.title}
        {count[e.id]}
      </div>
    );
  })}
</div>

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

const clone = React.cloneElement(c, {
  onSetCount: cnt => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }
});

Когда очередной виджет сообщает значение своего счетчика, мы обновляем стэйт, который представляет собой объект, ключами которого являются идентификаторы виджетов, а значениями - счетчики.

Выглядит все просто. За исключением того, что этот вариант не работает. Он просто ререндерится бесконечно. Причина этого проста. При вызове cloneElement, мы всегда передаем новую функцию (разную для каждого виджета)

const clone = React.cloneElement(c, {
  onSetCount: cnt => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }
});

В коде же виджета есть такой эффект:

useEffect(() => {
  onSetCount(data.length);
}, [onSetCount, data]);

Он срабатывает, когда onSetCount меняется, и приводит к ререндеру страницы. Ререндер страницы приводи к передачи новой функции onSetCount, что приводит с тому, что срабатывает эффект в виджете и страница ререндерится вновь, и так далее.

Да, конечно, можно убрать onSetCount из зависимостей в useEffect, но за такое не только жизнь, но даже линтер бьет по рукам.

Давайте как-то попробуем мемоизировать передаваемый пропс.

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

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  ...

  const createOnSetCount = useMemo(() => {
    return memoize(id => cnt => {
      setCount(cnts => ({ ...cnts, [id]: cnt }));
    });
  }, []);

  return (
    <>
      ...
      <div>
        {React.Children.map(children, c => {
          const { id } = c.props;
          const clone = React.cloneElement(c, {
            onSetCount: createOnSetCount(id)
          });
          return clone;
        })}
      </div>
    </>
  );
};

То есть теперь мы в onSetCount передаем единожды сконструированную функцию, свою для каждого виджета.

Оно работает, бесконечный рендер ушел. Ура. Но:

  • Код выглядит не просто для такой казалось бы простой задачи.

  • Мы на ровном месте получили зависимость от lodash (да, мы ее используем, у нас продукт живет много лет уже) за счет использования функции memoize. От lodash мы стараемся уходить. Да и вообще, зачем тянуть лишние зависимости, если можно обойтись без них.

  • Каждый виджет все равно рендерится несколько раз, а именно столько раз, сколько у нас виджетов плюс один (начальный рендер). То есть решение у нас получилось не масштабируемым.

Первым шагом избавляемся от lodash, здесь он вообще не в тему:

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  ...

  const onSetCount = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  return (
    <>
      ...
      <div>
        {React.Children.map(children, c => {
          const clone = React.cloneElement(c, {
            onSetCount
          });
          return clone;
        })}
      </div>
    </>
  );
};

Теперь в качестве пропса onSetCount в каждый виджет передается одна и та же функция. Но теперь она требует наличия двух аргументов вместо одного. Виджет обязан в нее передавать свой id.

useEffect(() => {
  onSetCount(id, data.length);
}, [onSetCount, data, id]);

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

Как мы рассуждали. У нас два пути - либо не делать cloneElement, просто рендерить children. Тогда при изменении стейта children не будут перерисовываться, так как children приходит из вне как пропс.

Либо отделить стейт. Тогда cloneElement не будет мешаться и его можно не трогать.

Попробуем первый путь - избавиться от cloneElement.

cloneElement нам здесь нужен для того, чтобы дополнительно виджету передать функцию onSetCount. Как иначе мы можем всем виджетам передать эту функцию кроме как в пропсы? Ключ к разгадке здесь лежит в слове всем. Сразу возникает в голове слово context. На самом деле, react context заслужил не самую хорошую репутацию в плане производительности, но тут, думаю, скорее дело в неаккуратном его использовании. С контекстом наш код превратился в нечто подобное:

const ApiContext = createContext();

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  ...

  const onSetCount = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  return (
    <>
      ...
      <div>
        <ApiContext.Provider value={onSetCount}>{children}</ApiContext.Provider>
      </div>
    </>
  );
};

Код виджетов теперь использует useContext

const Widget = ({ id }) => {
 const onSetCount = useContext(ApiContext);
 useEffect(() => {
    onSetCount(id, data.length);
  }, [onSetCount, data, id]);  
};

Прекрасно. Это сработало. Благодаря использованию контекста и паттерну children as props мы добились желаемого - каждый виджет рендерится ровно один раз.

"А можно все же как-то без контекста" - спросите вы. Да, конечно. И это второй путь, который был у нас в головах. Идея в том, чтобы не использовать контекст, вместо этого вынести работу со стэйтом из страницы.

Создадим для левого меню со счетчиками и названиями виджетов отдельный компонент:

const List = ({ listItems, register }) => {
  const [count, setCount] = useState({});

  const update = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  useEffect(() => {
    register(update);
  }, [register, update]);

  return (
    <div>
      {listItems.map(e => {
        return (
          <div key={e.id}>
            {e.title}
            {count[e.id]}
          </div>
        );
      })}
    </div>
  );
};

Помимо списка элементов для отображения (listItems) пусть этот компонент принимает еще и пропс-функцию register. С помощью этот функции меню будет сообщать в родительскую страницу, какую функцию нужно вызвать в тот момент, когда очередной виджет сообщит ей значение своего счетчика. В функцию register мы передадим функцию update - она занимается изменением стэйта. Сам стэйт так же переехал в компонент List.

Код страницы тогда приобретает следующие черты:

const Page = ({ children }) => {

  ...

  const handler = useRef();

  const register = useCallback(cb => {
    handler.current = cb;
  }, []);

  const onSetCount = useCallback((id, cnt) => {
    handler.current(id, cnt);
  }, []);

  return (
    <>
      <div>
        <List listItems={listItems} register={register} />
      </div>
      <div>
        {React.Children.map(children, c => {
          const clone = React.cloneElement(c, {
            onSetCount
          });
          return clone;
        })}
      </div>
    </>
  );
};

Страница передает в List пропс register.

<List listItems={listItems} register={register} />

Эта функция будет вызвана компонентом List и получит в качестве аргумента колбэк, который сохранит с использованием useRef:

const handler = useRef();

const register = useCallback(cb => {
  handler.current = cb;
}, []);

Когда очередной виджет сообщает странице значение своего счетчика, вызывается функция onSetCount:

const onSetCount = useCallback((id, cnt) => {
  handler.current(id, cnt);
}, []);

Она просто вызывает зарегистрированную List-ом колбэк-функцию. Ее вызов в свою очередь приводит к обновлению стэйта компонента List (благодаря вызову функции update).

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

"В react уже есть этот механизм" - скажите вы. И будете как всегда правы. Хук useImperativeHandle. Давайте посмотрим как мы можем с его помощью переписать наш последний вариант:

const List = React.forwardRef((props, ref) => {
  const [count, setCount] = useState({});

  const update = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  useImperativeHandle(
    ref,
    () => {
      return {
        update
      };
    },
    [update]
  );

  return (
    <div>
      {props.listItems.map(e => {
        return (
          <div key={e.id}>
            {e.title}
            {count[e.id]}
          </div>
        );
      })}
    </div>
  );
});
const Page = ({ children }) => {

  ...

  const list = useRef();

  const onSetCount = useCallback((id, cnt) => {
    list.current?.update(id, cnt);
  }, []);

  return (
    <>
      <div>
        <List ref={list} listItems={listItems} />
      </div>
      <div>
        {React.Children.map(children, c => {
          const clone = React.cloneElement(c, {
            onSetCount
          });
          return clone;
        })}
      </div>
    </>
  );
};

Чуть попроще с одной стороны.

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

Стоит отметить, что скорее всего в данном случае проблему лишних рендеров можно было бы решить одной строчкой - обернуть все виджеты в memo. Но memo имеет свои накладные расходы. Кроме того, не memo единым можно оптимизировать react-приложения. Иногда простая декомпозиция, которую мы применили в данном случае, дает хороший результат.

Спасибо, что дочитали.