前端性能优化:如何避免重排(Reflow)和重绘(Repaint)

在前端性能优化中,重排和重绘是两个非常重要的概念。
页面滚动卡顿、动画掉帧、交互延迟,很多时候并不是 JavaScript 计算本身太慢,而是代码频繁触发了浏览器的样式计算、布局计算和像素绘制。

本文将系统讲解:

  • 浏览器渲染页面的基本流程;
  • 什么是重排和重绘;
  • 哪些操作容易触发重排和重绘;
  • 什么是强制同步布局和布局抖动;
  • 如何通过批量读写、缓存布局、requestAnimationFrametransform、虚拟列表等方式优化;
  • Vue 和 React 项目中的常见优化方法;
  • 如何使用 Chrome DevTools 定位布局和绘制问题。

一、浏览器是如何渲染页面的

浏览器将 HTML、CSS 和 JavaScript 转换成最终页面,大致会经过以下流程:

HTML
  ↓
DOM Tree
  ↓
CSS
  ↓
CSSOM Tree
  ↓
Render Tree
  ↓
Style
  ↓
Layout
  ↓
Paint
  ↓
Composite

其中几个关键阶段分别是:

1. Style

浏览器根据 CSS 选择器、继承关系和层叠规则,计算每个元素最终生效的样式。

例如:

.container .item.active {
  width: 200px;
  color: #1677ff;
}

浏览器需要确认:

  • 哪些元素匹配该选择器;
  • 哪些属性被覆盖;
  • 哪些属性被继承;
  • 每个元素最终使用什么样式。

2. Layout

Layout 也称为 Reflow,即重排。

浏览器会计算每个元素的:

  • 宽度;
  • 高度;
  • 位置;
  • 内边距;
  • 外边距;
  • 相对父元素的位置;
  • 对其他元素的影响。

3. Paint

Paint 即绘制。

浏览器会把元素绘制成像素,包括:

  • 文字;
  • 背景;
  • 边框;
  • 阴影;
  • 图片;
  • 渐变;
  • 圆角。

4. Composite

Composite 即图层合成。

浏览器会将不同图层合成为最终画面,再交给屏幕显示。

现代浏览器中的动画优化,很多时候就是尽量让变化只发生在 Composite 阶段,避免重新执行 Layout 和 Paint。


二、什么是重排

重排,也称为:

  • Reflow;
  • Layout。

当元素的几何信息发生变化时,浏览器需要重新计算页面布局。

例如:

const box = document.querySelector(".box");

box.style.width = "300px";

如果 .box 的宽度发生变化,浏览器可能需要重新计算:

  • .box 自身尺寸;
  • .box 子元素的位置;
  • .box 后续兄弟元素的位置;
  • 父元素高度;
  • 页面滚动区域。

这就是重排。


三、为什么重排成本较高

重排通常不是只影响当前元素。

例如:

<div class="layout">
  <header class="header">Header</header>
  <main class="content">Content</main>
  <footer class="footer">Footer</footer>
</div>

如果修改 Header 高度:

const header = document.querySelector(".header");

header.style.height = "200px";

可能导致:

Header 高度变化
→ Content 向下移动
→ Footer 向下移动
→ Layout 总高度变化
→ 页面滚动高度变化
→ 相关区域重新绘制

DOM 数量越多、布局层级越复杂,重排的成本通常越高。


四、什么是重绘

重绘,也称为:

  • Repaint;
  • Paint。

当元素的外观发生变化,但尺寸和位置没有变化时,浏览器通常只需要重新绘制元素。

例如:

const box = document.querySelector(".box");

box.style.backgroundColor = "#1677ff";

背景色变化不会改变元素的宽高和位置,因此一般只会触发重绘。

常见可能触发重绘的属性包括:

color
background-color
border-color
box-shadow
outline
visibility

五、重排和重绘的关系

通常情况下:

重排会引起重绘,但重绘不一定引起重排。

例如修改宽度:

修改 width
→ Layout
→ Paint
→ Composite

修改背景色:

修改 background-color
→ Paint
→ Composite

