Проблема устаревших замыканий и способы ее решения в React. Часть 1

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

Попробуем проникнуть в очередную тайну JavaScript-кода, чтобы выяснить:

  • что такое замыкания, как они появляются и зачем нужны;
  • что такое “устаревшее” замыкание и почему оно возникает.

Предупреждение: если вы никогда не сталкивались с замыканиями в React, эта статья может взорвать ваш мозг. Рекомендую запастись достаточным количеством шоколада, чтобы стимулировать клетки мозга во время чтения.

Задача

Представьте, что вы реализуете форму с несколькими полями ввода. Одно из полей представляет собой очень тяжелый компонент из какой-то внешней библиотеки. У вас нет доступа к его внутренним элементам, поэтому вы не можете устранить проблемы с его производительностью. Но этот компонент очень нужен в форме, поэтому вы решили обернуть его в React.memo, чтобы минимизировать повторные рендеринги при изменении состояния формы. Как-то так:

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
const [value, setValue] = useState();

return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo />
</>
);
};

Пока все хорошо. Тяжелый компонент принимает только одно строковое свойство, например title, и коллбэк onClick. Это происходит при нажатии кнопки “done” (“готово”) внутри компонента. Отправить данные формы также довольно просто: достаточно передать title и onClick.

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// сюда передаем данные формы
console.log(value);
};

return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};

А вот теперь перед вами встает дилемма. Как известно, каждое свойство компонента, обернутое в React.memo, должно быть либо примитивным значением, либо постоянным между повторными рендерами. В противном случае мемоизация не сработает. Поэтому технически нужно обернуть onClick в useCallback:

const onClick = useCallback(() => {
// здесь передаем данные
}, []);

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

const onClick = useCallback(() => {
// сюда подаются данные
console.log(value);

// добавление значения к зависимости
}, [value]);

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

Поищем другие решения. В React.memo есть функция сравнения. Она позволяет более пристально контролировать сравнение свойств в React.memo. Обычно React сам сравнивает все свойства “до” со всеми свойствами “после”. Если предоставить ему эту функцию, он будет полагаться на возвращаемый ею результат. Если функция вернет true, то React будет знать, что свойства одинаковы, а компонент не нужно повторно рендерить. Похоже, это именно то, что нам нужно.

У нас есть только одно свойство, которое следует обновить,  —  title, так что это не так уж сложно:

const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);

Код для всей формы будет выглядеть следующим образом:

const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);

const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// сюда передаем данные формы
console.log(value);
};

return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};

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

За исключением одной маленькой проблемы: это не работает так, как нужно. Если ввести что-то в поле ввода, а затем нажать кнопку, то value, записанное в onClick, будет undefined. Но оно не может быть undefined. Ввод работает, как и ожидалось, и если добавить console.log за пределами onClick, логирование выполняется корректно. Только не внутри onClick.

// здесь логирование корректно 
console.log(value);

const onClick = () => {
// всегда undefined
console.log(value);
};

Полный пример смотрите здесь:

Что же происходит?

Мы столкнулись с так называемой проблемой “устаревшего замыкания”. И чтобы ее решить, необходимо немного углубиться в, пожалуй, самую сложную тему в JavaScript: что такое замыкания и как они работают.

Область видимости и замыкания в JavaScript

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

function something() {
//
}
const something = () => {};

Мы создали локальную область видимости  —  область в коде, в которой переменные, объявленные внутри, не будут видны снаружи.

const something = () => {
const value = 'text';
};

console.log(value); // не сработает, value локально по отношению к функции something

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

const something = () => {
const inside = () => {
const value = 'text';
};

console.log(value); // не сработает, value локально для функции inside
};

Однако в обратном направлении путь открыт. Самая внутренняя функция будет “видеть” все переменные, объявленные снаружи.

const something = () => {
const value = 'text';

const inside = () => {
// отлично, value здесь доступно
console.log(value);
};
};

Это достигается путем создания так называемого “замыкания”. Функция внутри “замыкает” все данные, поступающие извне. По сути, это снапшот всех “внешних” данных, замороженных во времени и хранящихся отдельно в памяти.

Теперь вместо того чтобы создавать value внутри функции something, передадим его в качестве аргумента и возвратим функцию inside:

const something = (value) => {
const inside = () => {
// отлично, value здесь доступно
console.log(value);
};

return inside;
};

