add internal design docs

This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent 68daa2d029
commit 824e165ede
4 changed files with 306 additions and 1 deletions

View File

@@ -34,8 +34,18 @@ rrweb 主要由 3 部分组成:
- 补充更多单元测试
- 随机在更多网站上运行集成测试
## Internal Design
[序列化](./docs/serialization.md)
[增量快照](./docs/observer.md)
[回放](./docs/replay.md)
## Contribute Guide
为了保证录制和回放时可以对应到一致的数据结构rrweb 采用 typescript 开发以提供更强的类型支持。
[Typescript 手册](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html)
1. Fork 需要修改的 rrweb 组件仓库
2. `npm install` 安装所需依赖
3. 修改代码并通过测试
@@ -43,7 +53,7 @@ rrweb 主要由 3 部分组成:
除了添加集成测试和单元测试之外rrweb 还提供了交互式的测试工具。
运行 `npm rnu repl`,将会启动浏览器并在命令行要求输入一个测试的 url
运行 `npm run repl`,将会启动浏览器并在命令行要求输入一个测试的 url
```
Enter the url you want to record, e.g https://react-redux.realworld.io:

122
docs/observer.md Normal file
View File

@@ -0,0 +1,122 @@
# 增量快照
在完成一次全量快照之后,我们就需要基于当前视图状态观察所有可能对视图造成改动的事件,在 rrweb 中我们已经观察了以下事件(将不断增加):
- DOM 变动
- 节点创建、销毁
- 节点属性变化
- 文本变化
- 鼠标移动
- 鼠标交互
- mouse up、mouse down
- click、double click、context menu
- focus、blur
- touch start、touch move、touch end
- 页面或元素滚动
- 视窗大小改变
- 输入
## Mutation Observer
由于我们在回放时不会执行所有的 JavaScript 脚本,所以例如这样的场景我们需要完整记录才能够实现回放:
> 点击 button出现 dropdown menu选择第一项dropdown menu 消失
回放时,在“点击 button”执行之后 dropdown menu 不会自动出现,因为已经没有 JavaScript 脚本为我们执行这件事,所以我们需要记录 dropdown menu 相关的 DOM 节点的创建以及后续的销毁,这也是录制中的最大难点。
好在现代浏览器已经给我们提供了非常强大的 API —— [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) 用来完成这一功能。
此处我们不具体讲解 MutationObserver 的基本使用方式,只专注于在 rrweb 中我们需要做哪些特殊处理。
首先要了解 MutationObserver 的触发方式为**批量异步**回调,具体来说就是会在一系列 DOM 变化发生之后将这些变化一次性回调,传出的是一个 mutation 记录数组。
这一机制在常规使用时不会有问题,因为从 mutation 记录中我们可以获取到变更节点的 JS 对象,可以做很多等值比较以及访问父子、兄弟节点等操作来保证我们可以精准回放一条 mutation 记录。
但是在 rrweb 中由于我们有序列化的过程,我们就需要更多精细的判断来应对各种场景。
### 新增节点
例如以下两种操作会生成相同的 DOM 结构,但是产生不同的 mutation 记录:
```
body
n1
n2
```
1. 创建节点 n1 并 append 在 body 中,再创建节点 n2 并 append 在 n1 中。
2. 创建节点 n1、n2将 n2 append 在 n1 中,再将 n1 append 在 body 中。
第 1 种情况将产生两条 mutation 记录,分别为增加节点 n1 和增加节点 n2第 2 种情况则只会产生一条 mutation 记录,即增加节点 n1。
**注意**,在第一种情况下虽然 n1 append 时还没有子节点,但是由于上述的批量异步回调机制,当我们处理 mutation 记录时获取到的 n1 是已经有子节点 n2 的状态。
受第二种情况的限制,我们在处理新增节点时必须遍历其所有子孙节点,才能保证所有新增节点都被记录,但是这一策略应用在第一种情况中就会导致 n2 被作为新增节点记录两次,回放时就会产生与原页面不一致的 DOM 结构。
因此,在处理一次回调中的多个 mutation 记录时我们需要“惰性”处理新增节点,即在遍历每条 mutation 记录遇到新增节点时先收集,再在全部 mutation 遍历完毕之后对收集的新增节点进行去重操作,保证不遗漏节点的同时每个节点只被记录一次。
在[序列化设计](./serialization.md)中已经介绍了我们需要维护一个 `id -> Node` 的映射,因此当出现新增节点时,我们需要将新节点序列化并加入映射中。但由于我们为了去重新增节点,选择在所有 mutation 记录遍历完毕之后才进行序列化,在以下示例中就会出现问题:
1. mutation 记录1新增节点 n1。我们暂不处理等待最终去重后序列化。
2. mutation 记录2n1 新增属性 a1。我们试图将它记录成一次增量快照但会发现无法从映射中找到 n1 对应的 id因为此时它还未被序列化。
由此可见,由于我们对新增节点进行了延迟序列化的处理,所有 mutation 记录也都需要先收集,再新增节点去重并序列化之后再做处理。
### 移除节点
在处理移除节点时,我们需要做以下处理:
1. 移除的节点还未被序列化,则说明是在本次 callback 中新增的节点,无需记录,并且从新增节点池中将其移除。
2. 上步中在一次 callback 中被新增又移除的节点我们将其称为 dropped node用于最终处理新增节点时判断节点的父节点是否已经 drop。
### 属性变化覆盖写
尽管 MutationObserver 是异步批量回调,但是我们仍然可以认为在一次回调中发生的 mutations 之间时间间隔极短,因此在记录 DOM 属性变化时我们可以通过覆盖写的方式优化增量快照的体积。
例如对一个 `<textarea>` 进行 resize 操作,会触发大量的 width 和 height 属性变化的 mutation 记录。虽然完整记录会让回放更加真实,但是也可能导致增量快照数量大大增加。进行取舍之后,我认为在同一次 mutation callback 中只记录同一个节点某一属性的最终值即可,也就是后续的 mutation 记录会覆盖写之前已有的 mutation 记录中的属性变化部分。
## 鼠标移动
通过记录鼠标移动位置,我们可以在回放时模拟鼠标移动轨迹。
尽量保证回放时鼠标移动流畅的同时也要尽量减少对应增量快照的数量,所以我们需要在监听 mousemove 的同时进行两层节流处理。第一层是每 20 ms 最多记录一次鼠标坐标,第二层是每 500 ms 最多发送一次鼠标坐标集合,第二层的主要目的是避免一次请求内容过多而做的分段。
### 时间逆推
我们在每个增量快照生成时会记录一个时间戳,用于在回放时按正确的时间差回放。但是由于节流处理的影响,鼠标移动对应增量快照的时间戳会比实际记录时间要更晚,因此我们需要记录一个用于校正的负时间差,在回放时将时间校准。
## 输入
我们需要观察 `<input>`, `<textarea>`, `<select>` 三种元素的输入,包含人为交互和程序设置两种途径的输入。
#### 人为交互
对于人为交互的操作我们主要靠监听 input 和 change 两个事件观察,需要注意的是对不同事件但值相同的情况进行去重。此外 `<input type="radio" />` 也是一类特殊的控件,如果多个 radio 元素的组件 name 属性相同,那么当一个被选择时其他都会被反选,但是不会触发任何事件,因此我们需要单独处理。
### 程序设置
通过代码直接设置这些元素的属性也不会触发事件,我们可以通过劫持对应属性的 setter 来达到监听的目的,示例代码如下:
```typescript
function hookSetter<T>(
target: T,
key: string | number | symbol,
d: PropertyDescriptor,
): hookResetter {
const original = Object.getOwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, {
set(value) {
// put hooked setter into event loop to avoid of set latency
setTimeout(() => {
d.set!.call(this, value);
}, 0);
if (original && original.set) {
original.set.call(this, value);
}
},
});
return () => hookSetter(target, key, original || {});
}
```
注意为了避免我们在 setter 中的逻辑阻塞被录制页面的正常交互,我们应该把逻辑放入 event loop 中异步执行。

48
docs/replay.md Normal file
View File

@@ -0,0 +1,48 @@
# 回放
rrweb 的设计原则是尽量少的在录制端进行处理,最大程度减少对被录制页面的影响,因此在回放端我们需要做一些特殊的处理。
## 高精度计时器
在回放时我们会一次性拿到完整的快照链,如果将所有快照依次同步执行我们可以直接获取被录制页面最后的状态,但是我们需要的是同步初始化第一个全量快照,再异步地按照正确的时间间隔依次重放每一个增量快照,这就需要一个高精度的计时器。
之所以强调**高精度**,是因为原生的 `setTimeout` 并不能保证在设置的延迟时间之后准确执行,例如主线程阻塞时就会被推迟。
对于我们的回放功能而言,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发生,因此我们通过 `requestAnimationFrame` 来实现一个不断校准的定时器,确保绝大部分情况下增量快照的重放延迟不超过一帧。
同时自定义的计时器也是我们实现“快进”功能的基础。
## 补全缺失节点
在[增量快照](../observer.md)中提到了 rrweb 使用 MutationObserver 时的延迟序列化策略,这一策略可能导致以下场景中我们不能记录完整的增量快照:
```
parent
child2
child1
```
1. parent 节点插入子节点 child1
2. parent 节点在 child1 之前插入子节点 child2
按照实际执行顺序 child1 会被 rrweb 先序列化,但是在序列化新增节点时我们除了记录父节点之外还需要记录相邻节点,从而保证回放时可以把新增节点放置在正确的位置。但是此时 child 1 相邻节点 child2 已经存在但是还未被序列化,我们会将其记录为 `id: -1`(不存在相邻节点时 id 为 null
重放时当我们处理到新增 child1 的增量快照时,我们可以通过其相邻节点 id 为 -1 这一特征知道帮助它定位的节点还未生成,然后将它临时放入”缺失节点池“中暂不插入 DOM 树中。
之后在处理到新增 child2 的增量快照时,我们正常处理并插入 child2完成重放之后检查 child2 的相邻节点 id 是否指向缺失节点池中的某个待添加节点,如果吻合则将其从池中取出插入对应位置。
## 模拟 Hover
在许多前端页面中都会存在 `:hover` 选择器对应的 CSS 样式,但是我们并不能通过 JavaScript 触发 hover 事件。因此回放时我们需要模拟 hover 事件让样式正确显示。
具体方式包括两部分:
1. 遍历 CSS 样式表,对于 `:hover` 选择器相关 CSS 规则增加一条完全一致的规则,但是选择器为一个特殊的 class例如 `.:hover`
2. 当回放 mouse up 鼠标交互事件时,为事件目标及其所有祖先节点都添加 `.:hover` 类名mouse down 时再对应移除。
## 从任意时间点开始播放
除了基础的回放功能之外,我们还希望 `rrweb-player` 这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条至任意时间点播放。
实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执行之前的快照链,再正常异步执行之后的快照链就可以做到从任意时间点开始播放的效果。

125
docs/serialization.md Normal file
View File

@@ -0,0 +1,125 @@
# 序列化
如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存。例如通过以下的代码实现(使用 jQuery 简化示例,仅保存 body 部分):
```javascript
// record
const snapshot = $('body').clone();
// replay
$('body').replaceWith(snapshot);
```
我们通过将 DOM 对象整体保存在内存中实现了快照。
但是这个对象本身并不是**可序列化**的,因此我们不能将其保存为特定的文本格式(例如 JSON进行传输也就无法做到远程录制所以我们首先需要实现将 DOM 及其视图状态序列化的方法。在这里我们不使用一些开源方案例如 [parse5](https://github.com/inikulin/parse5) 的原因包含两个方面:
1. 我们需要实现一个“非标准”的序列化方法,下文会详细展开。
2. 此部分代码需要运行在被录制的页面中,要尽可能的控制代码量,只保留必要功能。
## 序列化中的特殊处理
之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理:
1. 去脚本化。被录制页面中的所有 JavaScript 都不应该被执行,例如我们会在重建快照时将 `script` 标签改为 `noscript` 标签,此时 script 内部的内容就不再重要,录制时可以简单记录一个标记值而不需要将可能存在的大量脚本内容全部记录。
2. 记录没有反映在 HTML 中的视图状态。例如 `<input type="text" />` 输入后的值不会反映在其 HTML 中,而是通过 `value` 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成 `<input type="text" value="recordValue" />`
3. 相对路径转换为绝对路径。回放时我们会将被录制的页面放置在一个 `<iframe>` 中,此时的页面 URL为重放页面的地址如果被录制页面中有一些相对路径就会产生错误所以在录制时就要将相对路径进行转换同样的 CSS 样式表中的相对路径也需要转换。
4. 尽量记录 CSS 样式表的内容。如果被录制页面加载了一些同源的 样式表,我们则可以获取到解析好的 CSS rules录制时将能获取到的样式都 inline 化,这样可以让一些内网环境(如 localhost的录制也有比较好的效果。
## 唯一标识
同时,我们的序列化还应该包含全量和增量两种类型,全量序列化可以将一个 DOM 树转化为对应的树状数据结构。
例如以下的 DOM 树:
```html
<html>
<body>
<header>
</header>
</body>
</html>
```
会被序列化成类似这样的数据结构:
```json
{
"type": "Document",
"childNodes": [
{
"type": "Element",
"tagName": "html",
"attributes": {},
"childNodes": [
{
"type": "Element",
"tagName": "head",
"attributes": {},
"childNodes": [],
"id": 3
},
{
"type": "Element",
"tagName": "body",
"attributes": {},
"childNodes": [
{
"type": "Text",
"textContent": "\n ",
"id": 5
},
{
"type": "Element",
"tagName": "header",
"attributes": {},
"childNodes": [
{
"type": "Text",
"textContent": "\n ",
"id": 7
}
],
"id": 6
}
],
"id": 4
}
],
"id": 2
}
],
"id": 1
}
```
这个序列化的结果中有两点需要注意:
1. 我们遍历 DOM 树时是以 Node 为单位,因此除了场景的元素类型节点以为,还包括 Text Node、Comment Node 等所有 Node 的记录。
2. 我们给每一个 Node 都添加了唯一标识 `id`,这是为之后的增量快照做准备。
想象一下如果我们在同页面中记录一次点击按钮的操作并回放,我们可以用以下格式记录该操作(也就是我们所说的一次增量快照):
```javascript
type clickSnapshot = {
source: 'MouseInteraction';
type: 'Click';
node: HTMLButtonElement;
}
```
再通过 `snapshot.node.click()` 就能将操作再执行一次。
但是在实际场景中,虽然我们已经重建出了完整的 DOM但是却没有办法将增量快照中被交互的 DOM 节点和已存在的 DOM 关联在一起。
这就是唯一标识 `id` 的作用,我们在录制端和回放端维护随时间变化完全一致的 `id -> Node` 映射,并随着 DOM 节点的创建和销毁进行同样的更新,保证我们在增量快照中只需要记录 `id` 就可以在回放时找到对应的 DOM 节点。
上述示例中的数据结构相应的变为:
```typescript
type clickSnapshot = {
source: 'MouseInteraction';
type: 'Click';
id: Number;
}
```