前端知库

rrweb 录屏回放是怎样实现的?全量快照、增量快照和隐私脱敏怎样配合?

rrweb 记录的不是视频,而是 DOM 数据。开始时把整棵 DOM 序列化成全量快照,每个节点分配 id;之后用 MutationObserver 和事件监听把 DOM 变化、鼠标、滚动、输入记成增量事件。回放时在 iframe 里重建 DOM 并按时间应用事件。密码框默认脱敏,maskAllInputs、blockClass、maskTextClass 控制其他内容。

参考答案

简答

用户反馈“点了提交没反应”,开发者想看他当时到底做了什么,rrweb 这类工具就能把那段操作“回放”出来。它录下来的不是视频,而是 DOM 数据:开始时把整个页面的 DOM 序列化成一份全量快照,之后只记录变化。

全量快照里每个节点都有一个数字 id。之后 rrweb 用 MutationObserver 记录节点增删、属性和文字的变化,再监听鼠标、滚动、输入、窗口大小这些事件,每条增量事件只写节点 id。回放时先在 iframe 里按快照重建 DOM,再按时间顺序把增量事件一条条应用上去。

这样录下来的是 JSON,比录视频小得多,回放时还能看到真实的 DOM 结构。页面原来的脚本在回放时不会执行,界面变化全部来自录下来的事件。

要注意隐私。rrweb 默认只把密码框的值替换成 *,其他输入要开 maskAllInputs 或配置 maskInputOptions;带 rr-block 类的元素整块不录,回放时显示为同样大小的占位;rr-mask 类的元素文字会被打码。最好在采集端就脱敏,不要把敏感数据发出去再处理。

全量快照记录了什么

rrweb 的文档解释了为什么要自己写序列化:直接克隆出的 DOM 只能留在内存里,不能变成 JSON 发到服务器。所以全量快照会逐个节点遍历 DOM,把每个节点写成包含类型、标签名、属性、子节点的普通对象,并给它分配一个唯一的数字 id。

序列化时还要处理几件事,否则回放会出错。script 元素会换成占位的 noscript,脚本内容丢掉,因为回放时页面脚本不能执行。相对路径会改成绝对路径,因为回放页面的地址和原页面不同,CSS 里的相对路径也要转换。外部样式表会读出已解析的规则写成内联样式,这样内网或 localhost 的样式也能正确回放。输入框里用户输入的值不在 HTML 里,序列化时也会读出来存成属性。

增量快照怎样记录变化

全量快照之后,rrweb 只记录变化。文档列出的增量来源有:DOM 变化(节点增删、属性变化、文字变化)、鼠标移动、鼠标交互(点击、双击、聚焦、触摸等)、页面或元素滚动、窗口大小变化、输入。

DOM 变化靠 MutationObserver。它是浏览器提供的接口,DOM 改变后会批量回调一次,告诉你哪些节点增加、删除,哪些属性改了。rrweb 在一次回调里做了去重:同一个节点只记录一次;同一批里先加后删的节点直接丢掉;同一属性只保留最后的值。

有些变化 MutationObserver 看不到。代码直接给 input.value 赋值不会触发它,所以 rrweb 还劫持了这些属性的 setter。鼠标移动非常频繁,rrweb 最多每 20 毫秒取一次坐标,最多每 500 毫秒发送一批。

每条增量事件只引用节点 id。录制端和回放端各维护一张“id 到节点”的映射表,节点增删时同步更新,回放时按 id 找到要操作的节点。

用一个小例子看快照加增量

下面用普通对象模拟 DOM,演示“全量快照分配 id,增量事件按 id 修改”的过程。真实的 rrweb 结构更复杂,这里只看思路。

JavaScript
let nextId = 1;
function snapshot(node) {
  return {
    id: nextId++,
    tag: node.tag,
    text: node.text,
    children: (node.children || []).map(snapshot),
  };
}

function rebuild(tree) {
  const map = new Map();
  (function walk(n) {
    map.set(n.id, n);
    n.children.forEach(walk);
  })(tree);
  return map;
}

function applyEvent(map, event) {
  if (event.type === 'add') {
    const node = { ...event.node, children: [] };
    map.get(event.parentId).children.push(node);
    map.set(node.id, node);
  }
  if (event.type === 'text') map.get(event.id).text = event.text;
  if (event.type === 'remove') {
    const parent = map.get(event.parentId);
    parent.children = parent.children.filter((c) => c.id !== event.id);
    map.delete(event.id);
  }
}

