本文作者:姜凌后台云音乐中有很多内容管理系统(ContentManagementSystem,CMS),用来支持对业务,运维同学在使用过程中遇到问题时,希望开发者及时反馈,解决问题;痛点是开发者没有问题站点,很难快速定位问题。通常的场景是:操作华生同学:“Sherlock,我在配置mlog标签的时候,会提示这个标签不存在,请帮我查一下,急。”开发同学Sherlock:“别慌,我去看看。”(打开测试环境的运行管理后台,运行了一会儿,一切都很正常。。。)开发同学Sherlock:“我这里正常,你办公室在哪,我去看看你的地方。”运营同学Watson:“我在北京……”开发同学Sherlock:“我在杭州……”为了了解运营同学在使用中遇到的问题及时反馈相关问题,定位并解决遇到的使用问题CMS用户尽快,设计实现一键式问题上报插件,用于还原问题现场,主要包括两部分:记录和展示:ThemisRecord插件:上报用户基本信息,用户权限,APIrequest&result,errorstack,screenrecordingandlisteningplatform承接展示:展示录屏播放,用户,request,errorstack信息上报流程问题一键上报插件设计的主要流程如图所示在录屏过程中,插件需要分别采集用户基本信息、API请求数据、错误堆栈信息和录屏信息,并上传到NOSc响亮的聆听平台。在整个汇报过程中,如何实现操作录屏和回放是一个难点。经过考察,我们发现rrweb这个开源库可以很好的满足我们的需求。rrweb库支持的场景包括录屏回放、自定义事件、控制台录放,其中录屏回放是最常用的场景。具体请参见场景示例。本文主要介绍rrweb库的屏幕录制和回放的实现原理。rrweb库rrweb主要由三个库组成:rrweb、rrweb-player和rrweb-snapshot:rrweb:提供了record和replay两种方法;record方法用于记录页面DOM的变化,replay方法支持根据时间戳恢复DOM的变化。rrweb-player:基于svelte模板实现,为rrweb提供播放GUI工具,支持暂停、倍速播放、拖拽时间线等功能。内部调用了rrweb提供的replay等方法。rrweb-snapshot:包括两个功能:快照和重建。snapshot用于将DOM序列化为增量快照,rebuilding负责将增量快照恢复为DOM。了解rrweb库的原理,可以从以下几个关键问题入手:如何实现事件监听如何序列化DOM如何实现自定义定时器如何实现事件监听基于rrweb实现录屏,通常有以下几种方法用于记录事件,通过emit回调方法可以获取DOM变化对应的所有事件。拿到事件后,可以根据业务需要进行处理。比如我们的一键举报插件会上传到云端,开发者可以在监听平台上从云端拉取数据回放。letevents=[];rrweb.record({//emit选项是必需的emit(event){//将事件推送到事件数组events.push(event);},});record方法里面会去监听初始化事件,比如DOM元素变化,鼠标移动,鼠标交互,滚动等等,都有自己专属的事件监听方法。本文主要关注DOM元素变化的监控和处理过程。要实现对DOM元素变化的监听,离不开浏览器提供的MutationObserverAPI。在DOM发生一系列变化后,API会批量异步触发回调,将DOM变化通过MutationRecord数组传递给回调方法。关于MutationObserver的详细介绍,可以去MDN查看。rrweb内部也是基于这个API来实现监控的。回调方法是MutationBuffer类提供的processMutations方法:constobserver=newMutationObserver(mutationBuffer.processMutations.bind(mutationBuffer),);mutationBuffer.processMutations方法会根据MutationRecord.type的值进行不同的处理:type==='attributes':表示DOM属性的变化,所有属性变化的节点都会记录在this.attributes数组中,结构为{node:Node,attributes:{}},attributestype==='characterData':表示characterData节点的变化,会记录在this.texts数组,结构为{node:Node,value:string},value为characterData节点的最新值;type==='childList':表示子节点树的childList的变化。与前两种相比,加工会更加复杂。childList的增量快照当childList发生变化时,如果每次都完整记录整个DOM树,那么数据会非常庞大??,显然不是一个可行的方案。所以rrweb采用了增量快照的处理方式。有三个keySet:addedSet、movedSet、droppedSet,分别对应三个节点操作:adding、moving、deleting,类似于React的diff机制。这里使用Set结构来实现DOM节点的去重处理。添加节点遍历MutationRecord.addedNodes节点,将未序列化的节点添加到addedSet中,如果该节点存在于删除集dropedSet中,则将其从dropedSet中移除。示例:创建节点n1和n2,将n2附加到n1,并将n1附加到body。bodyn1n2以上节点操作只会产生一个MutationRecord记录,即添加n1,而“n2appendton1”的过程不会产生MutationRecord记录,所以在遍历MutationRecord.addedNodes节点时,需要遍历它的子节点,否则将丢失n2节点。遍历完所有的MutationRecord记录数组后,会对addedSet中的节点进行统一序列化。每个节点序列化处理的结果为:exporttypeaddedNodeMutation={parentId:number;nextId:编号|无效的;node:serializedNodeWithId;}DOM通过parentId和nextId建立关联关系。如果DOM节点的父节点或下一个兄弟节点没有被序列化,则无法准确定位该节点,需要先存储后处理。rrweb使用一个双向链表addList来存储尚未添加父节点的节点。向addList插入节点时:如果DOM节点的previousSibling已经存在于链表中,则在node.previousSibling节点之后插入,如果DOM节点的nextSibling已经存在于链表中,则不会插入到node.nextSibling节点之前,那么它将被插入到链表的头部。通过这种方式添加,可以保证兄弟节点的顺序。DOM节点的nextSibling必须在这个节点的后面,而previousSibling必须在这个节点的前面;addedSet的序列化处理完成后,会逆序遍历addList链表,从而保证DOM节点的nextSibling一定先于DOM节点序列化,下次序列化DOM节点时,你可以得到nextId。节点移动遍历MutationRecord.addedNodes节点,如果记录的节点有__sn属性,则添加到movedSet。__sn属性表示一个已经序列化的DOM节点,也就是移动节点。在序列化movedSet中的节点之前,会判断其父节点是否已经被移除:如果父节点被移除,则不需要处理,跳过;如果未删除父节点,则序列化该节点。节点删除遍历MutationRecord.removedNodes节点:如果该节点是本次新加入的节点,则忽略该节点,将该节点从addedSet中移除,同时记录到dropedSet中,处理时需要用到新节点:虽然我们移除了这个节点,但是它的子节点可能仍然存在于addedSet中。在处理addedSet节点时,会判断其祖先节点是否被移除;this.removes中记录了要删除的节点,以及parentId和节点id。如何序列化DOMMutationBuffer实例会调用snapshot的serializeNodeWithId方法来序列化DOM节点。serializeNodeWithId内部调用serializeNode方法,根据nodeType序列化Document、Doctype、Element、Text、CDATASection、Comment等不同类型的节点。关键是Element的序列化:遍历元素的属性,调用transformAttribute方法将资源路径处理为绝对路径;for(const{name,value}ofArray.from((nasHTMLElement).attributes)){attributes[name]=transformAttribute(doc,tagName,name,value);}passed检查元素是否包含blockClass类名,或者是否匹配blockSelector选择器,判断元素是否需要隐藏;为了保证隐藏的元素不影响页面布局,会返回一个空的等宽等高的元素;constneedBlock=_isBlockedElement(n作为HTMLElement,blockClass,blockSelector,);区分外部样式文件和内联样式,序列化CSS样式,将css样式中引用的资源的相对路径转换为绝对路径;对于外链文件,通过CSSStyleSheet实例的cssRules读取所有的样式,拼接成一个字符串,放到_cssText属性中;if(tagName==='link'&&inlineStylesheet){//document.styleSheets获取所有外部链接样式conststylesheet=Array.from(doc.styleSheets).find((s)=>{returns.href===(n作为HTMLLinkElement).href;});//获取项目css文件对应的所有规则的字符串constcssText=getCssRulesString(stylesheetasCSSStyleSheet);如果(cssText){删除attributes.rel;删除属性.href;//将css文件中的资源路径转换为绝对路径attributes._cssText=absoluteToStylesheet(cssText,stylesheet!.href!,);}}调用maskInputValue方法对用户输入数据进行加密;将canvas转为base64图片并保存,记录媒体当前播放时间,元素滚动位置等;返回一个序列化的最终对象,serializedNode,包含之前处理过的属性。序列化的关键是每个节点都会有一个唯一的id,其中rootId代表它所属文档的id,这有助于我们在播放时识别根节点return{type:NodeType.Element,tagName,attributes,childNodes:[],isSVG,needBlock,rootId,};事件时间戳获取序列化DOM节点后,统一调用wrapEvent方法为事件添加时间戳,播放到达时需要用到。functionwrapEvent(e:event):eventWithTime{return{...e,timestamp:Date.now(),};}序列化idserializeNodeWithId方法会在序列化时从DOM节点的__sn.id属性中读取id,如果是不存在,调用genId生成新的id赋值给__sn.id属性。这个id用来唯一标识DOM节点,通过id建立id->DOM映射关系,帮助我们在播放的时候找到对应的DOM节点。functiongenId():number{return_id++;}constserializedNode=Object.assign(_serializedNode,{id});如果DOM节点有子节点,会递归调用serializeNodeWithId方法,最后返回一个类似下面的树状数据结构:,childNodes:[{//...}],isSVG,needBlock,rootId,}}],rootId,};如何实现在自定义定时器的播放过程中,为了支持进度条的随意拖动和播放速度的设置(如上图所示),自定义实现了高精度定时器Timer。关键属性和方法为:exportdeclareclassTimer{//初始播放位置,对应进度条拖动的任意时间点timeOffset:number;//播放速度speed:number;//回放动作队列私有动作;//添加播放动作队列addActions(actions:actionWithDelay[]):void;//开始播放start():void;//设置播放速度setSpeed(speed:number):void;}播放入口通过Replayer提供的播放方法,可以在iframe中播放上面记录的事件。constreplayer=newrrweb.Replayer(events);replayer.play();第一步,在初始化rrweb.Replayer实例的时候,会创建一个iframe作为事件播放的容器,然后调用createPlayerService创建两个service来处理事件播放的逻辑,createSpeedService用来控制播放速度。第二步会调用replayer.play()方法触发PLAY事件类型,开始事件播放的处理流程。//this.service是createPlayerService创建的播放控制服务实例//timeOffset值是鼠标拖动后的时间偏移this.service.send({type:'PLAY',payload:{timeOffset}});baselinetime支持随机拖放poke生成播放的关键是传入时间偏移timeOffset参数:播放总时长=events[n].timestamp-events[0].timestamp,n为总时长事件队列减一;时间轴总时长为播放的总时长,鼠标拖动起始位置对应的时间轴坐标为timeOffset;根据初始事件的时间戳和timeOffset计算拖动后的基线时间戳(baselineTime);然后从所有事件队列中根据事件的时间戳,截取基线时间戳(baselineTime)之后的事件队列,即需要回放的事件队列。PlaybackActionQueueConversion获取到事件队列后,需要遍历事件队列,根据事件类型转换成对应的播放Action,添加到自定义定时器Timer的Action队列中。actions.push({doAction:()=>{castFn();},delay:event.delay!,});doAction是播放时要调用的方法,会根据不同的EventType做播放处理,比如DOM元素的变化对应增量事件EventType.IncrementalSnapshot。如果是增量事件类型,回放Action会调用applyIncremental方法应用增量快照,根据序列化的节点数据构造实际的DOM节点,是之前序列化DOM的逆过程,添加到iframe容器。delay=event.timestamp-baselineTime,即当前事件的时间戳与基线时间戳的差值requestAnimationFrame定时播放Timer自定义定时器是一个高精度的定时器,主要是start方法使用了requestAnimationFrame异步处理队列的定时播放;与浏览器原生的setTimeout和setInterval相比,requestAnimationFrame不会被主线程任务阻塞,而setTimeout和setInterval的执行可能会阻塞。其次,performance.now()时间函数用于计算当前播放时间;performance.now()会返回一个由浮点数表示的时间戳,精度可达微秒,高于其他可用的时间函数,例如Date.now()只能返回毫秒级别。publicstart(){this.timeOffset=0;//performance.timing.navigationStart+performance.now()约等于Date.now()letlastTimestamp=performance.now();//动作队列const{actions}=this;常量自我=这个;函数检查(){consttime=performance.now();//self.timeOffset为当前播放时长:elapsedplaybackduration*playbackspeed(speed)accumulated//原因是累加,因为在播放过程中,速度可能变化多次self.timeOffset+=(time-lastTimestamp)*self。速度;lastTimestamp=时间;//遍历Action队列while(actions.length){constaction=actions[0];//差异是相对于`BaselineTimestamp`而言的,当前已经播放了{timeOffset}ms//所以所有“Difference<=currentplaybackduration”的动作都需要播放if(self.timeOffset>=action.delay){行动.转移();动作.doAction();}else{休息;}}if(actions.length>0||self.liveMode){self.raf=requestAnimationFrame(check);}}this.raf=requestAnimationFrame(检查);}完成播放动作队列转换完成后,会调用timer.start()方法,按照正确的时间间隔依次执行播放。在每个requestAnimationFrame回调中,都会依次遍历Action队列。如果当前Action与基线时间戳的差值小于当前播放时长,说明本次异步回调中需要触发Action,调用action.doAction方法实现本次增量快照的播放.回放的Action将从队列中删除,保证下次requestAnimationFrame回调不会重新执行。总结在了解了“如何实现事件监听”、“如何序列化DOM”、“如何实现自定义定时器”这几个重点问题后,我们就基本掌握了rrweb的工作流程。另外rrweb在回放中我也使用了iframe的沙盒模式来实现对一些JS行为的限制。有兴趣的同学可以详细了解一下。总之,基于rrweb,可以很方便的帮助我们实现屏幕录制和回放功能,比如目前CMS业务中使用的一键上报功能。通过结合API请求、错误堆栈信息和屏幕录制回放功能,帮助开发者定位问题并解决问题,让你也成为夏洛克。本文由网易云音乐前端团队发布。未经授权禁止任何形式的转载。我们常年招前端、iOS、Android。如果你准备换工作,又恰好喜欢云音乐,那就加入我们吧grp.music-fe(at)corp.netease.com!
