fast-forward implementation v1

related to #6

Since the currently 'play at any time offset' implementation is pretty simple,
there are many things we can do to optimize its performance.

In this patch, we do the following optimizations:
1. Ignore some of the events during fast forward.
   For example, when we are going to fast forward to 10 minutes later,
   we do not need to perform mouse movement events during this period.
2. Use a fragment element as the 'virtual parent node'.
   So newly added DOM nodes will be appended to this fragment node,
   and finally being appended into the document as a batch operation.
These changes reduce a lot of time which was spent on reflow/repaint previously.
I've seen a 10 times performance improvement within these approaches.

And there are still some things we can do better but not in this patch.
1. We can build a virtual DOM tree to store the mutations of DOM.
   This will minimize the number of DOM operations.
2. Another thing that may help UX is to make the fast forward process async and cancellable.
   This may make the drag and drop interactions in the player's UI looks smooth.
This commit is contained in:
Yanzhen Yu
2020-07-11 10:07:34 +08:00
committed by yz-yu
parent ef2c991012
commit 4bf533a675
4 changed files with 477 additions and 162 deletions

View File

@@ -4,6 +4,16 @@ import {
listenerHandler,
hookResetter,
blockClass,
eventWithTime,
EventType,
IncrementalSource,
addedNodeMutation,
removedNodeMutation,
textMutation,
attributeMutation,
mutationData,
scrollData,
inputData,
} from './types';
import { INode } from 'rrweb-snapshot';
@@ -172,3 +182,222 @@ export function polyfill() {
.forEach as unknown) as NodeList['forEach'];
}
}
export function needCastInSyncMode(event: eventWithTime): boolean {
switch (event.type) {
case EventType.DomContentLoaded:
case EventType.Load:
case EventType.Custom:
return false;
case EventType.FullSnapshot:
case EventType.Meta:
return true;
default:
break;
}
switch (event.data.source) {
case IncrementalSource.MouseMove:
case IncrementalSource.MouseInteraction:
case IncrementalSource.TouchMove:
case IncrementalSource.MediaInteraction:
return false;
case IncrementalSource.ViewportResize:
case IncrementalSource.StyleSheetRule:
case IncrementalSource.Scroll:
case IncrementalSource.Input:
return true;
default:
break;
}
return true;
}
export type TreeNode = {
id: number;
mutation: addedNodeMutation;
parent?: TreeNode;
children: Record<number, TreeNode>;
texts: textMutation[];
attributes: attributeMutation[];
};
export class TreeIndex {
public tree!: Record<number, TreeNode>;
private removeNodeMutations!: removedNodeMutation[];
private textMutations!: textMutation[];
private attributeMutations!: attributeMutation[];
private indexes!: Map<number, TreeNode>;
private removeIdSet!: Set<number>;
private scrollMap!: Map<number, scrollData>;
private inputMap!: Map<number, inputData>;
constructor() {
this.reset();
}
public add(mutation: addedNodeMutation) {
const parentTreeNode = this.indexes.get(mutation.parentId);
const treeNode: TreeNode = {
id: mutation.node.id,
mutation,
children: [],
texts: [],
attributes: [],
};
if (!parentTreeNode) {
this.tree[treeNode.id] = treeNode;
} else {
treeNode.parent = parentTreeNode;
parentTreeNode.children[treeNode.id] = treeNode;
}
this.indexes.set(treeNode.id, treeNode);
}
public remove(mutation: removedNodeMutation) {
const parentTreeNode = this.indexes.get(mutation.parentId);
const treeNode = this.indexes.get(mutation.id);
const deepRemoveFromMirror = (id: number) => {
this.removeIdSet.add(id);
const node = mirror.getNode(id);
node?.childNodes.forEach((childNode) =>
deepRemoveFromMirror(((childNode as unknown) as INode).__sn.id),
);
};
const deepRemoveFromTreeIndex = (node: TreeNode) => {
this.removeIdSet.add(node.id);
Object.values(node.children).forEach((n) => deepRemoveFromTreeIndex(n));
const _treeNode = this.indexes.get(node.id);
if (_treeNode) {
const _parentTreeNode = _treeNode.parent;
if (_parentTreeNode) {
delete _treeNode.parent;
delete _parentTreeNode.children[_treeNode.id];
this.indexes.delete(mutation.id);
}
}
};
if (!treeNode) {
this.removeNodeMutations.push(mutation);
deepRemoveFromMirror(mutation.id);
} else if (!parentTreeNode) {
delete this.tree[treeNode.id];
this.indexes.delete(treeNode.id);
deepRemoveFromTreeIndex(treeNode);
} else {
delete treeNode.parent;
delete parentTreeNode.children[treeNode.id];
this.indexes.delete(mutation.id);
deepRemoveFromTreeIndex(treeNode);
}
}
public text(mutation: textMutation) {
const treeNode = this.indexes.get(mutation.id);
if (treeNode) {
treeNode.texts.push(mutation);
} else {
this.textMutations.push(mutation);
}
}
public attribute(mutation: attributeMutation) {
const treeNode = this.indexes.get(mutation.id);
if (treeNode) {
treeNode.attributes.push(mutation);
} else {
this.attributeMutations.push(mutation);
}
}
public scroll(d: scrollData) {
this.scrollMap.set(d.id, d);
}
public input(d: inputData) {
this.inputMap.set(d.id, d);
}
public flush(): {
mutationData: mutationData;
scrollMap: TreeIndex['scrollMap'];
inputMap: TreeIndex['inputMap'];
} {
const {
tree,
removeNodeMutations,
textMutations,
attributeMutations,
} = this;
const batchMutationData: mutationData = {
source: IncrementalSource.Mutation,
removes: removeNodeMutations,
texts: textMutations,
attributes: attributeMutations,
adds: [],
};
const walk = (treeNode: TreeNode, removed: boolean) => {
if (removed) {
this.removeIdSet.add(treeNode.id);
}
batchMutationData.texts = batchMutationData.texts
.concat(removed ? [] : treeNode.texts)
.filter((m) => !this.removeIdSet.has(m.id));
batchMutationData.attributes = batchMutationData.attributes
.concat(removed ? [] : treeNode.attributes)
.filter((m) => !this.removeIdSet.has(m.id));
if (
!this.removeIdSet.has(treeNode.id) &&
!this.removeIdSet.has(treeNode.mutation.parentId) &&
!removed
) {
batchMutationData.adds.push(treeNode.mutation);
if (treeNode.children) {
Object.values(treeNode.children).forEach((n) => walk(n, false));
}
} else {
Object.values(treeNode.children).forEach((n) => walk(n, true));
}
};
Object.values(tree).forEach((n) => walk(n, false));
for (const id of this.scrollMap.keys()) {
if (this.removeIdSet.has(id)) {
this.scrollMap.delete(id);
}
}
for (const id of this.inputMap.keys()) {
if (this.removeIdSet.has(id)) {
this.inputMap.delete(id);
}
}
const scrollMap = new Map(this.scrollMap);
const inputMap = new Map(this.inputMap);
this.reset();
return {
mutationData: batchMutationData,
scrollMap,
inputMap,
};
}
private reset() {
this.tree = [];
this.indexes = new Map();
this.removeNodeMutations = [];
this.textMutations = [];
this.attributeMutations = [];
this.removeIdSet = new Set();
this.scrollMap = new Map();
this.inputMap = new Map();
}
}