修改 transform

修改 transform
→ Composite

因此,一般可以把性能成本理解为:

Layout > Paint > Composite

但实际成本仍然取决于:

  • DOM 数量;
  • 重排影响范围;
  • 绘制面积;
  • CSS 效果复杂度;
  • 浏览器实现;
  • 设备性能。

六、哪些操作容易触发重排

1. 修改宽度和高度

element.style.width = "300px";
element.style.height = "200px";

常见属性:

width
height
min-width
max-width
min-height
max-height

2. 修改内边距和外边距

element.style.padding = "20px";
element.style.marginTop = "16px";

常见属性:

padding
margin

3. 修改元素位置

element.style.top = "100px";
element.style.left = "50px";

常见属性:

top
right
bottom
left

4. 修改字体相关属性

element.style.fontSize = "20px";
element.style.lineHeight = "32px";

常见属性:

font-size
font-family
font-weight
line-height
letter-spacing

字体变化可能影响文字宽度、换行和元素高度。


5. 添加或删除 DOM 节点

container.appendChild(newElement);
container.removeChild(oldElement);

DOM 结构变化后,浏览器通常需要重新计算布局。


6. 修改 display

element.style.display = "none";

display: none 会让元素退出文档流,因此通常会触发重排。

重新显示:

element.style.display = "block";

元素重新参与布局,同样可能触发重排。


7. 修改 class

element.classList.add("active");

是否触发重排,取决于 class 修改了哪些属性。

例如:

.active {
  width: 300px;
}

会影响布局。

而:

.active {
  color: red;
}

通常只影响绘制。


8. 改变窗口尺寸

用户调整浏览器窗口大小时,响应式布局需要重新计算。

window.addEventListener("resize", () => {
  updateLayout();
});

resize 是高频事件,回调中如果包含复杂布局计算,容易造成卡顿。


七、哪些读取操作可能触发强制同步布局

很多开发者认为只有“写入样式”才会触发重排。

实际上,某些读取布局信息的操作,也可能迫使浏览器立即执行 Layout。

常见 API:

element.offsetWidth
element.offsetHeight
element.offsetTop
element.offsetLeft

element.clientWidth
element.clientHeight

element.scrollWidth
element.scrollHeight
element.scrollTop

element.getBoundingClientRect()

window.getComputedStyle(element)

例如:

element.style.width = "300px";

const width = element.offsetWidth;

第一行修改样式后,浏览器原本可能准备稍后统一计算布局。

但第二行要求立即返回最新宽度,所以浏览器必须马上执行 Layout。

这种现象称为:

Forced Synchronous Layout

即强制同步布局。


八、什么是布局抖动

布局抖动,也称为:

Layout Thrashing

典型特征是不断交叉执行:

写 DOM
→ 读布局
→ 写 DOM
→ 读布局

错误示例:

const items = document.querySelectorAll(".item");

items.forEach((item) => {
  item.style.width = "200px";

  const height = item.offsetHeight;

  item.style.height = `${height + 10}px`;
});

每次循环都可能发生:

修改 width
→ 读取 offsetHeight
→ 强制执行 Layout
→ 修改 height
→ 下一轮再次执行 Layout

如果列表中有几百或几千个元素,性能会明显下降。


九、优化原则:批量读取,批量写入

避免布局抖动最重要的原则是:

先统一读取布局信息,再统一写入样式。

错误写法:

const items = document.querySelectorAll(".item");

items.forEach((item) => {
  const width = item.offsetWidth;

  item.style.width = `${width + 20}px`;
});

推荐写法:

const items = [
  ...document.querySelectorAll(".item"),
];

const widths = items.map((item) => {
  return item.offsetWidth;
});

items.forEach((item, index) => {
  item.style.width = `${widths[index] + 20}px`;
});

执行过程变为:

批量读取
→ 批量写入

而不是:

读取
→ 写入
→ 读取
→ 写入

十、缓存布局信息

如果一个布局值不会频繁变化,就不应该每次事件触发时都重新读取。

不推荐:

