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

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
09-Мар-2021 15:33

Проблема, которую решаемКонтекст в реакте может содержать множество значений и разные потребители контекста могут использовать только часть значений. Однако при изменении любого значения из контекста перерендарятся все потребители (в частности все компоненты, которые используют 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инга
  • нельзя использовать в классовых компонентах
  • нельзя использовать один контекст в нескольких провайдерах
  • не работает если значение контекста является примитивом
  • проксирует только первый уровень значений контекста
ЗаключениеЯ надеюсь, статья вам понравилась и была познавательной. У меня на это ушёл весь выходной и надеюсь не зря. Пока я писал статью, я наткнулся ещё на одну библиотеку, которая решает ту же проблему с оптимизацией перерисовок при использовании контекста. Решение этой библиотеки, на мой взгляд, самое правильное из виденных мной. Её исходники гораздо более читабельны и они подарили мне пару идей, как сделать наш пример продакшен реди, не поменяв способа использования. Если встречу положительный отклик от вас, то напишу и о новой реализации.Всем спасибо за внимание.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_reactjs, #_usecontext, #_react, #_hooks, #_javascript, #_proxy, #_optimizatsija (оптимизация), #_pererisovka (перерисовка), #_javascript, #_reactjs
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 14-Май 13:54
Часовой пояс: UTC + 5