Медленный CSS — это не медленные файлы. Это layout thrashing, принудительные синхронные layout'ы, неправильно размещённые слои. Понимание работы браузерного rendering pipeline превращает хаотичный код в плавные 60fps.
JavaScript → Style → Layout → Paint → CompositeОптимальные анимации работают только на Composite уровне: transform и opacity.
// Плохо: чтение и запись перемежаются — каждое чтение вынуждает браузер сделать 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 в конце
})/* Браузер заранее создаёт GPU composite layer */
.animated-card {
will-change: transform, opacity;
}
/* Хорошо: применять перед анимацией */
.card:hover {
will-change: transform;
}
/* Плохо: везде и всегда */
* { will-change: transform; } /* Убивает память GPU *//* 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;
}/* Браузер пропускает рендеринг элементов вне viewport */
.post {
content-visibility: auto;
contain-intrinsic-size: 0 300px; /* Примерный размер для скроллбара */
}Для длинных страниц ускоряет первый рендер в 3–10 раз.
/* 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)
})
})
}/* Быстро — 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 — это не медленные файлы. Это layout thrashing, принудительные синхронные layout'ы, неправильно размещённые слои. Понимание работы браузерного rendering pipeline превращает хаотичный код в плавные 60fps.
JavaScript → Style → Layout → Paint → CompositeОптимальные анимации работают только на Composite уровне: transform и opacity.
// Плохо: чтение и запись перемежаются — каждое чтение вынуждает браузер сделать 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 в конце
})/* Браузер заранее создаёт GPU composite layer */
.animated-card {
will-change: transform, opacity;
}
/* Хорошо: применять перед анимацией */
.card:hover {
will-change: transform;
}
/* Плохо: везде и всегда */
* { will-change: transform; } /* Убивает память GPU *//* 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;
}/* Браузер пропускает рендеринг элементов вне viewport */
.post {
content-visibility: auto;
contain-intrinsic-size: 0 300px; /* Примерный размер для скроллбара */
}Для длинных страниц ускоряет первый рендер в 3–10 раз.
/* 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)
})
})
}/* Быстро — 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.