← HTML & CSS/CSS-производительность и оптимизация#45 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: старт в frontendПрактика: DOM и событияТермин: DOMМаршрут: старт с нуля

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

Медленный CSS — это не медленные файлы. Это layout thrashing, принудительные синхронные layout'ы, неправильно размещённые слои. Понимание работы браузерного rendering pipeline превращает хаотичный код в плавные 60fps.

Rendering Pipeline браузера

JavaScript → Style → Layout → Paint → Composite
  • Style: применяет CSS-правила к DOM
  • Layout (Reflow): вычисляет размеры и позиции — дорого
  • Paint: рисует пиксели — средне
  • Composite: накладывает слои — дёшево
  • Оптимальные анимации работают только на Composite уровне: transform и opacity.

    Layout Thrashing — главный враг производительности

    // Плохо: чтение и запись перемежаются — каждое чтение вынуждает браузер сделать Layout
    elements.forEach(el => {
      const width = el.offsetWidth    // Чтение → браузер делает Layout
      el.style.width = width + 10 + 'px'  // Запись → инвалидирует Layout
      const height = el.offsetHeight  // Чтение → браузер опять делает Layout!
      el.style.height = height + 10 + 'px'
    })
    // N элементов = N синхронных Layout (очень дорого)
    
    // Хорошо: сначала все чтения, потом все записи
    const measurements = elements.map(el => ({
      width: el.offsetWidth,       // Все чтения вместе
      height: el.offsetHeight,
    }))
    // Один Layout для всех чтений
    
    measurements.forEach((m, i) => {
      elements[i].style.width = m.width + 10 + 'px'   // Все записи вместе
      elements[i].style.height = m.height + 10 + 'px' // Один Layout в конце
    })

    will-change — подсказка браузеру

    /* Браузер заранее создаёт GPU composite layer */
    .animated-card {
      will-change: transform, opacity;
    }
    
    /* Хорошо: применять перед анимацией */
    .card:hover {
      will-change: transform;
    }
    
    /* Плохо: везде и всегда */
    * { will-change: transform; }  /* Убивает память GPU */

    contain — изоляция компонента

    /* content: браузер не проверяет снаружи при изменении внутри */
    .widget {
      contain: content;   /* = layout + paint + style */
    }
    
    .card-grid > .card {
      contain: layout;   /* Изменения внутри не влияют на внешний layout */
    }
    
    /* strict: самое сильное — нужны явные размеры */
    .fixed-widget {
      contain: strict;   /* = size + layout + paint + style */
      width: 300px;
      height: 200px;
    }

    content-visibility: auto

    /* Браузер пропускает рендеринг элементов вне viewport */
    .post {
      content-visibility: auto;
      contain-intrinsic-size: 0 300px;  /* Примерный размер для скроллбара */
    }

    Для длинных страниц ускоряет первый рендер в 3–10 раз.

    Производительные CSS-свойства

    /* Composite-only (GPU, не вызывают Layout или Paint) */
    transform: translate/scale/rotate;
    opacity: 0.5;
    filter: blur/brightness;    /* Только compositor thread */
    
    /* Только Paint (не Layout) */
    color, background, border-color, box-shadow;
    
    /* Layout (дорого, избегать в анимациях) */
    width, height, top, left, margin, padding, font-size;

    Инструменты измерения

    // Performance API — измерение времени
    performance.mark('layout-start')
    calculateLayout()
    performance.mark('layout-end')
    performance.measure('layout', 'layout-start', 'layout-end')
    
    const [measure] = performance.getEntriesByName('layout')
    console.log('Layout занял:', measure.duration.toFixed(2), 'мс')
    
    // requestAnimationFrame — правильный способ читать layout
    function readLayout(elements) {
      return new Promise(resolve => {
        requestAnimationFrame(() => {
          const data = elements.map(el => ({
            width: el.offsetWidth,
            height: el.offsetHeight,
            rect: el.getBoundingClientRect(),
          }))
          resolve(data)
        })
      })
    }

    Selector Performance

    /* Быстро — simple selectors */
    .card { }
    #main { }
    
    /* Медленно — deep descendant */
    div > ul > li > a > span { }
    
    /* Медленно — universal */
    * + * { }
    
    /* Лучше — конкретные классы */
    .nav-link { }

    Примеры

    Демонстрация layout thrashing vs batched reads/writes с измерением времени

    // Layout Thrashing vs Batched DOM operations
    const container = document.createElement('div')
    container.style.cssText = 'font-family: sans-serif; padding: 16px;'
    document.body.appendChild(container)
    
    function createElements(count) {
      const els = []
      const frag = document.createDocumentFragment()
      for (let i = 0; i < count; i++) {
        const el = document.createElement('div')
        el.style.cssText = `
          width: 50px; height: 30px;
          background: hsl(${i * 36}deg, 70%, 60%);
          display: inline-block;
          margin: 2px;
          border-radius: 4px;
        `
        frag.appendChild(el)
        els.push(el)
      }
      container.appendChild(frag)
      return els
    }
    
    const COUNT = 30
    const elements = createElements(COUNT)
    
    // === Плохой способ: layout thrashing ===
    function badWay(els) {
      const start = performance.now()
    
      els.forEach(el => {
        const w = el.offsetWidth     // Чтение → Layout
        el.style.width = w + 2 + 'px'  // Запись → инвалидируем
        const h = el.offsetHeight   // Чтение → ещё один Layout!
        el.style.height = h + 2 + 'px'
      })
    
      return performance.now() - start
    }
    
    // === Хороший способ: batch read/write ===
    function goodWay(els) {
      const start = performance.now()
    
      // Фаза 1: все чтения (один Layout)
      const measurements = els.map(el => ({
        w: el.offsetWidth,
        h: el.offsetHeight,
      }))
    
      // Фаза 2: все записи (без промежуточных чтений)
      measurements.forEach((m, i) => {
        els[i].style.width = m.w + 2 + 'px'
        els[i].style.height = m.h + 2 + 'px'
      })
    
      return performance.now() - start
    }
    
    // Сбрасываем размеры
    function reset(els) {
      els.forEach(el => { el.style.width = '50px'; el.style.height = '30px' })
      // Принудительный Layout для синхронизации
      void container.offsetWidth
    }
    
    // Запускаем тесты
    reset(elements)
    const t1 = badWay(elements)
    console.log(`Layout thrashing (${COUNT} элементов):  ${t1.toFixed(3)}мс`)
    
    reset(elements)
    const t2 = goodWay(elements)
    console.log(`Batched reads/writes (${COUNT} элементов): ${t2.toFixed(3)}мс`)
    console.log(`Разница: ${(t1/t2).toFixed(1)}x`)
    
    // requestAnimationFrame — ещё лучше
    function rafBatch(els, callback) {
      // Читаем в текущем кадре
      const data = els.map(el => ({ w: el.offsetWidth, h: el.offsetHeight }))
      // Пишем в следующем кадре (после paint)
      requestAnimationFrame(() => {
        callback(data, els)
        console.log('RAF batched write завершён')
      })
    }
    
    reset(elements)
    rafBatch(elements, (data, els) => {
      data.forEach((m, i) => {
        els[i].style.width = m.w + 2 + 'px'
        els[i].style.height = m.h + 2 + 'px'
      })
    })

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

    Медленный CSS — это не медленные файлы. Это layout thrashing, принудительные синхронные layout'ы, неправильно размещённые слои. Понимание работы браузерного rendering pipeline превращает хаотичный код в плавные 60fps.

    Rendering Pipeline браузера

    JavaScript → Style → Layout → Paint → Composite
  • Style: применяет CSS-правила к DOM
  • Layout (Reflow): вычисляет размеры и позиции — дорого
  • Paint: рисует пиксели — средне
  • Composite: накладывает слои — дёшево
  • Оптимальные анимации работают только на Composite уровне: transform и opacity.

    Layout Thrashing — главный враг производительности

    // Плохо: чтение и запись перемежаются — каждое чтение вынуждает браузер сделать Layout
    elements.forEach(el => {
      const width = el.offsetWidth    // Чтение → браузер делает Layout
      el.style.width = width + 10 + 'px'  // Запись → инвалидирует Layout
      const height = el.offsetHeight  // Чтение → браузер опять делает Layout!
      el.style.height = height + 10 + 'px'
    })
    // N элементов = N синхронных Layout (очень дорого)
    
    // Хорошо: сначала все чтения, потом все записи
    const measurements = elements.map(el => ({
      width: el.offsetWidth,       // Все чтения вместе
      height: el.offsetHeight,
    }))
    // Один Layout для всех чтений
    
    measurements.forEach((m, i) => {
      elements[i].style.width = m.width + 10 + 'px'   // Все записи вместе
      elements[i].style.height = m.height + 10 + 'px' // Один Layout в конце
    })

    will-change — подсказка браузеру

    /* Браузер заранее создаёт GPU composite layer */
    .animated-card {
      will-change: transform, opacity;
    }
    
    /* Хорошо: применять перед анимацией */
    .card:hover {
      will-change: transform;
    }
    
    /* Плохо: везде и всегда */
    * { will-change: transform; }  /* Убивает память GPU */

    contain — изоляция компонента

    /* content: браузер не проверяет снаружи при изменении внутри */
    .widget {
      contain: content;   /* = layout + paint + style */
    }
    
    .card-grid > .card {
      contain: layout;   /* Изменения внутри не влияют на внешний layout */
    }
    
    /* strict: самое сильное — нужны явные размеры */
    .fixed-widget {
      contain: strict;   /* = size + layout + paint + style */
      width: 300px;
      height: 200px;
    }

    content-visibility: auto

    /* Браузер пропускает рендеринг элементов вне viewport */
    .post {
      content-visibility: auto;
      contain-intrinsic-size: 0 300px;  /* Примерный размер для скроллбара */
    }

    Для длинных страниц ускоряет первый рендер в 3–10 раз.

    Производительные CSS-свойства

    /* Composite-only (GPU, не вызывают Layout или Paint) */
    transform: translate/scale/rotate;
    opacity: 0.5;
    filter: blur/brightness;    /* Только compositor thread */
    
    /* Только Paint (не Layout) */
    color, background, border-color, box-shadow;
    
    /* Layout (дорого, избегать в анимациях) */
    width, height, top, left, margin, padding, font-size;

    Инструменты измерения

    // Performance API — измерение времени
    performance.mark('layout-start')
    calculateLayout()
    performance.mark('layout-end')
    performance.measure('layout', 'layout-start', 'layout-end')
    
    const [measure] = performance.getEntriesByName('layout')
    console.log('Layout занял:', measure.duration.toFixed(2), 'мс')
    
    // requestAnimationFrame — правильный способ читать layout
    function readLayout(elements) {
      return new Promise(resolve => {
        requestAnimationFrame(() => {
          const data = elements.map(el => ({
            width: el.offsetWidth,
            height: el.offsetHeight,
            rect: el.getBoundingClientRect(),
          }))
          resolve(data)
        })
      })
    }

    Selector Performance

    /* Быстро — simple selectors */
    .card { }
    #main { }
    
    /* Медленно — deep descendant */
    div > ul > li > a > span { }
    
    /* Медленно — universal */
    * + * { }
    
    /* Лучше — конкретные классы */
    .nav-link { }

    Примеры

    Демонстрация layout thrashing vs batched reads/writes с измерением времени

    // Layout Thrashing vs Batched DOM operations
    const container = document.createElement('div')
    container.style.cssText = 'font-family: sans-serif; padding: 16px;'
    document.body.appendChild(container)
    
    function createElements(count) {
      const els = []
      const frag = document.createDocumentFragment()
      for (let i = 0; i < count; i++) {
        const el = document.createElement('div')
        el.style.cssText = `
          width: 50px; height: 30px;
          background: hsl(${i * 36}deg, 70%, 60%);
          display: inline-block;
          margin: 2px;
          border-radius: 4px;
        `
        frag.appendChild(el)
        els.push(el)
      }
      container.appendChild(frag)
      return els
    }
    
    const COUNT = 30
    const elements = createElements(COUNT)
    
    // === Плохой способ: layout thrashing ===
    function badWay(els) {
      const start = performance.now()
    
      els.forEach(el => {
        const w = el.offsetWidth     // Чтение → Layout
        el.style.width = w + 2 + 'px'  // Запись → инвалидируем
        const h = el.offsetHeight   // Чтение → ещё один Layout!
        el.style.height = h + 2 + 'px'
      })
    
      return performance.now() - start
    }
    
    // === Хороший способ: batch read/write ===
    function goodWay(els) {
      const start = performance.now()
    
      // Фаза 1: все чтения (один Layout)
      const measurements = els.map(el => ({
        w: el.offsetWidth,
        h: el.offsetHeight,
      }))
    
      // Фаза 2: все записи (без промежуточных чтений)
      measurements.forEach((m, i) => {
        els[i].style.width = m.w + 2 + 'px'
        els[i].style.height = m.h + 2 + 'px'
      })
    
      return performance.now() - start
    }
    
    // Сбрасываем размеры
    function reset(els) {
      els.forEach(el => { el.style.width = '50px'; el.style.height = '30px' })
      // Принудительный Layout для синхронизации
      void container.offsetWidth
    }
    
    // Запускаем тесты
    reset(elements)
    const t1 = badWay(elements)
    console.log(`Layout thrashing (${COUNT} элементов):  ${t1.toFixed(3)}мс`)
    
    reset(elements)
    const t2 = goodWay(elements)
    console.log(`Batched reads/writes (${COUNT} элементов): ${t2.toFixed(3)}мс`)
    console.log(`Разница: ${(t1/t2).toFixed(1)}x`)
    
    // requestAnimationFrame — ещё лучше
    function rafBatch(els, callback) {
      // Читаем в текущем кадре
      const data = els.map(el => ({ w: el.offsetWidth, h: el.offsetHeight }))
      // Пишем в следующем кадре (после paint)
      requestAnimationFrame(() => {
        callback(data, els)
        console.log('RAF batched write завершён')
      })
    }
    
    reset(elements)
    rafBatch(elements, (data, els) => {
      data.forEach((m, i) => {
        els[i].style.width = m.w + 2 + 'px'
        els[i].style.height = m.h + 2 + 'px'
      })
    })

    Задание

    Создай галерею карточек с производительными анимациями. Все hover-эффекты должны использовать только `transform` и `opacity` (не `width`, `height`, `top`). Добавь `will-change: transform` для карточек перед анимацией. Используй `contain: layout` для изоляции карточек и `content-visibility: auto` для ленивого рендеринга.

    Подсказка

    `contain: layout` — изменения внутри не влияют на внешний layout. `transition: transform 0.2s ease, opacity 0.2s` — только производительные свойства. При `:hover`: `transform: translateY(-4px) scale(1.02)`. `will-change: transform` при наведении создаёт GPU-слой. `content-visibility: auto` — ленивый рендеринг элементов вне viewport.

    Загружаем среду выполнения...
    Загружаем AI-помощника...