window.addEventListener("scroll", () => {
  const target = document.querySelector(".target");
  const targetTop = target.offsetTop;

  if (window.scrollY > targetTop) {
    showHeader();
  }
});

问题:

  • 每次滚动都查询 DOM;
  • 每次滚动都读取布局;
  • 高频事件中重复计算。

推荐:

const target = document.querySelector(".target");

let targetTop = target.offsetTop;

function updateTargetTop() {
  targetTop = target.offsetTop;
}

window.addEventListener("resize", updateTargetTop);

window.addEventListener("scroll", () => {
  if (window.scrollY > targetTop) {
    showHeader();
  }
});

如果页面中有动态内容导致位置变化,可以在必要时重新计算,而不是每一帧都计算。


十一、批量修改样式

1. 使用 class 切换状态

不推荐:

element.style.width = "200px";
element.style.height = "100px";
element.style.marginTop = "20px";
element.style.backgroundColor = "#1677ff";
element.style.color = "#fff";

推荐:

element.classList.add("active");
.active {
  width: 200px;
  height: 100px;
  margin-top: 20px;
  color: #fff;
  background-color: #1677ff;
}

优点:

  • 样式集中管理;
  • 可维护性更高;
  • 减少多次内联样式赋值;
  • 方便浏览器统一处理样式变化。

2. 使用 cssText

也可以一次性写入多个样式:

element.style.cssText = `
  width: 200px;
  height: 100px;
  margin-top: 20px;
  color: #fff;
  background-color: #1677ff;
`;

需要注意:

设置 cssText 可能覆盖元素原有的内联样式。

因此,项目中通常更推荐使用 class。


十二、批量插入 DOM

1. 逐个插入

不推荐:

const list = document.querySelector(".list");

for (let i = 0; i < 1000; i++) {
  const item = document.createElement("div");

  item.textContent = `Item ${i}`;

  list.appendChild(item);
}

逐个插入会反复修改 DOM 树。


2. 使用 DocumentFragment

推荐:

const list = document.querySelector(".list");
const fragment = document.createDocumentFragment();

for (let i = 0; i < 1000; i++) {
  const item = document.createElement("div");

  item.textContent = `Item ${i}`;

  fragment.appendChild(item);
}

list.appendChild(fragment);

先在内存中完成节点构建,最后一次性插入页面。


3. 使用字符串统一生成

const html = Array.from(
  {
    length: 1000,
  },
  (_, index) => {
    return `
      <div class="item">
        Item ${index}
      </div>
    `;
  }
).join("");

document.querySelector(".list").innerHTML = html;

适用于静态内容。

但要注意:

  • 不要直接插入未经处理的用户输入;
  • 防止 XSS;
  • 替换 innerHTML 会销毁旧节点和旧事件绑定。

十三、动画优先使用 transform 和 opacity

在动画中频繁修改以下属性,通常会触发布局:

top
left
width
height
margin
padding

错误示例:

let left = 0;

function animate() {
  left += 1;

  element.style.left = `${left}px`;

  requestAnimationFrame(animate);
}

animate();

如果每一帧修改 left,浏览器可能持续执行布局计算。

推荐:

let x = 0;

function animate() {
  x += 1;

  element.style.transform = `translateX(${x}px)`;

  requestAnimationFrame(animate);
}

animate();

transform 通常不会改变文档流布局,更适合高频动画。


推荐的动画属性

transform
opacity

示例:

.card {
  transition:
    transform 0.3s ease,
    opacity 0.3s ease;
}

.card:hover {
  transform: translateY(-8px) scale(1.02);
  opacity: 0.95;
}

不推荐频繁动画的属性

width
height
top
left
margin
padding
font-size

例如:

.panel {
  width: 200px;
  transition: width 0.3s;
}

.panel.open {
  width: 400px;
}

这种动画可能持续触发布局。

可以根据视觉需求考虑使用位移、缩放或遮罩替代。


十四、使用 requestAnimationFrame 合并视觉更新

requestAnimationFrame 会在浏览器下一次绘制前执行回调。

