在这里插入图片描述

页面卡顿别慌:3天吃透重绘重排,性能直接起飞

页面卡顿别慌:3天吃透重绘重排,性能直接起飞

开头先唠两句

你有没有遇到过那种尴尬时刻——页面一滚动就卡成PPT,鼠标滚轮都快搓出火星子了,屏幕上的内容还在那儿一帧一帧地蹦?或者你写了个看起来挺正常的动画效果,结果在某些低端机上直接变成幻灯片放映?明明代码逻辑没毛病,语法检查全通过,为啥浏览器就是不给力,非要跟你对着干?

说实话,我早期写前端的时候也经常被这事儿搞得头大。那时候年轻气盛,总觉得是浏览器太垃圾,或者是用户电脑配置不行。后来踩的坑多了才明白,大部分性能问题其实都是自己作出来的。浏览器已经尽力了,它背后那套渲染机制复杂得很,咱们写代码的时候稍微不注意,就会触发一些昂贵的操作,让浏览器疯狂加班。

今天咱不整那些虚头巴脑的概念,也不念八股文式的定义。直接扒开浏览器内核,看看它到底在忙啥,为啥你改了一行代码,它就要从头算一遍。咱们重点聊聊**重绘(Repaint)重排(Reflow)**这俩货——这俩概念听起来挺技术,实际上跟装修房子一个道理,搞懂了能帮你省下大把的性能开销。

我保证,看完这篇文章,你下次写代码的时候手会抖一下,脑子里会闪过"这行代码会不会触发重排"的念头。这就对了,敬畏性能才是成为老前端的第一步。


重绘重排这俩货到底是啥

浏览器渲染流程:从HTML到像素点的奇幻之旅

要搞懂重绘重排,得先知道浏览器是怎么把一堆HTML标签变成屏幕上花花绿绿的像素的。这个过程其实挺曲折的,浏览器背后默默干了好多活儿,咱们一条一条捋:

第一步:解析HTML生成DOM树

浏览器拿到HTML字符串,开始一个词一个词地读,遇到标签就创建DOM节点,遇到文本就创建文本节点。最后生成一棵DOM树,这就是咱们JS操作的那个document对象。

第二步:解析CSS生成CSSOM树

同时,浏览器还在解析CSS文件和<style>标签里的样式,生成CSSOM(CSS Object Model)树。这里要注意,CSS的解析是阻塞的,所以为啥都说CSS放头部、JS放底部,就是让浏览器早点拿到样式信息。

第三步:DOM + CSSOM = Render Tree(渲染树)

浏览器把DOM树和CSSOM树捏在一起,生成渲染树。注意,渲染树只包含可见元素,display: none的节点不会进渲染树,headmeta这些也不会。

第四步:Layout(布局/重排)

浏览器根据渲染树计算每个节点的几何信息——位置、大小、边距、边框等等。这个过程很复杂,因为元素之间互相影响,父元素变了子元素可能也要变,兄弟元素之间也可能互相挤。

第五步:Paint(绘制/重绘)

有了几何信息,浏览器开始画图。它把页面分成很多图层(Layer),每个图层分别绘制,最后合成(Composite)成最终页面。

第六步:Composite(合成)

把各个图层按照正确的顺序叠在一起,显示到屏幕上。现代浏览器一般用GPU来做这事儿,所以性能还不错。

好,流程讲完了。现在说重点:重排就是重新走第四步,重绘就是重新走第五步。看起来只差一步,但性能开销天差地别。

重排:动一发牵全身的噩梦

重排(Reflow)也叫回流,就是浏览器重新计算布局的过程。为啥说它噩梦?因为布局计算是递归的、全局的

你想啊,你改了一个div的宽度,浏览器得想:这个div变大了,它里面的子元素是不是要重新排?子元素排完了,父元素是不是也要调整?旁边的兄弟元素是不是被挤走了?整个文档流可能都要重新算一遍。这就像你推倒了多米诺骨牌,一张接一张全倒了。

更坑的是,重排一定会触发重绘。因为位置大小都变了,浏览器肯定得重新画啊。所以重排是性能杀手中的杀手,能躲就躲。

重绘:只是重新上个色

重绘(Repaint)就温和多了。它发生在元素的几何属性没变,只是视觉样式变了的时候。比如你把背景色从红色改成蓝色,文字颜色从黑色改成白色,或者加个box-shadow——这些操作不会改变元素在文档流中的位置和大小,所以浏览器不需要重新计算布局,只需要重新绘制这个元素。

重绘不一定会触发重排,但重排一定会触发重绘。记住这个关系,面试要考的(开玩笑)。

性能对比:一个天上一个地下

咱们用代码实测一下差距。下面这段代码分别测试纯重绘和触发重排的性能:

