performance.mark 本身不触发渲染,也不能保证标记时间点与“上帧”对齐;需配合 requestAnimationFrame 等生命周期钩子,分阶段锚定 JS 执行完成、DOM 更新完成、视觉呈现完成三个关键节点。
直接结论:performance.mark 本身不触发渲染,也不能保证标记时间点与“上帧”对齐;要建立从逻辑触发到真正上帧的链路,必须把 mark 和浏览器渲染生命周期的关键钩子(如 requestAnimationFrame、setTimeout(0)、queueMicrotask)配合使用,并明确区分「JS 执行完成」「DOM 更新完成」「视觉呈现完成」三个阶段。
performance.mark('render-end') 只记录 JS 调用那一刻的时间戳,但此时 DOM 可能还没更新,更不意味着像素已绘制到屏幕。常见误判场景:
useEffect 或 Vue 的 nextTick 回调里打点,只代表虚拟 DOM 已提交,不代表真实 DOM 已插入或样式已计算element.innerHTML = ... 后立刻 mark('dom-ready'),但 layout/paint 尚未触发,getBoundingClientRect() 可能仍返回旧值DOMContentLoaded 或 load 事件打点,它们和首帧渲染无强关联,尤其在 SSR/SSG 场景下偏差可达数百毫秒关键不是“打得多”,而是每个 mark 是否锚定在可验证的渲染就绪信号上。推荐组合方式:
performance.mark('click-start')(如按钮 onclick)requestAnimationFrame 回调中打 performance.mark('raf-dom-updated') —— 这是浏览器保证 DOM 已更新、样式已计算、布局已完成的最早时机requestAnimationFrame(即第二次 rAF),打 performance.mark('raf-painted') —— 此时浏览器已将帧提交至合成器,大概率已上屏(注意:仍受 vsync 和 compositor pipeline 影响,但已是前端可捕获的最接近“上帧”的信号)setTimeout(0) 或 queueMicrotask 替代 rAF:它们只能保证 JS 执行顺序,无法确保 layout/paint 完成,打点会偏早performance.measure() 看似简单,但在渲染链路中极易静默失败:
'raf-painted' 可能被后一次的 mark 覆盖(因同名覆盖机制),建议用唯一 trace ID 构造名称,例如 `raf-painted-${traceId}`
offsetHeight > 0 是否为真,再打点'click-start',在第 3 帧打 'raf-painted',然后 measure —— 数据虽能算出,但已失去“单次交互响应”的业务意义;应按 trace ID 分组,在同一流程内闭环渲染链路数据天然稀疏且易丢失,不处理好这三点,大盘基本不可信:
PerformanceObserver 监听 'measure' 类型,而不是轮询 getEntriesByType('measure'):前者能实时捕获,后者在页面卸载前可能来不及读取navigator.sendBeacon(),且 payload 中显式包含 entry.startTime 和 entry.duration:因为 entry.name 是字符串,服务端需靠它做路由分发(如识别 'search-click-to-paint')performance.clearMarks(traceIdPrefix):否则长期运行的 SPA 页面会堆积大量无用 mark,拖慢 DevTools 加载,甚至触发浏览器 entry 限制(默认约 150 条)真正难的不是打点,而是判断“上帧”这个动作到底属于哪一环——是 JS 执行完?DOM 改完?layout 完?paint 完?还是 composite 完?浏览器不暴露这些细节,你得用 rAF + 可观测性断言去逼近它。一旦选错锚点,整个链路耗时就只是数学游戏,和用户真实感知脱节。