前端的重绘重排

网页生成过程

网页的生成是一个复杂的过程,主要包括以下几个阶段:

  1. HTML解析阶段
    • 浏览器逐行解析HTML文档。
    • 构建DOM(Document Object Model)树。
    • 遇到<script>标签会暂停解析,执行脚本(除非使用async/defer)。
  2. CSS解析阶段
    • 解析外部CSS文件和内联样式。
    • 构建CSSOM(CSS Object Model)树。
    • 解析过程是递归的(从右向左选择器匹配)。
  3. 渲染树构建
    • 合并DOM树和CSSOM树。
    • 只包含需要显示的节点(如不包含display:none的元素)。
    • 计算每个节点的CSS属性值。
  4. 布局阶段(Layout/Reflow)
    • 计算每个节点在视口中的确切位置和大小。
    • 从根节点开始递归计算。
    • 生成"盒模型"精确信息。
  5. 绘制阶段(Paint)
    • 将布局计算的几何信息转换为屏幕上的实际像素。
    • 通常分为多个图层(Composite Layers)进行绘制。
    • 最后进行图层合成(Compositing)。
<!DOCTYPE html>
<html>

<head>
  <title>网页生成示例</title>
  <style>
    /* 定义一个盒子样式 */
   .box {
      width: 200px;
      height: 200px;
      background-color: lightblue;
      margin: 20px;
    }
  </style>
</head>

<body>
  <!-- 构建DOM节点 -->
  <div class="box"></div>
  <script>
    // 打印信息,表明DOM内容已加载
    console.log('DOMContentLoaded');
    // 获取元素并修改其样式
    const box = document.querySelector('.box');
    box.style.border = '2px solid red';
  </script>
</body>

</html>

重绘(Repaint)

重绘是指视觉外观改变但是几何位置不变的时候,浏览器需要重新绘制元素外观的过程。

举例

颜色、背景、阴影、文字内容等不会影响元素布局的内容的改变会触发重绘。

触发条件

  • 修改元素的 color、background-color、border-style、box-shadow 等样式属性。
  • 修改 visibility(不影响布局,但会改变视觉状态)。
  • 修改文本内容(如 textContent)。
  • 滚动条样式的变化(如 scrollbar-width)。
<!DOCTYPE html>
<html>

<body>
  <!-- 改变颜色触发重绘 -->
  <p id="color-change">改变颜色触发重绘</p>
  <button onclick="changeColor()">改变颜色</button>

  <!-- 改变背景触发重绘 -->
  <div id="bg-change" style="width: 100px; height: 100px; background-color: lightgreen;">改变背景触发重绘</div>
  <button onclick="changeBackground()">改变背景</button>

  <!-- 改变文本内容触发重绘 -->
  <span id="text-change">改变文本内容触发重绘</span>
  <button onclick="changeText()">改变文本</button>

  <script>
    function changeColor() {
      const p = document.getElementById('color-change');
      p.style.color = 'blue';
    }

    function changeBackground() {
      const div = document.getElementById('bg-change');
      div.style.backgroundColor = 'orange';
    }

    function changeText() {
      const span = document.getElementById('text-change');
      span.textContent = '文本已改变';
    }
  </script>
</body>

</html>

重排(Reflow)

重排是指几何位置或者尺寸发生改变的时候就会触发重排,更新页面布局的行为。

举例

元素宽度、高度、边距、定位、添加或删除DOM节点等操作会触发重排。

触发条件

  • 几何属性修改:width、height、padding、margin、border-width、top、left、transform(2D/3D 转换会触发合成,但若涉及布局变化也会触发重排)等。
  • 布局相关操作
    • 添加或删除可见的 DOM 节点、修改 display(如 none 会触发重排,visibility: hidden 仅触发重绘)、flex/grid 布局的变化。
  • 获取布局信息(浏览器会强制同步布局计算):offsetWidth、offsetHeight、offsetTop、offsetLeft、scrollWidth、scrollHeight、clientWidth、clientHeight、getBoundingClientRect() 等属性或方法的调用。

特点

重排成本很高,会影响整个界面布局,导致父子元素连锁变化。浏览器尽可能批量处理重排,但是频繁重排会导致性能瓶颈。

以下是多个触发重排的代码示例:

<!DOCTYPE html>
<html>

<body>
  <!-- 改变宽度触发重排 -->
  <div id="width-change" style="width: 100px; height: 100px; background-color: lightblue;">改变宽度触发重排</div>
  <button onclick="changeWidth()">改变宽度</button>

  <!-- 添加节点触发重排 -->
  <div id="node-add">添加节点触发重排</div>
  <button onclick="addNode()">添加节点</button>

  <!-- 修改display触发重排 -->
  <div id="display-change" style="width: 100px; height: 100px; background-color: lightgreen;">修改display触发重排</div>
  <button onclick="changeDisplay()">修改display</button>

  <script>
    function changeWidth() {
      const div = document.getElementById('width-change');
      div.style.width = '200px';
    }

    function addNode() {
      const div = document.getElementById('node-add');
      const newDiv = document.createElement('div');
      newDiv.textContent = '新添加的节点';
      div.appendChild(newDiv);
    }

    function changeDisplay() {
      const div = document.getElementById('display-change');
      div.style.display = 'none';
    }
  </script>
