回流与重绘:性能优化的幕后英雄

大家好,前端工程师们是不是经常被这个问题困扰:为什么有时候我们的页面会卡顿?明明代码看起来没什么问题,但用户体验就是不够丝滑?今天我们就来聊聊前端性能优化中最关键的两个概念 ------ 回流(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. 回流比重绘更消耗性能,为什么?

是不是好奇为什么大家都说回流比重绘更消耗性能?我们来看看浏览器渲染页面的完整过程:

  1. 解析HTML,构建DOM树
  2. 解析CSS,构建CSSOM树
  3. 将DOM树和CSSOM树结合,形成渲染树(RenderTree)
  4. 布局(Layout):计算每个节点在屏幕上的确切位置和大小
  5. 绘制(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>

这种布局方式现在很少使用,为什么呢?

  1. 回流成本高:table中任何一个单元格的改变,都会导致整个表格的重新布局
  2. 语义不合适:table本应用于表格数据,不是用来做页面布局的
  3. 灵活性差:难以响应式适配不同设备

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加速,动画就多一分丝滑。

你们的项目中是否遇到过因回流导致的性能问题?有什么独特的解决方案?欢迎在评论区分享讨论!

相关推荐
To_OC7 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
To_OC7 小时前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
vipbic8 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy8 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志8 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble9 小时前
c端系统,其实更像一个信息展示平台
前端
你怎么知道我是队长10 小时前
JavaScript的变量和数据类型介绍
开发语言·javascript·ecmascript
李姆斯10 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
m沐沐10 小时前
【深度学习】dlib 人脸关键点检测
人工智能·深度学习·opencv·计算机视觉·pycharm·人脸识别·关键点检测
名字还没想好☜11 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js