Translated design docs to English (#19)

* Renamed original documentation files

* Add translated English design docs
This commit is contained in:
Daniël van de Giessen
2018-12-31 10:02:19 +01:00
committed by yz-yu
parent 576980d7db
commit 0f227492dc
10 changed files with 464 additions and 145 deletions

View File

@@ -44,8 +44,6 @@ rrweb is mainly composed of 3 parts:
## Internal Design
*Since the design docs were originally written in Chinese, we do not have the English version yet, but it would be available as soon as possible.*
- [serialization](./docs/serialization.md)
- [incremental snapshot](./docs/observer.md)
- [replay](./docs/replay.md)

View File

@@ -45,10 +45,10 @@ rrweb 主要由 3 部分组成:
## Internal Design
- [序列化](./docs/serialization.md)
- [增量快照](./docs/observer.md)
- [回放](./docs/replay.md)
- [沙盒](./docs/sandbox.md)
- [序列化](./docs/serialization.zh_CN.md)
- [增量快照](./docs/observer.zh_CN.md)
- [回放](./docs/replay.zh_CN.md)
- [沙盒](./docs/sandbox.zh_CN.md)
## Contribute Guide

View File

@@ -1,42 +1,42 @@
# 增量快照
# Incremental snapshots
After completing a full snapshot, we need to record events that change the state.
在完成一次全量快照之后,我们就需要基于当前视图状态观察所有可能对视图造成改动的事件,在 rrweb 中我们已经观察了以下事件(将不断增加):
Right now, rrweb records the following events (we will expand upon this):
- DOM 变动
- 节点创建、销毁
- 节点属性变化
- 文本变化
- 鼠标移动
- 鼠标交互
- mouse upmouse down
- clickdouble clickcontext menu
- focusblur
- touch starttouch movetouch end
- 页面或元素滚动
- 视窗大小改变
- 输入
- DOM changes
- Node creation, deletion
- Node attribute changes
- Text changes
- Mouse movement
- Mouse interaction
- mouse up, mouse down
- click, double click, context menu
- focus, blur
- touch start, touch move, touch end
- Page or element scrolling
- Window size changes
- Input
## Mutation Observer
Since we don't execute any JavaScript during replay, we instead need to record all changes scripts make to the document.
由于我们在回放时不会执行所有的 JavaScript 脚本,所以例如这样的场景我们需要完整记录才能够实现回放:
Consider this example:
> User clicks a button. A dropdown menu appears. User selects the first item. The dropdown menu disappears.
> 点击 button出现 dropdown menu选择第一项dropdown menu 消失
During replay, the dropdown menu does not automatically appear after the "click button" is executed, because the original JavaScript is not part of the recording. Thus, we need to record the creation of the dropdown menu DOM nodes, the selection of the first item, and subsequent deletion of the dropdown menu DOM nodes. This is the most difficult part.
回放时,在“点击 button”执行之后 dropdown menu 不会自动出现,因为已经没有 JavaScript 脚本为我们执行这件事,所以我们需要记录 dropdown menu 相关的 DOM 节点的创建以及后续的销毁,这也是录制中的最大难点。
Fortunately, modern browsers have provided us with a very powerful API which can do exactly this: [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
好在现代浏览器已经给我们提供了非常强大的 API —— [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) 用来完成这一功能。
This documentation does not explain the basic usages of MutationObserver, but only focusses on aspects in particular relevant to rrweb.
此处我们不具体讲解 MutationObserver 的基本使用方式,只专注于在 rrweb 中我们需要做哪些特殊处理。
The first thing to understand is that MutationObserver uses a **Bulk Asynchronous** callback. Specifically, there will be a single callback after a series of DOM changes occur, and it is passed an array of multiple mutation records.
首先要了解 MutationObserver 的触发方式为**批量异步**回调,具体来说就是会在一系列 DOM 变化发生之后将这些变化一次性回调,传出的是一个 mutation 记录数组。
This mechanism is not problematic for normal use, because we do not only have the mutation record, but we can also directly access the DOM object of the mutated node as well as any parent, child and sibling nodes.
这一机制在常规使用时不会有问题,因为从 mutation 记录中我们可以获取到变更节点的 JS 对象,可以做很多等值比较以及访问父子、兄弟节点等操作来保证我们可以精准回放一条 mutation 记录。
However in rrweb, since we have a serialization process, we need more sophisticated soluation to be able to deal with various scenarios.
但是在 rrweb 中由于我们有序列化的过程,我们就需要更多精细的判断来应对各种场景。
### 新增节点
例如以下两种操作会生成相同的 DOM 结构,但是产生不同的 mutation 记录:
### Add node
For example, the following two operations generate the same DOM structure, but produce a different set of mutation records:
```
body
@@ -44,58 +44,52 @@ body
n2
```
1. 创建节点 n1 append 在 body 中,再创建节点 n2 append 在 n1 中。
2. 创建节点 n1、n2将 n2 append 在 n1 中,再将 n1 append 在 body 中。
1. Create node n1 and append it as child of body, then create node n2 and append it as child of n1.
2. Create nodes n1 and n2, then append n2 as child to of n1, then append n1 as child of body.
第 1 种情况将产生两条 mutation 记录,分别为增加节点 n1 和增加节点 n2第 2 种情况则只会产生一条 mutation 记录,即增加节点 n1。
In the first case, two mutation records will be generated, namely adding node n1 and adding node n2; in the second case, only one mutation record will be generated, that is, node n1 (including children) is added.
**注意**,在第一种情况下虽然 n1 append 时还没有子节点,但是由于上述的批量异步回调机制,当我们处理 mutation 记录时获取到的 n1 是已经有子节点 n2 的状态。
**Note**: In the first case, although n1 has no child node when it is added, due to the above-mentioned batch asynchronous callback mechanism, when we receive the mutation record and process the n1 node the it already has the child node n2 in the DOM.
受第二种情况的限制,我们在处理新增节点时必须遍历其所有子孙节点,才能保证所有新增节点都被记录,但是这一策略应用在第一种情况中就会导致 n2 被作为新增节点记录两次,回放时就会产生与原页面不一致的 DOM 结构。
Due to the second case, when processing new nodes we must traverse all its descendants to ensure that all new nodes are recorded, however this strategy will cause n2 to be (incorrectly) recorded during the first record. Then, when processing the second record, adding a the node for a second time will result in a DOM structure that is inconsistent with the original page during replay.
因此,在处理一次回调中的多个 mutation 记录时我们需要“惰性”处理新增节点,即在遍历每条 mutation 记录遇到新增节点时先收集,再在全部 mutation 遍历完毕之后对收集的新增节点进行去重操作,保证不遗漏节点的同时每个节点只被记录一次。
Therefore, when dealing with multiple mutation records in a callback, we need to "lazely" process the newly added nodes, that is, first collect all raw, unprocessed nodes when we go through each mutation record, and then after we've been through all the mutation records we determine the the order nodes were added to the DOM. When new these nodes are added we perform deduplication to ensure that each node is only recorded once and we check no nodes were missed.
在[序列化设计](./serialization.md)中已经介绍了我们需要维护一个 `id -> Node` 的映射,因此当出现新增节点时,我们需要将新节点序列化并加入映射中。但由于我们为了去重新增节点,选择在所有 mutation 记录遍历完毕之后才进行序列化,在以下示例中就会出现问题:
We already introduced in the [serialization design document](./serialization.md) that we need to maintain a mapping of `id -> Node`, so when new nodes appear, we need to serialize the new nodes and add them to the map. But since we want to perform deduplication, and thus only serialize after all the mutation records have been processed, some problems may arise, as demonstrated in the following example:
1. mutation 记录1新增节点 n1。我们暂不处理等待最终去重后序列化。
2. mutation 记录2n1 新增属性 a1。我们试图将它记录成一次增量快照但会发现无法从映射中找到 n1 对应的 id因为此时它还未被序列化。
1. mutation record 1, add node n1. We will not serialize it yet, since we are waiting for the final deduplication.
2. mutation record 2, n1 added attribute a1. We tried to record it as an incremental snapshot, but we found that we couldn't find the id for n1 from the map because it was not serialized yet.
由此可见,由于我们对新增节点进行了延迟序列化的处理,所有 mutation 记录也都需要先收集,再新增节点去重并序列化之后再做处理。
As you can see, since we have delayed serialization of the newly added nodes, all mutation records also need to be processed first, and only then the new nodes can be de-duplicated without causing trouble.
### 移除节点
### Remove node
When processing mutation records, we may encounter a removed node that has not yet been serialized. That indicates that it is a newly added node, and the "add node" mutation record is also somewhere in the mutation records we received. We label these nodes as "dropped nodes".
在处理移除节点时,我们需要做以下处理:
There are two cases we need to handle here:
1. Since the node was removed already, there is no need to replay it, and thus we remove it from the newly added node pool.
2. This also applies to descendants of the dropped node, thus when processing newly added nodes we need to check if it has a dropped node as an ancestor.
1. 移除的节点还未被序列化,则说明是在本次 callback 中新增的节点,无需记录,并且从新增节点池中将其移除。
2. 上步中在一次 callback 中被新增又移除的节点我们将其称为 dropped node用于最终处理新增节点时判断节点的父节点是否已经 drop。
### Attribute change
Although MutationObserver is an asynchronous batch callback, we can still assume that the time interval between mutations occurring in a callback is extremely short, so we can optimize the size of the incremental snapshot by overwriting some data when recording the DOM property changes.
### 属性变化覆盖写
For example, resizing a `<textarea>` will trigger a large number of mutation records with varying width and height properties. While a full record will make replay more realistic, it can also result in a large increase in the number of incremental snapshots. After making a trade-off, we think that only the final value of an attribute of the same node needs to be recorded in a single mutation callback, that is, each subsequent mutation record will overwrite the attribute change part of the mutation record that existing before the write.
尽管 MutationObserver 是异步批量回调,但是我们仍然可以认为在一次回调中发生的 mutations 之间时间间隔极短,因此在记录 DOM 属性变化时我们可以通过覆盖写的方式优化增量快照的体积。
## Mouse movement
By recording the mouse movement position, we can simulate the mouse movement trajectory during replay.
例如对一个 `<textarea>` 进行 resize 操作,会触发大量的 width 和 height 属性变化的 mutation 记录。虽然完整记录会让回放更加真实,但是也可能导致增量快照数量大大增加。进行取舍之后,我认为在同一次 mutation callback 中只记录同一个节点某一属性的最终值即可,也就是后续的 mutation 记录会覆盖写之前已有的 mutation 记录中的属性变化部分。
Try to ensure that the mouse moves smoothly during replay and also minimize the number of corresponding incremental snapshots, so we need to perform two layers of throttling while listening to mousemove. The first layer records the mouse coordinates at most once every 20 ms, the second layer transmits the mouse coordinate set at most once every 500 ms to ensure a single snapshot doesn't accumulate a lot of mouse position data and becomes too large.
## 鼠标移动
### Time reversal
We record a timestamp when each incremental snapshot is generated so that during replay it can be applied at the correct time. However, due to the effect of throttling, the timestamps of the mouse movement corresponding to the incremental snapshot will be later than the actual recording time, so we need to record a negative time difference for correction and time calibration during replay.
通过记录鼠标移动位置,我们可以在回放时模拟鼠标移动轨迹。
## Input
We need to observe the input of the three elements `<input>`, `<textarea>`, `<select>`, including human input and programmatic changes.
尽量保证回放时鼠标移动流畅的同时也要尽量减少对应增量快照的数量,所以我们需要在监听 mousemove 的同时进行两层节流处理。第一层是每 20 ms 最多记录一次鼠标坐标,第二层是每 500 ms 最多发送一次鼠标坐标集合,第二层的主要目的是避免一次请求内容过多而做的分段。
### Human input
For human input, we mainly rely on listening to the input and change events. It is necessary to deduplicate different events triggered for the same the human input action. In addition, `<input type="radio" />` is also a special kind of control. If the multiple radio elements have the same name attribute, then when one is selected, the others will be reversed, but no event will be triggered on those others, so this needs to be handled separately.
### 时间逆推
我们在每个增量快照生成时会记录一个时间戳,用于在回放时按正确的时间差回放。但是由于节流处理的影响,鼠标移动对应增量快照的时间戳会比实际记录时间要更晚,因此我们需要记录一个用于校正的负时间差,在回放时将时间校准。
## 输入
我们需要观察 `<input>`, `<textarea>`, `<select>` 三种元素的输入,包含人为交互和程序设置两种途径的输入。
#### 人为交互
对于人为交互的操作我们主要靠监听 input 和 change 两个事件观察,需要注意的是对不同事件但值相同的情况进行去重。此外 `<input type="radio" />` 也是一类特殊的控件,如果多个 radio 元素的组件 name 属性相同,那么当一个被选择时其他都会被反选,但是不会触发任何事件,因此我们需要单独处理。
### 程序设置
通过代码直接设置这些元素的属性也不会触发事件,我们可以通过劫持对应属性的 setter 来达到监听的目的,示例代码如下:
### Programmatic changes
Setting the properties of these elements directly through the code will not trigger the MutationObserver. We can still achieve monitoring by hijacking the setter of the corresponding property. The sample code is as follows:
```typescript
function hookSetter<T>(
@@ -119,4 +113,4 @@ function hookSetter<T>(
}
```
注意为了避免我们在 setter 中的逻辑阻塞被录制页面的正常交互,我们应该把逻辑放入 event loop 中异步执行。
Note that in order to prevent our logic in the setter from blocking the normal interaction of the recorded page, we should put the logic into the event loop and execute it asynchronously.

122
docs/observer.zh_CN.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.zh_CN.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 中异步执行。

View File

@@ -1,47 +1,42 @@
# 回放
# Replay
A design principle of rrweb is to process as little as possible on the recording side, minimizing the impact on the recorded page. This means we need to do some special processing on the replay side.
rrweb 的设计原则是尽量少的在录制端进行处理,最大程度减少对被录制页面的影响,因此在回放端我们需要做一些特殊的处理。
## High precision timer
During replay, we will get the complete snapshot chain at one time. If all the snapshots are executed in sequence, we can directly get the last state of the recorded page, but what we need is to synchronously initialize the first full snapshot, and then apply the remaining incremental snapshots asynchronously. Using a time interval we replay each incremental snapshot one after the other, which requires a high-precision timer.
## 高精度计时器
The reason why **high precision** is emphasized is because the native `setTimeout` does not guarantee accurate execution after the set delay time, for example, when the main thread is blocked.
在回放时我们会一次性拿到完整的快照链,如果将所有快照依次同步执行我们可以直接获取被录制页面最后的状态,但是我们需要的是同步初始化第一个全量快照,再异步地按照正确的时间间隔依次重放每一个增量快照,这就需要一个高精度的计时器。
For our replay function, this inprecise delay is unacceptable and can lead to various weird phenomena, so we implement a constantly calibrated timer with `requestAnimationFrame` to ensure that in most cases incremental snapshots have a replay delay of no more than one frame.
之所以强调**高精度**,是因为原生的 `setTimeout` 并不能保证在设置的延迟时间之后准确执行,例如主线程阻塞时就会被推迟。
At the same time, the custom timer is also the basis for our "fast forward" function.
对于我们的回放功能而言,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发生,因此我们通过 `requestAnimationFrame` 来实现一个不断校准的定时器,确保绝大部分情况下增量快照的重放延迟不超过一帧。
同时自定义的计时器也是我们实现“快进”功能的基础。
## 补全缺失节点
在[增量快照设计](./observer.md)中提到了 rrweb 使用 MutationObserver 时的延迟序列化策略,这一策略可能导致以下场景中我们不能记录完整的增量快照:
## Completing missing nodes
The delay serialization strategy when rrweb uses MutationObserver is mentioned in the [incremental snapshot design](./observer.md), which may result in the following scenarios where we cannot record a full incremental snapshot:
```
parent
child2
child1
node bar
node foo
```
1. parent 节点插入子节点 child1
2. parent 节点在 child1 之前插入子节点 child2
1. Node `foo` is added as a child of the parent
2. Node `bar` is added before existing child `foo`
按照实际执行顺序 child1 会被 rrweb 先序列化,但是在序列化新增节点时我们除了记录父节点之外还需要记录相邻节点,从而保证回放时可以把新增节点放置在正确的位置。但是此时 child 1 相邻节点 child2 已经存在但是还未被序列化,我们会将其记录为 `id: -1`(不存在相邻节点时 id 为 null
According to the actual execution order, `foo` will be serialized by rrweb first, but when serializing new nodes, we need to record adjacent nodes in addition to the parent node, to ensure that the newly added nodes can be placed in the correct position during replay. At this point `bar` already exists but has not been serialized, so we will record it as `id: -1` (or, if there are no neighbors `null` as the id to indicate it doesn't exist).
重放时当我们处理到新增 child1 的增量快照时,我们可以通过其相邻节点 id -1 这一特征知道帮助它定位的节点还未生成,然后将它临时放入”缺失节点池“中暂不插入 DOM 树中。
During replay, when we process the incremental snapshot of the new `foo`, we know that its neighbor hasn't been inserted yet because it has an id of -1, and then temporarily put it into the "missing node pool". It is not inserted into the DOM tree.
之后在处理到新增 child2 的增量快照时,我们正常处理并插入 child2完成重放之后检查 child2 的相邻节点 id 是否指向缺失节点池中的某个待添加节点,如果吻合则将其从池中取出插入对应位置。
After processing the incremental snapshot of the new n1, we normally process and insert `bar`. After the replay is completed, we check whether the neighbor node id of `foo` points to a node which is in the missing node pool. If it matches, then it will be removed from the pool and be inserted into the DOM tree.
## 模拟 Hover
## Simulation Hover
CSS styles for the `:hover` selector are present in many web pages, but we can't trigger the hover state via JavaScript. So when playing back we need to simulate the hover state to make the style display correctly.
在许多前端页面中都会存在 `:hover` 选择器对应的 CSS 样式,但是我们并不能通过 JavaScript 触发 hover 事件。因此回放时我们需要模拟 hover 事件让样式正确显示。
The specific method includes two parts:
具体方式包括两部分:
1. Traverse the CSS stylesheet, adding the CSS rules for the `:hover` selector just like in the original, but with an additional special selector class, such as `.:hover`.
2. When playing back the mouse up mouse interaction event, add the `.:hover` class name to the event target and all its ancestors, and remove it when the mouse moves away again.
1. 遍历 CSS 样式表,对于 `:hover` 选择器相关 CSS 规则增加一条完全一致的规则,但是选择器为一个特殊的 class例如 `.:hover`
2. 当回放 mouse up 鼠标交互事件时,为事件目标及其所有祖先节点都添加 `.:hover` 类名mouse down 时再对应移除。
## Play from any point in time
In addition to the basic replay features, we also want players like `rrweb-player` to provide similar functionality to video players, such as dragging and dropping to the progress bar to any point in time.
## 从任意时间点开始播放
除了基础的回放功能之外,我们还希望 `rrweb-player` 这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条至任意时间点播放。
实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执行之前的快照链,再正常异步执行之后的快照链就可以做到从任意时间点开始播放的效果。
In actual implementation, we pass a start time to the method. We can then divide the snapshot chain into two parts: The parts before and the part after the start time. Then, the snapshot chain before the start time is executed synchronously, and then the snapshot chain after the starting times uses the normal asynchronous execution. This way we can achieve starting replay from any point in time.

47
docs/replay.zh_CN.md Normal file
View File

@@ -0,0 +1,47 @@
# 回放
rrweb 的设计原则是尽量少的在录制端进行处理,最大程度减少对被录制页面的影响,因此在回放端我们需要做一些特殊的处理。
## 高精度计时器
在回放时我们会一次性拿到完整的快照链,如果将所有快照依次同步执行我们可以直接获取被录制页面最后的状态,但是我们需要的是同步初始化第一个全量快照,再异步地按照正确的时间间隔依次重放每一个增量快照,这就需要一个高精度的计时器。
之所以强调**高精度**,是因为原生的 `setTimeout` 并不能保证在设置的延迟时间之后准确执行,例如主线程阻塞时就会被推迟。
对于我们的回放功能而言,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发生,因此我们通过 `requestAnimationFrame` 来实现一个不断校准的定时器,确保绝大部分情况下增量快照的重放延迟不超过一帧。
同时自定义的计时器也是我们实现“快进”功能的基础。
## 补全缺失节点
在[增量快照设计](./observer.zh_CN.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` 这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条至任意时间点播放。
实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执行之前的快照链,再正常异步执行之后的快照链就可以做到从任意时间点开始播放的效果。

View File

@@ -1,32 +1,27 @@
# 沙盒
# Sandbox
在[序列化设计](./serialization.md)中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript在重建快照的过程中我们将所有 `script` 标签改写为 `noscript` 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 `script` 标签中的,例如 HTML 中的 inline script、表单提交等。
In the [serialization design](./serialization.md) we mentioned the "de-scripting" process, that is, we will not execute any JavaScript in the recorded page during replay, but instead reproduce its effects the snapshots. The `script` tag is rewritten as a `noscript` tag to solve some of the problems. However, there are still some scripted behaviors that are not included in the `script` tag, such as inline scripts in HTML, form submissions, and so on.
脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。
There are many kinds of scripting behaviors. A filtering approach to getting rid of these scripts will never be a complete solution, and once a script slips through and is executed, it may cause irreversible unintended consequences. So we use the iframe sandbox feature provided by HTML for browser-level restrictions.
## iframe sandbox
We reconstruct the recorded DOM in an `iframe` element when we rebuild the snapshot. By setting its `sandbox` attribute, we can disable the following behavior:
我们在重建快照时将被录制的 DOM 重建在一个 `iframe` 元素中,通过设置它的 `sandbox` 属性,我们可以禁止以下行为:
- Form submission
- pop-up window such as `window.open`
- JS script (including inline event handlers and `javascript:` URLs)
- 表单提交
- `window.open` 等弹出窗
- JS 脚本(包含 inline event handler 和 `<URL>`
This is in line with our expectations, especially when dealing with JS scripts is safer and more reliable than implementing this security ourselves.
这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。
## Avoid link jumps
When you click the a element link, the default event is to jump to the URL corresponding to its href attribute. During replay, we will ensure visually correct replay by rebuilding the page DOM after the jump, and the original jump should be prohibited.
## 避免链接跳转
Usually we will capture all an elements click events through the event handler proxy and disable the default event via `event.preventDefault()`. But when we put the replay page in the sandbox, all the event handlers will not be executed, and we will not be able to implement the event delegation.
当点击 a 元素链接时默认事件为跳转至它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页面 DOM 的方式保证视觉上的正确重放,而原本的跳转则应该被禁止执行。
When replaying interactive events, note that replaying the JS `click` event is not nessecary because click events do not have any impact when JS is disabled. However, in order to optimize the replay effect, we can add special animation effects to visualize elements being clicked with the mouse, to clearly show the viewer that a click has occurred.
通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 `event.preventDefault()` 禁用默认事件。但当我们将回放页面放在沙盒内时,所有的 event handler 都将不被执行,我们也就无法实现事件代理。
重新查看我们回放交互事件增量快照的实现,我们会发现其实 `click` 事件是可以不被重放的。因为在禁用 JS 的情况下点击行为并不会产生视觉上的影响,也无需被感知。
不过为了优化回放的效果,我们可以在点击时给模拟的鼠标元素添加特殊的动画效果,用来提示观看者此处发生了一次点击。
## iframe 样式设置
由于我们将 DOM 重建在 iframe 中,所以我们无法通过父页面的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执行的情况下 `noscript` 标签会被显示,而我们希望将其隐藏,就需要动态的向 iframe 中添加样式,示例代码如下:
## iframe style settings
Since we're rebuilding the DOM in an iframe, we can't affect the elements in the iframe through the CSS stylesheet of the parent page. But if JS scripts are not allowed to execute, the `noscript` tag will be displayed, and we want to hide it. So we need to dynamically add styles to the iframe. The sample code is as follows:
```typescript
const injectStyleRules: string[] = [
@@ -42,4 +37,4 @@ for (let idx = 0; idx < injectStyleRules.length; idx++) {
}
```
需要注意的是这个插入的 style 元素在被录制页面中并不存在,所以我们不能将其序列化,否则 `id -> Node` 的映射将出现错误。
Note that this inserted style element does not exist in the original recorded page, so we can't serialize it, otherwise the `id -> Node` mapping will be wrong.

45
docs/sandbox.zh_CN.md Normal file
View File

@@ -0,0 +1,45 @@
# 沙盒
在[序列化设计](./serialization.zh_CN.md)中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript在重建快照的过程中我们将所有 `script` 标签改写为 `noscript` 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 `script` 标签中的,例如 HTML 中的 inline script、表单提交等。
脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。
## iframe sandbox
我们在重建快照时将被录制的 DOM 重建在一个 `iframe` 元素中,通过设置它的 `sandbox` 属性,我们可以禁止以下行为:
- 表单提交
- `window.open` 等弹出窗
- JS 脚本(包含 inline event handler 和 `<URL>`
这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。
## 避免链接跳转
当点击 a 元素链接时默认事件为跳转至它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页面 DOM 的方式保证视觉上的正确重放,而原本的跳转则应该被禁止执行。
通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 `event.preventDefault()` 禁用默认事件。但当我们将回放页面放在沙盒内时,所有的 event handler 都将不被执行,我们也就无法实现事件代理。
重新查看我们回放交互事件增量快照的实现,我们会发现其实 `click` 事件是可以不被重放的。因为在禁用 JS 的情况下点击行为并不会产生视觉上的影响,也无需被感知。
不过为了优化回放的效果,我们可以在点击时给模拟的鼠标元素添加特殊的动画效果,用来提示观看者此处发生了一次点击。
## iframe 样式设置
由于我们将 DOM 重建在 iframe 中,所以我们无法通过父页面的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执行的情况下 `noscript` 标签会被显示,而我们希望将其隐藏,就需要动态的向 iframe 中添加样式,示例代码如下:
```typescript
const injectStyleRules: string[] = [
'iframe { background: #f1f3f5 }',
'noscript { display: none !important; }',
];
const styleEl = document.createElement('style');
const { documentElement, head } = this.iframe.contentDocument!;
documentElement!.insertBefore(styleEl, head);
for (let idx = 0; idx < injectStyleRules.length; idx++) {
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
}
```
需要注意的是这个插入的 style 元素在被录制页面中并不存在,所以我们不能将其序列化,否则 `id -> Node` 的映射将出现错误。

View File

@@ -1,6 +1,5 @@
# 序列化
如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存。例如通过以下的代码实现(使用 jQuery 简化示例,仅保存 body 部分):
# Serialization
If you only need to record and replay changes within the browser locally, then we can simply save the current view by deep copying the DOM object. For example, the following code implementation (simplified example with jQuery, saves only the body part):
```javascript
// record
@@ -9,27 +8,27 @@ const snapshot = $('body').clone();
$('body').replaceWith(snapshot);
```
我们通过将 DOM 对象整体保存在内存中实现了快照。
We now implemented a snapshot by saving the whole DOM object in memory.
但是这个对象本身并不是**可序列化**的,因此我们不能将其保存为特定的文本格式(例如 JSON进行传输也就无法做到远程录制所以我们首先需要实现将 DOM 及其视图状态序列化的方法。在这里我们不使用一些开源方案例如 [parse5](https://github.com/inikulin/parse5) 的原因包含两个方面:
But the object itself is not **serializable**, meaning we can't save it to a specific text format (such as JSON) for transmission. We need that to do remote recording, and thus we need to implement a method for serializing the DOM data.
1. 我们需要实现一个“非标准”的序列化方法,下文会详细展开。
2. 此部分代码需要运行在被录制的页面中,要尽可能的控制代码量,只保留必要功能。
We do not use an existing open source solutions such as [parse5](https://github.com/inikulin/parse5) for two reasons:
## 序列化中的特殊处理
1. We need to implement a "non-standard" serialization method, which will be discussed in detail below.
2. This part of the code needs to run on the recorded page, and we want to control the amount of code as much as possible, only retaining the necessary functions.
之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理:
## Special handling in serialization
The reason why our serialization method is non-standard is because we still need to do the following parts:
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的录制也有比较好的效果。
1. Output needs to be descriptive. All JavaScript in the original recorded page should not be executed on replay. In rrweb we do this by replacing `script` tags with placeholder `noscript` tags in snapshots. The content inside the script is no longer important. We instead record any changes to the DOM that scripts cause, and we do not need to fully record large amounts of script content that may be present on the original web page.
2. Recording view state that is not reflected in the HTML. For example, the value of `<input type="text" />` will not be reflected in its HTML, but will be recorded by the `value` attribute. We need to read the value and store it as a property when serializing. So it will look like `<input type="text" value="recordValue" />`.
3. Relative paths are converted to absolute paths. During replay, we will place the recorded page in an `<iframe>`. The page URL at this time is the address of the replay page. If there are some relative paths in the recorded page, an error will occur when the user tries to open them, so when recording we need to convert relative paths. Relative paths in the CSS style sheet also need to be converted.
4. We want to record the contents of the CSS style sheet. If the recorded page links to external style sheets, we can get its parsed CSS rules from the browser, generate an inline style sheet containing all these rules. This way stylesheets that are not always accessible (for example, because they are located on an intranet or localhost) are included in the recording and can be replayed correctly.
## 唯一标识
## Uniquely identifies
At the same time, our serialization should also include both full and incremental types. Full serialization can transform a DOM tree into a corresponding tree data structure.
同时,我们的序列化还应该包含全量和增量两种类型,全量序列化可以将一个 DOM 树转化为对应的树状数据结构。
例如以下的 DOM 树:
For example, the following DOM tree:
```html
<html>
@@ -40,7 +39,7 @@ $('body').replaceWith(snapshot);
</html>
```
会被序列化成类似这样的数据结构:
Will be serialized into a data structure like this:
```json
{
@@ -92,12 +91,12 @@ $('body').replaceWith(snapshot);
}
```
这个序列化的结果中有两点需要注意:
There are two things to note in this serialization result:
1. 我们遍历 DOM 树时是以 Node 为单位,因此除了场景的元素类型节点以为,还包括 Text Node、Comment Node 等所有 Node 的记录。
2. 我们给每一个 Node 都添加了唯一标识 `id`,这是为之后的增量快照做准备。
1. When we traverse the DOM tree, we use Node as the unit. Therefore, in addition to the "element type" nodes in the DOM, we also include records of all other types of Nodes such as Text Node and Comment Node.
2. We add a unique identifier `id` to each Node, which is used for subsequent incremental snapshots.
想象一下如果我们在同页面中记录一次点击按钮的操作并回放,我们可以用以下格式记录该操作(也就是我们所说的一次增量快照):
Imagine if we recorded the click of a button on the same page and played it back, we can record the operation in the following format (that is what we call an incremental snapshot):
```javascript
type clickSnapshot = {
@@ -107,13 +106,13 @@ type clickSnapshot = {
}
```
再通过 `snapshot.node.click()` 就能将操作再执行一次。
The operation can be executed again by `snapshot.node.click()`.
但是在实际场景中,虽然我们已经重建出了完整的 DOM但是却没有办法将增量快照中被交互的 DOM 节点和已存在的 DOM 关联在一起。
However, in the actual scenario, although we have reconstructed the complete DOM, there is no way to associate the interacting DOM nodes in the incremental snapshot with the existing DOM.
这就是唯一标识 `id` 的作用,我们在录制端和回放端维护随时间变化完全一致的 `id -> Node` 映射,并随着 DOM 节点的创建和销毁进行同样的更新,保证我们在增量快照中只需要记录 `id` 就可以在回放时找到对应的 DOM 节点。
This is the reason for the identifier `id`. We maintain the `id -> Node` mapping that is exactly the same over time on both the recording and replay sides, and they both are updated when DOM nodes are created and destroyed, ensuring that we use unique increasing numbers in the snapshots, and only the `id` needs to be recorded to find the corresponding DOM node during replay.
上述示例中的数据结构相应的变为:
The data structure in the above example becomes correspondingly:
```typescript
type clickSnapshot = {
@@ -122,4 +121,3 @@ type clickSnapshot = {
id: Number;
}
```

125
docs/serialization.zh_CN.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;
}
```