从用户那里拿到一个有点“惨烈”的需求:做一个用户操作录制回放,目的是复现问题、分析用户行为。结果系统上线后遇到三个现象——录出来的 JSON 文件比录屏视频还大;回放时浏览器卡成幻灯片;更让人冒冷汗的是,用户在某一步输入的密码,直接在快照里明文出现了。
这三个问题放在一起,不是“三个bug”那么简单。它说明录制回放方案在数据模型、回放架构和安全边界三个层面同时出了问题。如果你也准备做录制回放,或者已经在做但被这些问题卡住,这篇文章值得认真看完。
先说一句核心判断:JSON 录制比视频大,不是偶然,而是录制方式决定的必然结果。视频记录的是像素变化,录制 JSON 记录的是应用状态和用户事件;像素变化可以被编码器压缩到很小,而结构化的 JSON 事件流如果不做差分化、采样和压缩,体积会随着用户操作次数线性膨胀。回放卡顿的根因也不只是“文件大”,更多是重放时全量重建 DOM 带来的主线程压力。密码明文问题则说明,录制链路里没有做输入级脱敏。
这篇文章从“为什么 JSON 会比视频大”讲起,拆解录制回放的三个核心问题,再给出可直接落地的数据模型、代码示例和安全方案。读完你能解决三件事:把录制体积降下来、把回放流畅度提上去、把敏感信息挡在存储之外。
1. 录制回放的基础原理:视频帧与 JSON 事件流的本质差异
要理解“为什么 JSON 比视频大”,先要理解录制回放到底在录什么。
1.1 视频录制:像素层面的周期性压缩
视频录制是采集屏幕上的像素数据,再用编码器(如 H.264、H.265)做帧内压缩和帧间压缩。相邻视频帧之间变化很小,编码器只需要记录“变化区域”和“运动矢量”,所以屏幕变化的视频体积增长是相对缓慢的。一个 1080P 的屏幕,即使连续录 10 分钟,生成的视频也可能只有几十兆,因为大部分时间是静态画面。
1.2 JSON 录制:语义事件与状态快照
前端录制回放方案(例如业界常见的 rrweb 设计思路)记录的不是像素,而是页面状态和用户操作事件。典型事件包括:
- DOM 变化:节点新增、删除、属性修改、文本内容修改。
- 用户交互:mouse move、mouse click、scroll、input。
- 视图状态:滚动位置、焦点变化、iframe 内容。
- 网络请求:XHR、Fetch 的请求与响应。
- 自定义业务事件:登录、下单、切换页面等。
每条事件在录制时都会生成一段结构化 JSON。比如一次 mouse move 的典型数据可能是:
{ "type": 3, "timestamp": 1715000000000, "x": 320, "y": 512, "id": "node-3", "frame": 12 }这段 JSON 只有不到 100 字节,看着不大。但问题在于:一次用户操作可能会触发大量事件。比如用户快速拖动滚动条,一秒内可能产生 60 到 120 个 scroll 事件;用户在输入框里打字,每次按键可能同时触发 input 事件、DOM 文本变化事件和组件状态变化事件。如果录制器不对事件做采样和合并,一个小时的操作录下来,产生几十万甚至上百万条 JSON 事件并不夸张。
1.3 关键差异
| 对比维度 | 视频录制 | JSON 事件录制 |
|---|---|---|
| 记录对象 | 像素矩阵 | 应用语义、DOM 状态、用户操作 |
| 数据单位 | 视频帧 | 结构化事件 |
| 冗余消除方式 | 帧间压缩 | 差分快照、事件采样 |
| 压缩率 | 高,静态画面极低码率 | 低,高频操作下膨胀明显 |
| 还原能力 | 视觉还原 | 可还原应用状态,支持 Debug |
| 空间 | 观看视频 | 分析用户行为、复现 Bug |
这里要特别指出:JSON 录制并不是“亏”的。它虽然比视频大,但带来的是视频做不到的语义还原能力——你可以在回放时拿到当时的网络请求、控制台日志、组件状态,甚至可以定位到具体代码路径。真正的问题不是“JSON 不该用”,而是“不能把 JSON 当成无限膨胀的日志去用”。
2. 为什么录出来的 JSON 比视频还大
三个核心放大因素,任何一个不处理,体积都会失控。
2.1 事件密度过高:高频事件被无脑记录
很多团队第一版录制器就是在监听器回调里直接 push 一条 JSON。这种写法在功能演示时没问题,在真实用户环境里会迅速暴露问题。
举一个典型场景:用户在一个表格页面上滚动了 30 秒。鼠标移动事件、滚动事件、可能还有懒加载图片触发的新 DOM 节点插入事件,这三类事件加起来可能产生 5000 到 10000 条 JSON。如果你再开启了“记录所有鼠标移动轨迹”,这个数字还要再膨胀五到十倍。
高频事件不做节流和采样,是体积膨胀的第一来源。
2.2 全量快照被反复保存
录制回放需要还原页面状态。常见做法是每隔一段时间保存一次完整 DOM 快照,或者录制开始时保存一次快照,后面只记录增量事件。这是合理的。问题出在有些设计偷懒:每次快照都把整个 document 的 outerHTML 序列化一遍。
假设一个页面的 DOM 序列化后是 300KB,录制器每 30 秒保存一次全量快照。录制 30 分钟,只快照就有 18000KB,约 17MB。如果再把 CSS、Canvas 状态、iframe 内容加进去,体积会更大。
全量快照不适合高频保存。正确的做法是“低频全量 + 高频增量”。
2.3 存储与传输阶段没有压缩
原始 JSON 文件本身是文本格式,里面有大量可压缩的重复结构。比如所有事件都带有timestamp、type、id字段,字段名在每一条里都会重复出现。如果录制器直接把 JSON 存到数据库或上传到对象存储,不做任何压缩,体积会比 gzip 压缩后的数据大 5 到 10 倍。
我见过一个项目,录制一小时用户操作,原始 JSON 有 800MB。用 gzip 压缩后只有 150MB,再对重复 Key 做字典化处理之后,只剩 60MB。这就是“存储层不做任何努力”的代价。
2.4 密码明文出现在快照里
第三个问题最严重。用户输入密码时,如果录制器直接记录 input 事件的值,那么 password 的明文就会进入 JSON 事件流。随后这个 JSON 如果被上传到服务端、被存入日志、被数据库同步工具复制,密码的泄露面会被无限放大。
这已经不是性能问题,而是安全事故。准确说,录制回放系统在采集层就应该设置“安全红线”,对所有输入框内容区分对待。
3. 回放卡顿的根源:不只是“数据大”
很多人以为回放卡顿是因为文件太大、传输太慢。传输只是第一层,真正让浏览器卡成幻灯片的是回放执行机制。
3.1 在主线程全量重建 DOM
回放到一个快照点时,回放器通常会在浏览器主线程里重建整个 DOM。一次全量快照如果包含 10000 个节点,从 parser 解析 HTML 到 appendChild,再到样式重算和布局,主线程会阻塞几十毫秒甚至几百毫秒。用户看到的效果就是“画面突然卡住”。
如果回放器又采用了“每到一个时间点就重建一次 DOM”的笨方案,那卡顿会持续出现,表现为一帧一帧地跳。
3.2 事件回放不做合并和批量处理
录制时产生的高频事件,回放时如果也一条一条地执行,性能会被放大。比如录制时一秒钟产生了 60 条 scroll 事件,回放时执行 60 次 scroll 位置调整,浏览器会强制执行 60 次布局计算。
真正高效的回放引擎应该把高频事件做合并:同一个节点的滚动事件在同一个时间片内只执行最后一次,DOM 文本变化也应该是“合并后一次性提交”,而不是逐条触发微任务。
3.3 内存历史快照不断累积
回放过程中,为了支持“跳到任意时间点”,很多实现会把所有历史事件按时间戳存成一个数组,并且保留对 DOM 节点的引用。录制时间越长,内存占用越高。一旦内存超过浏览器阈值,页面就会失去响应。
这里需要明确:这不是“数据量太大”单一因素,而是数据模型没有设计好。录制的是一次线性操作流,回放时应该可以顺序消费,而不是把所有历史都驻留在内存里。
4. 环境准备与前置条件
在设计录制回放方案时,我先假设你的工程背景是这样的,以便后续代码示例可以直接对号入座:
- 前端项目:Web 应用,React 或 Vue,运行在 Chrome / Edge 等现代浏览器。
- 后端服务:Spring Boot 或 Node.js,提供录制文件的上传、存储和下发接口。
- 数据库:MySQL 或 PostgreSQL,存储录制会话元信息和文件索引。
- 存储层:本地磁盘或者对象存储(OSS / S3 / MinIO),存放压缩后的录制文件。
版本细节不需要死记,本文示例重点演示通用思路,版本请以实际项目为准。
在进入代码之前,有一条必须强调的原则:不要在真实生产环境直接使用“录制所有 input 值”的原始实现。开发调试可以在测试环境开全量,但上生产之前必须做脱敏和权限控制。
5. 数据模型设计:把“全量 JSON 日志”改成“结构化录制协议”
要同时解决体积、卡顿和安全问题,核心不是加压缩算法,而是先在数据模型上做四个拆分。
5.1 快照数据与事件数据分离
录制数据分成两类:
- 初始快照(Initial Snapshot):录制开始时保存一次完整 DOM 状态,用于回放时建立初始页面。
- 增量事件(Incremental Events):后续所有变化,只记录“变更点”,不重复保存整个页面。
这种模型的最大好处是:全量快照只存一份,后续体积不再与页面复杂度挂钩,而是与用户操作频率挂钩。你再怎么滚屏,一份 300KB 的初始快照不会变成 600KB。
5.2 差分快照:按时间窗口生成“基线 + 差异”
完全靠增量事件也有风险:如果录制时间很长,增量事件本身会越积越多。此时需要引入“差分快照”,也就是每隔一段时间生成一个基线快照,然后后续增量只记录相对这个基线的变化。
伪代码示意:
会话开始 —— 全量快照 S0 第 1 分钟 —— 增量事件 E1..En 第 2 分钟 —— 增量事件 En+1..Em 到达阈值 —— 生成差分快照 S1(基于 S0 + 增量) 之后 —— 增量事件继续这样回放时不需要从 0 分钟开始累加所有事件到第 30 分钟,而是直接定位到第 2 分钟的差分快照,再应用少量增量事件。
5.3 高频事件采样与合并
在录制器层做处理:
- mouse move 事件:节流到每 50ms 最多记录一次,或者按像素距离采样。
- scroll 事件:节流到每 100ms 记录一次,回放时做动画插值。
- input 事件:只记录最终值和变化位置,不记录每次按键。
5.4 输入脱敏:安全边界前置
在录制器向事件流写入数据之前,对敏感字段进行替换。密码框、验证码输入框、信用卡输入框等,一律不记录真实值,统一用***或占位字符串替代。
6. 核心流程拆解
整个录制回放系统可以拆成四条链路,每一环都有优化的发力点。
6.1 前端录制链路
监听事件 -> 事件预处理 -> 脱敏过滤 -> 节流采样 -> 编码为 JSON -> 压缩 -> 上传6.2 服务端存储链路
接收压缩流 -> 解码 -> 校验协议版本 -> 分离快照与增量 -> 压缩存储 -> 更新索引服务端不一定要做太多业务逻辑,但要保证文件不丢失、格式可校验、生命周期可控。
6.3 回放链路
按时间点加载数据 -> 初始化快照 -> 顺序消费增量事件 -> 批量渲染 -> 定时器推进 -> 节点复用6.4 安全审计链路
脱敏规则配置 -> 脱敏规则校验 -> 录制内容扫描 -> 违规数据拦截 -> 告警安全链路不要做成“事后扫描”,而要在写入前就拦截。因为一旦明文密码进入存储,你再扫描删除也可能已经被日志系统、同步工具复制了多份。
7. 完整示例与代码实现
下面给出三个基础示例,分别覆盖录制端脱敏、服务端差分存储和回放端优化。三个示例可以单独运行,也可以组合成一套最小系统。
7.1 示例一:前端录制器,带节流与密码脱敏
假设我们写一个轻量的前端录制器,不依赖第三方录制库,只捕获我们关心的事件。
文件路径:src/recorder.js
const RECORD_EVENTS = ["click", "scroll", "input", "mousemove", "dblclick"]; class SessionRecorder { constructor(options = {}) { this.events = []; this.enabled = false; this.throttleTime = options.throttleTime || 50; this.lastMouseMoveTime = 0; this.lastScrollTime = 0; this.sensitiveSelectors = [ 'input[type="password"]', 'input[data-sensitive="true"]', '.credit-card-input' ]; } start() { this.enabled = true; RECORD_EVENTS.forEach((eventName) => { window.addEventListener(eventName, this.handleEvent.bind(this), true); }); } stop() { this.enabled = false; } handleEvent(event) { if (!this.enabled) return; let shouldRecord = true; let recordData; if (event.type === "mousemove") { if (Date.now() - this.lastMouseMoveTime < this.throttleTime) { shouldRecord = false; } else { this.lastMouseMoveTime = Date.now(); recordData = { type: "mousemove", id: this.getNodeId(event.target), x: event.clientX, y: event.clientY }; } } else if (event.type === "scroll") { if (Date.now() - this.lastScrollTime < 100) { shouldRecord = false; } else { this.lastScrollTime = Date.now(); recordData = { type: "scroll", id: this.getNodeId(event.target), scrollTop: event.target.scrollTop || window.scrollY, scrollLeft: event.target.scrollLeft || window.scrollX }; } } else if (event.type === "input") { recordData = this.buildInputEvent(event); } else { recordData = { type: event.type, id: this.getNodeId(event.target), timestamp: Date.now() }; } if (shouldRecord && recordData) { recordData.timestamp = Date.now(); this.events.push(recordData); } } buildInputEvent(event) { const target = event.target; if (this.isSensitiveElement(target)) { // 关键:敏感输入框不记录真实值 return { type: "input", id: this.getNodeId(target), // 只记录“用户输入过了”,不记录内容 value: null, sensitive: true }; } return { type: "input", id: this.getNodeId(target), value: target.value }; } isSensitiveElement(el) { if (!el || !el.matches) return false; if (el.matches('input[type="password"]')) return true; return this.sensitiveSelectors.some((selector) => el.matches(selector)); } getNodeId(node) { if (!node) return ""; if (node.id) return `#${node.id}`; if (node === document.body) return "body"; return node.tagName.toLowerCase() + "-" + this.getNodeIndex(node); } getNodeIndex(node) { let index = 0; let sibling = node.previousElementSibling; while (sibling) { index++; sibling = sibling.previousElementSibling; } return index; } getRecordingData() { return JSON.stringify({ version: 1, startedAt: this.startedAt, events: this.events }); } reset() { this.events = []; } } export default SessionRecorder;这段代码的关键逻辑:
- 监听器统一走
handleEvent,先判断是否需要记录。 - 鼠标移动事件做了 50ms 节流,滚动事件做了 100ms 节流;高频事件被降频,但画面效果不会被明显破坏。
buildInputEvent里检查输入框是不是 password 或配置了>package com.example.recording; import java.util.zip.GZIPOutputStream; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; public class SnapshotService { private static final int DIFF_THRESHOLD = 2000; public RecordFile storeSession(String sessionId, String initialSnapshot, String eventsJson) throws Exception { if (eventsJson.length() < DIFF_THRESHOLD) { // 增量事件还不足以生成新的差分快照,直接压缩存储 return new RecordFile( sessionId, compress(eventsJson), "INCREMENTAL", null ); } // 到达阈值:将当前 events 累加上一次的基线,生成一段可独立回放的分片 String baseline = buildBaseline(initialSnapshot, eventsJson); return new RecordFile( sessionId, compress(baseline), "DIFF_SNAPSHOT", buildMeta(eventsJson.length(), System.currentTimeMillis()) ); } private String buildBaseline(String initialSnapshot, String eventsJson) { // 真实工程中这里可以基于 JSON Patch 或自研 diff 引擎 // 生成一个结构更紧凑的合并后快照 return "{" + "\"initialSnapshot\":" + initialSnapshot + ",\"mergedEvents\":" + eventsJson + "}"; } private byte[] compress(String raw) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (GZIPOutputStream gzip = new GZIPOutputStream(baos)) { gzip.write(raw.getBytes(StandardCharsets.UTF_8)); } return baos.toByteArray(); } private String buildMeta(int size, long timestamp) { return "{" + "\"eventCount\":" + size + ",\"timestamp\":" + timestamp + "}"; } static class RecordFile { final String sessionId; final byte[] data; final String type; final String meta; RecordFile(String sessionId, byte[] data, String type, String meta) { this.sessionId = sessionId; this.data = data; this.type = type; this.meta = meta; } } }这个示例的意义不在于把
buildBaseline写成生产级 diff 引擎,而在于建立“分块存储”的思维:- 录制数据不再是一整个无限增长的 JSON 文件。
- 每达到一个阈值,就把当前片段固化成一段可独立回放的分片。
- 所有分片都经过 GZIP 压缩,存储体积大幅下降。
生产环境里,你可以用 JSON Patch(RFC 6902)或自研的 DOM diff 结果作为分片内容,再用压缩工具输出。
7.3 示例三:回放端按需加载与事件合并
回放端的问题是“数据大、事件多”。优化思路是回放器不要一次性加载整个回放文件,而是按时间片加载,并在渲染前对高频事件做合并。
文件路径:
src/player.jsclass SessionPlayer { constructor() { this.events = []; this.currentIndex = 0; this.timer = null; this.speed = 1; this.listeners = []; } loadSession(sessionData) { // 只加载当前时间窗内的事件,而不是一整个大文件 this.events = this.filterByTimeWindow(sessionData, 0, 5000); this.renderInitialSnapshot(sessionData.initialSnapshot); } filterByTimeWindow(sessionData, startTime, endTime) { return sessionData.events.filter( (event) => event.timestamp >= startTime && event.timestamp <= endTime ); } mergeEvents(events) { // 合并同一节点、同一时间窗内的同类事件 const mergedMap = new Map(); events.forEach((event) => { if (event.type === "scroll" || event.type === "mousemove") { const key = `${event.type}-${event.id}`; mergedMap.set(key, event); } else { // 非高频事件仍然保留顺序 this.listeners.push(event); } }); return Array.from(mergedMap.values()).sort((a, b) => a.timestamp - b.timestamp); } play() { const merged = this.mergeEvents(this.events); let idx = 0; this.timer = setInterval(() => { if (idx >= merged.length) { clearInterval(this.timer); this.timer = null; return; } const event = merged[idx]; this.applyEvent(event); idx++; }, 16 / this.speed); } applyEvent(event) { const node = document.querySelector(event.id); if (!node) return; if (event.type === "scroll") { node.scrollTop = event.scrollTop || 0; node.scrollLeft = event.scrollLeft || 0; } else if (event.type === "input") { if (!event.sensitive) { node.value = event.value || ""; } } } destroy() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } } export default SessionPlayer;回放器的关键逻辑:
filterByTimeWindow只加载当前时间窗的事件,避免一次性解析几十万条 JSON。mergeEvents把高频的 scroll / mousemove 事件按“类型 + 节点”合并为最新值,明显减少 DOM 更新次数。applyEvent里同样检查sensitive字段,敏感输入框不回填真实值,从根源上防止密码在回放界面二次泄露。
7.4 运行与验证方式
如果你要用这三个示例跑一个最小流程,可以按下面顺序做:
- 把
recorder.js放到前端页面中:
npm run dev打开页面,输入一段文本,滚动页面,点击按钮。
在控制台调用:
const recorder = new SessionRecorder({ throttleTime: 50 }); recorder.start(); // 操作页面… recordedData = recorder.getRecordingData(); console.log(recordedData);预期输出中,password 输入框对应的 input 事件应该包含
"value": null, "sensitive": true,而不是明文密码。- 把 JSON 数据传给后端的
SnapshotService.storeSession,观察返回的字节数组大小和压缩率。
8. 运行结果与效果验证
下面的数据不是某个特定项目的基准值,而是这类方案中常见的趋势。你可以用同一份录制数据做前后对比测试。
优化项 优化前 优化后 说明 高频事件数量 10000 条/分钟 约 3000 条/分钟 节流与采样 存储大小(原始 JSON) 80MB 12MB GZIP + 分片 首屏回放加载时间 8 秒 1.5 秒 按时间窗加载 回放帧率 8 FPS 30 FPS 左右 事件合并 密码明文存储 存在 不存在 敏感输入脱敏 验证方式:
- 录制一段 1 分钟的用户操作,同时打开开发者工具 Performance 面板,观察回放时的主线程脚本执行时间和帧率。
- 对比优化前后录制文件大小,用
gzip -l查看压缩比。 - 搜索录制 JSON 中是否出现
"password"或"type":"password"附近有真实密码值。
如果验证失败,优先检查顺序是:录制端是否真的触发了脱敏规则 -> 上传的数据是否经过压缩 -> 回放端是否按时间窗加载 -> 事件合并逻辑是否正确处理了节点 id。
9. 常见问题与排查思路
问题现象 可能原因 排查方式 解决方案 录制文件依然很大 没有做差分快照,仍然周期性保存全量 DOM 检查存储分片的 type字段,统计全量快照数量增加阈值控制,改为低频全量 + 高频增量 回放卡顿 高频事件没有被合并,或回放在主线程执行了过多 DOM 操作 用 Performance 面板查看 scroll与layout耗时在回放端做事件合并,合并后只保留最终状态 密码还是明文出现 脱敏规则只覆盖了 input[type=password],没有覆盖 iframe、自定义组件、富文本等场景搜索录制 JSON 中的敏感字段 扩展脱敏选择器,把安全校验放到事件写入前 压缩后无法还原 快照与增量的协议版本不一致 检查录制协议 version 是否在服务端被解析 在数据模型中增加 schema 版本号,服务端做兼容校验 回放到一半失去响应 内存里保留了整个事件流的引用,或存在 DOM 节点泄漏 使用 Chrome Memory 面板分析回放前后的堆内存 回放结束后主动释放引用,使用虚拟列表渲染快照树 脱敏后业务回溯困难 只看 “sensitive: true” 无法判断用户到底做了什么 记录输入框的 key 与触发顺序,但隐藏 value 增加事件行为轨迹描述,如“输入了 8 个字符” 10. 最佳实践与工程建议
10.1 安全脱敏:从录制到展示全链路覆盖
密码脱敏不是前端做完就结束。服务端存储前要做一次“录制数据安全扫描”,防止前端脱敏规则被绕过或者老版本客户端上传明文数据。
推荐策略:
- 前端在事件写入前脱敏,这是第一道防线。
- 服务端在写入存储前做第二道校验,识别所有包含
value字段的 input 事件,如果发现真实密码字段,直接丢弃该字段并记录告警日志。 - 回放端展示时,再次检查敏感标记,不回填真实值。
10.2 存储层:分片、压缩与生命周期
录制数据是典型的热数据 + 冷数据混合体:
- 最近 7 天录制数据,需要被频繁回放,建议存储在高性能介质。
- 超过 30 天的录制数据,压缩后存入冷存储,降低存储成本。
- 对同一会话,建议按“会话 ID + 时间窗口”分片,避免一个超大文件导致后续加载困难。
10.3 协议版本管理
录制数据是会被长期保存的。一年前录制的数据,一年后如果需要回放,你的回放器必须还能解析。因此建议在录制协议中显式加入:
version: 1当事件结构发生变化时,升级 version,同时在回放器中保留旧版本的解析逻辑。不要指望“所有数据格式永远不变”。
10.4 回放引擎的性能边界
回放引擎一定要避免“全量重建 DOM”。更推荐的方案是:
- 初始快照使用
template+cloneNode方式快速创建。 - 增量事件按时间片批量渲染,用
requestAnimationFrame控制渲染频率。 - 滚动、鼠标移动类事件合并为最终状态后一次性更新。
- 长时间回放时,主动释放不可见节点的引用。
10.5 监控与告警
对录制回放系统建立三个基础指标:
- 每秒录制事件数(EPS),用于发现事件量异常。
- 单会话录制文件大小,超过阈值时触发告警。
- 敏感信息命中次数,每次命中都要人工确认是否泄露。
11. 总结与后续学习方向
回到文章开头的问题:JSON 录制比视频大、回放卡、密码明文,其实不是一个偶发事故,而是录制回放系统设计时三个维度没有考虑完整。
数据维度上,要通过“初始快照 + 增量事件 + 差分快照 + 压缩”把体积控制住;性能维度上,要通过“时间窗加载 + 事件合并 + 节点复用”让回放流畅;安全维度上,要通过“前端脱敏 + 服务端校验 + 回放端不还原”守住敏感信息。
技术上有几个方向值得继续深入:
- JSON Patch 与自研 diff 引擎:怎么用最小数据结构表达 DOM 变化。
- Web Worker 回放:把事件消费和渲染计算放到非主线程。
- 录制协议 Schema 设计:如何让旧数据在未来依然可解析。
- 隐私合规:录制内容涉及个人信息时,数据存储与访问的权限边界如何设计。
如果你准备在生产环境上线录制回放,建议先用测试页面完整验证体积和安全性,再逐步放开到真实用户。录制回放是排查问题的利器,但不该成为数据膨胀和敏感信息泄露的入口。