function render(n) {
  const inner = n.text !== undefined ? n.text : n.children.map(render).join('');
  return `<${n.tag}>${inner}</${n.tag}>`;
}

const full = snapshot({ tag: 'ul', children: [{ tag: 'li', text: '苹果' }, { tag: 'li', text: '香蕉' }] });
const incremental = [
  { type: 'add', parentId: 1, node: { id: 4, tag: 'li', text: '橙子' } },
  { type: 'text', id: 2, text: '青苹果' },
  { type: 'remove', parentId: 1, id: 3 },
];

const replayMap = rebuild(structuredClone(full));
const root = replayMap.get(1);
console.log(render(root)); // <ul><li>苹果</li><li>香蕉</li></ul>
incremental.forEach((event) => applyEvent(replayMap, event));
console.log(render(root)); // <ul><li>青苹果</li><li>橙子</li></ul>
console.log(replayMap.size); // 3

全量快照给 ul 分配了 1,两个 li 分别是 2 和 3。三条增量事件都没有保存节点本身的引用,只写了 id,所以能变成 JSON 存下来。回放端先重建映射表,再按 id 找到节点应用变化,最后得到和录制时一样的结果,映射表里也同步删掉了 3 号节点。

这也说明了为什么回放必须从全量快照开始:只有增量事件,就不知道 2 号节点是什么。rrweb 文档也说,默认情况下回放需要全部事件。

录制和回放的基本用法

文档推荐安装 @rrweb/record 和 @rrweb/replay,原来的 rrweb 包已标为弃用。record 返回一个停止函数:

JavaScript
import { record } from '@rrweb/record';

const events = [];
const stop = record({
  emit(event) {
    events.push(event);
  },
  maskAllInputs: true,
  blockClass: 'rr-block',
});

回放时用 Replayer,调用 play() 开始,也可以传毫秒数从某个时间点开始。需要带进度条的界面,可以用基于它的 rrweb-player。

JavaScript
import { Replayer } from '@rrweb/replay';
import '@rrweb/replay/dist/style.css';

const replayer = new Replayer(events);
replayer.play();

如果一直录、只想保留最近一段,可以用 checkoutEveryNth 或 checkoutEveryNms 定期重新生成全量快照,emit 的第二个参数 isCheckout 会告诉你新快照开始了,这时可以丢掉更早的事件。因为增量事件是一条链,文档说保留的长度不精确:设 checkoutEveryNth: 200,最后会留下 200 到 400 条事件。文档也说多数情况下不需要配置它。

隐私脱敏怎样配置

rrweb 的默认值只遮住密码框:maskInputOptions 默认是 { password: true },maskAllInputs 默认是 false。所以手机号、身份证这类普通输入框默认会被原样录下来,要主动开启脱敏。几个常用选项的作用如下:

  • maskAllInputs: true:所有输入框的值都替换成 *。
  • maskInputOptions:按输入框类型选择要脱敏的类别。
  • blockClass(默认 rr-block)和 blockSelector:匹配的元素整块不录,回放时显示为同样大小的占位。
  • maskTextClass(默认 rr-mask):匹配元素及其子元素里的文字被打码。
  • ignoreClass(默认 rr-ignore):不记录匹配元素的输入事件。
  • maskInputFn、maskTextFn:自定义脱敏函数。

这些处理都发生在录制端,敏感内容不会出现在事件数据里。如果先原样上报再在服务端打码,敏感数据就已经离开了用户的浏览器。

回放端也有安全问题。回放的 iframe 默认在沙箱里不能运行脚本,文档提醒,UNSAFE_replayCanvas 会给 iframe 加上 allow-scripts,去掉这层保护,所以不安全。

常见追问

为什么不直接录视频?

录视频要一直截图编码,耗性能,数据也大。rrweb 记录的是 DOM 变化,页面不动时几乎没有数据,回放时还能看到真实的 DOM 结构。代价是有些内容录不全,比如 Canvas 默认不录,要开 recordCanvas。

回放时页面的接口请求、脚本会重新执行吗?

不会。序列化时脚本已经换成了 noscript,回放时也不执行 JavaScript,文档说正因如此才要记录脚本造成的所有 DOM 变化。界面上的变化全部来自录下来的事件。

Sentry 的会话回放和 rrweb 是什么关系?

两者解决的是同一类问题,接入和采样配置见《错误监控平台怎样接入?以 Sentry 为例讲 SDK 初始化、Source Map 上传、release 关联和采样》。用 rrweb 自建时,事件存储、采样和上报都要自己做。