Overview
Performance optimization is critical for editors handling large documents. Key strategies include incremental updates, batching, virtual DOM, and lazy rendering.
Incremental DOM Updates
Diff Algorithm
Only update the parts of the DOM that actually changed:
class IncrementalRenderer {
#nodeMap = new Map(); // nodeId -> DOM element
update(document, previousDocument) {
// Find differences
const diff = this.#diff(previousDocument, document);
// Apply only the changes
diff.forEach(change => {
switch (change.type) {
case 'insert':
this.#insertNode(change.node, change.path);
break;
case 'delete':
this.#deleteNode(change.path);
break;
case 'update':
this.#updateNode(change.path, change.attrs);
break;
case 'move':
this.#moveNode(change.path, change.newPath);
break;
}
});
}
#diff(oldDoc, newDoc) {
const changes = [];
// Compare node trees using longest common subsequence
const lcs = this.#longestCommonSubsequence(
oldDoc.children,
newDoc.children
);
let oldIndex = 0;
let newIndex = 0;
let lcsIndex = 0;
while (oldIndex < oldDoc.children.length || newIndex < newDoc.children.length) {
if (lcsIndex < lcs.length &&
oldDoc.children[oldIndex]?.id === lcs[lcsIndex].id) {
// Node unchanged, recurse into children
this.#compareNodes(
oldDoc.children[oldIndex],
newDoc.children[newIndex],
[oldIndex],
changes
);
oldIndex++;
newIndex++;
lcsIndex++;
} else if (oldIndex < oldDoc.children.length &&
(lcsIndex >= lcs.length ||
oldDoc.children[oldIndex].id !== lcs[lcsIndex].id)) {
// Node deleted
changes.push({
type: 'delete',
path: [oldIndex]
});
oldIndex++;
} else {
// Node inserted
changes.push({
type: 'insert',
node: newDoc.children[newIndex],
path: [newIndex]
});
newIndex++;
}
}
return changes;
}
}Patch Application
Apply patches efficiently:
class PatchApplier {
applyPatches(container, patches) {
// Sort patches by index (reverse order for deletions)
const sorted = patches.sort((a, b) => {
if (a.type === 'delete' && b.type === 'delete') {
return b.index - a.index; // Delete from end
}
return a.index - b.index;
});
sorted.forEach(patch => {
switch (patch.type) {
case 'insert':
const newNode = this.#renderNode(patch.node);
container.insertBefore(newNode, container.children[patch.index]);
break;
case 'delete':
container.removeChild(container.children[patch.index]);
break;
case 'update':
this.#updateNodeAttributes(
container.children[patch.index],
patch.attrs
);
break;
case 'move':
const node = container.children[patch.from];
container.insertBefore(node, container.children[patch.to]);
break;
}
});
}
}Batch Updates
Update Batching
Batch multiple operations to avoid unnecessary renders:
class Editor {
#pendingOperations = [];
#renderScheduled = false;
applyOperation(operation) {
// Add to pending operations
this.#pendingOperations.push(operation);
// Schedule render (debounced)
this.#scheduleRender();
}
#scheduleRender() {
if (this.#renderScheduled) return;
this.#renderScheduled = true;
// Use requestAnimationFrame for smooth updates
requestAnimationFrame(() => {
this.#flushOperations();
this.#renderScheduled = false;
});
}
#flushOperations() {
if (this.#pendingOperations.length === 0) return;
// Apply all pending operations
const operations = this.#pendingOperations;
this.#pendingOperations = [];
// Batch apply
this.#batchApply(operations);
// Single render for all operations
this.render();
}
// Manual batching API
batch(callback) {
const wasBatching = this.#isBatching;
this.#isBatching = true;
try {
callback();
} finally {
this.#isBatching = wasBatching;
if (!this.#isBatching) {
this.#flushOperations();
}
}
}
}
// Usage
editor.batch(() => {
editor.insertText('Hello');
editor.insertText(' ');
editor.insertText('World');
// Single render after batch completes
});Debouncing
Debounce frequent updates:
class DebouncedRenderer {
#pendingRender = null;
#delay = 16; // ~60fps
scheduleRender() {
if (this.#pendingRender) {
clearTimeout(this.#pendingRender);
}
this.#pendingRender = setTimeout(() => {
this.render();
this.#pendingRender = null;
}, this.#delay);
}
// Throttle for critical updates
throttleRender() {
if (this.#lastRender && Date.now() - this.#lastRender < this.#delay) {
return;
}
this.render();
this.#lastRender = Date.now();
}
}Virtual DOM Pattern
VDOM Implementation
Use a virtual representation to compute changes before touching the DOM:
class VirtualDOM {
#root = null;
render(document) {
return this.#renderNode(document);
}
#renderNode(node) {
return {
type: node.type,
props: node.attrs || {},
key: node.id,
children: node.children?.map(child => this.#renderNode(child)) || []
};
}
patch(oldTree, newTree) {
// Compare virtual trees
const patches = this.#diff(oldTree, newTree);
// Apply patches to actual DOM
this.#applyPatches(patches);
}
#diff(oldNode, newNode) {
const patches = [];
if (oldNode.type !== newNode.type) {
// Node type changed, replace
patches.push({
type: 'replace',
oldNode,
newNode
});
} else {
// Same type, check props and children
const propPatches = this.#diffProps(oldNode.props, newNode.props);
if (propPatches.length > 0) {
patches.push({
type: 'props',
node: oldNode,
patches: propPatches
});
}
// Diff children
const childPatches = this.#diffChildren(oldNode.children, newNode.children);
patches.push(...childPatches);
}
return patches;
}
}Reconciliation
Efficient reconciliation algorithm:
class Reconciler {
reconcile(oldChildren, newChildren) {
// Use keys for efficient matching
const oldKeyMap = new Map(
oldChildren.map((child, i) => [child.key || i, i])
);
const newKeyMap = new Map(
newChildren.map((child, i) => [child.key || i, i])
);
const patches = [];
// Find moved nodes
for (const [key, newIndex] of newKeyMap) {
const oldIndex = oldKeyMap.get(key);
if (oldIndex !== undefined && oldIndex !== newIndex) {
patches.push({
type: 'move',
key,
from: oldIndex,
to: newIndex
});
}
}
// Find inserted nodes
for (const [key, newIndex] of newKeyMap) {
if (!oldKeyMap.has(key)) {
patches.push({
type: 'insert',
node: newChildren[newIndex],
index: newIndex
});
}
}
// Find deleted nodes
for (const [key, oldIndex] of oldKeyMap) {
if (!newKeyMap.has(key)) {
patches.push({
type: 'delete',
index: oldIndex
});
}
}
return patches;
}
}Lazy Rendering
Render only visible content:
class LazyRenderer {
#viewport = { top: 0, bottom: window.innerHeight };
#renderedNodes = new Set();
render(document) {
// Only render nodes in viewport
const visibleNodes = this.#getVisibleNodes(document);
visibleNodes.forEach(node => {
if (!this.#renderedNodes.has(node.id)) {
this.#renderNode(node);
this.#renderedNodes.add(node.id);
}
});
// Remove nodes outside viewport
this.#renderedNodes.forEach(nodeId => {
if (!visibleNodes.find(n => n.id === nodeId)) {
this.#removeNode(nodeId);
this.#renderedNodes.delete(nodeId);
}
});
}
#getVisibleNodes(document) {
// Calculate which nodes are in viewport
const nodes = [];
this.#traverse(document, (node, rect) => {
if (this.#isInViewport(rect)) {
nodes.push(node);
}
});
return nodes;
}
#isInViewport(rect) {
return rect.top < this.#viewport.bottom &&
rect.bottom > this.#viewport.top;
}
}