适合:

  • 滚动更新;
  • 拖拽;
  • 动画;
  • 高频视觉变化。

示例:

let ticking = false;

window.addEventListener(
  "scroll",
  () => {
    if (ticking) {
      return;
    }

    ticking = true;

    requestAnimationFrame(() => {
      updateScrollProgress();

      ticking = false;
    });
  },
  {
    passive: true,
  }
);

即使一帧内滚动事件触发多次,页面也只会更新一次。


十五、滚动进度条优化示例

HTML:

<div class="scroll-progress"></div>

CSS:

.scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  transform: scaleX(0);
  transform-origin: left center;
  background-color: #1677ff;
  z-index: 9999;
}

JavaScript:

const progress = document.querySelector(
  ".scroll-progress"
);

let ticking = false;

function updateProgress() {
  const scrollTop =
    document.documentElement.scrollTop;

  const maxScrollTop =
    document.documentElement.scrollHeight -
    document.documentElement.clientHeight;

  const ratio =
    maxScrollTop > 0
      ? scrollTop / maxScrollTop
      : 0;

  progress.style.transform = `scaleX(${ratio})`;
}

window.addEventListener(
  "scroll",
  () => {
    if (ticking) {
      return;
    }

    ticking = true;

    requestAnimationFrame(() => {
      updateProgress();

      ticking = false;
    });
  },
  {
    passive: true,
  }
);

这里使用:

transform: scaleX()

而不是反复修改:

width

可以减少布局计算。


十六、display、visibility 和 opacity 的区别

display: none

.hidden {
  display: none;
}

特点:

  • 不占据布局空间;
  • 不响应事件;
  • 会改变页面布局;
  • 通常触发重排。

visibility: hidden

.hidden {
  visibility: hidden;
}

特点:

  • 仍然占据布局空间;
  • 元素不可见;
  • 不响应鼠标事件;
  • 通常不会改变布局结构。

opacity: 0

.hidden {
  opacity: 0;
}

特点:

  • 仍然占据布局空间;
  • 默认仍可能响应事件;
  • 通常只涉及绘制或合成。

如果透明元素不应点击,可以补充:

.hidden {
  opacity: 0;
  pointer-events: none;
}

对比如下:

方式 占据布局空间 可见 默认可交互 通常是否影响布局
display: none
visibility: hidden
opacity: 0

十七、正确使用 will-change

will-change 可以提前告诉浏览器,某个属性即将变化。

.card {
  will-change: transform;
}

浏览器可能提前为元素创建独立合成层。

适用于:

  • 高频 transform 动画;
  • 拖拽元素;
  • 即将开始的复杂动画。

不要这样写:

* {
  will-change: transform;
}

也不要给大量元素长期设置 will-change

因为可能导致:

  • 创建过多图层;
  • 占用更多显存;
  • 图层管理成本增加;
  • 合成性能下降。

可以在动画前添加,动画结束后移除:

element.style.willChange = "transform";

element.addEventListener(
  "transitionend",
  () => {
    element.style.willChange = "auto";
  },
  {
    once: true,
  }
);

十八、使用 content-visibility 优化长页面

对于内容很长的页面,可以使用:

.section {
  content-visibility: auto;
  contain-intrinsic-size: 600px;
}

content-visibility: auto 可以让浏览器跳过视口外内容的布局和绘制。

适合:

  • 长文章;
  • 长设置页面;
  • 多模块后台页面;
  • 复杂内容区块。

示例:

<section class="section">
  大量复杂内容
</section>
.section {
  content-visibility: auto;
  contain-intrinsic-size: 600px;
}

contain-intrinsic-size 用于提供预估尺寸,减少滚动条变化和页面跳动。

使用前需要结合浏览器兼容性评估。


十九、使用 CSS Containment 限制影响范围

CSS 的 contain 属性可以限制元素内部变化对外部布局的影响。

.card {
  contain: layout paint;
}

常见值:

contain: layout;
contain: paint;
contain: size;
contain: style;
contain: content;
contain: strict;

适合:

  • 仪表盘卡片;
  • 独立组件;
  • 列表项;
  • 互不影响的内容区块。

