控制台里突然刷出一屏[Violation] Forced reflow while executing JavaScript took 87ms,然后页面滚一下来一条、点一下来一条,开发机上还没什么感觉,一上真机就开始"滑动粘手""打字掉帧"——这个场景做 Vue 的同学基本都躲不掉。这条警告不是报错,不影响功能,所以它常年被埋在日志里没人管,但它几乎是所有"页面越用越卡"问题的第一现场。它说的是一件很具体的事:JavaScript 执行过程中触发了强制同步重排(Forced reflow),浏览器为了把最新的布局数据交还给你,被迫中断当前 JS 执行,提前把渲染流水线跑了一遍,这一趟花了 87 毫秒。而 87 毫秒在 60 帧的节奏里,等于你连丢五帧还多。
这篇文章想解决的就是这一类问题:Vue 项目里为什么特别容易踩到 Forced reflow、怎么用 DevTools 把它钉到具体那一行代码、读写分离到底该怎么写、第三方库和动画场景怎么绕过去。内容偏实战,代码可以整段抄走改,适合写过一两个 Vue 项目、开始关心性能但还没系统整理过渲染流程的同学;如果你只是想知道"警告关掉行不行",那答案是不行,后面会说为什么。
1. 先把这条警告翻译成人话
1.1 浏览器渲染一帧要经过哪几步
现代浏览器的渲染主流程可以粗略拆成五段:JavaScript 执行 → 样式计算(Style / Recalculate Style)→ 布局(Layout,也就是重排 / Reflow)→ 绘制(Paint)→ 合成(Composite)。JS 阶段你改 DOM、改 class、改 inline style;样式计算把 CSS 规则解析成每个元素最终的计算样式;布局拿到计算样式后,算出每个盒子在页面上的精确位置和尺寸;绘制把盒子变成像素指令;合成把这些指令按图层拼成你看到的画面。
关键点在于,布局这一步是"惰性"的。你在 JS 里改了el.style.width = '200px',浏览器不会立刻重算布局,它只会在内部标记"这个元素的布局脏了",然后攒着。攒到什么时候?攒到两种时刻:一是真的要画下一帧了,二是有代码来问它要布局结果。后者就是灾难的起点。
因为一旦你问它要布局结果,它没法用旧数据糊弄你(旧数据已经过期了),只能立刻停下来,把样式计算和布局全部跑完,再把答案给你。这中间你的 JS 是被打断的,主线程被布局占满,这就是"同步重排"。所谓 Forced,重点在"被迫"——浏览器本来想拖到帧末一起算的,被你逼着提前算了。
1.2 到底哪些操作会"逼"浏览器重排
能触发强制重排的操作,本质都是"读取几何信息"。这些属性不是存在元素身上的静态值,而是布局算完才有的结果,所以读它们就等于向浏览器要账。常见的清单如下:
| 触发源 | 典型写法 | 说明 |
|---|---|---|
| 偏移尺寸 | offsetTopoffsetLeftoffsetWidthoffsetHeight | 最常被踩的一类,循环里读它等于开重排循环 |
| 客户区尺寸 | clientWidthclientHeightclientTopclientLeft | 不含边框,常用于算滚动容器可视高度 |
| 滚动尺寸与位置 | scrollTopscrollLeftscrollWidthscrollHeight | 写scrollTop是写操作,但连着读就会交替 |
| 几何矩形 | getBoundingClientRect()getClientRects() | 定位类组件、拖拽、Tooltip 全靠它 |
| 计算样式 | getComputedStyle(el).width等布局相关属性 | 读color未必触发,读width必触发 |
| CSS 变量 | getComputedStyle(el).getPropertyValue('--x') | 变量参与了布局计算时同样要 flush |
| 焦点与滚动定位 | element.focus()scrollIntoView() | 内部需要知道元素位置才能滚动 |
| Range 相关 | range.getBoundingClientRect() | 富文本编辑器里非常常见 |
这里有个非常重要的判断标准,很多人搞混:连续读是安全的,连续写也是安全的,只有读写交替才致命。你一口气读一百个元素的offsetHeight,浏览器只会重排一次,因为第一次读之后布局就是干净的,剩下的读都在用同一份新鲜数据;你一口气写一百个元素的宽高,浏览器一次都不重排,因为布局被推迟到帧末。但你要是"读一个、写一个、再读一个、再写一个",那就是一百次完整重排。
注意:
console.log(el.offsetWidth)这种调试语句本身就是读操作。有时候你只是加了几行日志想看看尺寸,警告就冒出来了,别把这个锅甩给业务代码。
1.3 警告里那个 "took 87ms" 是怎么来的
Chrome 会把每一次强制布局的耗时算出来,超过一定阈值(经验上单次几十毫秒级别)就会在控制台记一条 Violation。数字越大,说明这次重排的代价越离谱。为什么能这么大?因为一次重排不只是算你这一个元素,布局的波及范围可能是整棵子树甚至整个文档。改了外层容器的宽度,里面所有依赖百分比的子元素、所有的文本换行、所有的 flex/grid 分配都要跟着重算。一个几千行的表格,一次重排干到几十毫秒一点都不夸张。
更麻烦的是它会累积。你在mousemove里搞一次强制重排,鼠标移动一秒触发六十次,那就是六十次重排,主线程被切成碎片,用户看到的就是拖拽跟不上手。单看一条警告好像无所谓,乘上触发频率就是灾难。
2. Vue 项目为什么格外容易踩这个坑
2.1 响应式更新的批处理救了你,也骗了你
Vue 3 的更新是异步批处理的:你在一个 tick 里改了十个ref,它只会在微任务队列里 flush 一次,patch一次 DOM。这本身是非常好的设计,它天然把"写"集中起来了。问题出在"读"上——Vue 只帮你批处理了写,没管你的读。
于是很常见的写法就变成了这样:组件里某个方法先改了一个状态,然后await nextTick(),接着遍历 DOM 子节点读尺寸,读完根据尺寸再改一次状态。这一轮下来,写、读、写交替得非常干净,每一次读都在逼浏览器 flush。而且因为 Vue 的patch是深度优先遍历虚拟 DOM,一个父组件的更新可能牵动几十个子组件同时挂载,你在onMounted里读一次尺寸,就是几十次读挤在同一帧,如果中间还夹杂着子组件的写操作,交替就发生了。
还有一个隐蔽的点:Vue 的nextTick用的是微任务(Promise.then),它跑在浏览器渲染之前的任意时刻,跟帧的节奏没关系。你在微任务里读布局,浏览器一样得立刻满足你。所以nextTick不是"等渲染完",它只是"等 DOM 更新完",DOM 更新完不代表布局算完了。这个区别决定了你很多"我已经nextTick了为什么还有警告"的困惑。
2.2 组件化本身会制造"隐形读写交替"
单文件组件写多了,你很难一眼看出哪里在读哪里在写,因为读写散落在不同组件里。举个我真实遇到过的例子:一个父组件列表,子组件里有个mounted钩子,用于"根据自身宽度决定标题要不要省略号"。子组件挂载时会读自己的clientWidth;而父组件在插入这些子组件之前,刚刚设置过容器的padding。结果就是子组件每挂载一个,读一次宽度,而父组件的样式写入又让布局脏掉,下一个子组件读的时候又得重排。一百个子组件就是一百次重排,控制台刷屏。
这种问题在组件层级越深、复用越多的项目里越严重,因为写操作发生在祖先,读操作发生在后代,中间隔着好几层组件边界,你根本不会把它们联系起来。这也是为什么我认为这条警告不能只靠"看到就改",得建立一个意识:任何"读 DOM 尺寸"的代码,都要问一句"我前面有没有刚写过样式"。
2.3 Transition 和动画钩子里的固定开销
Vue 的<Transition>在实现上为了拿到过渡起点,需要在元素插入后、添加过渡类名之前读取一次布局信息,这个操作本身就会强制重排。单个元素无所谓,一两个毫秒的事;但列表过滤、v-show批量切换、路由切换带动画的时候,同一帧内可能有几十个元素同时走这套流程,代价就上来了。
v-show还有额外一层:display: none和display: block的切换会直接让布局失效,切完再去读尺寸,就是一次完整重排。有些同学为了做"折叠展开动画"用v-show配合读取内容高度,在长列表里滚动着展开,卡顿感会非常明显。
2.4 第三方库是重灾区,而且你往往改不动
ECharts、Swiper、各类拖拽库、虚拟表格、富文本编辑器,这些库的定位逻辑基本都建立在getBoundingClientRect()上。它们初始化的时候要量容器尺寸,resize的时候要重新量,弹出面板的时候要算位置。你没法改它们的源码,只能从两个方向下手:控制调用时机和控制调用频率。
时机上,别在mounted同步初始化一屏几十个图表,让它们在requestAnimationFrame里排队;频率上,resize回调必须防抖,而且防抖的落地方式最好是rAF节流加时间阈值,不是简单的setTimeout。这部分在第 5 节会展开写具体代码。
3. 把这个警告钉到具体那一行代码
3.1 Performance 面板的正确读法
控制台的 Violation 只告诉你"发生了",不告诉你"在哪"。真正定位得靠 Performance 面板。操作步骤是:打开 DevTools,切到 Performance,点左上角的录制按钮,然后在页面上复现卡顿操作(滚动、拖拽、切换列表),三五秒后停止录制。
拿到火焰图之后,看顶上那几条横轴色块。紫色偏蓝的是 Layout(布局),深紫偏红的是 Recalculate Style。如果你看到一串密密麻麻的小紫色块,每个都很短但数量巨大,中间还夹着黄色(Scripting)的小块,那就是典型的 layout thrashing——读写交替的指纹。如果是一个巨大的紫色块,那说明是单次重排范围太大,方向就不一样了,该考虑的是减少布局波及面,比如contain、content-visibility。
再往下看,切换到 Bottom-Up 或者 Call Tree 视图,按 Self Time 排序。通常在紫色块下面能直接看到你的业务函数名,点进去就能定位到源码行。如果看到的是一堆getBoundingClientRect、offsetHeight之类的原生调用,展开它的调用栈,业务代码一定在里面。
3.2 用代码给自己埋点,抓出超时的那一次
Performance 面板好用,但它只能"事后看",而且信息量大、有学习成本。想快速定位,我习惯直接给关键 API 打个补丁,统计每次耗时和调用栈。这段代码只在开发环境加,上线前删掉或包在if (import.meta.env.DEV)里:
// dev-perf.js —— 只在开发环境引入 if (import.meta.env.DEV) { const wrap = (obj, name) => { const raw = obj[name] if (typeof raw !== 'function') return obj[name] = function (...args) { const t0 = performance.now() const result = raw.apply(this, args) const cost = performance.now() - t0 // 阈值可以调,抓大放小 if (cost > 5) { console.warn(`[slow ${name}] ${cost.toFixed(1)}ms`, this) console.trace() } return result } } wrap(Element.prototype, 'getBoundingClientRect') wrap(Element.prototype, 'getClientRects') wrap(Range.prototype, 'getBoundingClientRect') // 几何属性是 getter,用 defineProperty 包一层 const geometryProps = ['offsetWidth', 'offsetHeight', 'offsetTop', 'offsetLeft', 'clientWidth', 'clientHeight', 'scrollWidth', 'scrollHeight'] geometryProps.forEach((prop) => { const owner = prop.startsWith('scroll') ? Element.prototype : HTMLElement.prototype const desc = Object.getOwnPropertyDescriptor(owner, prop) if (!desc || !desc.get) return Object.defineProperty(owner, prop, { ...desc, get() { const t0 = performance.now() const value = desc.get.call(this) const cost = performance.now() - t0 if (cost > 5) { console.warn(`[slow read ${prop}] ${cost.toFixed(1)}ms`, this) console.trace() } return value } }) }) }这里有个细节值得说:offsetWidth这类属性是getter,不是方法,所以不能像getBoundingClientRect那样直接替换函数,得用Object.getOwnPropertyDescriptor拿到原始 getter 再包一层。另外注意scrollWidth挂在Element上,offsetWidth挂在HTMLElement上,挂错原型链会报错,我上面做了个简单区分。
埋点阈值别设太小,5ms 以下的不看。跑一遍页面,控制台会直接告诉你哪一行慢、调用栈是什么,比在火焰图里翻半天高效得多。
3.3 一张现场排查清单
真到了线上或者别人移交的项目里,时间紧,不可能慢慢打补丁。我整理了一张按"现象"反推"原因"的表,先对号入座再动手:
| 现场现象 | 大概率原因 | 第一步做什么 |
|---|---|---|
| 拖拽/鼠标跟随明显延迟 | mousemove里读getBoundingClientRect并写样式 | 把读取挪到mousedown,移动过程只写transform |
| 长列表滚动顿挫 | 列表项内读offsetTop做吸附/瀑布流 | 改用IntersectionObserver或缓存偏移量 |
| 输入框打字掉帧 | input事件里读scrollHeight做高度自适应 | 用rAF节流 +textarea的field-sizing或缓存 |
| 弹层/下拉定位抖动 | Popper 类库在滚动事件里持续测量 | 限制resize/scroll触发源,或用 CSS 定位 |
| 一屏图表初始化时白屏卡住 | 多个图表mounted同步初始化争抢主线程 | 用rAF分批初始化,配合骨架屏 |
| 折叠展开动画卡 | v-show切换后立刻读内容高度 | 改v-if加缓存高度,或用grid-template-rows动画 |
| 窗口拉伸时整体卡顿 | resize回调没防抖,全量重排 | rAF节流,只在帧内算一次 |
这张表覆盖了八成左右的常见情况。如果你遇到的警告既不属于拖拽也不属于滚动,那大概率是某个第三方组件的初始化或resize,用第 3.2 节的埋点脚本跑一遍最快。
4. 核心解药:读写分离到底怎么做
4.1 为什么读写分离能根治
原理其实一句话:把同一帧内所有的读集中在前半段,所有的写集中在后半段,读之前保证布局是干净的,写之后不再去问布局要数据。这样一帧内最多强制重排一次,甚至零次(如果读的时候没有脏布局)。
这套思路业界叫 fastdom(名字来自 FastDOM 这个库),核心就是一个"两阶段队列":measure放读任务,mutate放写任务,统一调度。自己实现一个简化版完全够用,五十行以内。
4.2 一个可以直接抄的调度器
// scheduler.js const readQueue = [] const writeQueue = [] let rafId = 0 let flushing = false function flush() { rafId = 0 flushing = true // 第一阶段:集中读,此时布局应当是最新的 const reads = readQueue.splice(0) for (let i = 0; i < reads.length; i++) { try { reads[i]() } catch (e) { console.error(e) } } // 第二阶段:集中写,写操作不会立刻触发重排 const writes = writeQueue.splice(0) for (let i = 0; i < writes.length; i++) { try { writes[i]() } catch (e) { console.error(e) } } flushing = false // 写入过程中如果又产生了新的任务(常见于写里嵌套了读),排到下一帧 if (readQueue.length || writeQueue.length) schedule() } function schedule() { if (rafId) return rafId = requestAnimationFrame(flush) } export function measure(fn) { readQueue.push(fn) // 关键:如果当前不在 flush 中,说明还没到帧,可以立即安排; // 如果正在写阶段追加读任务,绝不能同步执行,否则立刻退化成 thrashing schedule() } export function mutate(fn) { writeQueue.push(fn) if (!flushing) schedule() }有三个地方是我踩过坑之后特意加固的:
第一,schedule用requestAnimationFrame而不是微任务。微任务在当前宏任务结束时就跑了,可能还在同一帧的 JS 阶段里,读到的布局照样要 flush;rAF回调发生在浏览器决定要渲染这一帧的时刻,此时上一轮写入的样式已经被浏览器纳入计算,读起来更"干净"。
第二,用一个flushing标志判断当前阶段。如果mutate阶段的函数里又调了measure,绝对不能同步执行那个读,否则你精心设计的读写分离当场失效。我的做法是让它在flush结束时检测队列非空,重新排一帧。
第三,每个任务包try/catch。调度器是全局的,一个任务抛错不能拖垮整批任务,线上排查时这一点很关键。
4.3 在 Vue 组件里怎么落地
有了调度器,业务代码的改法就很机械了——把"读"包进measure,把"写"包进mutate。但 Vue 里有几个地方要注意。
第一,不要在measure回调里直接改响应式状态。改状态会触发 Vue 的更新调度,虽然它也是异步的,但在这个上下文里容易让数据流变得不可预测。清爽的做法是:读阶段把结果收集到一个普通对象里,写阶段再拿这些数据统一提交给 Vue。
第二,await nextTick()之后不要直接读尺寸。正确的姿势是:
import { nextTick } from 'vue' import { measure, mutate } from './scheduler' async function syncLayout() { await nextTick() // 等 DOM 更新完 measure(() => { // 再等一个渲染帧,读干净布局 const items = [...listRef.value.children] const heights = items.map(el => el.offsetHeight) // 连续读,只重排一次 mutate(() => { items.forEach((el, i) => { el.style.setProperty('--row-h', heights[i] + 'px') // 连续写 }) }) }) }注意这里读和写的顺序:先nextTick保证 DOM 存在,再measure保证布局新鲜,读的时候用map一次性收集完(不要在循环里写),然后mutate里批量写。这套流程下来,整个函数最多触发一次强制重排。
第三,组件卸载时要清理。调度器是模块级的,如果组件卸载后队列里还有引用着已销毁 DOM 的回调,读的时候就会拿到null。我的处理是在回调里做空值判断,或者给任务加一个cancelled标记,在onUnmounted里置位。
4.4 什么情况下不用调度器,直接 rAF 就够
调度器解决的是"多个模块零散读写"的问题。如果你只有一个函数,读写都在里面,那直接requestAnimationFrame更轻:
let pending = false let cachedRect = null function onScroll() { if (!pending) { pending = true requestAnimationFrame(() => { pending = false // 这里集中做读+写,注意读必须放在写前面 const top = container.getBoundingClientRect().top sticky.style.transform = top < 0 ? `translateY(${-top}px)` : 'none' }) } }这个模式叫"rAF 节流",本质上是把一个高频事件压缩成"每帧最多执行一次"。它在滚动、拖拽、mousemove、resize场景里几乎是万能的,代码量小、没有依赖,建议先把这个用起来,再考虑上调度器。
提示:
resize事件还有个更现代的替代品ResizeObserver。它比window上的resize精准得多(能观察单个元素),但要注意它的回调本身也是"布局之后、绘制之前"执行,回调里读尺寸是安全的,写尺寸却可能触发循环。要写就包一层rAF,或者直接在回调里做防抖。
5. 高频场景逐个击破
5.1 拖拽:把读取挪到事件开始那一刻
拖拽是 Forced reflow 的头号来源。坏的写法几乎人人写过:
// 反面教材,每一帧都在读写交替 function onMouseMove(e) { const rect = box.getBoundingClientRect() // 读,强制重排 box.style.left = rect.left + e.movementX + 'px' // 写,布局脏了 box.style.top = rect.top + e.movementY + 'px' }每一次mousemove都是"读一次、写一次",而且读的还是自己刚刚写脏的元素,重排跑不掉了。正确做法是把状态存在变量里,事件开始时读一次,移动过程纯计算加写:
const state = { startX: 0, startY: 0, originLeft: 0, originTop: 0, moving: false, x: 0, y: 0 } let rafId = 0 function onMouseDown(e) { const rect = box.getBoundingClientRect() // 整个拖拽过程只读这一次 state.startX = e.clientX state.startY = e.clientY state.originLeft = rect.left state.originTop = rect.top state.moving = true document.addEventListener('mousemove', onMouseMove) } function onMouseMove(e) { if (!state.moving) return state.x = state.originLeft + (e.clientX - state.startX) state.y = state.originTop + (e.clientY - state.startY) if (!rafId) rafId = requestAnimationFrame(paint) } function paint() { rafId = 0 // 用 translate3d 而不是 left/top box.style.transform = `translate3d(${state.x}px, ${state.y}px, 0)` }两个改动带来两个收益:第一,整个拖拽只强制重排一次;第二,用transform替代left/top,元素在合成层上移动,连布局和绘制都省了,只剩下合成,流畅度是数量级的差别。这个技巧是我做拖拽交互时最常用的一招,记住"动位置用 transform,不动 left/top"基本能躲掉一半的卡顿。
5.2 长列表与虚拟滚动
v-for渲染几千条数据,然后在某个方法里遍历子元素读offsetTop,这是瀑布流和吸附滚动的经典写法,也是经典的重排炸弹。改法有两条路。
轻量的一条是缓存加批量:在列表数据变化之后,用一个measure任务一次性把所有子元素的偏移量读出来存到数组里,之后滚动过程中只查数组,不碰 DOM。注意这个缓存要在容器尺寸变化时失效重建,ResizeObserver是合适的钩子。
彻底的一条是上虚拟滚动。虚拟滚动的核心思想是只渲染视口内的元素,元素数量从几千降到几十,任何遍历操作的代价都变得可以接受。如果要自己实现,关键点是:行高固定或提前测量、用绝对定位撑开滚动高度、滚动事件用rAF节流。行高不固定的场景,通常做法是先按估算行高渲染,再用ResizeObserver或者measure任务逐个修正真实高度并回填缓存。
对于大多数业务项目,我建议直接用成熟库而不是自己写。选库的时候看一眼它在滚动过程中有没有读取getBoundingClientRect,有的库为了做"滚动到指定项"会在每次滚动时测量,那就得配个节流。
5.3 动画:优先动不会触发布局的属性
做动画有一条铁律:只动transform和opacity,其他属性尽量别碰。原因是这两个属性在合成阶段处理,改它们不会引起样式重算和布局重排;而改width、height、margin、padding、top、left、font-size这些,必然重排,而且很可能牵连一大片。
Vue 的<Transition>默认用的是opacity和transform之外的类名切换,所以自定义过渡的时候要自己写对。常见的折叠展开如果想平滑,别用height: 0到height: auto(auto没法过渡,而且会重排),用grid-template-rows: 0fr到1fr或者transform: scaleY加transform-origin。前者是纯粹的小技巧,兼容性不错。
如果用 Vue 的 JS 钩子做动画(@before-enter、@enter这类),记得每次在enter回调里做一次el.offsetHeight强制重排来"激活"过渡,这是标准做法,但别在循环里对几十个元素同时做。批量过渡的场景,我通常改成 CSS 类切换加transition-delay错开,让浏览器自己安排。
5.4 第三方库:控时机、控频率、控范围
第三方库有三个可下手的地方。控时机指不要在mounted里同步初始化。一屏十个图表,每个初始化都要量容器、算布局,堆在一起就是几百毫秒。把它们塞进rAF或者用IntersectionObserver做"进视口才初始化":
onMounted(() => { const io = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return io.unobserve(entry.target) requestAnimationFrame(() => initChart(entry.target)) }) }, { rootMargin: '200px' }) chartEls.value.forEach(el => io.observe(el)) })rootMargin: '200px'是提前量,用户还没滚到就初始化完了,体验更顺。
控频率指给库的resize、scroll回调加节流。很多库自己不做这件事。如果它的 API 允许传回调,就在外面包一层rAF节流;如果它是监听window.resize的,可以自己在初始化前先把尺寸算好传进去,减少它内部测量的次数。
控范围指用 CSS 限制布局的波及面,下一节讲。
5.5 CSS 侧的两个低成本优化
contain属性可以让浏览器知道"这个元素的内部布局不影响外部",从而把重排范围锁在局部。常用值是contain: layout paint(布局和绘制都不外溢),列表项、卡片、弹层都很适合加。加了之后,改动内部的尺寸不会引起整个页面的布局重算,收益非常直接。
content-visibility: auto更进一步,让屏幕外元素的渲染被跳过。长文档、长列表里效果明显,但要配contain-intrinsic-size给它一个占位尺寸,否则滚动条会跳。这两个属性现代浏览器都支持得不错,属于"加一行、收益立竿见影"的类型。
还有will-change: transform,很多人拿它当万金油到处加。它的作用是提前把元素提升为独立合成层,减少动画过程中的重排和重绘。但别滥用——每个合成层都要占显存,几十上百个层会把内存吃光,反而更卡。我的习惯是只在确实要做高频动画的元素上加,动画结束就移除。
6. 常见问题与避坑速查
6.1 问题速查表
| 问题 | 原因 | 解决 |
|---|---|---|
nextTick之后读尺寸仍报警告 | nextTick是微任务,不等渲染帧 | 读操作包进rAF或调度器 |
| 单个元素动画也报警告 | 动画属性触发了布局(width/height/top) | 改用transform/opacity |
| 只在低端机上卡 | 高端机重排耗时低,低于阈值不报 | 用 Performance 面板看,别依赖控制台 |
加了will-change反而更卡 | 合成层数量过多占显存 | 只在动画期间加,结束后移除 |
resize里代码很少也卡 | 没节流,一次拉伸触发几十次 | 用rAF节流并加最小间隔 |
| 生产环境没有警告 | Violation 只在 DevTools 打开时采集 | 生产排查靠 Performance + 性能指标 |
| 组件卸载后报错 | 调度队列里的回调引用了已销毁 DOM | 回调内判空,或加取消标记 |
列表用index做 key 更卡 | 复用错位导致大量 DOM 重建 | 用稳定的业务 id 做 key |
6.2 几个容易搞反的细节
第一个反直觉的点:getComputedStyle读color、font-weight这类纯绘制属性,通常不会触发重排;但读width、height、margin这些布局属性一定触发。所以别一看到getComputedStyle就紧张,看你读的是哪个属性。
第二个:scrollTop的读取是重排的来源之一,但对滚动容器来说,它更常见的坑是"读scrollHeight算总高"。这个属性依赖全部子元素布局,代价很高,能缓存就缓存。
第三个:v-if和v-show在这件事上的表现不同。v-if是增删节点,插入新节点必然让布局脏掉;v-show是切换display,同样让布局脏掉。两者都不"省",区别在渲染成本和状态保持上,别指望用v-show能绕开重排。
第四个:offsetTop是相对于offsetParent的,不是相对于视口。很多同学算错位置是因为没注意offsetParent会被position: relative的祖先改变。算视口坐标老老实实用getBoundingClientRect。
6.3 我在实际项目里踩过的坑
第一个坑是埋点本身拖慢了页面。有一段时间我在Element.prototype上包了好几层补丁,本地跑得好好的,一上真机开发包就卡成幻灯片。原因是补丁里的performance.now()和console.trace()本身开销就不小,console.trace()尤其重,在mousemove高频场景里直接拖垮主线程。后来我改成只在超过阈值且做了采样(比如每 20 次记录一次)的情况下才打日志,问题就没了。
第二个坑是调度器里的死循环。我最初的版本在flush结束时无条件重新schedule(),结果一个任务里不断往队列里塞新任务,把rAF变成了忙循环,页面反而更卡。后来加了"单帧最多迭代两次"的限制,超出的任务顺着排到下一帧,才稳下来。
第三个坑是以为transform是万能药。transform确实不触发布局,但如果它作用的元素上有一个filter或者复杂的box-shadow,照样会触发重绘,代价并不低。还有transform会影响position: fixed子元素的定位基准,做全屏遮罩的时候要特别注意。
第四个坑比较隐蔽:document.body上的 class 切换会引发全页重排。项目里做主题切换、字号切换的时候,我在body上切了个 class,里面包含font-size变量,结果整页所有文本重新换行,一次重排上百毫秒。后来改成用 CSS 变量配合contain,把影响限制在主要内容区,才把这一下优化掉。
说到底,Forced reflow 这条警告的价值不在于它本身,而在于它是一个信号——它告诉你,你的代码正在用一种"命令式、逐步确认"的方式跟浏览器打交道,而浏览器更希望你"一次说清楚"。把读和写分开、把动位置交给transform、把高频事件压到每帧一次,这三件事做到位,控制台里那串红色的[Violation]基本就会消失,页面手感也会跟着变一个档次。