之前的文章有简单介绍rrweb
的底层设计,这篇文章开始会记录rrweb
的源码。rrweb
的源码由 3 个仓库组成:
rrweb-snapshot
: 包含snapshot
和rebuild
功能。snapshot
用于将DOM
及其状态转化为可序列化的数据结构;rebuild
则是将snapshot
记录的数据结构重建为对应的DOM
。rrweb
: 包含record
和replay
两个功能。record
用于记录DOM
中的所有变更(mutation
);replay
则是将记录的变更按照对应的时间一一重放。rrweb-player
:为rrweb
提供一套UI
控件,提供基于GUI
的暂停、快进、拖拽至任意时间点播放等功能。
本文是第一篇,记录学习第 2 个仓库rrweb
的笔记。
record
用于记录 DOM
中的所有变更(mutation
),包括初始时的一次全量DOM
序列化,以及后续的增量变更。它最核心的是record
函数,内部的主要代码如下:
1 |
|
record
函数内部,takeFullSnapshot
用于记录全量DOM
,而initObservers
则会监听页面各种事件来记录增量变更。函数最后返回另一个函数,用于停止记录页面变更。
takeFullSnapshot
内部利用rrweb-snapshot
来序列化DOM
:
1 | function takeFullSnapshot(isCheckout = false) { |
核心还是利用rrweb-snapshot
来做序列化的工作,这个库的源码在之后时间够的话再研究。
initObservers
设置各种事件监听,每种事件触发时都会对应一个增量记录。
1 | function initObservers(o: observerParam, hooks: hooksParam = {}): listenerHandler { |
initMutationObserver
监听各种DOM
变动,需要处理MutationObserver
的批量异步回调机制和增量变更之间的冲突。代码细节太多没看,详情参考官网文章。
除了MutationObserver
比较复杂,剩下几个监听代码都比较简单,这里稍微总结下:
initMoveObserver
:监听鼠标移动、移动端触摸屏移动。包含两层节流,第一层50ms
记录一次移动,第二层每500ms
固定记录一次并触发增量变更initMouseInteractionObserver
:监听鼠标交互,单击、双击等initScrollObserver
:监听滚动事件,节流100ms
initViewportResizeObserver
:监听window
的视口尺寸变化,节流200ms
initInputObserver
:监听input
元素的变动,涉及各种input type
的特殊处理- 不记录
password
输入框 - 如果设置了文本加密,则将所有输入文本替换为
*
- 如果选中的是
radio
框,将所有相同name
的其他radio
取消选中 - 监听
input
和change
事件 - 拦截
input
、select
、textArea
元素的setter
,以监听在js
代码里设置这些DOM
的值
- 不记录
其中拦截setter
的代码利用了Object.defineProperty
,可以单独把实现细节拿出来说一说。
1 | // 这些元素的特定属性可以在JavaScript代码中直接设置,而不会触发DOM事件 |
以上就是record
函数的核心逻辑了,稍微小结一下:分为全量DOM
序列化和增量变更记录两大部分;全量序列化利用的是rrweb-snapshot
库;增量变量是通过监听各种页面事件来做到的,监听的事件有:
DOM
变动- 节点创建、销毁
- 节点属性变化
- 文本变化
鼠标移动
- 鼠标交互
mouse up
、mouse down
click
、double click
、context menu
focus
、blur
touch start
、touch move
、touch end
- 页面或元素滚动
- 视窗大小改变
input
输入
给一个使用record
的简单例子:
1 | import * as rrweb from 'rrweb'; |
replay
回放核心逻辑,将记录的变更按照对应的时间一一重放。包含两个Class
:Replayer
实现回放控制、Timer
实现时间戳控制,保证在正确的时间点回放正确的变更。
Replayer
先看看它的构造函数核心逻辑:
1 | class Replayer { |
除了一些核心数据的初始化之外,就是setuoDom
方法了。
setupDom
用于构建回放页面的关键DOM
元素,最核心的是两个:iframe
沙盒、鼠标模拟元素。
1 | // 设置回放的核心DOM元素: warpper、鼠标模拟元素、iframe沙盒 |
此时回放页面的结构如下:
1 | root |
不难猜出mouse
应该是利用position: absoute
定位到 wrapper 内部,在回放时会动态设置它的top/right/bottom/left
属性模拟鼠标移动。在src/replay/styles/style.css
文件中可以找到它的样式:
1 | .replayer-mouse { |
最终模拟出来的样式是这样的:
在初始化Replayer
之后,需要由外部player-ui
手动调用play
、pause
、resume
来模拟回放,依次看看实现。
play
1 | /** |
注意这里是如何实现指定时间点播放的:
- 传入的
timeOffset
决定了baselineTime
,baselineTime
表示从record
阶段的哪个时间戳开始回放 isSync
为true
表示此event
位于起始播放时间戳之前,不会被放到timer
的actoins
数组中,会被直接执行掉。而Timer
是在每一帧异步取出一些action
执行。所以我们会看到baselineTime
之前的action
一闪而过,后续的action
会一帧一帧的“播放”。
传给timer
的每个action
都带有一个delay
属性,表示在播放到何时执行此action
,大体来说:
1 | delay = event.timestamp - this.baselineTime; |
即由绝对时间戳转换为相对时间。
由于在录制时生成的event
具有多种type
,不同type
表示不同动作,有的表示全量DOM
序列化,有的表示增量mutation
。所以在 getCastFn
通过闭包进行了一次包装,这样timer
就可以不管实现细节,直接无脑执行castFn
就行。
1 | private getCastFn(event: eventWithTime, isSync = false) { |
重建完整 DOM
到沙盒 iframe
1 | private rebuildFullSnapshot( |
最核心的rebuild
方法位于rrweb-snapshot
包,这个后续的文章会专门分析。将全量DOM
以及样式插入到iframe
后,后续就是挨个异步增量变更了。
异步执行增量变更
这块的代码细节很多,只会讲主要脉络,太细节的我也没怎么看 🤣
1 | this.applyIncremental(event, isSync); |
除了一堆跟播放器相关的细节外,剩下的就是applyIncremental
方法了,它用于应用一个增量变更到当前沙盒状态中。在录制过程中处理的种种细节在这里都会一一小心处理,所以这个函数内部会有一个很大的switch-case
:
1 | applyIncremental(e: incrementalSnapshotEvent & { timestamp: number },isSync: boolean) { |
阅读细节时可以参照这篇文章来看。
看完了play
方法后,pause
和resume
方法就很轻松了,尤其是resume
方法除了一些初始操作外剩下都一样,这里就不啰嗦了。
Timer
Replayer
会传递一些列带有时间戳的actions
,Timer
会将他们按时间排序,然后在每一帧刷新时取出符合条件的action
来执行。
1 | public start() { |
这里注意一下是如何实现倍数回放的:
1 | self.timeOffset += (time - lastTimestamp) * config.speed; // 计时器走过的时长,比如10s |
config.speed
就是配置的倍数,默认是1
。如果配置为2
,那么在原先相同时间内就会走过 2 倍的时长,即self.timeOffset
的大小是原先的2
倍。
1 | if (self.timeOffset >= action.delay) { |
在这里会判断每个action
是否小于self.timeOffset
,也就是说这个action
是不是当前进度条之前的action
了,通过这种简单巧妙的方法就实现了倍数播放。
最后对replay
做个小结就是:
- 使用
iframe
当沙盒,独立的div
元素模拟鼠标 - 使用
rrweb-snapshot
重建全量记录的 DOM,并放置到iframe
中 - 借助
Timer
实现异步播放逻辑,执行每个增量event
- 提供了核心
play
、pause
、resume
方法,提供给外部的player-ui
使用 - 支持指定时间点开始播放、倍数播放,播放器
ui
传入配置即可 - 继承了
emitter
,关键事件会通知上层