Небольшая практика с JS Proxy для оптимизации перерисовок React компонентов при использовании useContext

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

Проблема, которую решаем

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

Цель

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

Идея

Узнавать, что используется компонентом обернув возвращаемое useSmartContextом значение в Proxy.

Реализация

Шаг 1.

Создаём собственно наш хук.

const useSmartContext(context) {
  const usedFieldsRef = useRef(new Set());

  const proxyRef = useRef(
    new Proxy(
      {},
      {
        get(target, prop) {
          usedPropsRef.current.add(prop);
          return context._currentValue[prop];
        }
      }
    )
  );

  return proxyRef.current;
}

Мы завели список, в котором будем хранить используемые поля контекста. Создали прокси с get ловушкой, в которой заполняем этот список. Target нам не важен, так что первым аргументом я передал пустой объект {}.

Шаг 2.

Нужно получать значение контекста при его обновлении и сравнивать значение полей из списка usedPropsRef с предыдущими значениями. Если что-то изменилось, то тригерить ререндеринг. Использовать useContext внутри своего хука мы не может, а то наш хук тоже начнёт вызывать ререндеринг на все изменения. Тут и начинаются танцы с бубном. Изначально я надеялся подписаться на изменения контекста с помощью context.Consumer. А именно вот так:

React.createElement(context.Consumer, {}, (newContextVakue) => {/* handle */})

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

Было решено лезть в исходники React, чтобы найти как это делается в оригинальном хуке useContext. Я, честно, запутался в исходниках и не нашёл ничего, что бы мне сильно помогло. Но за кое что всё-таки зацепился. У контекста есть свойство _currentValue. Оно во время рендеринга устанавливается, а потом сбрасывается сразу в undefined. Пробуем ловить изменения этого свойства! Proxy тут уже не поможет, так как мы не можем сказать реакту использовать наше прокси вместо оригинального объекта. Пробуем Object.defineProperty.


  let val = context._currentValue;
  let notEmptyVal = context._currentValue;
  Object.defineProperty(context, "_currentValue", {
    get() {
      return val;
    },
    set(newVal) {
      if (newVal) {
        // вот оно новое значение контекста!
      }
      val = newVal;
    }
  });

И это работает! Но быстро стала понятна проблема: каждый новый потребитель контекста при использование useSmartContext будет заново делать Object.defineProperty и тем самым отменять все такие же действия предыдущих потребителей контекста. Тут я решил отказаться от цели сделать только один новый хук useSmartContext  и добавить к ней свой конструктор хука вместо createContext.

export const createListenableContext = () => {
  const context = createContext();

  const listeners = [];
  let val = context._currentValue;
  let notEmptyVal = context._currentValue;
  Object.defineProperty(context, "_currentValue", {
    get() {
      return val;
    },
    set(newVal) {
      if (newVal) {
        listeners.forEach((cb) => cb(notEmptyVal, newVal));
        notEmptyVal = newVal;
      }
      val = newVal;
    }
  });

  context.addListener = (cb) => {
    listeners.push(cb);

    return () => listeners.splice(listeners.indexOf(cb), 1);
  };

  return context;
};

Вышло хорошо, хотя и хрупко. Теперь наш хук может подписаться на контекст, созданный этим конструктором

const useSmartContext = (context) => {
  const usedFieldsRef = useRef(new Set());
  useEffect(() => {
    const clear = context.addListener((prevValue, newValue) => {
      let isChanged = false;
      usedFieldsRef.current.forEach((usedProp) => {
        if (!prevValue || newValue[usedProp] !== prevValue[usedProp]) {
          isChanged = true;
        }
      });

      if (isChanged) {
        // надо ререндерить
      }
    });

    return clear;
  }, [context]);

  const proxyRef = useRef(
    new Proxy(
      {},
      {
        get(target, prop) {
          usedFieldsRef.current.add(prop);
          return context._currentValue[prop];
        }
      }
    )
  );

  return proxyRef.current;
};

Шаг 3.

Нужно заставить компонент перерисоваться. Для этого я решил завести в хуке своё булево состояние с помощью useState и менять его, когда нужно перерендерить компонент. Выглядит довольно костыльно, но работает. Есть у кого-то идеи как это сделать лучше?

// ...
const [, rerender] = useState();
const renderTriggerRef = useRef(true);
// ...  
if (isChanged) {
  renderTriggerRef.current = !renderTriggerRef.current;
  rerender(renderTriggerRef.current);
}

Со всем, что получилось можете поиграться здесь. От примера с демонстрацией ошибки он отличается минимально. useContext->useSmartContext и createContext->createListenableContext.

Проблемы

Конечно, то что получилось не следует применять в продакшене!

  • опора на внутреннюю реализацию реакта, которая в будущем может поменяться

  • все проблемы Monkey patchинга

  • нельзя использовать в классовых компонентах

  • нельзя использовать один контекст в нескольких провайдерах

  • не работает если значение контекста является примитивом

  • проксирует только первый уровень значений контекста

Заключение

Я надеюсь, статья вам понравилась и была познавательной. У меня на это ушёл весь выходной и надеюсь не зря.

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

Всем спасибо за внимание.

Источник: https://habr.com/ru/post/546124/


Интересные статьи

Интересные статьи

Разрабатываете на React или просто интересуетесь данной технологией? Тогда добро пожаловать в мой новый проект — Тотальный React. Введение Я работаю с React уже 5 лет, однако, ког...
Уже не первый год в мире обсуждается проблема загрязнения пластиком окружающей среды и мирового океана. В европейский странах (и в некоторых других) уже давно используется биоразлагае...
Автор статьи объясняет, как реализовать в HAProxy ограничение скорости обработки запросов (rate limiting) с определенных IP-адресов. Команда Mail.ru Cloud Solutions перевела его статью — надеем...
Привет, Хабр! В не такие уж далёкие годы, на первом курсе «программистского» факультета, мне нравилось задавать товарищам по учёбе вопрос: «Зачем вы вообще пошли сюда учиться?» Точной статистики ...
В современном вебе, время загрузки страницы сайта — одна из важнейших метрик. Даже миллисекунды могу оказывать огромное влияние на Вашу прибыль и медленная загрузка страницы может легко навредить...