Чисті функції та побічні ефекти в JavaScript
Чиста функція - функція, яка завжди повертає однаковий результат для однакових аргументів і нічого не змінює поза собою.
Теорія
TL;DR
- Аналогія: чиста функція як торговий автомат. Ті самі монети - та сама кола. Він не чіпає запаси магазину.
- Головна різниця: чиста - результат залежить тільки від аргументів; нечиста - від часу, глобальних змінних, зовнішнього стану.
- Правило вибору: чиста для математики, логіки, трансформацій даних. Нечиста тільки там де не уникнути (API, DOM, таймери).
constне означає чистоту. Об'єкт оголошений черезconstможна мутувати.- Мемоізація (
React.memo,useMemo) працює коректно тільки з чистими функціями.
Швидкий приклад
// Чиста: однаковий вхід завжди дає однаковий вихід
const add = (a, b) => a + b;
add(2, 3); // 5, завжди
// Нечиста: результат залежить від зовнішньої змінної discount
let discount = 0.1;
const getPrice = (price) => price * (1 - discount);
getPrice(100); // 90 сьогодні, 80 якщо discount стане 0.2
// Чистий варіант: передаємо discount як аргумент
const getPricePure = (price, discount) => price * (1 - discount);
getPricePure(100, 0.1); // 90, завжди для цих аргументівНечиста getPrice читає discount із зовнішньої області видимості (scope). Зміниш цю змінну в іншому тесті або в іншому місці коду - і поведінка функції зміниться. Саме в цьому проблема.
Головна різниця
Чиста функція як математична формула: результат повністю визначається аргументами і більше нічим. Побічні ефекти (side effects) ламають цю гарантію - через читання зовнішнього стану (Date.now(), глобальна змінна) або запис до нього (array.push(), localStorage.setItem()). Після цього не можна гарантувати що однаковий виклик дасть однаковий результат.
Коли використовувати
- Математичні та логічні обчислення: чиста. Без винятків.
- Сортування, фільтрація, трансформація масивів: чиста (повертай новий масив, не мутуй вхідні дані).
- Рендеринг React-компонентів, Redux-редюсери: чиста. Алгоритм узгодження React на це покладається.
- API-запити, таймери, оновлення DOM, логування: нечиста за природою. Ізолюй їх на межі системи.
- Потрібна мемоізація: тільки чиста. Кешування мовчки ламається на нечистих функціях.
Як V8 це обробляє
V8 (рушій Chrome і Node.js) застосовує inline caching і escape analysis до функцій, які не читають зовнішній стан і не мутують нічого. Він може розміщувати повернені значення на стеку замість купи і пропускати зайві пошуки властивостей. Як тільки функція торкається зовнішнього стану або виконує I/O - V8 скасовує ці оптимізації. Тому Redux вимагає чистих редюсерів, а Immer використовує Proxy-пастки щоб симулювати мутації і при цьому повертати новий об'єкт стану.
Типові помилки
Помилка 1: Array.map завжди чистий
map повертає новий масив, але колбек може виконувати будь-який код. Якщо він чіпає зовнішній стан - весь виклик нечистий.
let calls = 0;
[1, 2].map(n => { calls++; return n * 2; });
// Новий масив повернуто, але calls === 2 тепер. Побічний ефект.Виправлення: тримай колбеки без стану. Всі залежності передавай як аргументи.
Помилка 2: const = незмінність
const блокує перепризначення. Але не блокує мутацію самого об'єкта чи масиву.
const arr = [1, 2];
arr.push(3); // arr тепер [1, 2, 3]. Мутація є.Виправлення: spread для нового масиву або Object.freeze для поверхневої незмінності.
Помилка 3: замикання (closure) з мутабельним станом
Замикання захоплює змінні із зовнішньої області видимості. Якщо вони мутабельні - функція нечиста, навіть якщо виглядає чисто.
let x = 1;
const increment = () => x++; // Читає і змінює зовнішній x. Нечиста.Виправлення: передавай значення як параметр замість захоплення.
Помилка 4: async-функція може бути чистою
fetch завжди нечистий: залежить від мережі, часу і стану сервера. Обгортка не змінює цього.
const getData = () => fetch('/api'); // Нечиста. Мережа - зовнішній стан.Виправлення: мокай I/O в тестах. Тримай нечистий код на межі системи.
Помилка 5: Object.freeze заморожує глибоко
Object.freeze заморожує тільки перший рівень. Вкладені об'єкти залишаються мутабельними.
const state = Object.freeze({ user: { name: 'Alex' } });
state.user.name = 'Bob'; // Спрацює. Вкладений об'єкт не заморожений.Виправлення: розгортай кожен рівень при оновленні: { ...state, user: { ...state.user, name: 'Bob' } }.
Де зустрічається
- React: чисті компоненти коректно оптимізуються через
React.memo. Нечисті можуть давати застарілий рендер. - Redux: редюсери завжди чисті. Immer дозволяє писати мутативний синтаксис і при цьому повертати новий стан.
- Node/Express: чисті валідатори (схеми Joi) запускаються перед записом у БД. Валідація чиста, запис - ні.
- Ramda/Lodash-fp: побудовані навколо чистих функцій.
R.mapіR.filterне мутують вхідні дані.
Якось я дві години шукав баг у розрахунку суми в кошику. Виявилось, функція читала глобальний прапор знижки, який обнулявся в іншому тесті. Зробив функцію чистою - передав знижку аргументом. Баг і зламаний тест зникли за одну зміну.
Питання на співбесіді
Q: Чи може функція з console.log всередині бути чистою?
A: Ні. console.log пише в зовнішній потік I/O. Це побічний ефект, незалежно від того що повертає функція.
Q: Чи є Array.sort() чистим методом?
A: Ні. Він мутує оригінальний масив. Використовуй [...arr].sort() щоб отримати відсортовану копію і залишити оригінал недоторканим.
Q: Чому React.memo не працює з нечистими компонентами?
A: React.memo пропускає ре-рендер коли пропси не змінились. Нечистий компонент може повернути інший результат для тих самих пропсів (наприклад, читаючи Date.now()), і кешований результат стане неправильним.
Q: Redux Toolkit використовує Immer в createSlice. Чи роблять це редюсери нечистими?
A: Ні. Immer перехоплює мутації через Proxy і повертає новий об'єкт стану. Для Redux редюсер залишається чистим: отримує стан, повертає новий. V8 не скасовує оптимізації бо справжня мутація не виходить за межі функції.
Q: Що таке референційна прозорість (referential transparency)?
A: Чиста функція є референційно прозорою: будь-який її виклик можна замінити результатом і програма поведеться так само. Саме це робить мемоізацію та паралельне виконання безпечними.
Приклади
Базовий: робимо функцію ціни чистою
// Нечиста: читає зовнішню змінну discount
let discount = 0.1;
const getPriceImpure = (price) => price * (1 - discount);
console.log(getPriceImpure(100)); // 90
discount = 0.2;
console.log(getPriceImpure(100)); // 80 - той самий виклик, інший результат
// Чиста: всі входи явні
const getPricePure = (price, discount) => price * (1 - discount);
console.log(getPricePure(100, 0.1)); // 90, завжди
console.log(getPricePure(100, 0.2)); // 80, завжди - передбачуваноКожна зовнішня залежність стає параметром. Тепер функцію можна тестувати з будь-якими значеннями без налаштування або скидання стану.
Середній: фільтрація списку задач у React
// Нечиста: мутує оригінальний масив (ламає узгодження React)
const filterCompletedImpure = (todos) => {
todos.forEach((todo, i) => {
if (todo.complete) todos.splice(i, 1);
});
return todos;
};
// Чиста: повертає новий масив, оригінал незмінний
const filterCompleted = (todos) =>
todos.filter(todo => !todo.complete);
const todos = [
{ id: 1, complete: true },
{ id: 2, complete: false },
];
console.log(filterCompleted(todos));
// [{ id: 2, complete: false }]
console.log(todos);
// [{ id: 1, complete: true }, { id: 2, complete: false }] - незміннийReact порівнює попередні та нові пропси щоб вирішити чи перерендерити. Якщо мутувати масив замість повернення нового - посилання залишається тим самим і React може пропустити оновлення.
Складний: поверхневе заморожування vs глибоке клонування в Redux-стилі
const state = { count: 5, user: { name: 'Alex' } };
// Object.freeze - поверхневий
const frozen = Object.freeze({ ...state });
frozen.count = 10; // Тихий fail у звичайному режимі, TypeError у strict
frozen.user.name = 'Bob'; // Спрацьовує. Вкладений об'єкт НЕ заморожений.
// Чисте оновлення через spread
const nextState = { ...state, count: state.count + 1 };
console.log(nextState); // { count: 6, user: { name: 'Alex' } }
console.log(state); // { count: 5, user: { name: 'Alex' } } - незмінний
// Глибоке оновлення: розгортаємо кожен рівень
const nextStateDeep = {
...state,
user: { ...state.user, name: 'Bob' },
};
console.log(nextStateDeep.user); // { name: 'Bob' }
console.log(state.user); // { name: 'Alex' } - безпечноУ продакшені Immer обробляє вкладені оновлення автоматично. Розуміння чому він існує показує що ти знаєш обмеження, а не просто API.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.