avoid exponential DOM diffing

This commit is contained in:
Araq 2019-08-15 17:15:07 +02:00 • committed by Andreas Rumpf
commit e422087217
2 changed files with 124 additions and 109 deletions

View file

@ -13,11 +13,11 @@ proc kout*[T](x: T) {.importc: "console.log", varargs, deprecated.}
type
PatchKind = enum
pkReplace, pkRemove, pkAppend, pkInsertBefore, pkDetach
pkReplace, pkRemove, pkAppend, pkInsertBefore, pkDetach, pkSame
Patch = object
k: PatchKind
parent, current: Node
n: VNode
newNode, oldNode: VNode
PatchV = object
parent, newChild: VNode
pos: int
@ -117,7 +117,7 @@ proc wrapEvent(d: Node; n: VNode; k: EventKind;
# --------------------- DOM diff -----------------------------------------
template detach(n: VNode) =
addPatch(kxi, pkDetach, nil, nil, n)
addPatch(kxi, pkDetach, nil, nil, nil, n)
template attach(n: VNode) =
n.dom = result
@ -134,7 +134,9 @@ proc getVNodeById*(id: cstring; kxi: KaraxInstance = kxi): VNode =
if kxi.byId.contains(id):
result = kxi.byId[id]
proc vnodeToDom*(n: VNode; kxi: KaraxInstance = nil): Node =
proc toDom*(n: VNode; useAttachedNode: bool; kxi: KaraxInstance = nil): Node =
if useAttachedNode:
if n.dom != nil: return n.dom
if n.kind == VNodeKind.text:
result = document.createTextNode(n.text)
attach n
@ -145,7 +147,7 @@ proc vnodeToDom*(n: VNode; kxi: KaraxInstance = nil): Node =
return result
elif n.kind == VNodeKind.vthunk:
let x = callThunk(vcomponents[n.text], n)
result = vnodeToDom(x, kxi)
result = toDom(x, useAttachedNode, kxi)
#n.key = result.key
attach n
return result
@ -163,14 +165,14 @@ proc vnodeToDom*(n: VNode; kxi: KaraxInstance = nil): Node =
x.expanded = x.renderImpl(x)
# x.updatedImpl(x, nil)
assert x.expanded != nil
result = vnodeToDom(x.expanded, kxi)
result = toDom(x.expanded, useAttachedNode, kxi)
attach n
return result
else:
result = document.createElement(toTag[n.kind])
attach n
for k in n:
appendChild(result, vnodeToDom(k, kxi))
appendChild(result, toDom(k, useAttachedNode, kxi))
# text is mapped to 'value':
if n.text != nil:
result.value = n.text
@ -231,7 +233,7 @@ when defined(profileKarax):
for i in low(DifferEnum)..high(DifferEnum):
echo i, " value: ", a[i]
proc eq(a, b: VNode): EqResult =
proc eq(a, b: VNode; recursive: bool): EqResult =
if a.kind != b.kind:
when defined(profileKarax): inc reasons[deKind]
return different
@ -250,7 +252,7 @@ proc eq(a, b: VNode): EqResult =
if a.text != b.text: return different
if a.len != b.len: return different
for i in 0..<a.len:
if eq(a[i], b[i]) == different: return different
if eq(a[i], b[i], recursive) == different: return different
elif a.kind == VNodeKind.dthunk:
return identical
elif a.kind == VNodeKind.verbatim:
@ -274,6 +276,14 @@ proc eq(a, b: VNode): EqResult =
if a.class != b.class or not eq(a.style, b.style) or not sameAttrs(a, b):
when defined(profileKarax): inc reasons[deSimilar]
return similar
if recursive:
if a.len != b.len:
return different
for i in 0..<a.len:
if eq(a[i], b[i], true) != identical:
return different
# Do not test event listeners here!
return result
@ -298,12 +308,13 @@ proc updateAttributes(newNode, oldNode: VNode) =
proc mergeEvents(newNode, oldNode: VNode; kxi: KaraxInstance) =
let d = oldNode.dom
for i in 0..<oldNode.events.len:
let k = oldNode.events[i][0]
let name = case k
of EventKind.onkeyuplater, EventKind.onkeyupenter: cstring"keyup"
else: toEventName[k]
d.removeEventListener(name, oldNode.events[i][2])
if d != nil:
for i in 0..<oldNode.events.len:
let k = oldNode.events[i][0]
let name = case k
of EventKind.onkeyuplater, EventKind.onkeyupenter: cstring"keyup"
else: toEventName[k]
d.removeEventListener(name, oldNode.events[i][2])
shallowCopy(oldNode.events, newNode.events)
applyEvents(oldNode)
@ -321,16 +332,18 @@ when false:
printV(n[i], depth & " ")
proc addPatch(kxi: KaraxInstance; ka: PatchKind; parenta, currenta: Node;
na: VNode) =
na, oldNode: VNode) =
let L = kxi.patchLen
if L >= kxi.patches.len:
# allocate more space:
kxi.patches.add(Patch(k: ka, parent: parenta, current: currenta, n: na))
kxi.patches.add(Patch(k: ka, parent: parenta, current: currenta,
newNode: na, oldNode: oldNode))
else:
kxi.patches[L].k = ka
kxi.patches[L].parent = parenta
kxi.patches[L].current = currenta
kxi.patches[L].n = na
kxi.patches[L].newNode = na
kxi.patches[L].oldNode = oldNode
inc kxi.patchLen
proc addPatchV(kxi: KaraxInstance; parent: VNode; pos: int; newChild: VNode) =
@ -344,26 +357,35 @@ proc addPatchV(kxi: KaraxInstance; parent: VNode; pos: int; newChild: VNode) =
kxi.patchesV[L].pos = pos
inc kxi.patchLenV
proc moveDom(dest, src: VNode) =
dest.dom = src.dom
src.dom = nil
assert dest.len == src.len
for i in 0..<dest.len:
moveDom(dest[i], src[i])
proc applyPatch(kxi: KaraxInstance) =
for i in 0..<kxi.patchLen:
let p = kxi.patches[i]
case p.k
of pkReplace:
let nn = vnodeToDom(p.n, kxi)
let nn = toDom(p.newNode, useAttachedNode = true, kxi)
if p.parent == nil:
replaceById(kxi.rootId, nn)
else:
p.parent.replaceChild(nn, p.current)
of pkSame:
moveDom(p.newNode, p.oldNode)
of pkRemove:
p.parent.removeChild(p.current)
of pkAppend:
let nn = vnodeToDom(p.n, kxi)
let nn = toDom(p.newNode, useAttachedNode = true, kxi)
p.parent.appendChild(nn)
of pkInsertBefore:
let nn = vnodeToDom(p.n, kxi)
let nn = toDom(p.newNode, useAttachedNode = true, kxi)
p.parent.insertBefore(nn, p.current)
of pkDetach:
let n = p.n
let n = p.oldNode
if n.id != nil: kxi.byId.del(n.id)
if n.kind == VNodeKind.component:
let x = VComponent(n)
@ -377,16 +399,38 @@ proc applyPatch(kxi: KaraxInstance) =
assert p.newChild.dom != nil
kxi.patchLenV = 0
proc diff(newNode, oldNode: VNode; parent, current: Node; kxi: KaraxInstance): EqResult =
# ASSUME: We patch both the virtual DOM and the real DOM and throw away
# the newly produced DOM. Thus on updates like 'newNode.dom = oldNode.dom'
# are required. The only exception is when the top level node is replaced.
# Then we have to take the new virtual DOM. In fact, we trigger a full DOM
# rebuild then. However, we don't have to consider old event handlers then
# so everything stays simple.
# We also do not produce "Patch sets" anymore, everything is done as simply
# as possible. Ok, let's assume that we seek to update event handler lists:
# The new node has captures to itself or to other new nodes, never to old
# nodes! --> We cannot ever use the old VDOM, we have to use the new virtual
# DOM. For identical nodes we need to take over the .dom field from the old
# node since we don't recompute them. This must be done recursively. In
# vnodeToDom we have to check whether the 'dom' field was already set. If so,
# There is nothing to do.
# "Similar" nodes can have the opposite effect; consider
#
# AAAABAAAA
# AAAACDAAAA
#
# In this example B did change to C and 'D' is new. However, replacing B by
# CD is fine.
#
proc diff(newNode, oldNode: VNode; parent, current: Node; kxi: KaraxInstance) =
when defined(stats):
if kxi.recursion > 100:
echo "newNode ", newNode.kind, " oldNode ", oldNode.kind, " eq ", eq(newNode, oldNode)
echo "newNode ", newNode.kind, " oldNode ", oldNode.kind, " eq ", eq(newNode, oldNode, false)
if oldNode.kind == VNodeKind.text:
echo oldNode.text
#return
#doAssert false, "overflow!"
inc kxi.recursion
result = eq(newNode, oldNode)
let result = eq(newNode, oldNode, false)
case result
of componentsIdentical:
kxi.components.add ComponentPair(oldNode: VComponent(oldNode),
@ -402,92 +446,56 @@ proc diff(newNode, oldNode: VNode; parent, current: Node; kxi: KaraxInstance): E
oldNode.text = newNode.text
oldNode.dom.nodeValue = newNode.text
if newNode.events.len != 0 or oldNode.events.len != 0:
mergeEvents(newNode, oldNode, kxi)
when false:
if oldNode.kind == VNodeKind.input or oldNode.kind == VNodeKind.textarea:
if oldNode.text != newNode.text:
oldNode.text = newNode.text
oldNode.dom.value = newNode.text
#if newNode.events.len != 0 or oldNode.events.len != 0:
# mergeEvents(newNode, oldNode, kxi)
let newLength = newNode.len
let oldLength = oldNode.len
if newLength == 0 and oldLength == 0: return result
if newLength == 0 and oldLength == 0: return
let minLength = min(newLength, oldLength)
assert oldNode.kind == newNode.kind
var commonPrefix = 0
let isSpecial = oldNode.kind == VNodeKind.component or
oldNode.kind == VNodeKind.vthunk or
oldNode.kind == VNodeKind.dthunk
template eqAndUpdate(a: VNode; i: int; b: VNode; j: int; info, action: untyped) =
let oldLen = kxi.patchLen
let oldLenV = kxi.patchLenV
assert i < a.len
assert j < b.len
let r = if isSpecial:
diff(a[i], b[j], parent, current, kxi)
else:
diff(a[i], b[j], current, current.childNodes[j], kxi)
case r
of identical, componentsIdentical, similar:
a[i] = b[j]
action
of usenewNode:
kxi.addPatchV(b, j, a[i])
action
# unfortunately, we need to propagate the changes upwards:
result = useNewNode
of different:
# undo what 'diff' would have done:
kxi.patchLen = oldLen
kxi.patchLenV = oldLenV
if result != different: result = r
break
# compute common prefix:
while commonPrefix < minLength:
eqAndUpdate(newNode, commonPrefix, oldNode, commonPrefix, cstring"prefix"):
if eq(newNode[commonPrefix], oldNode[commonPrefix], true) == identical:
kxi.addPatch(pkSame, nil, nil, newNode[commonPrefix], oldNode[commonPrefix])
inc commonPrefix
else:
break
# compute common suffix:
var oldPos = oldLength - 1
var newPos = newLength - 1
while oldPos >= commonPrefix and newPos >= commonPrefix:
eqAndUpdate(newNode, newPos, oldNode, oldPos, cstring"suffix"):
if eq(newNode[newPos], oldNode[oldPos], true) == identical:
kxi.addPatch(pkSame, nil, nil, newNode[newPos], oldNode[oldPos])
dec oldPos
dec newPos
else:
break
let pos = min(oldPos, newPos) + 1
# now the different children are in commonPrefix .. pos - 1:
for i in commonPrefix..pos-1:
let r = diff(newNode[i], oldNode[i], current, current.childNodes[i], kxi)
if r == usenewNode:
#oldNode[i] = newNode[i]
kxi.addPatchV(oldNode, i, newNode[i])
elif r != different:
newNode[i] = oldNode[i]
#else:
# result = usenewNode
diff(newNode[i], oldNode[i], current, current.childNodes[i], kxi)
if oldPos + 1 == oldLength:
for i in pos..newPos:
kxi.addPatch(pkAppend, current, nil, newNode[i])
result = usenewNode
kxi.addPatch(pkAppend, current, nil, newNode[i], nil)
else:
let before = current.childNodes[oldPos + 1]
for i in pos..newPos:
kxi.addPatch(pkInsertBefore, current, before, newNode[i])
result = usenewNode
kxi.addPatch(pkInsertBefore, current, before, newNode[i], nil)
# XXX call 'attach' here?
for i in pos..oldPos:
detach(oldNode[i])
#doAssert i < current.childNodes.len
kxi.addPatch(pkRemove, current, current.childNodes[i], nil)
result = usenewNode
kxi.addPatch(pkRemove, current, current.childNodes[i], nil, nil)
of different:
detach(oldNode)
kxi.addPatch(pkReplace, parent, current, newNode)
kxi.addPatch(pkReplace, parent, current, newNode, nil)
of usenewNode: doAssert(false, "eq returned usenewNode")
when defined(stats):
dec kxi.recursion
@ -517,24 +525,25 @@ proc applyComponents(kxi: KaraxInstance) =
x.renderedVersion = x.version
if oldExpanded == nil:
detach(x)
kxi.addPatch(pkReplace, parent, current, x.expanded)
kxi.addPatch(pkReplace, parent, current, x.expanded, nil)
when defined(karaxDebug):
echo "Component ", newNode.text, ": old expansion didn't exist"
else:
let res = diff(x.expanded, oldExpanded, parent, current, kxi)
if res == usenewNode:
when defined(karaxDebug):
echo "Component ", newNode.text, ": re-render triggered a DOM change (case A)"
discard "diff created a patchset for us, so this is fine"
elif res != different:
when defined(karaxDebug):
echo "Component ", newNode.text, ": re-render triggered no DOM change whatsoever"
x.expanded = oldExpanded
assert oldExpanded.dom != nil, "old expanded.dom is nil"
else:
when defined(karaxDebug):
echo "Component ", newNode.text, ": re-render triggered a DOM change (case B)"
assert x.expanded.dom != nil, "expanded.dom is nil"
diff(x.expanded, oldExpanded, parent, current, kxi)
when false:
if res == usenewNode:
when defined(karaxDebug):
echo "Component ", newNode.text, ": re-render triggered a DOM change (case A)"
discard "diff created a patchset for us, so this is fine"
elif res != different:
when defined(karaxDebug):
echo "Component ", newNode.text, ": re-render triggered no DOM change whatsoever"
x.expanded = oldExpanded
assert oldExpanded.dom != nil, "old expanded.dom is nil"
else:
when defined(karaxDebug):
echo "Component ", newNode.text, ": re-render triggered a DOM change (case B)"
assert x.expanded.dom != nil, "expanded.dom is nil"
inc i
setLen(kxi.components, 0)
@ -549,7 +558,7 @@ when defined(stats):
proc runDel*(kxi: KaraxInstance; parent: VNode; position: int) =
detach(parent[position])
let current = parent.dom
kxi.addPatch(pkRemove, current, current.childNodes[position], nil)
kxi.addPatch(pkRemove, current, current.childNodes[position], nil, nil)
parent.delete(position)
applyPatch(kxi)
doAssert same(kxi.currentTree, document.getElementById(kxi.rootId))
@ -557,11 +566,11 @@ proc runDel*(kxi: KaraxInstance; parent: VNode; position: int) =
proc runIns*(kxi: KaraxInstance; parent, kid: VNode; position: int) =
let current = parent.dom
if position >= parent.len:
kxi.addPatch(pkAppend, current, nil, kid)
kxi.addPatch(pkAppend, current, nil, kid, nil)
parent.add(kid)
else:
let before = current.childNodes[position]
kxi.addPatch(pkInsertBefore, current, before, kid)
kxi.addPatch(pkInsertBefore, current, before, kid, nil)
parent.insert(kid, position)
applyPatch(kxi)
doAssert same(kxi.currentTree, document.getElementById(kxi.rootId))
@ -569,7 +578,7 @@ proc runIns*(kxi: KaraxInstance; parent, kid: VNode; position: int) =
proc runDiff*(kxi: KaraxInstance; oldNode, newNode: VNode) =
let olddom = oldNode.dom
doAssert olddom != nil
discard diff(newNode, oldNode, nil, olddom, kxi)
diff(newNode, oldNode, nil, olddom, kxi)
# this is a bit nasty: Since we cannot patch the 'parent' of
# the current VNode (because we don't store it at all!), we
# need to override the fields individually:
@ -591,13 +600,12 @@ proc dodraw(kxi: KaraxInstance) =
newtree.id = kxi.rootId
kxi.toFocus = nil
if kxi.currentTree == nil:
let asdom = vnodeToDom(newtree, kxi)
let asdom = toDom(newtree, useAttachedNode = true, kxi)
replaceById(kxi.rootId, asdom)
else:
doAssert same(kxi.currentTree, document.getElementById(kxi.rootId))
let olddom = document.getElementById(kxi.rootId)
discard diff(newtree, kxi.currentTree, nil, olddom, kxi)
#kout cstring"patch len ", patches.len
diff(newtree, kxi.currentTree, nil, olddom, kxi)
when defined(profileKarax):
echo "<<<<<<<<<<<<<<"
echa reasons
@ -768,3 +776,6 @@ proc toChecked*(checked: bool): cstring =
proc toDisabled*(disabled: bool): cstring =
(if disabled: cstring"disabled" else: cstring(nil))
proc vnodeToDom*(n: VNode; kxi: KaraxInstance = nil): Node =
result = toDom(n, useAttachedNode = false, kxi)

View file

@ -22,15 +22,19 @@ proc shortRepr(n: VNode): string =
var err = 0
proc doDiff(a, b: VNode; expected: varargs[string]) =
discard diff(b, a, nil, vnodeToDom(a, kxi), kxi)
diff(b, a, nil, vnodeToDom(a, kxi), kxi)
var j = 0
for i in 0..<kxi.patchLen:
let p = $kxi.patches[i].k & " " & shortRepr(kxi.patches[i].n)
if i >= expected.len:
echo "patches differ; expected nothing but got: ", p
inc err
elif p != expected[i]:
echo "patches differ; expected ", expected[i], " but got: ", p
inc err
if kxi.patches[i].k != pkSame:
let n = if kxi.patches[i].k == pkDetach: kxi.patches[i].oldNode else: kxi.patches[i].newNode
let p = $kxi.patches[i].k & " " & shortRepr(n)
if j >= expected.len:
echo "patches differ; expected nothing but got: ", p
inc err
elif p != expected[j]:
echo "patches differ; expected ", expected[i], " but got: ", p
inc err
inc j
#hasDom(kxi.currentTree)
kxi.patchLen = 0