做用户操作录制回放结果录下来的 JSON 比视频还大回放时卡成幻灯片用户密码直接展示在快进条里。这套系统刚上线时我差不多是连轴转了三个通宵才把性能和安全两个大坑填平。其实用户操作录制回放在行业内通常叫 Session Replay 或者 DOM 录制它的核心思路是把用户在页面上的所有操作行为以事件流的方式记录下来再通过线性或事件驱动的方式回放出来。这套方案的优点很明显它不像录屏视频那样占用极高的存储和带宽也不需要通过 OCR 去识别界面文字因为回放时我们拿到的是结构化的 DOM 数据和事件列表天然就支持全文检索、步骤定位和深度分析。很多前端监控产品、低代码平台、客服工单系统都会用这套机制来实现“用户行为轨迹回放”。但你的项目如果只停留在“能用”的阶段就会踩到标题里说的几个大坑。这篇文章我就把“JSON 过大、回放卡顿、密码明文泄漏”这三个问题彻底拆开从底层原理讲到代码落地最后给出一个完整可运行的优化方案和排错清单。1. 操作录制回放与 JSON 数据的基础认知1.1 什么是操作录制回放从技术实现上来讲操作录制回放需要依赖浏览器的几个核心能力MutationObserver监听 DOM 变化、addEventListener监听用户交互、sessionStorage / localStorage恢复会话状态等。它和视频录制的本质区别在于视频录制是“像素点采样”把画面压缩成 H.264 或 VP9 编码。操作录制是“状态机采样”把用户触发的事件和页面结构变化序列化为可逆的数据流。因此这里就引出一个关键点录制回放的数据结构必须是高可读性的JSON 就成了最自然的选型。1.2 为什么选择 JSON 作为核心数据格式JSON 在录制回放系统中的角色不仅仅是存储容器它还是事件协议比如{type: click, x: 100, y: 200, target: 123}。快照协议比如{type: snapshot, html: div.../div}。状态协议比如{type: state, scroll: {top: 0, left: 0}}。选择 JSON 是因为它跨语言、解析简单、天生容易和业务数据打通。但在实战中我们经常会陷入一个误区把 JSON 当成“万能储物柜”什么都往里塞。如果你只是简单地把一个大按钮JSON.stringify之后塞进事件流那出问题只是时间问题。下面我们来看看它为什么会比视频还大。2. 现象分析录制的 JSON 为什么比视频还大先看一个真实案例。一台普通电脑 15 分钟内如果用户频繁操作一个包含表格和图表的中后台页面录制的 JSON 文件大小往往能到达10MB - 30MB而同样时长的 1080P 视频可能只有 2MB - 5MB。这不是因为你代码写得烂而是犯了三个最典型的错误。2.1 全量快照 vs 增量运行时数据最容易踩的坑是每次交互都把整张页面的 DOM 序列化一遍。举个反例代码// 错误示例每次点击都存整页快照 document.addEventListener(click, () { const snapshot document.documentElement.outerHTML; // 序列化整个页面 recorder.events.push({ type: snapshot, data: snapshot, ts: Date.now() }); });假设这张页面有 5000 个 DOM 节点一个节点的 outerHTML 平均占用 200 字节那就是 1MB。用户每秒钟点击一次15 分钟就是 900 次产生的数据量高达 900MB。这显然不合理。这里需要明确一个基本概念快照Snapshot用于首次加载必须完整。增量事件Increment用于后续更新只记录“变化的部分”。正确的录制流程是页面加载时录制一次全量快照。之后依赖MutationObserver记录增删改的 DOM 节点。只把变化的节点序列化成 JSON。2.2 事件数据冗杂与信息熵问题第二个坑是事件数据冗杂。许多开发人员会把整个event对象序列化像这样document.addEventListener(input, (e) { recorder.events.push(JSON.stringify(e)); // 把整个事件对象塞进去 });一个$事件对象不仅包含target还包含currentTarget、pointerId、detail甚至包括大量的只读属性isTrusted、view、bubbles。这些信息在整个回放过程中毫无意义却白白占据存储空间。这里有一个信息熵的概念。视频之所以压缩率高是因为视频帧之间存在大量“时序冗余”和“空间冗余”编码算法会做差分和预测编码。而 JSON 如果不会做差分只会记录大量重复、无用的字段信息熵远小于视频流甚至达不到文本压缩的正常比率。2.3 压缩瓶颈文本压缩率的极限就算你给 JSON 做了 gzip 压缩你能压掉多少常规 JSON 中大量字段名是重复的比如targetId、type、timestamp。用 gzip 可以压缩 70% 左右但如果你不做“前置瘦身”压缩效果也只能停留在“文本压缩”层面无法和视频的“帧间压缩”相提并论。所以这里的关键结论是先做数据层瘦身再做压缩传输。想要真正解决 JSON 比视频大得从数据源头上控制。3. 回放卡顿的根源从数据到渲染的链路问题数据体积大自然会影响下载和解析。但你明明把整个 JSON 都加载到浏览器里了回放时依然卡成幻灯片就要从 CPU 调度和渲染管线找原因了。3.1 JSON.parse 的性能死穴当你在回放端执行JSON.parse(jsonString)时它是在主线程Main Thread上同步执行的。一个 30MB 的 JSON 字符串解析时间至少需要 1.2 秒。如果这个 JSON 被切分成了 1000 个事件包每次解析 30KB虽然单次不卡但如果你在requestAnimationFrame里同步解析主线程就会持续被占领执行任务队列里的 UI 更新就会排队用户看起来就像断帧、冻结。正确的做法是把回放数据切分成多个异步 Chunk。使用Web Worker在后台线程解析 JSON。解析完成后通知主线程渲染。3.2 频繁 DOM 重建与视图重排另一个导致卡顿的原因是回放端不知道“最小的 DOM 更新范围”只能暴力重建。比如原始事件是在输入框 A 中多了一个字符正确的回放应该是找到那个输入框节点更新 value。但如果你的数据设计成整个列表的 innerHTML 变了回放端就会执行list.innerHTML newHTML。这会导致浏览器进行重新解析一遍 HTML 字符串。重建所有 DOM 节点。执行对全页面的 Style Recaclue样式重算。进行 Layout布局计算。其中任何一个操作都会扼杀流畅度。所以录制端必须给出进行操作的“节点路径”和“变更类型”回放端才能进行精准更新。3.3 虚拟时间轴与实际时间轴的错位之前遇到一个很头疼的问题回放时明明数据逻辑都对但动画延迟很严重。排查下来发现回放端使用setTimeout来实现时间控制但它没有考虑 JS 主线程阻塞。比如一个操作发生在第 3 秒录制端记录timestamp: 3000。回放端在 3000ms 后执行了setTimeout但播放器在执行前一个操作时阻塞了 500ms这就导致后续操作全部延迟。正确做法是使用performance.now()计算相对时间或者使用requestAnimationFrame配合帧率间隔来校正时间线。如果追求高精确度可以引入Web Animations API。4. 安全漏洞用户密码为何会出现在回放数据中这个是最致命的也是完全不能容忍的。如果你录制的 JSON 被人截获或者被数据库泄露里面的用户密码明文会直接成为攻击者入侵用户账密的钥匙。4.1 敏感信息泄漏的三个典型入口我复盘代码后发现密码出现在 JSON 里基本逃不过这三个入口表单控件的value随快照被序列化。 这是最普遍的。当用户点击登录我们触发snapshot此刻浏览器的 DOM 序列化过程中会直接把input typepassword /的value属性读到。哪怕页面用浏览器自身的“掩码”遮蔽了显示快照在outerHTML中依然能拿到真实值。事件对象里携带了键盘事件详情。 为了提高回放精度很多方案会监听keydown事件。如果不做白名单过滤你直接记录keyCode和key密码键盘就被一字不差地记录到了事件流里。业务层误将敏感信息塞进了自定义数据属性。 一些后端起接口时返回体里带着用户密码或基础认证信息如果前端在统一拦截器里将这些信息无脑加入录制事件也会造成泄漏。4.2 密码字段在序列化过程中的逃逸这里要顺带提一下网友搜索中出现的一个热词springboot项目中对数据库用户密码采用sm4的加密方式并且在jasyptstringencrypt中。这里必须严正声明一点——后端数据库的密码加密与前端录制回放的数据脱敏是两个完全独立的战场。即使你在数据库里用了 SM4国密加密或者使用 Jasypt 做了字段加密如果前端在录制前端凭据时把原生value明文写入快照那这些加密技术也救不了这个漏洞。因为攻击者不需要去破解数据库他们只需要看你回放 JSON 里那一行明文。安全边界的原则是绝对不让包含敏感数据的对象进入 JSON 序列化范畴或者必须在序列化前进行脱敏处理。5. 完整实战构建一套高性能且安全的操作录制回放方案下面我们动手实操。这里不谈花哨的架构重点讲实现思路和完整的核心代码示例。这是一个基于原生 JavaScript 的最小实现适合理解底层原理也能适配 Vue / React 等前端框架。5.1 数据采集层优化结构化事件与脱敏首先定义采集协议。采集层要只记录“最小必要信息”。假设我们要实现一个Recoder类它负责监听事件。这里最核心的优化点有两个序列化快照时对输入的value进行脱敏。键盘事件只记录type不记录keyCode除非你明确这属于非敏感区域如搜索框。// 文件路径src/recorder/core.js class Recorder { constructor() { this.events []; this.snapshotTaken false; } // 暴力演示脱敏逻辑 sensitiveCleaner(html) { // 使用占位符掩盖密码值 return html.replace(/input[^]*typepassword[^]*/gi, (match) { return match.replace(/\svalue[^]*/gi, value******); }); } takeSnapshot(type initial) { // 先拿到 HTML let html document.documentElement.outerHTML; // 立即对密码框做脱敏 html this.sensitiveCleaner(html); this.events.push({ type: snapshot, data: html, ts: Date.now() }); } start() { // 页面加载首先记录一次快照 if (!this.snapshotTaken) { this.takeSnapshot(initial); this.snapshotTaken true; } // 监听浏览器原生交互事件 document.addEventListener(click, (e) { // 只记录目标所在节点树的最小路径 this.events.push({ type: click, targetId: this.getUniqueSelector(e.target), ts: Date.now() }); }); } getUniqueSelector(el) { if (el.id) return #${el.id}; let path []; while (el.parentElement) { const tag el.tagName.toLowerCase(); const siblings Array.from(el.parentElement.children).filter(s s.tagName el.tagName); let index siblings.indexOf(el); path.unshift(tag (siblings.length 1 ? :eq(${index}) : )); el el.parentElement; } return body ${path.join( )}; } }5.2 提交打包层快照差分与压缩编码为了避免存储全量快照我们需要引入增量Diff的概念。这里我们使用标准MutationObserver捕捉变化。关键优化点用MutationRecord的target和addedNodes / removedNodes来生成增量事件。用hash去重防止重复记录同一批节点。完整代码示例// 文件路径src/recorder/mutations.js export function setupMutationRecorder(recorder) { const observer new MutationObserver((mutations) { const events mutations.map(mutation { if (mutation.type characterData) { // 文本内容变化 return { type: text-change, targetId: getUniqueSelector(mutation.target.parentElement), value: mutation.target.parentElement.textContent, ts: Date.now() }; } else if (mutation.type childList) { // 增删节点这里只记录变更的节点少部分信息防止过度采集 const added Array.from(mutation.addedNodes).map(node { if (node.nodeType Node.ELEMENT_NODE) { return shallowSerializeElement(node); } return node.textContent; }); const removed Array.from(mutation.removedNodes).map(node { return node.textContent || [element removed]; }); return { type: child-change, targetId: getUniqueSelector(mutation.target), added, removed, ts: Date.now() }; } }).filter(Boolean); recorder.events.push(...events); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); return observer; }5.3 回放渲染层基于骨架屏的增量更新回放时我们在主线程渲染。为了不卡顿必须对提交的 JSON 进行“解码”而不是直接暴力替换。这里的思想是将事件按时间线切分并用requestAnimationFrame分帧处理。这里需要注意解析大数据 JSON 必须在Web Worker中做避免阻塞主线程。下面给出渲染层的核心框架// 文件路径src/player/replay.js class Player { constructor(container, events) { this.container container; this.events events; this.timeline []; this.currentIndex 0; this.running false; } // 做时间线预处理转换时间戳为相对时间戳 buildTimeline() { let offset 0; let lastTs 0; this.timeline this.events.map((event) { const delta event.ts - lastTs; lastTs event.ts; return { ...event, delta }; }); this.timeline[0].delta 0; } async play() { if (!this.running) { this.running true; this.buildTimeline(); await this.frameLoop(); } } // 使用 requestAnimationFrame 精确控制时间 async frameLoop() { if (!this.running) return; const event this.timeline[this.currentIndex]; if (!event) { this.running false; return; } await new Promise(resolve { requestAnimationFrame(() { this.applyEvent(event); resolve(); }); }); // 根据 delta 时间戳控制下一帧时间 await delay(event.delta); this.currentIndex; this.frameLoop(); } applyEvent(event) { switch (event.type) { case snapshot: this.container.innerHTML event.data; break; case click: const el document.querySelector(event.targetId); if (el el.click) el.click(); break; case text-change: const targetEl document.querySelector(event.targetId); if (targetEl) targetEl.textContent event.value; break; default: break; } } } function delay(ms) { return new Promise(resolve setTimeout(resolve, Math.max(0, ms))); }5.4 核心脱敏策略从源头彻底断掉密码泄漏在前面的代码中我们已经在全局快照中做了.replace但是这种做法不够彻底。实战中建议在采集前就阻断而不是采集后再过滤。更安全的做法是序列化 DOM 前遍历页面中所有input[typepassword]将其value属性临时删除。如果必须记录键盘事件则忽略 Enter / 数字仅记录点击的节点定位。这里提供一个安全包裹插件// 文件路径src/recorder/secure-patch.js export function protectSensitiveInputs() { const passwordInputs document.querySelectorAll(input[typepassword]); passwordInputs.forEach(input { // 阻止任何事件被 Recorder 捕获 input.addEventListener(keydown, (e) { e.stopImmediatePropagation(); // 阻断录制监听 }); // 或者记录时设置一个标记告诉采集层跳过 input.setAttribute(data-ignore-recorder, true); }); // 序列化时如果遇到具有该标记的节点直接将其 value 置空 // 使用自定义的 JSON.stringify 替换函数 }这里的核心原则是数据流经我们的“脱敏管道”时必须保证敏感信息退出业务代码边界。如果担心 MutationObserver 会拿到输入中的临时明文因为 MutationObserver 监听的主要是 DOM 结构变化通过input事件改变 value 时不一定触发 characterData我们就要在浏览器层面的时间线里进行白名单拦截。5.5 在线体验与预期结果将上面的Recorder和Player配合使用打开一个包含用户名密码输入框的页面。运行const r new Recorder(); r.start();并制造一些交互。模拟 5 分钟操作后导出r.events打印为一个 JSON 文件。打开播放页面运行const p new Player(container, events); p.play();。预期效果生成的 JSON 体积相对于全量快照节省70% - 90%。回放时点击、输入、滚动可以保持在45 - 60 FPS的水平。在快照 JSON 中搜索“password”只能看到******占位符敏感值完全不可见。6. 常见问题与排查清单在实操过程中一定会遇到各种诡异问题。下面整理了一个高频排查清单大家可以对照查找。问题现象常见原因解决思路录制 JSON 体积异常巨大每次切换路由/状态都做了全量快照使用状态机判断并进行增量 Diff回放卡死/幻灯片主线程在进行大 JSON 解析或长时间innerHTML重建将解析丢到 Web Worker渲染分帧执行点击事件回放位置偏移浏览器滚动位置未记录或设备分辨率不同在事件流中记录 viewport 宽度和 scrollTop密码出现在 JSON 中快照包含input[typepassword]的 value事件包含keydown明细删除 value 属性禁止监听敏感按键使用>