// 测试重绘 vs 重排的性能差异
function testPerformance() {
    const container = document.getElementById('test-container');
    const count = 1000; // 操作1000次
    
    // 先准备1000个div
    let html = '';
    for (let i = 0; i < count; i++) {
        html += `<div class="test-box" id="box-${i}">Box ${i}</div>`;
    }
    container.innerHTML = html;
    
    // 测试1:只触发重绘(改背景色)
    console.time('重绘1000次');
    for (let i = 0; i < count; i++) {
        const box = document.getElementById(`box-${i}`);
        // 只改背景色,不触发布局变化
        box.style.backgroundColor = i % 2 === 0 ? '#ff6b6b' : '#4ecdc4';
    }
    console.timeEnd('重绘1000次');
    
    // 测试2:触发重排(改宽度)
    console.time('重排1000次');
    for (let i = 0; i < count; i++) {
        const box = document.getElementById(`box-${i}`);
        // 改宽度会触发重排,浏览器要重新计算布局
        box.style.width = (100 + i % 50) + 'px';
    }
    console.timeEnd('重排1000次');
}

// HTML结构
/*
<div id="test-container"></div>
<style>
.test-box {
    width: 100px;
    height: 100px;
    margin: 5px;
    display: inline-block;
    background-color: #ddd;
    transition: all 0.3s;
}
</style>
*/

跑一下这段代码,你会发现重排的时间可能是重绘的几十倍甚至上百倍。为啥差距这么大?因为重绘只是GPU重新填充像素,而重排要CPU重新计算几何关系,还要可能影响到其他元素。CPU计算布局可比GPU画图慢多了。


哪些操作会触发这俩玩意儿

知道了原理,咱们得知道具体哪些操作会踩雷。下面列的都是实战中常遇到的,建议收藏,写代码的时候对照着检查。

触发重排的"罪魁祸首"们

这些属性或操作一旦修改,浏览器就要重新计算布局:

尺寸相关:

// 这些属性修改都会触发重排
element.style.width = '200px';
element.style.height = '300px';
element.style.padding = '20px';
element.style.margin = '10px';
element.style.borderWidth = '5px';

位置相关:

// 修改位置信息
element.style.top = '100px';      // 对于定位元素
element.style.left = '50px';
element.style.position = 'absolute'; // 改变定位方式本身也会重排

内容相关:

// 改内容是最常见的重排触发器
element.innerHTML = '新内容';     // 内容变了,高度可能变
element.textContent = '新文本';
element.style.fontSize = '20px';  // 字体大了,占据空间变了
element.style.display = 'none';   // 元素消失,其他元素要填补位置

结构相关:

// DOM操作基本都会触发重排
parent.appendChild(newNode);      // 加新元素,其他元素可能被挤走
parent.removeChild(child);        // 删元素,后面的要往前补
parent.insertBefore(newNode, ref); // 插入元素
parent.replaceChild(newNode, old); // 替换元素

获取布局信息(这个最容易被忽视!):

// 读取这些属性会强制浏览器立即重排
const width = element.offsetWidth;      // 获取元素宽度
const height = element.offsetHeight;    // 获取元素高度
const top = element.offsetTop;          // 获取元素相对于offsetParent的top
const rect = element.getBoundingClientRect(); // 获取元素矩形信息
const styles = getComputedStyle(element);     // 获取计算样式
const scrollTop = element.scrollTop;        // 获取滚动位置

// 为啥读取也会触发重排?因为浏览器为了给你准确的值,
// 必须先把队列里的DOM操作都执行了,算出最新布局

只触发重绘的"安全操作"

这些操作相对安全,只会触发重绘:

// 颜色相关
element.style.color = '#ff0000';           // 文字颜色
element.style.backgroundColor = '#f0f0f0'; // 背景色
element.style.borderColor = '#000';        // 边框颜色(注意:改border-width会重排)

// 视觉效果
element.style.visibility = 'hidden';       // visibility:hidden不占据空间?不,它占据空间!
element.style.opacity = '0.5';             // 透明度
element.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; // 阴影
element.style.transform = 'rotate(45deg)'; // transform不会触发重排!这是重点

// 背景图和滤镜
element.style.backgroundImage = 'url(xxx)';
element.style.filter = 'blur(5px)';

display:none vs visibility:hidden:看着像,差老远

这俩属性都能让元素看不见,但触发机制完全不同:

<!-- HTML结构 -->
<div class="parent">
    <div id="box1" class="box">Box 1</div>
    <div id="box2" class="box">Box 2</div>
    <div id="box3" class="box">Box 3</div>
</div>

<style>
.box {
    width: 100px;
    height: 100px;
    display: inline-block;
    margin: 10px;
    background: #ff6b6b;
}
</style>

<script>
// display:none - 元素从渲染树移除,不占据空间,触发重排
document.getElementById('box1').style.display = 'none';
// 结果:box1消失了,box2和box3会向左移动填补位置
// 浏览器要重新计算整个父容器内所有元素的布局

// visibility:hidden - 元素还在渲染树,占据空间,只触发重绘
document.getElementById('box2').style.visibility = 'hidden';
// 结果:box2看不见了,但位置还在,box3不会移动
// 浏览器只需要把box2重绘成透明,不需要重新布局
</script>

性能对比: visibility:hiddendisplay:none轻量得多。如果你只是想暂时隐藏元素,后面还要显示,而且不希望影响布局,用visibility:hidden。如果确实要让元素彻底消失、释放空间,才用display:none

添加删除DOM节点:浏览器的加班时刻

每次你操作DOM树,浏览器都要重新计算受影响区域的布局:

// 糟糕的做法:循环里一次次操作DOM
function badAppend() {
    const list = document.getElementById('list');
    const items = ['苹果', '香蕉', '橙子', '葡萄', '西瓜'];
    
    // 每次appendChild都会触发重排!
    items.forEach(item => {
        const li = document.createElement('li');
        li.textContent = item;
        list.appendChild(li);  // 触发重排!
    });
}

// 好的做法:用文档片段批量操作
function goodAppend() {
    const list = document.getElementById('list');
    const items = ['苹果', '香蕉', '橙子', '葡萄', '西瓜'];
    const fragment = document.createDocumentFragment(); // 创建文档片段
    
    items.forEach(item => {
        const li = document.createElement('li');
        li.textContent = item;
        fragment.appendChild(li);  // 在内存中操作,不触发重排
    });
    
    // 一次性添加到DOM,只触发一次重排
    list.appendChild(fragment);
}

DocumentFragment是个好东西,它存在于内存中,不在主DOM树里。你在上面随便操作,浏览器都懒得理你。等你操作完了,一次性把它塞进DOM,浏览器只需要重排一次。


浏览器其实挺聪明的

批量处理:浏览器的"拖延症"

现代浏览器为了性能,其实挺会偷懒的。它不会你每改一行样式就立即重排一次,而是会把DOM操作先攒着,放到一个队列里,等时机合适了再批量处理。

// 浏览器会优化这段代码,可能不会触发100次重排
const element = document.getElementById('box');

// 这三次修改会被浏览器合并
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';

// 实际可能只触发一次重排,而不是三次

但是!这个优化有个前提:你不能在修改样式的中间去读取布局信息。一旦你读了,浏览器为了保证给你准确的值,必须立即把队列里的操作都执行了,算出最新布局。这就是"强制同步布局"(Forced Synchronous Layout)。

强制同步布局:打乱浏览器节奏的元凶

// 糟糕的例子:读一次改一次,交替进行
function badLoop() {
    const container = document.getElementById('container');
    const boxes = container.getElementsByClassName('box');
    
    // 假设有100个box
    for (let i = 0; i < boxes.length; i++) {
        // 先读取当前宽度(强制浏览器立即重排,确保值是最新的)
        const currentWidth = boxes[i].offsetWidth;
        
        // 再设置新宽度(又触发一次重排)
        boxes[i].style.width = (currentWidth + 10) + 'px';
        
        // 循环100次 = 触发100次重排!浏览器哭晕在厕所
    }
}

// 好的做法:先读取,再修改,分离读写
function goodLoop() {
    const container = document.getElementById('container');
    const boxes = container.getElementsByClassName('box');
    const widths = []; // 先存起来
    
    // 第一步:批量读取(触发一次重排)
    for (let i = 0; i < boxes.length; i++) {
        widths.push(boxes[i].offsetWidth);
    }
    
    // 第二步:批量修改(再触发一次重排)
    for (let i = 0; i < boxes.length; i++) {
        boxes[i].style.width = (widths[i] + 10) + 'px';
    }
    
    // 总共触发2次重排,而不是100次!
}

核心原则:批量读取,批量写入,读写分离。 这是性能优化里的黄金法则。

requestAnimationFrame:让渲染更顺滑

requestAnimationFrame(简称rAF)是浏览器提供的API,告诉浏览器"我有个动画要执行,你下次重绘之前叫我"。

// 不用rAF的动画:可能掉帧,可能卡顿
function badAnimation() {
    const box = document.getElementById('box');
    let position = 0;
    
    setInterval(() => {
        position += 2;
        box.style.left = position + 'px';  // 可能在不合适的时机触发重排
    }, 16); // 约60fps,但不精确
}

// 用rAF的动画:跟着浏览器的节奏走
function goodAnimation() {
    const box = document.getElementById('box');
    let position = 0;
    
    function step() {
        position += 2;
        box.style.left = position + 'px';
        
        // 请求下一帧继续执行
        if (position < 500) {
            requestAnimationFrame(step);
        }
    }
    
    requestAnimationFrame(step);
}

rAF的好处:

  1. 节奏同步:浏览器重绘前执行,不会丢帧
  2. 自动节流:页面不可见时自动暂停,省CPU
  3. 批量处理:多个rAF回调会在同一帧内批量执行

但是注意,rAF虽然能优化动画流畅度,但如果你在里面修改会触发重排的属性,该重排还是要重排。它只是帮你选对时机,不能减少重排次数。


实际开发中踩过的坑

坑1:循环里改DOM,页面卡到怀疑人生

这是我亲眼见过的代码,一个同事在渲染表格的时候,循环里直接操作DOM:

// 灾难代码:渲染1000行表格,页面假死3秒
function renderTableBad(data) {
    const tbody = document.querySelector('tbody');
    
    data.forEach((row, index) => {
        const tr = document.createElement('tr');
        
        // 每次创建tr都直接append,触发重排
        tbody.appendChild(tr);  // 重排+1
        
        row.forEach(cell => {
            const td = document.createElement('td');
            td.textContent = cell;
            tr.appendChild(td);  // 重排+1
        });
        
        // 如果数据有1000行,每行5个单元格 = 6000次重排!
    });
}

// 优化后:DocumentFragment + 字符串拼接
function renderTableGood(data) {
    const tbody = document.querySelector('tbody');
    
    // 方法1:DocumentFragment
    const fragment = document.createDocumentFragment();
    
    data.forEach((row, index) => {
        const tr = document.createElement('tr');
        
        row.forEach(cell => {
            const td = document.createElement('td');
            td.textContent = cell;
            tr.appendChild(td);  // 在fragment里操作,不触发重排
        });
        
        fragment.appendChild(tr);
    });
    
    // 一次性添加,只触发一次重排
    tbody.appendChild(fragment);
    
    // 方法2:字符串拼接(更快,但不安全,要防XSS)
    let html = '';
    data.forEach(row => {
        html += '<tr>';
        row.forEach(cell => {
            // 注意:实际要用转义函数处理cell内容,防XSS
            html += `<td>${escapeHtml(cell)}</td>`;
        });
        html += '</tr>';
    });
    tbody.innerHTML = html;  // 只触发一次重排,但会销毁重建所有DOM节点
}

实战经验: 对于大量数据渲染,字符串拼接通常比DocumentFragment更快,因为浏览器解析HTML字符串是C++层面的操作,比JS操作DOM快。但要注意XSS防护,别直接把用户输入塞进HTML。

坑2:动画用left/top还是transform?性能差出天际

很多新手写动画喜欢用lefttop,因为直观好理解。但这是个性能陷阱:

/* 糟糕的做法:触发重排 + 重绘 */
.bad-animation {
    position: absolute;
    left: 0;
    top: 0;
    transition: left 0.3s, top 0.3s;
}

.bad-animation:hover {
    left: 100px;  /* 触发重排! */
    top: 100px;   /* 又触发重排! */
}

/* 好的做法:只触发合成,GPU加速 */
.good-animation {
    transform: translate(0, 0);
    transition: transform 0.3s;
}

.good-animation:hover {
    transform: translate(100px, 100px);  /* 不触发重排!GPU直接处理 */
}

原理: transformopacity是特殊的属性,它们的变化不会触发重排和重绘,直接由GPU处理合成。现代浏览器会为这些元素创建独立的图层(Layer),GPU直接对这个图层做矩阵变换,快得很。

创建独立图层的其他方式:

.gpu-layer {
    /* 这些属性会让浏览器创建独立图层 */
    transform: translateZ(0);  /* 经典的"开启GPU加速"hack */
    will-change: transform;     /* 告诉浏览器这个元素要变了,提前准备
    opacity: 0.99;              /* 某些浏览器也会为此创建图层 */
    filter: blur(0);            /* 滤镜也会创建图层 */
}

坑3:列表渲染几千条数据,不用虚拟滚动等着崩

做过大数据量列表的都知道,直接渲染几千个DOM节点,页面基本就废了。即使数据拿到了,用户滚动的时候也会卡成狗。

// 灾难代码:直接渲染10000条数据
function renderAllItems(items) {
    const list = document.getElementById('list');
    const html = items.map(item => `
        <div class="item" style="height: 50px;">
            <img src="${item.avatar}" />
            <span>${item.name}</span>
        </div>
    `).join('');
    list.innerHTML = html;  // 浏览器:我太难了,要创建10000个节点
}

// 优化:虚拟滚动(Virtual Scrolling)
class VirtualScroller {
    constructor(container, items, itemHeight) {
        this.container = container;
        this.items = items;
        this.itemHeight = itemHeight;
        this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2; // 多渲染2个缓冲
        this.scrollTop = 0;
        
        // 创建占位元素,撑起滚动条
        this.placeholder = document.createElement('div');
        this.placeholder.style.height = (items.length * itemHeight) + 'px';
        container.appendChild(this.placeholder);
        
        // 创建可视区域容器
        this.viewport = document.createElement('div');
        this.viewport.style.position = 'absolute';
        this.viewport.style.top = '0';
        this.viewport.style.left = '0';
        this.viewport.style.right = '0';
        container.appendChild(this.viewport);
        
        // 监听滚动
        container.addEventListener('scroll', this.onScroll.bind(this));
        
        // 初始渲染
        this.render();
    }
    
    onScroll() {
        this.scrollTop = this.container.scrollTop;
        this.render();
    }
    
    render() {
        const startIndex = Math.floor(this.scrollTop / this.itemHeight);
        const endIndex = Math.min(startIndex + this.visibleCount, this.items.length);
        
        // 只渲染可视区域的数据
        const visibleItems = this.items.slice(startIndex, endIndex);
        
        this.viewport.style.transform = `translateY(${startIndex * this.itemHeight}px)`;
        
        // 复用DOM节点,而不是销毁重建
        let html = '';
        visibleItems.forEach((item, index) => {
            const realIndex = startIndex + index;
            html += `
                <div class="item" style="height: ${this.itemHeight}px;" data-index="${realIndex}">
                    <img src="${item.avatar}" loading="lazy" />
                    <span>${item.name}</span>
                </div>
            `;
        });
        
        this.viewport.innerHTML = html;
    }
}

// 使用
const scroller = new VirtualScroller(
    document.getElementById('list-container'),
    largeDataArray,  // 10000条数据
    50               // 每个item高50px
);

虚拟滚动的核心思想: 只渲染可视区域的数据,通过transform偏移来模拟滚动效果。用户以为自己在滚10000条数据,实际上DOM节点只有几十个。

坑4:响应式布局频繁触发resize事件,重排到你电脑发烧

做响应式布局的时候,经常需要监听窗口大小变化来调整布局。但resize事件触发非常频繁,如果里面做了昂贵的操作,页面直接卡死:

// 糟糕的做法:每次resize都立即计算
window.addEventListener('resize', () => {
    // 这里可能做了大量DOM操作和计算
    recalculateLayout();      // 触发重排
    adjustImageSizes();       // 又触发重排
    repositionElements();     // 再触发重排
    // 用户拖拽窗口大小时,这会被调用几十次,CPU直接爆炸
});

// 优化1:防抖(debounce)
function debounce(fn, delay) {
    let timer = null;
    return function(...args) {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

window.addEventListener('resize', debounce(() => {
    recalculateLayout();
}, 200));  // 停止拖拽200ms后才执行

// 优化2:节流(throttle)+ requestAnimationFrame
function throttleRAF(fn) {
    let ticking = false;
    return function(...args) {
        if (!ticking) {
            requestAnimationFrame(() => {
                fn.apply(this, args);
                ticking = false;
            });
            ticking = true;
        }
    };
}

window.addEventListener('resize', throttleRAF(() => {
    recalculateLayout();
}));  // 最多每帧执行一次,跟着浏览器的节奏走

防抖 vs 节流:

  • 防抖:等用户停下来了再执行(适合搜索框输入)
  • 节流:固定频率执行,不攒着(适合滚动、resize)

遇到问题咋排查

光知道原理不够,还得会抓现行。Chrome DevTools提供了强大的性能分析工具,咱们一个个看。

Performance面板:一眼看出哪里卡

打开DevTools,切到Performance面板,点击录制按钮,操作一下页面,再停止录制,你会看到一条详细的时间线。

// 测试代码:故意制造性能问题
function createPerformanceIssue() {
    const container = document.getElementById('container');
    
    // 强制同步布局:读写交替
    for (let i = 0; i < 100; i++) {
        const box = document.createElement('div');
        box.className = 'test-box';
        container.appendChild(box);
        
        // 读-写-读-写,触发多次重排
        const height = box.offsetHeight;  // 读,触发重排
        box.style.height = (height + 10) + 'px';  // 写,又触发重排
    }
}

录制后看Performance面板:

  • 黄色长条:JavaScript执行时间长
  • 紫色长条:样式计算(Recalculate Style)
  • 蓝色长条:布局(Layout/Reflow)
  • 绿色长条:绘制(Paint)
  • 灰色长条:合成(Composite)

如果你看到蓝色长条特别多特别长,说明重排是瓶颈。点击具体的长条,下面会显示调用栈,能看到是哪行代码触发的。

Layers面板:看谁在吃GPU资源

切到Layers面板(如果没有,点右上角三个点More tools > Layers),能看到页面被分成了哪些图层。

/* 给不同元素创建图层 */
.layer1 {
    transform: translateZ(0);  /* 强制创建图层 */
    will-change: transform;
}

.layer2 {
    position: fixed;           /* fixed定位也会创建图层 */
    will-change: scroll-position;
}

图层过多的问题: 每个图层都要占GPU内存,图层太多会导致内存不足,反而性能下降。所以will-changetranslateZ(0)别滥用,只在确实需要动画的元素上用。

Rendering面板:实时显示重绘区域

Esc打开Console,左下角点Rendering(如果没有,点Console旁边的三个点),勾选"Paint flashing"。

这时候页面上只要有重绘的区域,就会闪绿色。如果你滚动页面看到满屏绿光,说明重绘太频繁了。

还有"Layout Shift Regions"选项,勾选后重排的区域会闪紫色。这个功能特别适合检测CLS(Cumulative Layout Shift)问题,就是页面元素突然跳动的那种糟糕体验。

代码示例:故意制造问题然后排查

<!DOCTYPE html>
<html>
<head>
    <style>
        .container { width: 800px; margin: 0 auto; }
        .box { 
            width: 100px; 
            height: 100px; 
            background: #ff6b6b; 
            margin: 10px;
            display: inline-block;
        }
    </style>
</head>
<body>
    <div class="container" id="container"></div>
    <button onclick="triggerLayoutThrashing()">制造布局抖动</button>
    <button onclick="optimizedVersion()">优化版本</button>
    
    <script>
        // 制造布局抖动(Layout Thrashing)
        function triggerLayoutThrashing() {
            const container = document.getElementById('container');
            container.innerHTML = '';
            
            console.time('布局抖动版本');
            for (let i = 0; i < 100; i++) {
                const box = document.createElement('div');
                box.className = 'box';
                container.appendChild(box);
                
                // 致命错误:读-写-读-写交替
                const width = box.offsetWidth;           // 强制重排
                box.style.width = (width + 10) + 'px';   // 又触发重排
                const height = box.offsetHeight;         // 再强制重排
                box.style.height = (height + 10) + 'px'; // 继续重排
            }
            console.timeEnd('布局抖动版本');
            // Performance面板里会看到大量紫色Layout条
        }
        
        // 优化版本:读写分离
        function optimizedVersion() {
            const container = document.getElementById('container');
            container.innerHTML = '';
            
            console.time('优化版本');
            const boxes = [];
            const newSizes = [];
            
            // 第一步:批量创建和写入
            for (let i = 0; i < 100; i++) {
                const box = document.createElement('div');
                box.className = 'box';
                box.style.width = '110px';  // 直接设置,不读取
                box.style.height = '110px';
                boxes.push(box);
            }
            
            // 一次性添加到DOM,触发一次重排
            boxes.forEach(box => container.appendChild(box));
            
            // 第二步:如果需要读取,批量读取
            // 这里假设我们需要基于当前尺寸计算新尺寸
            boxes.forEach(box => {
                const rect = box.getBoundingClientRect();  // 批量读取,触发一次重排
                newSizes.push({
                    width: rect.width + 5,
                    height: rect.height + 5
                });
            });
            
            // 第三步:批量写入新尺寸
            boxes.forEach((box, index) => {
                box.style.width = newSizes[index].width + 'px';
                box.style.height = newSizes[index].height + 'px';
            });  // 这里再触发一次重排
            
            console.timeEnd('优化版本');
            // 总共2-3次重排,而不是400次!
        }
    </script>
</body>
</html>

打开这个页面,分别点两个按钮,同时录制Performance面板,对比看看时间线差异。优化版本的蓝色Layout条会少很多,总执行时间可能只有抖动版本的1/100。


一些能救命的优化技巧

技巧1:能用transform就别动left/top

这条说过很多次了,但值得再强调一遍。transformopacity是CSS属性中的"天选之子",它们的变化直接走GPU合成,不经过布局计算。

/* 别这么写 */
.slide-panel {
    position: fixed;
    left: -300px;
    transition: left 0.3s ease;
}
.slide-panel.open {
    left: 0;  /* 触发重排 */
}

/* 要这么写 */
.slide-panel {
    transform: translateX(-100%);  /* 移出屏幕外 */
    transition: transform 0.3s ease;
}
.slide-panel.open {
    transform: translateX(0);  /* GPU加速,丝滑 */
}

进阶:3D变换强制硬件加速

.gpu-accelerated {
    /* 这些属性会创建独立的合成层 */
    transform: translate3d(0, 0, 0);
    backface-visibility: hidden;
    perspective: 1000px;
}

技巧2:批量DOM操作先藏起来

如果你要做大量DOM修改,可以先把元素从文档流中移除,改完再塞回去。这样浏览器只需要在移除和插入时各重排一次,中间的操作都不触发重排。

// 优化前:每次修改都触发重排
function slowUpdate() {
    const list = document.getElementById('list');
    const items = list.children;
    
    for (let i = 0; i < items.length; i++) {
        items[i].style.width = '200px';      // 重排+1
        items[i].style.height = '100px';     // 重排+1
        items[i].textContent = 'New ' + i;   // 可能触发重排
    }
}

// 优化后:先隐藏,批量修改,再显示
function fastUpdate() {
    const list = document.getElementById('list');
    
    // 1. 先隐藏,从渲染树移除(但DOM节点还在)
    list.style.display = 'none';  // 触发1次重排
    
    const items = list.children;
    for (let i = 0; i < items.length; i++) {
        // 这些修改不会触发重排,因为元素不在渲染树
        items[i].style.width = '200px';
        items[i].style.height = '100px';
        items[i].textContent = 'New ' + i;
    }
    
    // 2. 显示回来,触发1次重排
    list.style.display = 'block';
    
    // 总共2次重排,而不是几百次
}

// 更优雅的方式:cloneNode
function elegantUpdate() {
    const list = document.getElementById('list');
    const clone = list.cloneNode(true);  // 深拷贝,不在文档流中
    
    const items = clone.children;
    for (let i = 0; i < items.length; i++) {
        items[i].style.width = '200px';
        items[i].style.height = '100px';
        items[i].textContent = 'New ' + i;
    }
    
    // 一次性替换,触发1次重排(但注意事件监听器会丢失)
    list.parentNode.replaceChild(clone, list);
}

注意: cloneNode方式会丢失原元素上的事件监听器和数据,适合纯展示型组件。如果是有交互的组件,用display:none的方式更安全。

技巧3:固定尺寸的元素尽量给宽高

浏览器计算布局的时候,如果能提前知道元素的尺寸,就能少做很多推测工作。

<!-- 不好:浏览器要等图片加载完才知道高度,可能导致布局抖动 -->

<img src="large-photo.jpg" alt="照片">

<!-- 好:提前告诉浏览器尺寸,它就能预留空间 -->
<img src="large-photo.jpg" width="800" height="600" alt="照片">

<!-- 或者用CSS aspect-ratio(现代浏览器支持) -->
<img src="large-photo.jpg" style="aspect-ratio: 4/3; width: 100%;" alt="照片">

CSS containment:让浏览器少管闲事

.widget {
    /* 告诉浏览器:这个元素内部的布局变化,不会影响外部 */
    contain: layout;
    
    /* 更严格的隔离:布局、样式、绘制都隔离 */
    contain: strict;
    
    /* 或者单独控制 */
    contain: layout paint;  /* 布局变化不引起外部重排,绘制不超出边界 */
}

contain: layout是个神器。比如你一个页面有很多独立的小组件,每个组件内部可能会动态调整布局。如果加了contain: layout,浏览器就知道"这货随便怎么变,都不会把外面的布局搞乱",于是就不会因为组件内部的变化而重排整个页面。

技巧4:will-change别乱用

will-change是告诉浏览器"这个元素马上要变了,你提前做准备"。用对了确实能优化性能,用错了就是性能毒药。

/* 错误用法:永久开启 */
.always-animated {
    will-change: transform;  /* 千万别这么写! */
}

/* 正确用法:动画前开启,动画后关闭 */
.element {
    transition: transform 0.3s;
}

.element:hover {
    will-change: transform;  /* 鼠标悬停时开启,用户可能要交互了 */
}

.element:not(:hover) {
    will-change: auto;  /* 鼠标移走立即关闭 */
}

/* JS动态控制更精确 */
function startAnimation(element) {
    element.style.willChange = 'transform';
    element.addEventListener('transitionend', () => {
        element.style.willChange = 'auto';
    }, { once: true });
}

will-change的代价:

  • 每个有will-change的元素都会创建独立的GPU图层
  • 图层占GPU内存,太多会导致内存不足
  • 创建和销毁图层本身也有开销

原则: 只在确定要动画的元素上用,动画结束立即释放。别当成transform: translateZ(0)的替代品到处撒。

技巧5:CSS选择器别写太复杂

这个很多人忽视,但复杂的CSS选择器会增加样式计算时间,间接影响渲染性能。

/* 糟糕:浏览器要从右往左匹配,先找所有span,再筛选在.card里的,再筛选在.container里的... */
.container .card div span .link { }

/* 好:尽量用类选择器,减少层级 */
.card-link { }

/* 更好:用BEM命名规范, flat的选择器 */
.user-card__link { }

JS操作样式也要快:

// 糟糕:直接改style,内联样式优先级高但难维护,且每次修改都触发重排
element.style.width = '100px';
element.style.height = '200px';
element.style.backgroundColor = '#f0f0f0';

// 好:用class批量控制,把样式抽离到CSS
element.classList.add('active-state');

// CSS里定义
/*
.active-state {
    width: 100px;
    height: 200px;
    background-color: #f0f0f0;
}
*/

classList的好处是,浏览器可以批量处理样式变化,而且样式在CSS文件里,浏览器解析一次就能复用。


最后说点实在的

别追求极致优化,用户体验优先

说了这么多优化技巧,但我要泼点冷水:别为了优化而优化。有些重排是避免不了的,该触发的时候就得触发。比如用户点击展开一个详情面板,内容高度变化,这时候重排是正常的,用户也预期页面会重新布局。

优化的目标是让浏览器少做无用功,而不是让浏览器不做功。如果为了省一次重排,把代码写得晦涩难懂,维护成本飙升,那就是本末倒置。

小项目不用太纠结,大项目性能问题早晚找上门

如果你只是做个简单的企业官网,页面就几个静态区块,那其实不用太纠结重绘重排。现代浏览器性能很强,小体量的页面随便写都流畅。

但如果你是做后台管理系统、数据可视化大屏、大型单页应用(SPA),那性能优化就是必修课。用户会在页面上操作几小时,数据量可能上万条,这时候每一个小优化都能累积成巨大的体验提升。

多看看大厂的性能优化案例

Google、Facebook、Twitter这些大厂都公开过很多性能优化实践。比如Google的Rail模型(Response、Animation、Idle、Load),Facebook的React虚拟DOM和Fiber架构,都是解决重绘重排问题的经典方案。

推荐几个值得学习的案例:

  • React Virtual Scroller:处理大数据列表的标准方案
  • Intersection Observer API:替代scroll事件监听,性能更好
  • CSS Containment:前面提到的,现代浏览器的利器
  • Web Workers:把复杂计算移出主线程,不阻塞渲染

下次页面卡顿时,先想想是不是又触发重排了

养成性能意识比记住具体技巧更重要。以后写代码的时候,每当你要:

  • 修改元素尺寸或位置
  • 读取offsetWidthgetBoundingClientRect
  • 在循环里操作DOM
  • 写CSS动画

停一下,想想: 这会触发重排吗?有必要吗?能不能用transform替代?能不能批量处理?

这种条件反射式的思考,会让你从"能跑就行"进阶到"跑得快还优雅"。


总结(虽然我说别写作文,但还是要收个尾)

重绘重排是前端性能优化的核心知识点,但不是要你死记硬背概念。关键是理解浏览器的工作原理,知道它什么时候会"加班",然后尽量别在这个时候烦它。

核心要点再划一遍:

  1. 重排比重绘贵得多,因为涉及布局计算
  2. 读写分离,批量读取布局信息,批量修改样式
  3. 用transform和opacity做动画,走GPU加速
  4. 虚拟滚动处理大数据,别一次性渲染几千个DOM
  5. 防抖节流处理高频事件,别让resize和scroll事件拖垮页面
  6. DevTools是好朋友,Performance面板多看看,养成排查习惯

性能优化是个无底洞,但也是个有明确ROI(投资回报)的事情。投入时间学习这些知识,回报是用户更流畅的体验,是你代码的专业度,是面试时候的谈资。

下次再遇到页面卡顿,别急着甩锅给后端或者运维,先打开DevTools,看看是不是自己又写了个for循环里读offsetWidth的神仙代码。找到问题,解决它,然后长个记性——这才是前端工程师的成长之路。

好了,就说这么多。去改代码吧,记得用transform别用left,用DocumentFragment别一次次appendChild,用requestAnimationFrame别用setInterval。浏览器会感谢你的,用户也会感谢你的,最重要的是,你自己会感谢自己——再也不用对着卡成PPT的页面抓头发了。

欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。

推荐:DTcode7的博客首页。
一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!


专栏系列(点击解锁) 学习路线(点击解锁) 知识定位
《微信小程序相关博客》 持续更新中~ 结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等
《AIGC相关博客》 持续更新中~ AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结
《HTML网站开发相关》 《前端基础入门三大核心之html相关博客》 前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识
《前端基础入门三大核心之JS相关博客》 前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。
通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心
《前端基础入门三大核心之CSS相关博客》 介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页
《canvas绘图相关博客》 Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化
《Vue实战相关博客》 持续更新中~ 详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅
《python相关博客》 持续更新中~ Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具
《sql数据库相关博客》 持续更新中~ SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能
《算法系列相关博客》 持续更新中~ 算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维
《IT信息技术相关博客》 持续更新中~ 作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识
《信息化人员基础技能知识相关博客》 无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方
《信息化技能面试宝典相关博客》 涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面
《前端开发习惯与小技巧相关博客》 持续更新中~ 罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等
《photoshop相关博客》 持续更新中~ 基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结
日常开发&办公&生产【实用工具】分享相关博客》 持续更新中~ 分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具

吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤

非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!

在这里插入图片描述

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