← Курс/Оптимизация производительности JavaScript#135 из 257+40 XP

Оптимизация производительности JavaScript

Краткий ответ

Оптимизация JS-кода охватывает несколько уровней: предотвращение утечек памяти, избегание layout thrashing в браузере, использование мемоизации для дорогих вычислений, debounce/throttle для частых событий, Web Workers для CPU-интенсивных задач. V8 лучше оптимизирует предсказуемый код — не меняй форму объектов и типы переменных после создания.

Полный разбор

Утечки памяти

// УТЕЧКА 1: Event listener без cleanup
class Timer {
  start() {
    // Каждый вызов start() добавляет новый listener
    window.addEventListener('resize', this.handleResize)
  }
  // Нет метода stop() с removeEventListener!
}

// ИСПРАВЛЕНИЕ:
class Timer {
  constructor() {
    this.handleResize = this.handleResize.bind(this)
  }
  start() {
    window.addEventListener('resize', this.handleResize)
  }
  stop() {
    window.removeEventListener('resize', this.handleResize)
  }
}

// УТЕЧКА 2: Замыкание держит ссылку на большой объект
function createLeak() {
  const hugeData = new Array(1000000).fill('data')

  return function() {
    // hugeData никогда не будет собран GC
    // даже если мы не используем его внутри
    console.log('done')
  }
}

// ИСПРАВЛЕНИЕ: убираем ссылку
function noLeak() {
  let hugeData = new Array(1000000).fill('data')
  const result = processData(hugeData)
  hugeData = null  // позволяем GC собрать данные

  return function() {
    console.log(result)
  }
}

// УТЕЧКА 3: Unbounded cache (кэш без лимита)
const cache = {}  // растёт бесконечно!

// ИСПРАВЛЕНИЕ: WeakMap (автоочистка) или LRU-кэш с лимитом
const weakCache = new WeakMap()  // ключи-объекты освобождаются автоматически

Layout Thrashing (принудительный синхронный layout)

// ПЛОХО: чтение и запись DOM вперемешку — каждое чтение форсирует reflow
function badLayout(elements) {
  elements.forEach(el => {
    const height = el.offsetHeight  // READ — forceлayout reflow
    el.style.height = height * 2 + 'px'  // WRITE
    // Следующая итерация: снова READ после WRITE — браузер обязан пересчитать!
  })
}

// ХОРОШО: сначала все reads, потом все writes (batching)
function goodLayout(elements) {
  // Фаза чтения
  const heights = elements.map(el => el.offsetHeight)

  // Фаза записи (браузер может батчить)
  elements.forEach((el, i) => {
    el.style.height = heights[i] * 2 + 'px'
  })
}

requestAnimationFrame для анимаций

// НЕ используй setInterval для анимаций
setInterval(() => {
  element.style.left = (x++) + 'px'
}, 16)  // может не совпасть с частотой экрана, вызывает jank

// requestAnimationFrame — синхронизирован с частотой обновления экрана
function animate() {
  element.style.left = (x++) + 'px'

  if (x < 100) {
    requestAnimationFrame(animate)  // следующий кадр
  }
}
requestAnimationFrame(animate)

Мемоизация

function memoize(fn) {
  const cache = new Map()

  return function(...args) {
    const key = JSON.stringify(args)

    if (cache.has(key)) {
      return cache.get(key)  // возвращаем кэшированный результат
    }

    const result = fn.apply(this, args)
    cache.set(key, result)
    return result
  }
}

// Для рекурсивных функций — мемоизация значительно ускоряет
const fib = memoize(function(n) {
  if (n <= 1) return n
  return fib(n - 1) + fib(n - 2)
})

// fib(40): без мемоизации ~1 млрд вызовов, с мемоизацией — 40

V8-оптимизации: мономорфизм

// ПЛОХО: V8 не может оптимизировать полиморфный код
function process(obj) {
  return obj.value * 2
}

process({ value: 1, name: 'a' })     // форма 1
process({ value: 2, extra: true })   // форма 2 — V8 деоптимизирует!

// ХОРОШО: всегда одна форма объекта
class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
}
// Все Point имеют одинаковую форму — V8 генерирует оптимальный машинный код

// ПЛОХО: изменение типа переменной
let x = 42      // integer
x = 'hello'     // string — V8 деоптимизирует переменную!

// ХОРОШО: стабильные типы
const count = 42
const text = 'hello'

Web Workers для CPU-интенсивных задач

// Главный поток (main.js)
// Тяжёлые вычисления блокируют UI — используй Worker
const worker = new Worker('worker.js')

worker.postMessage({ data: largeArray })

worker.onmessage = (event) => {
  console.log('Результат из Worker:', event.data.result)
}

// worker.js — выполняется в отдельном потоке
self.onmessage = (event) => {
  const { data } = event.data
  const result = heavyComputation(data)  // не блокирует UI
  self.postMessage({ result })
}

Связанные уроки курса

  • Замыкания — механизм, через который возникают утечки памяти и через который работает мемоизация
  • Рекурсия — мемоизация особенно важна для рекурсивных алгоритмов
  • requestAnimationFrame — детальный разбор анимаций в JS
  • WeakRef и FinalizationRegistry — слабые ссылки для избежания утечек памяти
  • Как отвечать на собеседовании

    Структурируй ответ по категориям: память, рендеринг, вычисления. Приведи конкретные примеры утечек (event listeners, замыкания). Обязательно упомяни мемоизацию и объясни, где она применима (чистые функции, дорогие вычисления). Упомяни requestAnimationFrame вместо setInterval для анимаций. Если спрашивают про большие объёмы данных — скажи про Web Workers.

    Красные флаги ответа

  • «Просто используй усё меньше циклов» без конкретики — интервьюер ожидает системного понимания, а не расплывчатых советов
  • Незнание про layout thrashing — частая причина производительных проблем в браузерных приложениях, базовая тема для frontend-разработчика
  • Мемоизация без упоминания ограничений — кэш без лимита сам является утечкой памяти, нужно знать о LRU и ограничении размера
  • Примеры

    Мемоизация с лимитом кэша, демонстрация утечки памяти и исправление, замер производительности

    // ===== МЕМОИЗАЦИЯ =====
    console.log('=== Мемоизация ===')
    
    function memoize(fn, maxSize = 100) {
      const cache = new Map()
    
      return function(...args) {
        const key = JSON.stringify(args)
    
        if (cache.has(key)) {
          console.log('Кэш hit:', key)
          return cache.get(key)
        }
    
        // LRU: если кэш полон — удаляем самый старый элемент
        if (cache.size >= maxSize) {
          const firstKey = cache.keys().next().value
          cache.delete(firstKey)
        }
    
        const result = fn.apply(this, args)
        cache.set(key, result)
        console.log('Кэш miss, вычислено:', result)
        return result
      }
    }
    
    // Тяжёлая функция — Фибоначчи
    function slowFib(n) {
      if (n <= 1) return n
      return slowFib(n - 1) + slowFib(n - 2)
    }
    
    // Мемоизированная версия
    const fastFib = memoize(function fib(n) {
      if (n <= 1) return n
      return fastFib(n - 1) + fastFib(n - 2)
    })
    
    console.log('\nfib(10):')
    console.log(fastFib(10))  // вычисляет
    
    console.log('\nfib(10) снова:')
    console.log(fastFib(10))  // из кэша
    
    // Замер разницы в производительности
    console.log('\n=== Замер производительности ===')
    
    let t0, t1
    
    // БЕЗ мемоизации
    t0 = Date.now()
    const resultSlow = slowFib(35)
    t1 = Date.now()
    console.log('slowFib(35):', resultSlow, '| Время:', t1 - t0, 'ms')
    
    // С мемоизацией
    const memoFib = memoize(function fibM(n) {
      if (n <= 1) return n
      return memoFib(n - 1) + memoFib(n - 2)
    })
    
    t0 = Date.now()
    const resultFast = memoFib(35)
    t1 = Date.now()
    console.log('memoFib(35):', resultFast, '| Время:', t1 - t0, 'ms')
    
    // ===== ПАТТЕРН УТЕЧКИ ПАМЯТИ И ИСПРАВЛЕНИЕ =====
    console.log('\n=== Утечка памяти: пример и исправление ===')
    
    // УТЕЧКА: unbounded cache
    function createLeakyService() {
      const requestCache = {}  // никогда не очищается!
    
      return {
        getData(key) {
          if (!requestCache[key]) {
            requestCache[key] = { data: new Array(1000).fill(key), timestamp: Date.now() }
          }
          return requestCache[key]
        },
        // нет метода для очистки кэша!
        getCacheSize() {
          return Object.keys(requestCache).length
        }
      }
    }
    
    // ИСПРАВЛЕНИЕ: кэш с TTL и лимитом
    function createSafeService(maxItems = 50, ttlMs = 60000) {
      const cache = new Map()
    
      function cleanup() {
        const now = Date.now()
        for (const [key, entry] of cache.entries()) {
          if (now - entry.timestamp > ttlMs) {
            cache.delete(key)
          }
        }
        // Также соблюдаем лимит по размеру (LRU)
        while (cache.size > maxItems) {
          const oldestKey = cache.keys().next().value
          cache.delete(oldestKey)
        }
      }
    
      return {
        getData(key) {
          cleanup()  // очищаем устаревшие записи
    
          if (cache.has(key)) {
            return cache.get(key).data
          }
    
          const data = { value: key, computed: Date.now() }
          cache.set(key, { data, timestamp: Date.now() })
          return data
        },
        getCacheSize() {
          return cache.size
        }
      }
    }
    
    const leaky = createLeakyService()
    const safe  = createSafeService(3)  // лимит 3 для демо
    
    // Симулируем много запросов
    for (let i = 0; i < 10; i++) {
      leaky.getData(`key-${i}`)
      safe.getData(`key-${i}`)
    }
    
    console.log('Leaky кэш размер:', leaky.getCacheSize())  // 10 (растёт)
    console.log('Safe кэш размер:', safe.getCacheSize())    // 3 (ограничен)
    
    // ===== МЕМОИЗАЦИЯ С РАЗНЫМИ ТИПАМИ АРГУМЕНТОВ =====
    console.log('\n=== Мемоизация сложных аргументов ===')
    
    const memoizeDeep = memoize((arr, multiplier) => {
      console.log('  Вычисление...')
      return arr.map(x => x * multiplier)
    })
    
    console.log(memoizeDeep([1, 2, 3], 2))  // вычисляет
    console.log(memoizeDeep([1, 2, 3], 2))  // из кэша
    console.log(memoizeDeep([1, 2, 3], 3))  // новые аргументы — вычисляет