Декораторы

Декоратор — обёртка вокруг функции, которая изменяет или расширяет её поведение, не меняя саму функцию. Паттерн основан на замыканиях и функциях высшего порядка.

Зачем нужно

Декораторы позволяют добавлять функциональность (логирование, кэширование, валидацию, ограничение вызовов) к любой функции без изменения её кода. Это реализация принципа Open/Closed — открыт для расширения, закрыт для изменения.

Где используется

Debounce/throttle, логирование, замер производительности, retry-логика, валидация аргументов, кэширование, авторизация, React HOC.

Предпосылки

Замыкания (Closures), Функции высшего порядка, Мемоизация

Базовый паттерн

function decorator(originalFn) {
  return function(...args) {
    // Действие ДО вызова
    console.log('Вызов с аргументами:', args);

    const result = originalFn.apply(this, args); // вызов оригинала

    // Действие ПОСЛЕ вызова
    console.log('Результат:', result);

    return result;
  };
}

function add(a, b) {
  return a + b;
}

const decoratedAdd = decorator(add);
decoratedAdd(2, 3);
// "Вызов с аргументами: [2, 3]"
// "Результат: 5"

Практические декораторы

Debounce — задержка до стабилизации

function debounce(fn, delay) {
  let timeoutId;

  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// Поиск с задержкой — вызов только после паузы в 300ms
const searchInput = document.querySelector('#search');
const search = debounce((query) => {
  console.log('Поиск:', query);
  fetch(`/api/search?q=${query}`);
}, 300);

searchInput.addEventListener('input', (e) => search(e.target.value));

Throttle — ограничение частоты вызовов

function throttle(fn, interval) {
  let lastTime = 0;

  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      return fn.apply(this, args);
    }
  };
}

// Обработка скролла — максимум раз в 100ms
const onScroll = throttle(() => {
  console.log('Позиция:', window.scrollY);
}, 100);

window.addEventListener('scroll', onScroll);

Throttle с trailing call

function throttle(fn, interval) {
  let lastTime = 0;
  let timeoutId = null;

  return function(...args) {
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      clearTimeout(timeoutId);
      timeoutId = null;
      lastTime = now;
      fn.apply(this, args);
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {
        lastTime = Date.now();
        timeoutId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

Spy — отслеживание вызовов

function spy(fn) {
  function wrapper(...args) {
    wrapper.calls.push({
      args: [...args],
      timestamp: Date.now()
    });
    return fn.apply(this, args);
  }

  wrapper.calls = ;
  return wrapper;
}

const sum = spy((a, b) => a + b);
sum(1, 2);
sum(3, 4);

console.log(sum.calls);
// [
//   { args: [1, 2], timestamp: ... },
//   { args: [3, 4], timestamp: ... }
// ]

Once — однократный вызов

function once(fn) {
  let called = false;
  let result;

  return function(...args) {
    if (called) return result;
    called = true;
    result = fn.apply(this, args);
    return result;
  };
}

const initialize = once(() => {
  console.log('Инициализация...');
  return { ready: true };
});

initialize; // "Инициализация..." → { ready: true }
initialize; // → { ready: true } (без повторного вызова)
initialize; // → { ready: true }

Delay — отложенный вызов

function delay(fn, ms) {
  return function(...args) {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(fn.apply(this, args));
      }, ms);
    });
  };
}

const delayedLog = delay(console.log, 1000);
delayedLog('Через секунду'); // выведет через 1с

Retry — повтор при ошибке

function retry(fn, attempts = 3, delay = 1000) {
  return async function(...args) {
    for (let i = 0; i < attempts; i++) {
      try {
        return await fn.apply(this, args);
      } catch (err) {
        if (i === attempts - 1) throw err;
        console.log(`Попытка ${i + 1} не удалась, повтор через ${delay}ms`);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  };
}

const fetchWithRetry = retry(
  async (url) => {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  },
  3,
  2000
);

Логирование и замер времени

function withLogging(fn, label) {
  return function(...args) {
    console.log(`[${label}] Вызов с:`, args);
    console.time(label);

    try {
      const result = fn.apply(this, args);
      console.log(`[${label}] Результат:`, result);
      return result;
    } catch (err) {
      console.error(`[${label}] Ошибка:`, err.message);
      throw err;
    } finally {
      console.timeEnd(label);
    }
  };
}

const processData = withLogging(
  (data) => data.map(x => x * 2),
  'processData'
);

processData([1, 2, 3]);
// [processData] Вызов с: [[1, 2, 3]]
// [processData] Результат: [2, 4, 6]
// processData: 0.05ms

Валидация аргументов

function validateArgs(...validators) {
  return function(fn) {
    return function(...args) {
      args.forEach((arg, i) => {
        if (validators[i] && !validators[i](arg)) {
          throw new TypeError(
            `Аргумент ${i} не прошёл валидацию: ${arg}`
          );
        }
      });
      return fn.apply(this, args);
    };
  };
}

const isNumber = (v) => typeof v === 'number' && !isNaN(v);
const isPositive = (v) => isNumber(v) && v > 0;

const safeDivide = validateArgs(isNumber, isPositive)(
  (a, b) => a / b
);

console.log(safeDivide(10, 2)); // 5
// safeDivide(10, 0);   // TypeError: Аргумент 1 не прошёл валидацию
// safeDivide('a', 2);  // TypeError: Аргумент 0 не прошёл валидацию

Композиция декораторов

function compose(...decorators) {
  return function(fn) {
    return decorators.reduceRight((decorated, decorator) => {
      return decorator(decorated);
    }, fn);
  };
}

// Применяем несколько декораторов
const enhancedFetch = compose(
  (fn) => retry(fn, 3),
  (fn) => withLogging(fn, 'fetch'),
  (fn) => throttle(fn, 1000)
)(fetch);

TC39 Decorators (Stage 3)

Синтаксический сахар для классов (пока не в стандарте):

// Будущий синтаксис (Stage 3)
function log(target, context) {
  return function(...args) {
    console.log(`Вызов ${context.name}`);
    return target.apply(this, args);
  };
}

class API {
  @log
  fetchData(url) {
    return fetch(url);
  }
}

Частые ошибки

1. Потеря this

// Плохо — this не передаётся
function broken(fn) {
  return function(...args) {
    return fn(...args); // this потерян!
  };
}

// Хорошо — передаём this через apply
function correct(fn) {
  return function(...args) {
    return fn.apply(this, args);
  };
}

2. Потеря свойств оригинальной функции

function myFn() {}
myFn.customProp = 'важно';

const decorated = once(myFn);
console.log(decorated.customProp); // undefined!

// Решение: копировать свойства
function preserveProps(wrapper, original) {
  Object.assign(wrapper, original);
  Object.defineProperty(wrapper, 'name', {
    value: original.name
  });
  return wrapper;
}

3. Debounce без отмены

// Добавь возможность отмены
function debounce(fn, delay) {
  let timeoutId;

  function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout( => fn.apply(this, args), delay);
  }

  debounced.cancel() = () => clearTimeout(timeoutId);
  return debounced;
}

const debouncedSearch = debounce(search, 300);
// При размонтировании компонента:
debouncedSearch.cancel();

Практика

  1. Напиши декоратор delay(fn, ms), откладывающий вызов
  2. Реализуй debounce с опцией immediate (вызов на переднем фронте)
  3. Создай декоратор limit(fn, maxCalls) — ограничение числа вызовов
  4. Напиши withCache(fn, ttl) — кэш с временем жизни
  5. Реализуй композицию декораторов и примени 3 декоратора к одной функции

Связанные темы

Ресурсы