</body>

</html>

重绘与重排的关系

重排通常伴随着重绘,但是重绘不一定有重排。

性能优化

为了提高网页性能,我们需要尽量减少重绘和重排的次数。以下是一些性能优化的方法:

  1. 减少重排重绘次数
    • 批量修改
// 不好的写法:多次修改样式(触发多次重排)
const element = document.createElement('div');
element.style.width = '100px';
element.style.height = '200px';
element.style.backgroundColor = 'red';

// 好的写法:通过 class 一次性修改
const newElement = document.createElement('div');
newElement.className = 'new-style';
.new-style {
  width: 100px;
  height: 200px;
  background-color: red;
}
- **使用文档碎片**:

批量操作 DOM 时,先将节点添加到 DocumentFragment 中,再一次性插入 DOM 树,避免多次触发重排。

const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const div = document.createElement('div');
  fragment.appendChild(div);
}
document.body.appendChild(fragment); // 仅触发一次重排
  1. 避免频繁的获取布局信息
    • 缓存布局信息:如需多次读取元素的布局属性(如 offsetWidth),先将其值缓存到变量中,避免重复触发重排。
// 不好的写法:多次读取 offsetWidth(触发多次重排)
const badElement = document.createElement('div');
for (let i = 0; i < 100; i++) {
  console.log(badElement.offsetWidth);
}

// 好的写法:缓存值
const goodElement = document.createElement('div');
const width = goodElement.offsetWidth;
for (let i = 0; i < 100; i++) {
  console.log(width);
}
  1. 利用层叠上下文(CSS 3D/硬文件加速)
    将频繁动画的元素脱离文档流,使用 will-change 或 transform: translateZ(0) 使其创建层叠上下文,让浏览器为其单独分配图层,避免影响其他元素的布局。
<!DOCTYPE html>
<html>

<head>
  <style>
   .animated-element {
      width: 100px;
      height: 100px;
      background-color: lightblue;
      will-change: transform;
      transform: translateZ(0);
      animation: move 2s infinite;
    }

    @keyframes move {
      from {
        transform: translateX(0);
      }
      to {
        transform: translateX(200px);
      }
    }
  </style>
</head>

<body>
  <div class="animated-element"></div>
</body>

</html>
  1. 合理使用display:none
    对需要频繁操作的元素,先设置 display: none(使其脱离文档流,重排成本降低),操作完成后再显示。
const element = document.createElement('div');
element.style.display = 'none'; // 触发一次重排
// 模拟执行大量 DOM 修改
for (let i = 0; i < 10; i++) {
  const newSpan = document.createElement('span');
  element.appendChild(newSpan);
}
element.style.display = 'block'; // 触发一次重排
  1. 使用 CSS 动画替代 JavaScript 动画
    CSS 动画(如 transition、animation)由浏览器优化处理,尽量避免通过 JavaScript 频繁修改样式触发重排。
<!DOCTYPE html>
<html>

<head>
  <style>
   .css-animation {
      width: 100px;
      height: 100px;
      background-color: lightgreen;
      transition: width 1s;
    }

   .css-animation:hover {
      width: 200px;
    }
  </style>
</head>

<body>
  <div class="css-animation"></div>
</body>

</html>
  1. 集中修改样式
<!DOCTYPE html>
<html>

<body>
  <div id="box" style="width: 100px; height: 100px; background-color: lightblue;"></div>
  <button onclick="badChange()">不好的写法</button>
  <button onclick="goodChange()">好的写法</button>

  <style>
   .new-style {
      left: 10px;
      top: 200px;
      transform: scale(1.1);
    }
  </style>

  <script>
    const box = document.getElementById('box');

    function badChange() {
      box.style.left = '10px';
      box.style.top = '200px';
      box.style.transform = 'scale(1.1)';
    }

    function goodChange() {
      box.classList.add('new-style');
    }
  </script>
</body>

</html>
  1. 不使用table布局
    table中任何元素变化都会导致整个表格重排,因此尽量避免使用。以下是一个简单的表格示例及对比:
<!DOCTYPE html>
<html>

<body>
  <!-- 使用 table 布局 -->
  <table border="1">
    <tr>
      <td>单元格1</td>
      <td>单元格2</td>
    </tr>
  </table>
  <button onclick="changeTable()">改变表格</button>

  <!-- 使用 div 布局 -->
  <div class="div-layout">
    <div class="div-cell">单元格1</div>
    <div class="div-cell">单元格2</div>
  </div>
  <button onclick="changeDiv()">改变 div</button>

  <style>
   .div-layout {
      display: flex;
    }

   .div-cell {
      border: 1px solid black;
      padding: 5px;
    }
  </style>

  <script>
    function changeTable() {
      const table = document.querySelector('table');
      const newCell = document.createElement('td');
      newCell.textContent = '新单元格';
      const row = table.rows[0];
      row.appendChild(newCell);
    }

    function changeDiv() {
      const divLayout = document.querySelector('.div-layout');
      const newDiv = document.createElement('div');
      newDiv.textContent = '新 div';
      newDiv.classList.add('div-cell');
      divLayout.appendChild(newDiv);
    }
  </script>
