There is an open source RRWeb project on the web that is written in TypeScript (see the TypeScript Tutorial for those not familiar with the language) and has three main parts: Rrweb-snapshot, rrWeb, and rrWeb-Player collect user actions such as mouse movements, control interactions, and can be played back to the maximum extent possible (see demo). It looks like a video, but it is not.

I will implement a very simple recording and playback plugin (uploaded to GitHub) that will only monitor the property changes of the text box and encapsulate it in a plugin. The core ideas and principles refer to RRWeb and make appropriate adjustments. The following figure from rrWeb’s Principles article shows that a complete DOM snapshot is made only at the beginning of recording, and all operation data is recorded afterwards, which is called Oplog (Operations Log). This allows the action to be played back, and the changes it made to the view.


Element serialization

1) Serialization

The first step is to serialize all the elements in the page into a normal object, which calls the json.stringify () method to pass the relevant data to the backend server.

The serialization() method takes a recursive approach, parsing elements one by one while preserving their hierarchy.

/** * DOM serialization */ serialization(parent) {let element = this.parseElement(parent); if (parent.children.length == 0) { parent.textContent && (element.textContent = parent.textContent); return element; } Array.from(parent.children, child => { element.children.push(this.serialization(child)); }); return element; }, /** * parse elements into serializable objects */ parseElement(element, id) {let attributes = {}; for (const { name, value } of Array.from(element.attributes)) { attributes[name] = value; } if (! Id = this.getid (); this.idMap.set(element, id); / / element as the key, ID value} return {children: [], ID: the ID, tagName: element. The tagName. ToLowerCase (), the attributes: attributes}; } /** * getID() {return this.id++; }Copy the code

ParseElement () implements the logic of parsing. A common element becomes one that contains the ID, tagName, Attributes, and children attributes. In Serialization (), textContent attributes are added as appropriate.

The ID is a unique identifier used to associate elements and will be used later when performing playback and collection actions. This.idmap uses the Map data structure new in ES6, which can use objects as keys, which are used to record the mapping between ids and elements.

Note that rrWeb traverses Node nodes, whereas I simply traversed elements for convenience, doing so would ignore text nodes in the page, such as <div>, which contains both the <span> element and two plain text nodes.

<div class=" uI-mb30 "> <div class=" UI-mb30 "> <span class=" text-red1 "> </span> <span class=" text-red1 "> </span>Copy the code

When you restore the DOM structure with this plug-in, you only get <span> elements, which shows that traversing only elements is flawed.

<div class=" uI-mb30 "> <span class="color-red1">100</span> <span class="color-red1">Copy the code

2) deserialize

Since there is serialization, there is deserialization, that is, parsing the ordinary objects generated above into DOM elements. Deserialization () also uses recursive methods to restore the DOM structure. In createElement(), this.idmap uses the ID as the key instead of the element as the key.

/** * DOM deserialization */ deserialization(obj) {let element = this.createElement(obj); if (obj.children.length == 0) { return element; } obj.children.forEach(child => { element.appendChild(this.deserialization(child)); }); return element; }, /** * parse the object into an element */ createElement(obj) {let element = document.createElement(obj); if (obj.id) { this.idMap.set(obj.id, element); } for (const name in obj.attributes) {element.setAttribute(name, obj.attributes[name]); } obj.textContent && (element.textContent = obj.textContent); return element; }Copy the code

2. Monitor DOM changes

Once you’re ready to serialize the elements, the next step is to log the associated actions as the DOM changes. There are two things involved here: the first is the action logging, and the second is the element monitoring.

1) Action recording

SetAction () is a method that records all actions, while setAttributeAction() is abstracted to handle changes to element attributes for later extension, and the ACTION_TYPE_ATTRIBUTE constant represents the action to modify the attribute.