Это приведет к следующему поведению:

const first = something('first');
const second = something('second');

first(); // логирует "first"
second(); // логирует "second"

Вызываем функцию something со значением “first” и присваиваем результат переменной. Результат  —  ссылка на функцию, объявленную внутри. Формируется замыкание. С этого момента, пока существует переменная first, хранящая эту ссылку, значение “first”, которое мы ей передали, заморожено, и функция inside будет иметь к нему доступ.

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

Это справедливо для любой переменной, объявленной локально внутри функции something:

const something = (value) => {
const r = Math.random();

const inside = () => {
// ...
};

return inside;
};

const first = something('first');
const second = something('second');

first(); // логирует случайное число
second(); // логирует другое случайное число

Это похоже на съемку динамичной сцены: как только нажимается кнопка, вся сцена “застывает” в кадре навсегда. Следующее нажатие кнопки ничего не изменит в ранее сделанном снимке.

В React мы постоянно создаем замыкания, даже не осознавая этого. Каждая коллбэк-функция, объявленная внутри компонента, является замыканием:

const Component = () => {
const onClick = () => {
// замыкание!
};

return <button onClick={onClick} />;
};

Все, что находится в хуке useEffect и useCallback, является замыканием:

const Component = () => {
const onClick = useCallback(() => {
// замыкание!
});

useEffect(() => {
// замыкание!
});
};

Все они будут иметь доступ к состоянию, свойствам и локальным переменным, объявленным в компоненте:

const Component = () => {
const [state, setState] = useState();

const onClick = useCallback(() => {
// отлично
console.log(state);
});

useEffect(() => {
// отлично
console.log(state);
});
};

Каждая функция внутри компонента является замыканием, поскольку сам компонент  —  это просто функция.

Проблема устаревших замыканий

Если вы пришли в мир JavaScript из языка, в котором нет замыканий, то все вышесказанное, хотя и несколько необычно, все же относительно просто. Стоит несколько раз создать пару-тройку функций, и все становится естественным. Можно годами писать React-приложения и не понимать концепцию “замыкания”.

Так в чем же тогда проблема? Почему замыкания  —  одна из самых страшных концепций в JavaScript и источник боли для многих разработчиков?

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

const something = (value) => {
const inside = () => {
console.log(value);
};

return inside;
};

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

const cache = {};

const something = (value) => {
if (!cache.current) {
cache.current = () => {
console.log(value);
};
}

return cache.current;
};

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

Однако если попробуем вызвать ее несколько раз, то увидим странную вещь:

const first = something('first');
const second = something('second');
const third = something('third');

first(); // логирует "first"
second(); // логирует "first"
third(); // логирует "first"

Сколько бы раз мы ни вызывали функцию something с разными аргументами, залогированное значение всегда будет first!

Мы только что создали так называемое “устаревшее замыкание”. Каждое замыкание замораживается в момент своего создания. Когда мы впервые вызвали функцию something, то создали замыкание, имеющее значение “first” в переменной value. Затем мы сохранили его в объекте, который находится вне функции something.

Когда мы вызвали функцию something в следующий раз, то, вместо создания новой функции с новым замыканием, возвратили ту, которую создали ранее. Ту, которая была заморожена с переменной “first” навсегда.

Чтобы исправить такое поведение, нужно заново создавать функцию и ее замыкание при каждом изменении value. Примерно так:

const cache = {};
let prevValue;

const something = (value) => {
// проверяем, не изменилось ли value
if (!cache.current || value !== prevValue) {
cache.current = () => {
console.log(value);
};
}

// обновляем
prevValue = value;
return cache.current;
};

Сохраним значение в переменной, чтобы сравнить следующее значение с предыдущим. А затем обновим замыкание cache.current при изменении переменной.

Теперь переменные будут логироваться корректно, и когда мы будем сравнивать функции с одинаковым значением, то это сравнение будет возвращать true:

const first = something('first');
const anotherFirst = something('first');
const second = something('second');

first(); // логирует "first"
second(); // логирует "second"
console.log(first === anotherFirst); // будет true

Поэкспериментируйте с кодом здесь:

Эта статья также доступна в видеоформате.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Nadia Makarevich: Fantastic closures and how to find them in React

Предыдущая статьяКак профессионально писать логи Python
Следующая статьяJetpack Compose Canvas: 10 практических примеров