Created
September 22, 2025 12:37
-
-
Save 0m3r/0df25697af5b7a569834e5958e107fc1 to your computer and use it in GitHub Desktop.
Find CLS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ===== CLS DIAGNOSTIC SCRIPT WITH SOLUTIONS ===== | |
| const trackedProps = [ | |
| "width", "height", "top", "left", "right", "bottom", | |
| "margin", "padding", "font-size", "line-height", | |
| "transform", "display", "position", "float", | |
| "overflow", "overflow-x", "overflow-y", | |
| "visibility", "z-index", "flex", "grid" | |
| ]; | |
| const ignoredProps = ["outline", "box-shadow", "border-color", "color", "background"]; | |
| const weight = { style: 3, class: 2, hidden: 1, src: 4, href: 2 }; | |
| const impactfulAttributes = Object.keys(weight); | |
| const lastStyles = new WeakMap(); | |
| const diffHistory = new WeakMap(); | |
| const elementMetadata = new WeakMap(); | |
| const EPSILON_PX = 0.5; | |
| // ===== ПРИЧИНИ CLS ТА РІШЕННЯ ===== | |
| const CLSCauses = { | |
| IMAGE_WITHOUT_DIMENSIONS: { | |
| name: "Зображення без розмірів", | |
| detect: (element, diffs) => { | |
| return element.tagName === 'IMG' && | |
| (!element.width || !element.height) && | |
| diffs.some(d => d.includes('width') || d.includes('height')); | |
| }, | |
| solution: "Додайте width і height атрибути або CSS розміри:\n<img src='...' width='300' height='200'>\nабо img { aspect-ratio: 3/2; width: 100%; }" | |
| }, | |
| FONT_LOADING: { | |
| name: "Завантаження шрифтів", | |
| detect: (element, diffs) => { | |
| return diffs.some(d => d.includes('font-size') || d.includes('line-height')) && | |
| document.fonts && document.fonts.status !== 'loaded'; | |
| }, | |
| solution: "Використайте font-display: swap і preload шрифти:\n<link rel='preload' href='font.woff2' as='font' type='font/woff2' crossorigin>\nfont-family: 'MyFont'; font-display: swap;" | |
| }, | |
| DYNAMIC_CONTENT: { | |
| name: "Динамічний контент без резерву місця", | |
| detect: (element, diffs) => { | |
| const hasHeightChange = diffs.some(d => d.includes('height')); | |
| const isContentContainer = element.children.length > 0 || element.textContent.length > 50; | |
| return hasHeightChange && isContentContainer; | |
| }, | |
| solution: "Резервуйте місце для динамічного контенту:\n.skeleton { min-height: 200px; }\nабо використайте placeholder з фіксованою висотою" | |
| }, | |
| IFRAME_EMBED: { | |
| name: "iframe без розмірів", | |
| detect: (element, diffs) => { | |
| return element.tagName === 'IFRAME' && | |
| diffs.some(d => d.includes('width') || d.includes('height')); | |
| }, | |
| solution: "Встановіть розміри для iframe:\n<iframe width='560' height='315'></iframe>\nабо .iframe-container { aspect-ratio: 16/9; }" | |
| }, | |
| FLEX_GRID_CHANGES: { | |
| name: "Зміни в Flexbox/Grid макеті", | |
| detect: (element, diffs) => { | |
| const style = getComputedStyle(element); | |
| return (style.display.includes('flex') || style.display.includes('grid')) && | |
| diffs.some(d => d.includes('width') || d.includes('height') || d.includes('flex')); | |
| }, | |
| solution: "Використайте фіксовані розміри в flex/grid:\n.flex-item { flex: 0 0 200px; }\n.grid-item { grid-template-rows: 200px; }" | |
| }, | |
| POSITION_CHANGES: { | |
| name: "Зміни позиціонування", | |
| detect: (element, diffs) => { | |
| return diffs.some(d => d.includes('position') || d.includes('top') || | |
| d.includes('left') || d.includes('transform')); | |
| }, | |
| solution: "Використайте transform замість top/left для анімацій:\ntransform: translateX(100px); /* замість left: 100px */" | |
| }, | |
| VISIBILITY_TOGGLE: { | |
| name: "Переключення видимості елементів", | |
| detect: (element, diffs) => { | |
| return diffs.some(d => d.includes('display') || d.includes('visibility')) && | |
| !element.hasAttribute('data-cls-safe'); | |
| }, | |
| solution: "Використайте opacity замість display для плавних переходів:\nopacity: 0; pointer-events: none; /* замість display: none */" | |
| }, | |
| NAVIGATION_MENU_SHIFTS: { | |
| name: "Зміщення навігаційного меню", | |
| detect: (element, diffs) => { | |
| const isNavElement = element.classList.contains('nav-item') || | |
| element.classList.contains('menu-item') || | |
| element.classList.contains('ui-menu-item') || | |
| element.closest('nav, .navigation, .menu'); | |
| const hasLayoutChange = diffs.some(d => d.includes('width') || d.includes('height') || d.includes('margin') || d.includes('padding')); | |
| return isNavElement && hasLayoutChange; | |
| }, | |
| solution: "Стабілізуйте навігаційне меню:\n.nav-item { min-height: 40px; }\n.menu { contain: layout; }\nабо використайте CSS Grid з фіксованими розмірами" | |
| }, | |
| FLEX_LAYOUT_REFLOW: { | |
| name: "Переобчислення Flexbox макету", | |
| detect: (element, diffs) => { | |
| const parent = element.parentElement; | |
| const isFlexChild = parent && (getComputedStyle(parent).display.includes('flex') || | |
| element.classList.toString().includes('flex')); | |
| const hasFlexChange = diffs.some(d => d.includes('width') || d.includes('height') || d.includes('flex')); | |
| return isFlexChild && hasFlexChange; | |
| }, | |
| solution: "Зафіксуйте розміри flex-елементів:\n.flex-item { flex: 0 0 auto; min-width: fit-content; }\nабо .flex-container { contain: layout; }" | |
| }, | |
| LATE_CSS_LOADING: { | |
| name: "Пізнє завантаження CSS або зміна стилів", | |
| detect: (element, diffs) => { | |
| // Перевіряємо чи є зміни що можуть вказувати на пізнє завантаження стилів | |
| const hasStyleChange = diffs.length > 0 && !element.__initialStylesSet; | |
| const isSmallShift = true; // Для малих зсувів часто причина в CSS | |
| return hasStyleChange && isSmallShift; | |
| }, | |
| solution: "Оптимізуйте завантаження CSS:\n- Використайте critical CSS inline\n- Додайте preload для CSS: <link rel='preload' href='styles.css' as='style'>\n- Мінімізуйте CSS та використайте HTTP/2" | |
| }, | |
| HOVER_STATE_CHANGES: { | |
| name: "Зміни при hover/focus станах", | |
| detect: (element, diffs) => { | |
| return element.matches(':hover, :focus, :active') && | |
| diffs.some(d => d.includes('padding') || d.includes('margin') || d.includes('width') || d.includes('height')); | |
| }, | |
| solution: "Стабілізуйте hover стани:\n.item:hover { transform: scale(1.05); /* замість padding/margin */ }\nабо зарезервуйте місце: .item { padding: 10px; transition: all 0.3s; }" | |
| } | |
| }; | |
| // ===== UTILITY FUNCTIONS ===== | |
| function parsePx(value) { | |
| if (!value) return 0; | |
| const num = parseFloat(value); | |
| return isNaN(num) ? 0 : num; | |
| } | |
| function isSignificantChange(prop, before, after) { | |
| if (before.endsWith && before.endsWith("px") && after.endsWith && after.endsWith("px")) { | |
| return Math.abs(parsePx(before) - parsePx(after)) > EPSILON_PX; | |
| } | |
| return before !== after; | |
| } | |
| function getElementSelector(element) { | |
| if (element.id) return `#${element.id}`; | |
| if (element.className) return `.${Array.from(element.classList).join('.')}`; | |
| return element.tagName.toLowerCase(); | |
| } | |
| function analyzeCause(element, diffs) { | |
| const causes = []; | |
| for (const [key, cause] of Object.entries(CLSCauses)) { | |
| if (cause.detect(element, diffs)) { | |
| causes.push(cause); | |
| } | |
| } | |
| // Додатковий аналіз для випадків коли не знайдено конкретної причини | |
| if (causes.length === 0) { | |
| causes.push(analyzeGenericCause(element, diffs)); | |
| } | |
| return causes; | |
| } | |
| function analyzeGenericCause(element, diffs) { | |
| const elementInfo = { | |
| tagName: element.tagName, | |
| classes: Array.from(element.classList), | |
| isVisible: element.offsetParent !== null, | |
| hasChildren: element.children.length > 0, | |
| isInViewport: isElementInViewport(element) | |
| }; | |
| // Аналізуємо специфічні паттерни для вашого випадку | |
| if (elementInfo.classes.some(cls => cls.includes('nav') || cls.includes('menu'))) { | |
| return { | |
| name: "Можлива причина: Ініціалізація навігаційного меню", | |
| solution: `Рекомендації для навігації: | |
| 1. Додайте CSS contain: layout для меню контейнера | |
| 2. Встановіть min-height для пунктів меню | |
| 3. Перевірте JavaScript ініціалізацію меню | |
| 4. Можливо меню завантажується асинхронно | |
| CSS рішення: | |
| .nav-container { contain: layout; } | |
| .menu-item { min-height: 40px; } | |
| .ui-menu-item { transition: none; }` | |
| }; | |
| } | |
| if (elementInfo.classes.some(cls => cls.includes('flex'))) { | |
| return { | |
| name: "Можлива причина: Переобчислення Flexbox макету", | |
| solution: `Рекомендації для Flex контейнерів: | |
| 1. Використайте фіксовані розміри замість auto | |
| 2. Додайте CSS contain для ізоляції макету | |
| 3. Перевірте чи не змінюються дочірні елементи динамічно | |
| CSS рішення: | |
| .flex-col-right { contain: layout; flex: 0 0 auto; } | |
| .flex-container { min-height: 100px; }` | |
| }; | |
| } | |
| return { | |
| name: "Загальна діагностика малих зсувів", | |
| solution: `Можливі причини малих layout shifts: | |
| 1. 📡 Асинхронне завантаження JavaScript/CSS | |
| 2. 🎨 Зміна стилів після ініціалізації | |
| 3. 📱 Адаптивний дизайн (media queries) | |
| 4. 🔧 Сторонні віджети або скрипти | |
| 5. ⚡ Web fonts завантаження | |
| Загальні рішення: | |
| - Додайте contain: layout для стабільних контейнерів | |
| - Встановіть explicit розміри де можливо | |
| - Використайте CSS Grid замість Flexbox для стабільніших макетів | |
| - Перевірте DevTools > Performance для точного визначення причин` | |
| }; | |
| } | |
| function isElementInViewport(element) { | |
| const rect = element.getBoundingClientRect(); | |
| return ( | |
| rect.top >= 0 && | |
| rect.left >= 0 && | |
| rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && | |
| rect.right <= (window.innerWidth || document.documentElement.clientWidth) | |
| ); | |
| } | |
| function generateReport(element, diffs, causes, shiftValue) { | |
| const selector = getElementSelector(element); | |
| console.group(`🔍 CLS Analysis Report - ${selector}`); | |
| console.log(`📊 Shift Impact: ${shiftValue.toFixed(5)}`); | |
| console.log(`🎯 Element:`, element); | |
| if (causes.length > 0) { | |
| console.group("🚨 Identified Causes:"); | |
| causes.forEach((cause, index) => { | |
| console.group(`${index + 1}. ${cause.name}`); | |
| console.log("💡 Solution:"); | |
| console.log(cause.solution); | |
| console.groupEnd(); | |
| }); | |
| console.groupEnd(); | |
| } else { | |
| console.log("❓ No specific cause identified. Check for:"); | |
| console.log("- Late-loading resources"); | |
| console.log("- JavaScript-triggered style changes"); | |
| console.log("- Third-party widgets"); | |
| } | |
| if (diffs.length > 0) { | |
| console.group("📋 Style Changes Detected:"); | |
| diffs.forEach(diff => console.log(diff)); | |
| console.groupEnd(); | |
| } | |
| console.groupEnd(); | |
| } | |
| // ===== CORE FUNCTIONALITY ===== | |
| function diffStyles(node, newStyles) { | |
| const oldStyles = lastStyles.get(node) || {}; | |
| const diffs = []; | |
| trackedProps.forEach(prop => { | |
| if (ignoredProps.includes(prop)) return; | |
| const oldVal = oldStyles[prop] || ""; | |
| const newVal = newStyles[prop] || ""; | |
| if (isSignificantChange(prop, oldVal, newVal)) { | |
| diffs.push(`${prop}: "${oldVal}" → "${newVal}"`); | |
| } | |
| }); | |
| if (diffs.length) { | |
| if (!diffHistory.has(node)) diffHistory.set(node, []); | |
| diffHistory.get(node).push(...diffs); | |
| } | |
| return diffs; | |
| } | |
| function observeNode(node) { | |
| if (!(node instanceof Element)) return; | |
| if (node.__clsObserved) return; | |
| node.__clsObserved = true; | |
| // Зберігаємо метадані елемента | |
| elementMetadata.set(node, { | |
| tagName: node.tagName, | |
| initialStyles: {}, | |
| createdAt: Date.now() | |
| }); | |
| const snapshot = {}; | |
| const computed = getComputedStyle(node); | |
| trackedProps.forEach(p => snapshot[p] = computed[p]); | |
| lastStyles.set(node, snapshot); | |
| new MutationObserver((mutations) => { | |
| if (node.__clsHighlighting) return; | |
| const filtered = mutations | |
| .filter(m => m.type === "attributes" && impactfulAttributes.includes(m.attributeName)) | |
| .map(m => ({ | |
| name: m.attributeName, | |
| value: node.getAttribute(m.attributeName) | |
| })); | |
| if (!filtered.length) return; | |
| filtered.sort((a, b) => (weight[a.name] || 0) - (weight[b.name] || 0)); | |
| const newComputed = getComputedStyle(node); | |
| const diffs = diffStyles(node, newComputed); | |
| if (!diffs.length) return; | |
| // Аналізуємо причини CLS | |
| const causes = analyzeCause(node, diffs); | |
| generateReport(node, diffs, causes, 0); // shiftValue буде доступний в PerformanceObserver | |
| const snapshotNew = {}; | |
| trackedProps.forEach(p => snapshotNew[p] = newComputed[p]); | |
| lastStyles.set(node, snapshotNew); | |
| }).observe(node, { attributes: true, attributeFilter: impactfulAttributes }); | |
| } | |
| function highlightNode(node, shiftValue) { | |
| if (node.__clsHighlighting) return; | |
| node.__clsHighlighting = true; | |
| const prevOutline = node.style.outline; | |
| const intensity = Math.min(shiftValue * 1000, 1); // Нормалізуємо до 0-1 | |
| const color = intensity > 0.1 ? 'red' : intensity > 0.05 ? 'orange' : 'yellow'; | |
| node.style.outline = `3px solid ${color}`; | |
| node.style.outlineOffset = '2px'; | |
| // Додаємо тултіп з інформацією | |
| const tooltip = document.createElement('div'); | |
| tooltip.style.cssText = ` | |
| position: absolute; | |
| background: rgba(0,0,0,0.8); | |
| color: white; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| z-index: 10000; | |
| pointer-events: none; | |
| `; | |
| tooltip.textContent = `CLS: ${shiftValue.toFixed(5)}`; | |
| const rect = node.getBoundingClientRect(); | |
| tooltip.style.left = rect.left + 'px'; | |
| tooltip.style.top = (rect.top - 30) + 'px'; | |
| document.body.appendChild(tooltip); | |
| setTimeout(() => { | |
| node.style.outline = prevOutline || ""; | |
| node.style.outlineOffset = ""; | |
| document.body.removeChild(tooltip); | |
| node.__clsHighlighting = false; | |
| }, 3000); | |
| } | |
| // ===== PERFORMANCE OBSERVER ===== | |
| let totalCLS = 0; | |
| new PerformanceObserver((list) => { | |
| for (const entry of list.getEntries()) { | |
| if (entry.hadRecentInput) continue; | |
| totalCLS += entry.value; | |
| console.group(`📐 Layout Shift Detected (Score: ${entry.value.toFixed(5)}, Total CLS: ${totalCLS.toFixed(5)})`); | |
| entry.sources.forEach(({ node }) => { | |
| if (!(node instanceof Element)) return; | |
| observeNode(node); | |
| highlightNode(node, entry.value); | |
| // Отримуємо поточні стилі для аналізу | |
| const currentStyles = {}; | |
| const computed = getComputedStyle(node); | |
| trackedProps.forEach(p => currentStyles[p] = computed[p]); | |
| const diffs = diffStyles(node, currentStyles); | |
| const causes = analyzeCause(node, diffs); | |
| generateReport(node, diffs, causes, entry.value); | |
| }); | |
| console.groupEnd(); | |
| // Попередження при високому CLS | |
| if (totalCLS > 0.25) { | |
| console.warn(`⚠️ HIGH CLS WARNING: Total CLS (${totalCLS.toFixed(5)}) exceeds Google's "Poor" threshold (0.25)`); | |
| } else if (totalCLS > 0.1) { | |
| console.warn(`⚠️ CLS WARNING: Total CLS (${totalCLS.toFixed(5)}) exceeds Google's "Good" threshold (0.1)`); | |
| } | |
| } | |
| }).observe({ type: "layout-shift", buffered: true }); | |
| // ===== ДОДАТКОВІ УТИЛІТИ ===== | |
| // Функція для детального аналізу конкретного елемента | |
| window.deepAnalyzeCLS = function(element) { | |
| if (typeof element === 'string') { | |
| element = document.querySelector(element); | |
| } | |
| if (!element) { | |
| console.error('Element not found'); | |
| return; | |
| } | |
| console.group(`🔬 Deep CLS Analysis for ${getElementSelector(element)}`); | |
| const rect = element.getBoundingClientRect(); | |
| const style = getComputedStyle(element); | |
| console.log('📐 Element dimensions:', { | |
| width: rect.width, | |
| height: rect.height, | |
| top: rect.top, | |
| left: rect.left | |
| }); | |
| console.log('🎨 Key styles:', { | |
| display: style.display, | |
| position: style.position, | |
| flex: style.flex, | |
| width: style.width, | |
| height: style.height, | |
| contain: style.contain | |
| }); | |
| // Перевіряємо батьківські елементи | |
| let parent = element.parentElement; | |
| let level = 0; | |
| console.group('👨👩👧👦 Parent hierarchy:'); | |
| while (parent && level < 3) { | |
| const parentStyle = getComputedStyle(parent); | |
| console.log(`Level ${level}: ${getElementSelector(parent)} - display: ${parentStyle.display}, contain: ${parentStyle.contain}`); | |
| parent = parent.parentElement; | |
| level++; | |
| } | |
| console.groupEnd(); | |
| // Рекомендації | |
| console.group('💡 Specific recommendations:'); | |
| if (element.classList.contains('ui-menu-item')) { | |
| console.log('🍔 Menu item detected:'); | |
| console.log('- Add: .ui-menu-item { min-height: 40px; contain: layout; }'); | |
| console.log('- Consider: transition: none to prevent smooth animations causing shifts'); | |
| } | |
| if (element.classList.toString().includes('flex')) { | |
| console.log('📏 Flex element detected:'); | |
| console.log('- Add: flex: 0 0 auto or specific width'); | |
| console.log('- Consider: contain: layout for flex container'); | |
| } | |
| if (rect.height < 50) { | |
| console.log('📏 Small element detected:'); | |
| console.log('- Consider adding min-height to prevent collapse'); | |
| console.log('- Check if content is loading asynchronously'); | |
| } | |
| console.groupEnd(); | |
| console.groupEnd(); | |
| // Запускаємо спостереження | |
| observeNode(element); | |
| return { | |
| element, | |
| recommendations: 'See console for detailed recommendations' | |
| }; | |
| }; | |
| // Функція для моніторингу всіх навігаційних елементів | |
| window.monitorNavigation = function() { | |
| const navElements = document.querySelectorAll(` | |
| [class*="nav-"], [class*="menu-"], [class*="ui-menu"], | |
| nav *, .navigation *, .menu * | |
| `); | |
| console.log(`🍔 Found ${navElements.length} navigation elements to monitor`); | |
| navElements.forEach((el, index) => { | |
| observeNode(el); | |
| if (index < 5) { // Показуємо тільки перші 5 | |
| console.log(`${index + 1}. ${getElementSelector(el)}`); | |
| } | |
| }); | |
| return navElements; | |
| }; | |
| // Функція для отримання звіту про CLS | |
| window.getCLSReport = function() { | |
| console.group('📊 CLS Summary Report'); | |
| console.log(`Total CLS Score: ${totalCLS.toFixed(5)}`); | |
| const recommendation = totalCLS <= 0.1 ? 'Good 👍' : | |
| totalCLS <= 0.25 ? 'Needs Improvement ⚠️' : | |
| 'Poor 🚨'; | |
| console.log(`Google PageSpeed Rating: ${recommendation}`); | |
| console.log('\n🎯 Quick Tips to Reduce CLS:'); | |
| console.log('1. Set explicit dimensions for images and iframes'); | |
| console.log('2. Use font-display: swap for web fonts'); | |
| console.log('3. Reserve space for dynamic content'); | |
| console.log('4. Avoid inserting content above existing content'); | |
| console.log('5. Use transform instead of changing layout properties'); | |
| console.groupEnd(); | |
| }; | |
| // Автоматичний звіт через 10 секунд | |
| setTimeout(() => { | |
| window.getCLSReport(); | |
| }, 10000); | |
| console.log(`🚀 Enhanced CLS Diagnostic Script loaded! | |
| Available commands: | |
| 📊 getCLSReport() - General CLS summary | |
| 🔍 analyzeCLS("selector") - Start monitoring specific element | |
| 🔬 deepAnalyzeCLS("selector") - Deep analysis of element | |
| 🍔 monitorNavigation() - Monitor all navigation elements | |
| Based on your output, try: | |
| 🎯 deepAnalyzeCLS(".ui-menu-item") | |
| 🎯 monitorNavigation() | |
| `); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment