大家好,前端工程师们是不是经常被这个问题困扰:为什么有时候我们的页面会卡顿?明明代码看起来没什么问题,但用户体验就是不够丝滑?今天我们就来聊聊前端性能优化中最关键的两个概念 ------ 回流(Reflow)和重绘(Repaint)。
1. 什么是回流与重绘?
回流(Reflow):浏览器的大工程
回流是当渲染树(RenderTree)中部分或全部元素的尺寸、结构或某些属性发生改变时,浏览器重新计算元素位置和几何属性的过程。简单来说,就是浏览器需要重新计算页面的布局。
js
// 触发回流的常见操作
document.body.appendChild(newElement); // DOM结构变化
element.style.width = '100px'; // 直接修改样式
element.classList.add('new-class'); // 可能改变元素尺寸的类
重绘(Repaint):浏览器的小工程
当页面元素样式改变,但不影响其在文档流中的位置时(如颜色、背景、visibility等),浏览器只需要重新绘制该元素,这个过程就叫重绘。
js
// 只触发重绘的操作
element.style.color = 'red'; // 颜色变化
element.style.backgroundColor = '#f5f5f5'; // 背景色变化
element.style.visibility = 'hidden'; // 可见性变化(但仍占空间)
2. 回流比重绘更消耗性能,为什么?
是不是好奇为什么大家都说回流比重绘更消耗性能?我们来看看浏览器渲染页面的完整过程:
- 解析HTML,构建DOM树
- 解析CSS,构建CSSOM树
- 将DOM树和CSSOM树结合,形成渲染树(RenderTree)
- 布局(Layout):计算每个节点在屏幕上的确切位置和大小
- 绘制(Paint):将计算好的节点绘制到屏幕上
回流会触发布局和绘制两个步骤,而重绘只触发绘制步骤。所以回流的代价更高!
3. 触发回流的方式有哪些?
叠个甲,以下这些操作都会触发回流,开发时要特别注意:
1. 页面首次渲染
html
<!DOCTYPE html>
<html>
<head>
<title>首次渲染</title>
<script>
// 记录开始时间
const startTime = performance.now();
window.onload = function() {
// 计算渲染耗时
const loadTime = performance.now() - startTime;
console.log(`页面渲染耗时: ${loadTime.toFixed(2)}ms`);
// 网页渲染速度直接关系到用户体验和留存率
// 据统计,页面每慢0.1秒,可能损失1000万用户!
}
</script>
</head>
<body>
<div>大量内容...</div>
<!-- 页面内容 -->
</body>
</html>
2. 浏览器窗口大小改变
js
// 监听窗口大小变化,会触发回流
window.addEventListener('resize', function() {
// 这里的代码会在每次窗口大小变化时执行
// 如果在这里操作DOM,可能会导致性能问题
console.log('窗口大小改变,触发回流');
// 优化方案:使用防抖函数限制回流频率
clearTimeout(window.resizeTimer);
window.resizeTimer = setTimeout(function() {
console.log('窗口大小调整完毕,执行一次回流操作');
// 在这里执行真正需要的DOM操作
}, 250);
});
3. 元素尺寸或位置发生改变
js
const box = document.getElementById('box');
// 不好的方式:多次触发回流
function badAnimation() {
for(let i = 0; i < 100; i++) {
box.style.left = i + 'px'; // 每次循环都会触发一次回流
}
}
// 好的方式:使用CSS transitions,只触发一次回流,后续由GPU处理
function goodAnimation() {
box.style.transition = 'left 1s ease';
box.style.left = '100px'; // 只触发一次回流
}
// 更好的方式:使用transform,可能完全不触发回流(走单独的图层)
function bestAnimation() {
box.style.transform = 'translateX(100px)'; // 可能不触发主文档回流
}
4. 元素内容的变化
js
const container = document.querySelector('.container');
// 糟糕写法:每次添加子元素都会触发回流
function badAppend() {
for(let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
container.appendChild(div); // 每次都触发回流!
}
}
// 优化写法:使用文档片段,只触发一次回流
function goodAppend() {
const fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
fragment.appendChild(div);
}
container.appendChild(fragment); // 只触发一次回流
}
5. display: none与display: block之间的切换
js
const modal = document.getElementById('modal');
// 触发回流的显示/隐藏方式
function toggleWithReflow() {
if(modal.style.display === 'none') {
modal.style.display = 'block'; // 触发回流
} else {
modal.style.display = 'none'; // 同样触发回流
}
}
// 减少回流的方式
function toggleWithoutReflow() {
// 通过提前计算和缓存元素尺寸,减少回流次数
if(!modal.cached) {
modal.cached = {
width: modal.offsetWidth,
height: modal.offsetHeight
};
}
if(modal.style.display === 'none') {
modal.style.display = 'block';
// 直接设置缓存的尺寸,避免浏览器重新计算
modal.style.width = modal.cached.width + 'px';
modal.style.height = modal.cached.height + 'px';
} else {
modal.style.display = 'none';
}
}
6. 字体大小的变化
js
const article = document.querySelector('.article');
// 不好的方式:直接修改字体大小,触发整篇文章的回流
function changeFontSize(size) {
article.style.fontSize = size + 'px'; // 触发回流
}
// 更好的方式:使用CSS类切换
function changeFontSizeWithClass(size) {
// 先移除所有字体大小类
article.classList.remove('font-small', 'font-medium', 'font-large');
// 添加对应大小的类
article.classList.add(`font-${size}`);
}
// CSS定义
/*
.font-small { font-size: 12px; }
.font-medium { font-size: 16px; }
.font-large { font-size: 20px; }
*/
7. 激活CSS伪类
html
<style>
.button {
padding: 10px 20px;
background-color: #3498db;
color: white;
transition: transform 0.2s;
}
/* 这种hover效果会触发回流,因为改变了元素尺寸 */
.button:hover {
padding: 15px 25px; /* 改变了尺寸,触发回流 */
}
/* 这种hover效果只会触发重绘,性能更好 */
.button-better:hover {
background-color: #2980b9; /* 只改变颜色,只触发重绘 */
}
/* 使用transform的hover效果,可能完全不触发回流 */
.button-best:hover {
transform: scale(1.1); /* 使用GPU加速,可能不触发回流 */
}
</style>
<button class="button">按钮1(会触发回流)</button>
<button class="button-better">按钮2(只触发重绘)</button>
<button class="button-best">按钮3(可能不触发回流)</button>
8. 查询某些属性或调用某些方法
js
const image = document.querySelector('.product-image');
// 下面的代码会强制浏览器回流,因为需要获取最新的布局信息
function badMeasure() {
// 频繁读取会导致强制回流
console.log(image.offsetWidth);
doSomething();
console.log(image.offsetHeight);
doSomethingElse();
console.log(image.getBoundingClientRect());
}
// 更好的方式:缓存布局信息,避免多次回流
function goodMeasure() {
// 读取一次,缓存所有需要的值
const width = image.offsetWidth;
const height = image.offsetHeight;
const rect = image.getBoundingClientRect();
// 使用缓存的值进行后续操作
doSomething(width);
doSomethingElse(height);
moreOperations(rect);
}
4. table布局为什么不推荐使用?
咱们来探讨一个实际案例。看这段代码:
html
<table>
<tr>
<td class="sidebar">左侧边栏</td>
<td class="main">主侧内容</td>
<td class="sidebar">右侧边栏</td>
</tr>
</table>
<script>
// 表格中的一个小改动会导致整个表格回流
function updateTableContent() {
document.querySelector('.main').textContent = '新的内容';
// 这个小改动会导致整个表格重新计算布局!
}
// 更现代的布局方式:Flexbox
/*
<div class="flex-container">
<div class="sidebar">左侧边栏</div>
<div class="main">主侧内容</div>
<div class="sidebar">右侧边栏</div>
</div>
*/
</script>
这种布局方式现在很少使用,为什么呢?
- 回流成本高:table中任何一个单元格的改变,都会导致整个表格的重新布局
- 语义不合适:table本应用于表格数据,不是用来做页面布局的
- 灵活性差:难以响应式适配不同设备
5. 性能优化实战技巧
说干就干,直接上干货,这些技巧可以立即应用到你的项目中:
技巧1:使用CSS3硬件加速
css
.accelerated {
transform: translateZ(0);
will-change: transform;
}
transform、opacity等属性在单独的图层中,不会触发主文档的回流。
技巧2:避免频繁操作样式
js
// 糟糕
for(let i = 0; i < 100; i++) {
element.style.top = i + 'px'; // 100次回流!
}
// 优秀
let fragment = document.createDocumentFragment();
for(let i = 0; i < 100; i++) {
let child = document.createElement('div');
fragment.appendChild(child);
}
element.appendChild(fragment); // 只有一次回流
技巧3:使用visibility而非display
html
<!-- 对比两种隐藏方式 -->
<div class="box vis_hid">使用visibility:hidden,只触发重绘</div>
<div class="box dis_none">使用display:none,会触发回流</div>
visibility:hidden只会导致重绘,而display:none会触发回流,因为它会改变页面布局。
6. 浏览器的渲染过程:从输入URL到像素呈现
想要真正掌握回流与重绘,我们必须深入理解浏览器的完整渲染流程。这个过程比很多人想象的要复杂得多,让我们一步步剖析:
第一阶段:资源获取与解析
1. 输入URL,浏览器发起请求
arduino
// 这一步在浏览器内部进行
GET https://example.com HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 ...
2. 下载HTML文档
js
// 网络请求示例
fetch('https://example.com')
.then(response => response.text())
.then(htmlString => {
// 此时获取到的是原始字节,需要转换为字符串
console.log('HTML字节数:', new Blob([htmlString]).size);
// 浏览器会根据Content-Type或<meta charset>将字节转为字符
});
3. HTML解析
html
<!-- 浏览器会解析这些标签和属性 -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">...</div>
<script src="app.js"></script>
</body>
</html>
第二阶段:构建渲染树
4. CSS处理流程
css
/* 下载的CSS文件会经过以下处理 */
body { margin: 0; }
.container { display: flex; }
/* 1. 下载字节码 (Content-Type: text/css) */
/* 2. 使用UTF-8等编码解析成文本 */
/* 3. 词法分析,分解为token */
/* 4. 构建CSS规则节点 */
/* 5. 最终形成CSSOM树 */
5. 构建渲染树(RenderTree)
js
// 伪代码表示DOM和CSSOM如何合并成渲染树
function createRenderTree(domNode, styleRules) {
// 跳过不可见元素,如<head>或display:none的元素
if (isNotVisible(domNode, styleRules)) {
return null;
}
// 应用样式规则到DOM节点
const renderNode = {
domNode: domNode,
computedStyle: computeStyles(domNode, styleRules),
children: []
};
// 递归处理子节点
for (let child of domNode.children) {
const childRenderNode = createRenderTree(child, styleRules);
if (childRenderNode) {
renderNode.children.push(childRenderNode);
}
}
return renderNode;
}
// DOM树 + CSSOM树 = 渲染树
const renderTree = createRenderTree(document.documentElement, cssomRules);
第三阶段:布局与分层
6. 布局(Layout)计算
js
// 伪代码示意布局过程
function calculateLayout(renderNode, parentBounds) {
// 计算盒模型尺寸
renderNode.box = {
width: calculateWidth(renderNode, parentBounds),
height: calculateHeight(renderNode, parentBounds),
x: calculateXPosition(renderNode, parentBounds),
y: calculateYPosition(renderNode, parentBounds)
};
// 递归计算子节点的布局
const childBounds = {
width: renderNode.box.width,
height: renderNode.box.height,
x: renderNode.box.x,
y: renderNode.box.y
};
for (let child of renderNode.children) {
calculateLayout(child, childBounds);
}
}
// 生成Layout树
calculateLayout(renderTree, viewport);
7. 图层(Layer)创建
css
/* 以下CSS属性可能会创建新的图层 */
/* z-index较高的元素 */
.modal {
z-index: 999;
}
/* position:fixed的元素 */
.header {
position: fixed;
top: 0;
}
/* CSS3动画和变换 */
.animated {
transition: transform 0.3s;
transform: translateZ(0); /* 强制创建新图层 */
}
/* 使用will-change提示浏览器 */
.optimized {
will-change: transform, opacity; /* 告诉浏览器这些属性会变化 */
}
8. GPU加速与合成优化
css
/* GPU加速示例 - 这些CSS属性通常会由GPU处理 */
.gpu-accelerated {
/* 使用3D变换触发GPU加速 */
transform: translate3d(0, 0, 0);
/* 或使用其他3D变换 */
transform: translateZ(0);
transform: rotate3d(0, 0, 1, 45deg);
/* opacity变化也通常由GPU处理 */
transition: opacity 0.3s ease;
}
/* 真实项目中的动画优化 */
@keyframes slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.optimized-animation {
animation: slide-in 0.5s ease forwards;
/* 这种动画会在单独的合成层中进行,不触发主文档的回流 */
}
第四阶段:绘制与合成
9. 图层绘制与合成
js
// 伪代码示意图层处理过程
function paintLayers(layers) {
// 每个图层分别绘制
const paintedLayers = layers.map(layer => {
return paintLayer(layer);
});
// 合成图层(考虑z-index等)
return compositeLayers(paintedLayers);
}
// 在真实浏览器中,这一步由渲染引擎完成
// 比如Chrome的Blink,Firefox的Gecko
// 最终输出像素到屏幕
实际示例:单图层 vs 多图层
单图层渲染(可能频繁触发回流):
html
<div class="container">
<div class="box" id="animatedBox">我会导致回流</div>
</div>
<script>
const box = document.getElementById('animatedBox');
// 这种动画会触发主文档回流
setInterval(() => {
box.style.width = (parseInt(getComputedStyle(box).width) + 1) + 'px';
}, 16); // 约60fps
</script>
多图层渲染(避免主文档回流):
html
<div class="container">
<div class="box gpu-layer" id="optimizedBox">我不会导致回流</div>
</div>
<style>
.gpu-layer {
transform: translateZ(0); /* 创建单独图层 */
}
</style>
<script>
const box = document.getElementById('optimizedBox');
// 这种动画不会触发主文档回流,由GPU直接处理
setInterval(() => {
// transform由GPU在单独图层处理,主线程可以专注其他任务
const currentX = parseFloat(getComputedStyle(box).transform.split(',')[4] || 0);
box.style.transform = `translateZ(0) translateX(${currentX + 1}px)`;
}, 16);
</script>
这个复杂的渲染流程展示了为什么我们需要关注回流与重绘。当你修改DOM或CSS时,浏览器可能需要重新执行这一系列昂贵的操作。合理利用图层、GPU加速和其他优化手段,可以显著提高页面性能和用户体验。
总结
通过深入理解浏览器的渲染机制,我们可以更有针对性地优化前端性能。回流和重绘是性能优化的关键瓶颈,减少它们的发生是提升用户体验的重要手段。
记住一句话:每少一次回流,用户体验就多一分流畅;每多用一次GPU加速,动画就多一分丝滑。
你们的项目中是否遇到过因回流导致的性能问题?有什么独特的解决方案?欢迎在评论区分享讨论!