Skip to content

Instantly share code, notes, and snippets.

@akyoto
Last active June 5, 2018 00:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save akyoto/31e35486d358806207da62a3e01f70ea to your computer and use it in GitHub Desktop.
Save akyoto/31e35486d358806207da62a3e01f70ea to your computer and use it in GitHub Desktop.
Diff and MutationQueue 🍰πŸ₯ž
import MutationQueue from "./MutationQueue"
// Diff provides diffing utilities to morph existing DOM elements
// into the target HTML string.
//
// Example:
// Diff.innerHTML(body, "<div>This is my new content</div>")
//
// Whatever contents will be in the body, they will be re-used and morphed
// into the new DOM defined by a simple HTML string. This is useful for
// Single Page Applications that use server rendered content. The server
// responds with the pre-rendered HTML and we can simply morph our current
// contents into the next page.
export default class Diff {
static persistentClasses = new Set<string>()
static persistentAttributes = new Set<string>()
static mutations: MutationQueue = new MutationQueue()
// innerHTML will diff the element with the given HTML string and apply DOM mutations.
static innerHTML(aRoot: HTMLElement, html: string): Promise<void> {
let container = document.createElement("main")
container.innerHTML = html
return new Promise((resolve, reject) => {
Diff.childNodes(aRoot, container)
this.mutations.wait(resolve)
})
}
// root will diff the document root element with the given HTML string and apply DOM mutations.
static root(aRoot: HTMLElement, html: string) {
return new Promise((resolve, reject) => {
let rootContainer = document.createElement("html")
rootContainer.innerHTML = html.replace("<!DOCTYPE html>", "")
Diff.childNodes(aRoot.getElementsByTagName("body")[0], rootContainer.getElementsByTagName("body")[0])
this.mutations.wait(resolve)
})
}
// childNodes diffs the child nodes of 2 given elements and applies DOM mutations.
static childNodes(aRoot: Node, bRoot: Node) {
let aChild = [...aRoot.childNodes]
let bChild = [...bRoot.childNodes]
let numNodes = Math.max(aChild.length, bChild.length)
for(let i = 0; i < numNodes; i++) {
let a = aChild[i]
// Remove nodes at the end of a that do not exist in b
if(i >= bChild.length) {
this.mutations.queue(() => aRoot.removeChild(a))
continue
}
let b = bChild[i]
// If a doesn't have that many nodes, simply append at the end of a
if(i >= aChild.length) {
this.mutations.queue(() => aRoot.appendChild(b))
continue
}
// If it's a completely different HTML tag or node type, replace it
if(a.nodeName !== b.nodeName || a.nodeType !== b.nodeType) {
this.mutations.queue(() => aRoot.replaceChild(b, a))
continue
}
// Text node:
// We don't need to check for b to be a text node as well because
// we eliminated different node types in the previous condition.
if(a.nodeType === Node.TEXT_NODE) {
this.mutations.queue(() => a.textContent = b.textContent)
continue
}
// HTML element:
if(a.nodeType === Node.ELEMENT_NODE) {
let elemA = a as HTMLElement
let elemB = b as HTMLElement
let removeAttributes: Attr[] = []
for(let x = 0; x < elemA.attributes.length; x++) {
let attrib = elemA.attributes[x]
if(attrib.specified) {
if(!elemB.hasAttribute(attrib.name) && !Diff.persistentAttributes.has(attrib.name)) {
removeAttributes.push(attrib)
}
}
}
this.mutations.queue(() => {
for(let attr of removeAttributes) {
elemA.removeAttributeNode(attr)
}
})
for(let x = 0; x < elemB.attributes.length; x++) {
let attrib = elemB.attributes[x]
if(!attrib.specified) {
continue
}
// If the attribute value is exactly the same, skip this attribute.
if(elemA.getAttribute(attrib.name) === attrib.value) {
continue
}
if(attrib.name === "class") {
let classesA = elemA.classList
let classesB = elemB.classList
let removeClasses: string[] = []
for(let className of classesA) {
if(!classesB.contains(className) && !Diff.persistentClasses.has(className)) {
removeClasses.push(className)
}
}
this.mutations.queue(() => {
for(let className of removeClasses) {
classesA.remove(className)
}
for(let className of classesB) {
if(!classesA.contains(className)) {
classesA.add(className)
}
}
})
continue
}
this.mutations.queue(() => elemA.setAttribute(attrib.name, attrib.value))
}
// Special case: Apply state of input elements
if(elemA !== document.activeElement && elemA instanceof HTMLInputElement && elemB instanceof HTMLInputElement) {
this.mutations.queue(() => {
(elemA as HTMLInputElement).value = (elemB as HTMLInputElement).value
})
}
}
Diff.childNodes(a, b)
}
}
}
// Computation time allowed per frame, in milliseconds.
// On a 100 Hz monitor this would ideally be 10 ms.
// On a 200 Hz monitor it should be 5 ms.
// However, the renderer also needs a bit of time,
// so setting the value a little lower guarantees smooth transitions.
const timeCapacity = 6.5
// MutationQueue queues up DOM mutations to batch execute them before a frame is rendered.
// It checks the time used to process these mutations and if the time is over the
// defined time capacity, it will pause and continue the mutations in the next frame.
export default class MutationQueue {
mutations: Array<() => void>
onClearCallBacks: Array<() => void>
constructor() {
this.mutations = []
this.onClearCallBacks = []
}
queue(mutation: () => void) {
this.mutations.push(mutation)
if(this.mutations.length === 1) {
window.requestAnimationFrame(() => this.mutateAll())
}
}
mutateAll() {
let start = performance.now()
for(let i = 0; i < this.mutations.length; i++) {
if(performance.now() - start > timeCapacity) {
this.mutations = this.mutations.slice(i)
window.requestAnimationFrame(() => this.mutateAll())
return
}
try {
this.mutations[i]()
} catch(err) {
console.error(err)
}
}
this.clear()
}
clear() {
this.mutations.length = 0
if(this.onClearCallBacks.length > 0) {
for(let callback of this.onClearCallBacks) {
callback()
}
this.onClearCallBacks.length = 0
}
}
wait(callBack: () => void) {
if(this.mutations.length === 0) {
callBack()
return
}
this.onClearCallBacks.push(callBack)
}
}
@akyoto
Copy link
Author

akyoto commented Jun 4, 2018

This code is used on notify.moe/forum when switching between forum tabs, for example.
It reuses existing DOM elements so it works best if source and target document are somewhat similar.

The syntax is TypeScript which is just type-annotated JavaScript.

Diff.innerHTML is the main API function and Diff.childNodes is the core.
Diff.innerHTML is supposed to be nearly equivalent to a typical .innerHTML = "abc" call, except that it re-uses elements and preserves state.
Diff.innerHTML will never modify classes or attributes that have been previously registered in Diff.persistentClasses or Diff.persistentAttributes, respectively.

MutationQueue is used to delay and batch DOM mutations using requestAnimationFrame to ensure smooth looking (60-144 FPS) transitions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment