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

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

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

相关推荐
帅夫帅夫1 分钟前
前端小白也能看懂的 Promise 原理与使用教程(附 async/await 升级指南)
前端
用户49810727802301 分钟前
浏览器原生支持的组件化方案?Web Components深度解毒指南
前端
每天都想睡觉的19001 分钟前
实现一个 React 版本的 Keep-Alive 组件,并支持 Tab 管理、缓存、关闭等功能
前端·react.js
轻语呢喃6 分钟前
前端路由:从传统页面跳转到单页应用(SPA)
前端·react.js·html
foxhuli22911 分钟前
echarts 绘制3D中国地图
前端
KeyNG_Jykxg12 分钟前
🥳Elx开源升级:XMarkdown 组件加入、Storybook 预览体验升级
前端·vue.js·人工智能
不吃香菜的猪15 分钟前
Vue3的组件通信方式
前端·javascript·vue.js
补三补四15 分钟前
RNN(循环神经网络)
人工智能·rnn·深度学习·神经网络·算法
hard_coding_wang34 分钟前
使用layui的前端框架过程中,无法加载css和js怎么办?
javascript·前端框架·layui
香蕉可乐荷包蛋43 分钟前
vue3中ref和reactive的使用、优化
前端·javascript·vue.js