Замыкания — пожалуй, одна из самых пугающих концепций 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
Поэкспериментируйте с кодом здесь:
Эта статья также доступна в видеоформате.
Читайте также:
- Разделение пользовательского интерфейса и логики в React: чистый код с безголовыми компонентами
- 5 самых полезных приемов в JavaScript
- Как выглядит нескучный модульный лендинг React
Читайте нас в Telegram, VK и Дзен
Перевод статьи Nadia Makarevich: Fantastic closures and how to find them in React