例如:

.dashboard-card {
  contain: layout paint;
}

浏览器可以更明确地知道:

  • 卡片内部布局不会影响外部;
  • 绘制区域不会超出卡片边界。

需要注意:

contain: size 等设置可能影响元素尺寸计算,不能盲目使用。


二十、长列表使用虚拟滚动

如果页面中有几千或几万条数据,即使单次重排次数不多,布局计算仍然会很慢。

普通列表:

<div
  v-for="item in list"
  :key="item.id"
>
  {{ item.name }}
</div>

如果 list 有 10000 条数据,就会创建大量 DOM 节点。

虚拟列表只渲染当前可见区域:

总数据:10000 条
实际 DOM:20~50 条

适用场景:

  • 大型表格;
  • 日志列表;
  • 消息列表;
  • 搜索结果;
  • 后台数据列表。

Vue 常用方案:

vue-virtual-scroller
Element Plus Virtualized Table

React 常用方案:

react-window
react-virtualized

二十一、图片和媒体元素提前设置尺寸

图片加载完成后,如果页面没有预留尺寸,内容可能突然向下移动。

不推荐:

<img
  src="/banner.jpg"
  alt="Banner"
/>

推荐:

<img
  src="/banner.jpg"
  width="1200"
  height="600"
  alt="Banner"
/>

或者使用 aspect-ratio

.image-wrapper {
  aspect-ratio: 2 / 1;
}

.image-wrapper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

这样可以:

  • 提前预留图片空间;
  • 减少页面布局变化;
  • 改善 CLS 指标。

二十二、字体加载导致的布局变化

Web Font 加载前后,字体宽度和高度可能不同。

例如:

body {
  font-family: "AppFont", sans-serif;
}

字体加载完成后,可能引起:

  • 文字重新换行;
  • 行高变化;
  • 按钮宽度变化;
  • 页面整体偏移。

优化方式:

  1. 选择尺寸接近的备用字体;
  2. 使用 font-display
  3. 预加载关键字体;
  4. 减少字体文件和字重数量。

示例:

@font-face {
  font-family: "AppFont";
  src: url("/fonts/app.woff2") format("woff2");
  font-display: swap;
}

预加载:

<link
  rel="preload"
  href="/fonts/app.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

二十三、表格布局优化

复杂表格容易频繁计算列宽。

可以使用:

table {
  width: 100%;
  table-layout: fixed;
}

table-layout: fixed 可以减少浏览器根据全部单元格内容反复计算列宽的成本。

大型表格还应考虑:

  • 固定列宽;
  • 虚拟滚动;
  • 减少复杂单元格;
  • 避免每个单元格单独绑定事件;
  • 分页加载;
  • 批量更新数据。

二十四、使用事件委托减少监听器数量

大量子元素不需要分别绑定事件。

不推荐:

const items = document.querySelectorAll(".item");

items.forEach((item) => {
  item.addEventListener("click", handleClick);
});

推荐使用事件委托:

const list = document.querySelector(".list");

list.addEventListener("click", (event) => {
  const item = event.target.closest(".item");

  if (!item || !list.contains(item)) {
    return;
  }

  handleClick(item);
});

优点:

  • 减少事件监听器数量;
  • 动态新增节点无需重新绑定;
  • 降低内存占用;
  • 简化列表维护。

事件委托不直接消除重排,但能减少大型 DOM 结构中的额外开销。


二十五、使用浏览器观察器 API

很多场景不需要在滚动或缩放事件中持续读取布局。

IntersectionObserver

适用于:

  • 图片懒加载;
  • 元素曝光;
  • 无限滚动;
  • 判断元素是否进入视口。
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) {
        return;
      }

      loadContent(entry.target);

      observer.unobserve(entry.target);
    });
  }
);

observer.observe(
  document.querySelector(".target")
);

ResizeObserver

适用于监听某个元素尺寸变化:

const observer = new ResizeObserver(
  (entries) => {
    for (const entry of entries) {
      console.log(entry.contentRect);
    }
  }
);

