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