Skip to main content

Чисті функції та побічні ефекти в JavaScript

Чиста функція - функція, яка завжди повертає однаковий результат для однакових аргументів і нічого не змінює поза собою.

Теорія

TL;DR

  • Аналогія: чиста функція як торговий автомат. Ті самі монети - та сама кола. Він не чіпає запаси магазину.
  • Головна різниця: чиста - результат залежить тільки від аргументів; нечиста - від часу, глобальних змінних, зовнішнього стану.
  • Правило вибору: чиста для математики, логіки, трансформацій даних. Нечиста тільки там де не уникнути (API, DOM, таймери).
  • const не означає чистоту. Об'єкт оголошений через const можна мутувати.
  • Мемоізація (React.memo, useMemo) працює коректно тільки з чистими функціями.

Швидкий приклад

javascript
// Чиста: однаковий вхід завжди дає однаковий вихід 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 повертає новий масив, але колбек може виконувати будь-який код. Якщо він чіпає зовнішній стан - весь виклик нечистий.

javascript
let calls = 0; [1, 2].map(n => { calls++; return n * 2; }); // Новий масив повернуто, але calls === 2 тепер. Побічний ефект.

Виправлення: тримай колбеки без стану. Всі залежності передавай як аргументи.

Помилка 2: const = незмінність

const блокує перепризначення. Але не блокує мутацію самого об'єкта чи масиву.

javascript
const arr = [1, 2]; arr.push(3); // arr тепер [1, 2, 3]. Мутація є.

Виправлення: spread для нового масиву або Object.freeze для поверхневої незмінності.

Помилка 3: замикання (closure) з мутабельним станом

Замикання захоплює змінні із зовнішньої області видимості. Якщо вони мутабельні - функція нечиста, навіть якщо виглядає чисто.

javascript
let x = 1; const increment = () => x++; // Читає і змінює зовнішній x. Нечиста.

Виправлення: передавай значення як параметр замість захоплення.

Помилка 4: async-функція може бути чистою

fetch завжди нечистий: залежить від мережі, часу і стану сервера. Обгортка не змінює цього.

javascript
const getData = () => fetch('/api'); // Нечиста. Мережа - зовнішній стан.

Виправлення: мокай I/O в тестах. Тримай нечистий код на межі системи.

Помилка 5: Object.freeze заморожує глибоко

Object.freeze заморожує тільки перший рівень. Вкладені об'єкти залишаються мутабельними.

javascript
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: Чиста функція є референційно прозорою: будь-який її виклик можна замінити результатом і програма поведеться так само. Саме це робить мемоізацію та паралельне виконання безпечними.

Приклади

Базовий: робимо функцію ціни чистою

javascript
// Нечиста: читає зовнішню змінну 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

javascript
// Нечиста: мутує оригінальний масив (ламає узгодження 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-стилі

javascript
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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?