/** * setAttributeAction(element) {let attributes = {type: ACTION_TYPE_ATTRIBUTE}; element.value && (attributes.value = element.value); this.setAction(element, attributes); SetAction (element, otherParam = {}) {// Since element is an object, Const id = this.idmap.get (element); const id = this.idmap.get (element); const action = Object.assign( this.parseElement(element, id), { timestamp: Date.now() }, otherParam ); this.actions.push(action); }Copy the code

In setAction(), timestamp is a timestamp that records the time at which the action took place, and all actions are inserted into the this.Actions array when the action is played back.

2) Element monitoring

Element monitoring takes place in two ways. The first is the browser-provided MutationObserver interface, which monitors changes to the attributes, child elements, and data of the target element. Once the change is monitored, the setAttributeAction() method is called.

Mutations => {mutations. ForEach (mutation => {const { type, target, oldValue, attributeName } = mutation; switch (type) { case "attributes": const value = target.getAttribute(attributeName); this.setAttributeAction(target); }}); }); Ob. observe(document, {attributes: true, // Monitor attributeOldValue: true, // Record the value of the attributeOldValue before changing subtree: True // Changes to targets and their descendants are monitored}); //ob.disconnect(); }Copy the code

The second is to monitor events for elements. This plugin only monitors input events for text boxes. When binding input events through the addEventListener() method, capture is used instead of bubbling, so that the binding is unified across the document.

/ monitoring the change of the text box * * * * / function observerInput () {const what = Object. GetOwnPropertyDescriptor ( HTMLInputElement.prototype, "value" ), _this = this; / / monitor through code updates the value attribute Object. DefineProperty (HTMLInputElement prototype, "value", { set(value) { setTimeout(() => { _this.setAttributeAction(this); // call asynchronously to avoid blocking pages}, 0); original.set.call(this, value); }}); / / capture input event document. AddEventListener (" input ", the event = > {const {target} = event; let text = target.value; this.setAttributeAction(target); }, {capture: true // capture}); }Copy the code

Special treatment is made for the value attribute, which can be modified by code, so the set() method of the value attribute is intercepted with the defineProperty() method, and the original logic is retained in the original variable.

If original.set.call() is not executed, the value assigned to the element will not be displayed in the text box on the page.

Now that the recording logic is complete, here is the plug-in’s constructor, which initializes the relevant variables.

*/ function JSVideo() {this.id = 1; / / function JSVideo() {this.id = 1; this.idMap = new Map(); This.dom = this.serialization(document.documentElement); this.actions = []; // Action log this.observer(); this.observerInput(); }Copy the code

Third, the playback

1) the sandbox

Playback is divided into two steps. The first step is to create an IFrame container and restore the DOM structure in the container. According to RRWeb, iframe was chosen because it can be used as a sandbox to prevent form submission, pop-ups, and JavaScript execution.

After the iframe element is created, the sandbox, style, window, and height attributes are configured for it, and in the load event, the this.dom is deserialized and the default <head> and <body> elements are removed.

CreateIframe () {let iframe = document.createElement("iframe"); iframe.setAttribute("sandbox", "allow-same-origin"); iframe.setAttribute("scrolling", "no"); iframe.setAttribute("style", "pointer-events:none; border:0;" ); iframe.width = `${window.innerWidth}px`; iframe.height = `${document.documentElement.scrollHeight}px`; iframe.onload = () => { const doc = iframe.contentDocument, root = doc.documentElement, html = this.deserialization(this.dom); For (const {name, value} of array. from(HTML. Attributes)) {root.setAttribute(name, value); } root.removeChild(root.firstElementChild); // Remove head root.removechild (root.firstelementChild); // Remove body array.from (html.children).foreach (child => {root.appendChild(child); }); SetTimeout (() => {this.replay(); }, 5000); }; document.body.appendChild(iframe); }Copy the code

Rrweb also changes the relative address of an element to an absolute address, special handling of links and other additional operations.

2) animation

The second step is the animation, which is to restore the action at that time. Instead of using a timer to simulate the animation, the more precise requestAnimationFrame() function is used.

Note that the previous defineProperty interception is triggered when the value attribute of the element is restored, which can be avoided by splitting into two plug-ins.

/** */ function replay() {if (this.actions.length == 0) return; Const timeOffset = 16.7; // Let startTime = this.actions[0]. Timestamp; // Start timestamp const state = () => {const action = this.actions[0]; let element = this.idMap.get(action.id); if (! Element) {// Stop animating if the element is not available; } if (startTime >= action.timestamp) { this.actions.shift(); switch (action.type) { case ACTION_TYPE_ATTRIBUTE: For (const name in action.attributes) {// Update element.setAttribute(name, action.attributes[name]); } // Trigger defineProperty intercepting, splitting into two plugins will avoid the problem action.value && (element.value = action.value); break; } } startTime += timeOffset; RequestAnimationFrame () requestAnimationFrame(state) if (this.actions.length > 0); }; state(); }Copy the code

To simulate the time interval, you need to use the timestamp that was previously stored in each element object. The default start time is the first action, and each subsequent call to requestAnimationFrame() adds a timeOffset variable to the start time.

When startTime exceeds the timestamp of the action, the action is executed, otherwise no logic is executed and the requestAnimationFrame() function is called again.

Rrweb has a multiple playback, which is actually a larger interval, in the interval to perform several more actions, to simulate the effect of double speed.

3) Simple examples

Suppose you have a form on the page that contains two text boxes to enter your name and cell phone. The timer will be used below, and the values will be input after a delay of a few seconds, and the gaza box will be added at the bottom of the current page to directly view the replay, as shown in the picture below.

const video = new JSVideo(), input = document.querySelector("[name=name]"), mobile = document.querySelector("[name=mobile]"); SetTimeout (function() {input.setattribute ("placeholder", "name"); }, 1000); SetTimeout (function() {input.value = "Strick"; }, 3000); SetTimeout (function() {mobile. Value = "13800138000"; }, 4000); SetTimeout (function() {video.createIframe(); }, 5000);Copy the code

The GitHub address is as follows:

Github.com/pwstrick/js…

References:

Rrweb: Open the black box for Web page recording and playback

MutationObserver

MutationRecord

reworkcss/css

Rrweb based screen recording and replay pages

A brief summary of the rrWeb underlying design

Rrweb source code parsing 1

Learn about MutationObserver in HTML5