Repeat重放机制在前端调试与测试中的实战应用与优化
项目初期的技术选型
去年底接手一个内部用的运营数据看板,需求很典型:支持「回放」用户在页面上的完整操作路径——点击、输入、滚动、表单提交,甚至 iframe 里的行为。老板说:“要能复现问题,别每次客服打电话来,我们还得靠用户口述。”
一开始我真想用录屏方案,但很快被自己否了:体积大、不能搜索、没法和后端日志对齐、移动端兼容一地鸡毛。后来翻文档看到 rrweb 的 Repeat 模式(也就是它的 replay 功能),就试了下 demo,发现它能把整个 DOM + 交互序列化成 JSON,再用 JS 精确还原——这个思路刚好卡在我们痛点上。
没多想,直接开干。毕竟 rrweb 官方文档写得挺清楚,API 就俩核心:record 和 replay。但现实是,文档没告诉你,“replay 能跑起来” 和 “replay 能在你项目里稳住” 是两回事。
最大的坑:性能问题
上线第一版回放页,加载一个 5 分钟的操作记录,首屏白屏 8 秒,控制台疯狂报 RangeError: Maximum call stack size exceeded。我盯着 DevTools 里堆栈看了五分钟,才意识到不是内存溢出,是 replay 过程中反复 patch DOM 导致递归过深——尤其是页面里嵌了两个 iframe,每个都带 React 子应用。
查源码发现 rrweb 默认用 document.createElement + appendChild 重建整个快照树,而我们的主站用了大量 innerHTML 渲染动态模块(历史包袱,别问)。replay 时它会先清空容器再逐节点插入,结果触发了某些模块的 unmount 钩子,钩子里又调了 setState,setState 又触发 rerender……死循环就这么来了。
折腾了半天发现,根本解法不是改 rrweb,而是提前接管 replay 容器的生命周期。我干脆把 replay div 剥离到独立 iframe 里,用 sandbox 隔离 JS 执行环境:
<iframe
id="replay-iframe"
sandbox="allow-scripts allow-same-origin"
srcdoc="<div id='replay-root'></div>"
style="width:100%;height:600px;border:none;"
></iframe>
然后在 iframe 内注入 replay 逻辑(注意跨域限制):
// 主页中
const iframe = document.getElementById('replay-iframe');
const iframeDoc = iframe.contentDocument;
iframeDoc.write(
<div id="replay-root"></div>
<script src="https://cdn.jsdelivr.net/npm/rrweb@1.2.3/dist/rrweb.min.js"></script>
<script>
const events = ${JSON.stringify(events)}; // 后端传来的 events 数组
const replayer = new rrweb.Replayer(events, {
root: document.getElementById('replay-root'),
// 关键:禁用自动播放,手动控制节奏
pauseAnimation: true,
// 避免 replay 时触发原页面事件监听器
mouseInteraction: false,
// 忽略 iframe 内部的 load 事件干扰
ignoreInvisible: true,
// 关键:跳过所有非必要重绘
skipInactive: true,
});
replayer.play();
</script>
);
这招亲测有效,白屏时间从 8s 降到 1.2s,CPU 占用也稳了。不过代价是:iframe 里的子应用如果依赖 window.parent 上的全局变量,就全挂了。我们项目里恰好有这么一个埋点 SDK,后来只能给它加了个 fallback 判断:if (window.parent !== window) { useMockGlobal() } ——不优雅,但够用。
字体和样式加载失败?那是你没等 CSS
另一个高频问题:replay 页面文字全是方块,或者按钮宽高错乱。排查半天发现是 replay 启动太早,CSS 文件还没加载完。rrweb 默认不关心样式加载状态,它只管按 events 时间戳执行 DOM 操作。
解决方案很土但有效:在 replay 初始化前,手动加载关键 CSS:
function loadCSS(url) {
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.onload = resolve;
link.onerror = reject;
document.head.appendChild(link);
});
}
// 加载完 CSS 再初始化 replayer
Promise.all([
loadCSS('/css/replay-base.css'),
loadCSS('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap'),
]).then(() => {
const replayer = new rrweb.Replayer(events, {
root: document.getElementById('replay-root'),
// 其他配置...
});
replayer.play();
});
这里注意:我踩过好几次坑,link.onload 在某些低版本 iOS Safari 上不触发,最后加了 3s 超时兜底。真实项目就是这么糙。
最终的解决方案
现在线上跑的版本是这样的:
- 所有 replay 页面强制走 iframe 隔离
- events 数据由后端压缩为 gzip+base64,前端 fetch 后
atob解码再JSON.parse(省了 60% 传输体积) - 自定义了一个
ReplayPlayer类,封装了 CSS 加载、iframe 注入、错误降级(比如 replay 失败时显示原始 events JSON) - 滚动行为做了特殊处理:rrweb 的 scroll event 默认只记 scrollTop,但我们业务需要精确还原 scrollLeft + behavior: smooth,所以写了插件补全:
// 自定义插件,patch rrweb 的 scroll event 处理
rrweb.addCustomEvent = function(type, payload) {
if (type === 'scroll') {
// 补充 scrollLeft 和 smooth 信息
payload.left = window.scrollX;
payload.behavior = 'smooth';
}
};
目前 95% 的回放能秒开,剩下 5% 是超长记录(>10 分钟)或含大量 Canvas 绘图的场景。Canvas 回放我们暂时没做,因为 rrweb 对它的支持是实验性的,且我们实际遇到的 bug 基本不涉及绘图——所以优先级调低了,就搁着。
回顾与反思
Repeat 重放确实是个好东西,但别把它当黑盒。rrweb 文档写得再好,也掩盖不了它在复杂 SPA 中的兼容性水坑。我们花了差不多三周,一半时间在 debug,一半时间在妥协。
做得好的地方:iframe 隔离方案简单粗暴但极其稳定;gzip+base64 优化让 3MB 的 events 降到 700KB;自定义插件机制让我们能快速修补 rrweb 的边界 case。
还能优化的:Canvas 和 Web Audio 的回放还是空白;目前 replay 不支持键盘快捷键跳转时间点(比如按 ← → 键快进/后退),这个功能已排期但还没动工;另外,rrweb v2 已经支持更细粒度的 performance profiling,我们还在观望升级成本。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 sourcemap 实现错误堆栈精准定位到 replay 时间点,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论