</body>

</html>
  1. 离线修改DOM
    • DOM设置display: none;,将其从渲染树中移除,然后再进行复杂修改操作,最后再将其显示出来。整个过程包含隐藏和显示共两次重排。
    • 或者使用DocumentFragment创建一个DOM碎片,然后在其上面批量操作DOM,操作完成之后再添加到文档中,这样只会触发一次重排。
// 方法一:使用 display: none
const element = document.createElement('div');
document.body.appendChild(element);
element.style.display = 'none';
for (let i = 0; i < 10; i++) {
  const newSpan = document.createElement('span');
  element.appendChild(newSpan);
}
element.style.display = 'block';

// 方法二:使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
  const newDiv = document.createElement('div');
  fragment.appendChild(newDiv);
}
document.body.appendChild(fragment);
  1. 分离读写操作
<!DOCTYPE html>
<html>

<body>
  <div id="div" style="width: 100px; height: 100px; background-color: lightblue;"></div>
  <button onclick="badReadWrite()">不好的读写操作</button>
  <button onclick="goodReadWrite()">好的读写操作</button>

  <script>
    const div = document.getElementById('div');

    function badReadWrite() {
      div.style.left = '10px';
      div.style.top = '10px';
      div.style.width = '20px';
      div.style.height = '20px';
      console.log(div.offsetLeft);
      console.log(div.offsetTop);
      console.log(div.offsetWidth);
      console.log(div.offsetHeight);
    }

    function goodReadWrite() {
      const width = div.offsetWidth;
      const height = div.offsetHeight;
      div.style.width = (width + 10) + 'px';
      div.style.height = (height + 10) + 'px';
      console.log(width);
      console.log(height);
    }
  </script>
</body>

</html>

"统一读,再统一写"的优化原理,实际上是浏览器渲染机制中的**布局抖动(Layout Thrashing)**问题的解决方案。以下是详细解释:

核心机制解析

  1. 浏览器渲染队列优化
    现代浏览器会维护一个"写操作队列",将连续的样式修改操作批量处理。这种批处理可以避免每次样式修改都立即触发重排。
  2. 强制同步布局(Forced Synchronous Layout)
    当JavaScript代码读取布局属性(如offsetWidth、clientHeight等)时,浏览器必须立即计算最新布局以保证返回准确值,这会强制刷新并执行当前队列中的所有待处理写操作。
  3. 典型问题场景
  // 反模式:读写交替
  const element = document.createElement('div');
  element.style.width = '100px';  // 写 - 加入队列
  let w1 = element.offsetWidth;   // 读 - 强制刷新队列(触发重排)
  element.style.height = '200px'; // 写 - 加入队列
  let h1 = element.offsetHeight;  // 读 - 再次强制刷新队列(触发重排)

这样会导致多次不必要的重排。

统一读写法示例

const element = document.createElement('div');
// 统一读写法示例
const width = element.offsetWidth;  // 第一次读取(触发1次重排)
console.log(width);                // 仅仅是日志输出,不涉及布局读取
console.log(width);                // 同上
  1. 第一次读取
    element.offsetWidth强制浏览器执行队列中的所有待处理操作,触发1次完整的重排计算,并将结果缓存到width变量。
  2. 后续console.log
    只是输出已缓存的变量值,不涉及任何布局属性读取,不会触发额外的重排。

为什么不会多次触发重排?

关键区别在于:

  • 读取布局属性offsetWidth等会强制重排。
  • 普通变量读取:已缓存的变量不会触发重排。

浏览器的工作流程:

[初始状态] 写队列: []

1. 读取offsetWidth:
   - 发现需要最新布局信息
   - 立即执行: 刷新写队列 → 计算布局 → 返回结果
   - 重排计数 +1

2. 后续console.log:
   - 只是JavaScript执行环境中的变量访问
   - 不涉及渲染引擎
   - 不会触发重排

实际开发中的正确实践

const element = document.createElement('div');
// 1. 先批量读取所有需要的布局信息
const width = element.offsetWidth;
const height = element.offsetHeight;

// 2. 然后执行批量样式修改
element.style.width = (width + 10) + 'px';
element.style.height = (height + 10) + 'px';

// 整个过程只触发1次初始读取时的重排

特殊情况的注意事项

即使统一读取,某些情况下仍可能触发多次重排:

const element1 = document.createElement('div');
const element2 = document.createElement('div');
const width1 = element1.offsetWidth; // 重排1
const width2 = element2.offsetWidth; // 可能再次重排

因为不同元素的布局计算可能有依赖关系,浏览器可能需要多次计算。最安全的做法是使用requestAnimationFrame来分离读写操作。

这种优化原理正是Facebook的React等框架实现虚拟DOM(Virtual DOM)批处理更新的核心机制之一。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值