读写分离本质是避免强制同步布局,需将dom写操作集中执行、读操作统一延至requestanimationframe回调开头触发,防止“写→读”穿插导致layout重算。

读写分离不是加个 requestAnimationFrame 就能自动生效的障眼法,它本质是强制让浏览器把 layout 计算“攒批处理”,避免在单次 JS 执行中反复打断渲染流水线。
为什么 offsetHeight + style.width 连用必然抖动
浏览器维护一个 layout 缓存;一旦你调用 offsetHeight、getBoundingClientRect() 等读取几何属性,而此时缓存已过期(比如刚改过 className 或插入了新节点),浏览器就必须立刻 flush 掉旧 layout、重新计算——这就是强制同步布局。紧接着你再设 style.width,又会标记 layout 过期;下一轮读操作又得重算。
这种“写→读→写→读”模式会让 layout 计算次数从 1 次飙升到 N 次,且每次都在主线程阻塞渲染。
- 常见错误场景:scroll 事件里一边
el.style.transform = `translateY(${y}px)`,一边又立刻读el.scrollHeight - React 中在
useLayoutEffect里既改样式又读尺寸,等同于原生抖动写法 -
getComputedStyle(el).height在 el 未挂载时返回auto,但依然触发 layout —— 它不看你是否需要值,只看“你问了”
真正有效的读写分离落地步骤
关键不在“分两次任务”,而在“读必须发生在 layout 已完成之后”。最稳妥的时机是 requestAnimationFrame 回调开头,此时浏览器已完成 layout、尚未 paint。
- 所有 DOM 写操作(
el.classList.add、el.style.opacity、el.setAttribute)集中放在一次同步任务中,中间不夹杂任何读布局调用 - 所有读操作(
el.offsetHeight、el.getBoundingClientRect())统一收集,放到下一个requestAnimationFrame的回调第一行执行 - 避免用多个 offset 属性拼尺寸,优先用
el.getBoundingClientRect()一次性获取全部,减少 layout 触发频次 - 滚动/缩放类高频场景,优先用
ResizeObserver或IntersectionObserver替代轮询 + 手动读写
容易被忽略的隐蔽雷区
很多人以为用了 raf 就安全了,但以下情况仍会触发强制同步布局:
-
offsetParent === null比el.offsetParent更可靠地判断元素是否已挂载;后者在某些 CSS(如transform、opacity)下可能返回非 null 但实际未参与 layout -
contain: layout或content-visibility: auto不代表“读尺寸不会触发 layout”——只要读,就强制计算,别指望它们帮你省掉 - 框架里用
useEffect读尺寸时,如果依赖项变化快(比如 scroll 频繁触发),仍可能因多次raf调度导致 layout 被重复触发,需加防抖或节流
布局抖动的根子不在 API 多少,而在 JS 执行逻辑是否尊重浏览器的渲染节奏。哪怕只读一次、写一次,只要穿插在同一个调用栈里,就足以打断整条流水线。