observer.observe(
  document.querySelector(".container")
);

相比监听全局 resizeResizeObserver 通常更精确。


二十六、Vue 3 中避免不必要的布局更新

1. 避免模板中执行复杂函数

不推荐:

<template>
  <div>
    {{ calculateLayoutResult() }}
  </div>
</template>

组件每次重新渲染,都可能再次执行该函数。

推荐使用 computed

<script setup>
import { computed } from "vue";

const layoutResult = computed(() => {
  return calculateLayoutResult();
});
</script>

<template>
  <div>
    {{ layoutResult }}
  </div>
</template>

2. 避免深度监听大型对象

不推荐:

watch(
  largeConfig,
  () => {
    updateLayout();
  },
  {
    deep: true,
  }
);

大型对象中任何深层属性变化,都可能触发回调。

推荐只监听必要字段:

watch(
  () => [
    largeConfig.width,
    largeConfig.height,
  ],
  () => {
    updateLayout();
  }
);

3. 保持 key 稳定

推荐:

<div
  v-for="item in list"
  :key="item.id"
>
  {{ item.name }}
</div>

不推荐在频繁增删和排序的列表中使用索引:

<div
  v-for="(item, index) in list"
  :key="index"
>
  {{ item.name }}
</div>

不稳定的 key 可能导致 Vue 错误复用或重新创建更多 DOM。


4. 正确使用 v-if 和 v-show

v-if

<Panel v-if="visible" />

特点:

  • 条件为 false 时不创建 DOM;
  • 切换时会创建或销毁节点;
  • 适合切换频率较低的场景。

v-show

<Panel v-show="visible" />

本质是切换:

display: none;

特点:

  • DOM 一直存在;
  • 切换成本较低;
  • 适合频繁显示和隐藏;
  • 仍会影响布局。

5. 大量静态内容使用 v-once

<div v-once>
  这部分内容不会再变化
</div>

v-once 可以避免后续重复更新静态内容。


二十七、React 中减少布局开销

1. 避免不必要的组件重新渲染

const Item = React.memo(function Item({
  item,
}) {
  return <div>{item.name}</div>;
});

当 props 没有变化时,React.memo 可以避免子组件重复渲染。


2. 保持 props 引用稳定

不推荐:

<Item
  style={{
    width: 200,
  }}
/>

每次渲染都会创建新的对象。

可以使用 class:

<Item className="item" />

或者:

const itemStyle = useMemo(
  () => ({
    width: 200,
  }),
  []
);

3. 谨慎使用 useLayoutEffect

useLayoutEffect 会在浏览器绘制前同步执行。

useLayoutEffect(() => {
  const rect =
    elementRef.current.getBoundingClientRect();

  setWidth(rect.width);
}, []);

如果在其中执行复杂计算或同步更新状态,可能阻塞浏览器绘制。

能使用 useEffect 的场景,不要强行使用 useLayoutEffect


4. 大型列表使用虚拟化

React 中可以使用:

react-window
react-virtualized

避免一次性渲染几千个 DOM 节点。


二十八、避免复杂 CSS 绘制

以下效果可能增加重绘成本:

box-shadow
filter: blur()
backdrop-filter
border-radius
gradient
mix-blend-mode

例如:

.card {
  backdrop-filter: blur(20px);
  box-shadow:
    0 20px 40px
    rgba(0, 0, 0, 0.25);
}

如果页面中有大量此类卡片,在滚动或动画时可能出现明显掉帧。

优化方式:

  • 减少模糊半径;
  • 减少阴影层数;
  • 避免大面积 backdrop-filter
  • 减小绘制区域;
  • 避免同时动画阴影和模糊;
  • 对重复复杂背景使用图片资源。

二十九、完整优化案例

下面是一段容易出现性能问题的代码:

const cards = document.querySelectorAll(
  ".card"
);

window.addEventListener("scroll", () => {
  cards.forEach((card) => {
    const rect =
      card.getBoundingClientRect();

    if (rect.top < window.innerHeight) {
      card.style.top =
        `${window.scrollY * 0.1}px`;

      card.style.opacity = "1";
    } else {
      card.style.opacity = "0";
    }
  });
});

