Last active
June 5, 2018 00:19
-
-
Save akyoto/31e35486d358806207da62a3e01f70ea to your computer and use it in GitHub Desktop.
Diff and MutationQueue π°π₯
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 andDiff.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 inDiff.persistentClasses
orDiff.persistentAttributes
, respectively.MutationQueue
is used to delay and batch DOM mutations usingrequestAnimationFrame
to ensure smooth looking (60-144 FPS) transitions.