问题包括:

  1. 滚动事件高频触发;
  2. 每次循环都调用 getBoundingClientRect()
  3. 读取布局后立即写入样式;
  4. 修改 top 可能触发布局;
  5. 所有卡片都参与计算;
  6. 没有使用 requestAnimationFrame
  7. 没有设置 passive: true

优化后:

const cards = [
  ...document.querySelectorAll(".card"),
];

const visibleCards = new Set();

let ticking = false;

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        visibleCards.add(entry.target);
      } else {
        visibleCards.delete(entry.target);
      }
    });
  }
);

cards.forEach((card) => {
  observer.observe(card);
});

function updateVisibleCards() {
  const offset = window.scrollY * 0.1;

  visibleCards.forEach((card) => {
    card.style.transform =
      `translate3d(0, ${offset}px, 0)`;

    card.style.opacity = "1";
  });
}

window.addEventListener(
  "scroll",
  () => {
    if (ticking) {
      return;
    }

    ticking = true;

    requestAnimationFrame(() => {
      updateVisibleCards();

      ticking = false;
    });
  },
  {
    passive: true,
  }
);

优化点:

  • 使用 IntersectionObserver 获取可见元素;
  • 只更新可见卡片;
  • 使用 requestAnimationFrame
  • 使用 transform 替代 top
  • 使用 passive: true
  • 避免滚动时读取所有元素布局。

三十、如何使用 Chrome DevTools 排查问题

1. Performance 面板

操作步骤:

  1. 打开 Chrome DevTools;
  2. 切换到 Performance
  3. 点击录制;
  4. 执行滚动、动画或点击;
  5. 停止录制;
  6. 查看 Main Thread。

重点关注:

Recalculate Style
Layout
Paint
Composite Layers

如果 Layout 占用很高,说明布局计算较重。

如果 Paint 区域很大,说明页面存在大面积重绘。


2. 查看 Forced Reflow

如果 Performance 面板出现:

Forced reflow

通常说明代码在修改样式后,立即读取了布局信息。

例如:

element.style.width = "300px";

console.log(element.offsetWidth);

3. Paint Flashing

打开:

DevTools
→ More tools
→ Rendering
→ Paint flashing

开启后,发生重绘的区域会被高亮。

如果滚动时整个页面不断闪烁,说明绘制范围可能过大。


4. Layout Shift Regions

在 Rendering 面板中启用:

Layout Shift Regions

用于观察页面中的布局偏移,适合排查 CLS 问题。


5. Performance Monitor

打开:

DevTools
→ More tools
→ Performance monitor

可以观察:

  • CPU 使用率;
  • JS Heap;
  • DOM Nodes;
  • Layouts / sec;
  • Style recalculations / sec。

如果每秒 Layout 次数很高,需要检查高频 DOM 读写。


三十一、使用 Performance API 测量代码耗时

可以使用 performance.now()

const startTime = performance.now();

updateLayout();

const endTime = performance.now();

console.log(
  `执行耗时:${endTime - startTime}ms`
);

也可以使用标记:

performance.mark("layout-start");

updateLayout();

performance.mark("layout-end");

performance.measure(
  "layout-task",
  "layout-start",
  "layout-end"
);

const entries =
  performance.getEntriesByName(
    "layout-task"
  );

console.log(entries);

需要注意:

JavaScript 函数耗时不完全等于浏览器 Layout 和 Paint 的耗时,最终仍需结合 DevTools Performance 分析。


三十二、常见误区

1. 所有 DOM 更新都会立即重排

不一定。

现代浏览器会尽量合并多次样式修改。

但是,如果代码要求立即读取最新布局,浏览器会被迫同步执行 Layout。

例如:

element.style.width = "300px";

const width = element.offsetWidth;

2. transform 一定不会重绘

不完全正确。

transform 通常不会触发布局,但仍可能涉及:

  • 图层合成;
  • 纹理上传;
  • 某些情况下重新绘制。

它的主要优势是尽量避开 Layout 阶段。


3. 重绘一定很便宜

不一定。

如果绘制区域很大,或者存在:

  • 大面积模糊;
  • 多层阴影;
  • 半透明叠加;
  • 渐变;
  • 大型背景图;

Paint 的成本仍可能很高。


4. will-change 越多越快

错误。

过多的 will-change 会创建大量图层,增加显存和合成成本。


5. 框架会自动解决所有性能问题

错误。

Vue 和 React 可以优化 DOM 更新过程,但如果最终修改的是:

width
height
top
left

浏览器仍然需要执行布局。

框架无法消除浏览器渲染阶段的基础成本。


三十三、面试常见问题

1. 重排和重绘有什么区别

重排是重新计算元素的位置和尺寸。

重绘是元素几何信息不变,但外观发生变化后重新绘制像素。

重排通常会引起重绘,重绘不一定会引起重排。


2. 什么是强制同步布局

当 JavaScript 修改样式后,又立即读取布局信息时,浏览器为了返回准确值,会被迫马上执行 Layout。

例如:

element.style.width = "300px";

console.log(element.offsetWidth);

3. 什么是布局抖动

布局抖动是指反复交叉执行 DOM 读取和 DOM 写入:

写入样式
→ 读取布局
→ 写入样式
→ 读取布局

这会导致浏览器频繁执行同步布局。


4. 哪些属性适合做动画

优先使用:

transform
opacity

它们通常不会触发布局,并且更容易由合成层处理。


5. 如何减少重排

常见方法:

  1. 批量读取 DOM;
  2. 批量写入 DOM;
  3. 缓存布局信息;
  4. 使用 class 批量修改样式;
  5. 使用 DocumentFragment
  6. 动画使用 transformopacity
  7. 使用 requestAnimationFrame
  8. 减少 DOM 数量;
  9. 使用虚拟列表;
  10. 使用 IntersectionObserverResizeObserver

三十四、性能优化检查清单

排查重排和重绘时,可以依次检查:

  • 是否在循环中读取 offsetWidthoffsetHeight
  • 是否在滚动事件中频繁调用 getBoundingClientRect()
  • 是否交叉执行 DOM 读取和写入;
  • 是否频繁修改 widthheighttopleft
  • 动画是否可以改用 transform
  • 是否重复查询同一个 DOM;
  • 是否可以缓存布局信息;
  • 是否可以使用 requestAnimationFrame
  • 是否可以使用 IntersectionObserver
  • 是否可以使用 ResizeObserver
  • 大列表是否需要虚拟滚动;
  • 图片是否设置了宽高或 aspect-ratio
  • 字体加载是否导致布局偏移;
  • 是否存在大面积阴影、模糊和透明叠加;
  • 是否错误使用大量 will-change
  • DevTools 中 Layout 和 Paint 是否占用过高。

三十五、总结

避免重排和重绘的核心,不是完全消除它们,而是:

减少触发次数、缩小影响范围、避免强制同步布局,并让视觉更新尽量停留在合成阶段。

重点记住以下原则。

1. 批量读取,批量写入

先读 DOM
再写 DOM

不要反复执行:

写
→ 读
→ 写
→ 读

2. 动画优先使用 transform 和 opacity

优先:

transform
opacity

尽量避免高频修改:

top
left
width
height
margin
padding

3. 高频更新使用 requestAnimationFrame

requestAnimationFrame(() => {
  updateUI();
});

4. 减少 DOM 数量

大型页面可以考虑:

  • 虚拟滚动;
  • content-visibility
  • CSS containment;
  • 按需渲染;
  • 分页加载。

5. 使用浏览器观察器 API

优先考虑:

IntersectionObserver
ResizeObserver

而不是在滚动和缩放事件中反复读取布局。


6. 使用 DevTools 验证优化结果

重点查看:

Recalculate Style
Layout
Paint
Composite Layers
Forced Reflow

最后用一句话概括:

减少布局计算,避免读写交叉;动画使用 transform,视觉更新跟随浏览器帧节奏。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