Precise DOM morphing
morphing typescript dom

Use specific operations for better performance

+71 -28
+9 -1
benchmark/index.html
··· 383 import morphdom from "https://unpkg.com/morphdom@2.7.7/dist/morphdom-esm.js" 384 // Try loading nanomorph from jsdelivr with ESM 385 import nanomorph from "https://cdn.jsdelivr.net/npm/nanomorph@5.4.3/+esm" 386 - // Alpine morph requires Alpine.js to be loaded, so we'll skip it 387 388 const sandbox = document.getElementById("sandbox") 389 let isRunning = false ··· 558 { name: "idiomorph", fn: (from, to) => Idiomorph.morph(from, to) }, 559 { name: "morphdom", fn: (from, to) => morphdom(from, to.cloneNode(true)) }, 560 { name: "nanomorph", fn: (from, to) => nanomorph(from, to.cloneNode(true)) }, 561 ] 562 563 for (const lib of libraries) {
··· 383 import morphdom from "https://unpkg.com/morphdom@2.7.7/dist/morphdom-esm.js" 384 // Try loading nanomorph from jsdelivr with ESM 385 import nanomorph from "https://cdn.jsdelivr.net/npm/nanomorph@5.4.3/+esm" 386 + // Load Alpine.js and Alpine morph 387 + import Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/module.esm.js" 388 + import Morph from "https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/module.esm.js" 389 + 390 + // Initialize Alpine with morph plugin 391 + Alpine.plugin(Morph) 392 + window.Alpine = Alpine 393 + Alpine.start() 394 395 const sandbox = document.getElementById("sandbox") 396 let isRunning = false ··· 565 { name: "idiomorph", fn: (from, to) => Idiomorph.morph(from, to) }, 566 { name: "morphdom", fn: (from, to) => morphdom(from, to.cloneNode(true)) }, 567 { name: "nanomorph", fn: (from, to) => nanomorph(from, to.cloneNode(true)) }, 568 + { name: "alpine-morph", fn: (from, to) => Alpine.morph(from, to) }, 569 ] 570 571 for (const lib of libraries) {
+62 -27
src/morphlex.ts
··· 3 const TEXT_NODE_TYPE = 3 4 const PARENT_NODE_TYPES = [false, true, false, false, false, false, false, false, false, true, false, true] 5 6 const candidateNodes: Set<number> = new Set() 7 const candidateElements: Set<number> = new Set() 8 const unmatchedNodes: Set<number> = new Set() ··· 322 if (from === to) return 323 if (from.isEqualNode?.(to)) return 324 325 - if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 326 - 327 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 328 if ((from as Element).localName === (to as Element).localName) { 329 this.#morphMatchingElements(from as Element, to as Element) ··· 333 } else { 334 this.#morphOtherNode(from, to) 335 } 336 - 337 - this.#options.afterNodeVisited?.(from, to) 338 } 339 340 #morphMatchingElements(from: Element, to: Element): void { 341 if (from.hasAttributes() || to.hasAttributes()) { 342 this.#visitAttributes(from, to) 343 } ··· 347 } else if (from.hasChildNodes() || to.hasChildNodes()) { 348 this.visitChildNodes(from, to) 349 } 350 } 351 352 #morphNonMatchingElements(from: Element, to: Element): void { 353 this.#replaceNode(from, to) 354 } 355 356 #morphOtherNode(from: ChildNode, to: ChildNode): void { 357 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 358 from.nodeValue = to.nodeValue 359 } else { 360 this.#replaceNode(from, to) 361 } 362 } 363 364 #visitAttributes(from: Element, to: Element): void { ··· 454 unmatchedElements.clear() 455 whitespaceNodes.clear() 456 457 - const seq: Array<number | undefined> = [] 458 - const matches: Array<number | undefined> = [] 459 460 for (let i = 0; i < fromChildNodes.length; i++) { 461 const candidate = fromChildNodes[i]! ··· 492 493 if (candidate.isEqualNode(element)) { 494 matches[unmatchedIndex] = candidateIndex 495 seq[candidateIndex] = unmatchedIndex 496 candidateElements.delete(candidateIndex) 497 unmatchedElements.delete(unmatchedIndex) ··· 512 candidateLoop: for (const candidateIndex of candidateElements) { 513 const candidate = fromChildNodes[candidateIndex] as Element 514 515 - // Match by exact id 516 - if (id !== "" && element.localName === candidate.localName && id === candidate.id) { 517 - matches[unmatchedIndex] = candidateIndex 518 - seq[candidateIndex] = unmatchedIndex 519 - candidateElements.delete(candidateIndex) 520 - unmatchedElements.delete(unmatchedIndex) 521 - break candidateLoop 522 - } 523 524 - // Match by idArray (to) against idSet (from) 525 - if (idArray) { 526 - const candidateIdSet = this.#idSetMap.get(candidate) 527 - if (candidateIdSet) { 528 - for (let i = 0; i < idArray.length; i++) { 529 - const arrayId = idArray[i]! 530 - if (candidateIdSet.has(arrayId)) { 531 - matches[unmatchedIndex] = candidateIndex 532 - seq[candidateIndex] = unmatchedIndex 533 - candidateElements.delete(candidateIndex) 534 - unmatchedElements.delete(unmatchedIndex) 535 - break candidateLoop 536 } 537 } 538 } ··· 558 ) { 559 matches[unmatchedIndex] = candidateIndex 560 seq[candidateIndex] = unmatchedIndex 561 candidateElements.delete(candidateIndex) 562 unmatchedElements.delete(unmatchedIndex) 563 break ··· 580 } 581 matches[unmatchedIndex] = candidateIndex 582 seq[candidateIndex] = unmatchedIndex 583 candidateElements.delete(candidateIndex) 584 unmatchedElements.delete(unmatchedIndex) 585 break ··· 595 const candidate = fromChildNodes[candidateIndex]! 596 if (candidate.isEqualNode(node)) { 597 matches[unmatchedIndex] = candidateIndex 598 seq[candidateIndex] = unmatchedIndex 599 candidateNodes.delete(candidateIndex) 600 unmatchedNodes.delete(unmatchedIndex) ··· 613 const candidate = fromChildNodes[candidateIndex]! 614 if (nodeType === candidate.nodeType) { 615 matches[unmatchedIndex] = candidateIndex 616 seq[candidateIndex] = unmatchedIndex 617 candidateNodes.delete(candidateIndex) 618 unmatchedNodes.delete(unmatchedIndex) ··· 641 const matchInd = matches[i] 642 if (matchInd !== undefined) { 643 const match = fromChildNodes[matchInd]! 644 645 if (!shouldNotMove[matchInd]) { 646 moveBefore(parent, match, insertionPoint) 647 } 648 - this.#morphOneToOne(match, node) 649 insertionPoint = match.nextSibling 650 } else { 651 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {
··· 3 const TEXT_NODE_TYPE = 3 4 const PARENT_NODE_TYPES = [false, true, false, false, false, false, false, false, false, true, false, true] 5 6 + const Operation = { 7 + EqualNode: 0, 8 + SameElement: 1, 9 + SameNode: 2, 10 + } as const 11 + 12 + type Operation = (typeof Operation)[keyof typeof Operation] 13 + 14 const candidateNodes: Set<number> = new Set() 15 const candidateElements: Set<number> = new Set() 16 const unmatchedNodes: Set<number> = new Set() ··· 330 if (from === to) return 331 if (from.isEqualNode?.(to)) return 332 333 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 334 if ((from as Element).localName === (to as Element).localName) { 335 this.#morphMatchingElements(from as Element, to as Element) ··· 339 } else { 340 this.#morphOtherNode(from, to) 341 } 342 } 343 344 #morphMatchingElements(from: Element, to: Element): void { 345 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 346 + 347 if (from.hasAttributes() || to.hasAttributes()) { 348 this.#visitAttributes(from, to) 349 } ··· 353 } else if (from.hasChildNodes() || to.hasChildNodes()) { 354 this.visitChildNodes(from, to) 355 } 356 + 357 + this.#options.afterNodeVisited?.(from, to) 358 } 359 360 #morphNonMatchingElements(from: Element, to: Element): void { 361 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 362 + 363 this.#replaceNode(from, to) 364 + 365 + this.#options.afterNodeVisited?.(from, to) 366 } 367 368 #morphOtherNode(from: ChildNode, to: ChildNode): void { 369 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 370 + 371 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 372 from.nodeValue = to.nodeValue 373 } else { 374 this.#replaceNode(from, to) 375 } 376 + 377 + this.#options.afterNodeVisited?.(from, to) 378 } 379 380 #visitAttributes(from: Element, to: Element): void { ··· 470 unmatchedElements.clear() 471 whitespaceNodes.clear() 472 473 + const seq: Array<number> = [] 474 + const matches: Array<number> = [] 475 + const op: Array<Operation> = [] 476 477 for (let i = 0; i < fromChildNodes.length; i++) { 478 const candidate = fromChildNodes[i]! ··· 509 510 if (candidate.isEqualNode(element)) { 511 matches[unmatchedIndex] = candidateIndex 512 + op[unmatchedIndex] = Operation.EqualNode 513 seq[candidateIndex] = unmatchedIndex 514 candidateElements.delete(candidateIndex) 515 unmatchedElements.delete(unmatchedIndex) ··· 530 candidateLoop: for (const candidateIndex of candidateElements) { 531 const candidate = fromChildNodes[candidateIndex] as Element 532 533 + if (element.localName === candidate.localName) { 534 + // Match by exact id 535 + if (id !== "" && id === candidate.id) { 536 + matches[unmatchedIndex] = candidateIndex 537 + op[unmatchedIndex] = Operation.SameElement 538 + seq[candidateIndex] = unmatchedIndex 539 + candidateElements.delete(candidateIndex) 540 + unmatchedElements.delete(unmatchedIndex) 541 + break candidateLoop 542 + } 543 544 + // Match by idArray (to) against idSet (from) 545 + if (idArray) { 546 + const candidateIdSet = this.#idSetMap.get(candidate) 547 + if (candidateIdSet) { 548 + for (let i = 0; i < idArray.length; i++) { 549 + const arrayId = idArray[i]! 550 + if (candidateIdSet.has(arrayId)) { 551 + matches[unmatchedIndex] = candidateIndex 552 + op[unmatchedIndex] = Operation.SameElement 553 + seq[candidateIndex] = unmatchedIndex 554 + candidateElements.delete(candidateIndex) 555 + unmatchedElements.delete(unmatchedIndex) 556 + break candidateLoop 557 + } 558 } 559 } 560 } ··· 580 ) { 581 matches[unmatchedIndex] = candidateIndex 582 seq[candidateIndex] = unmatchedIndex 583 + op[unmatchedIndex] = Operation.SameElement 584 candidateElements.delete(candidateIndex) 585 unmatchedElements.delete(unmatchedIndex) 586 break ··· 603 } 604 matches[unmatchedIndex] = candidateIndex 605 seq[candidateIndex] = unmatchedIndex 606 + op[unmatchedIndex] = Operation.SameElement 607 candidateElements.delete(candidateIndex) 608 unmatchedElements.delete(unmatchedIndex) 609 break ··· 619 const candidate = fromChildNodes[candidateIndex]! 620 if (candidate.isEqualNode(node)) { 621 matches[unmatchedIndex] = candidateIndex 622 + op[unmatchedIndex] = Operation.EqualNode 623 seq[candidateIndex] = unmatchedIndex 624 candidateNodes.delete(candidateIndex) 625 unmatchedNodes.delete(unmatchedIndex) ··· 638 const candidate = fromChildNodes[candidateIndex]! 639 if (nodeType === candidate.nodeType) { 640 matches[unmatchedIndex] = candidateIndex 641 + op[unmatchedIndex] = Operation.SameNode 642 seq[candidateIndex] = unmatchedIndex 643 candidateNodes.delete(candidateIndex) 644 unmatchedNodes.delete(unmatchedIndex) ··· 667 const matchInd = matches[i] 668 if (matchInd !== undefined) { 669 const match = fromChildNodes[matchInd]! 670 + const operation = op[matchInd]! 671 672 if (!shouldNotMove[matchInd]) { 673 moveBefore(parent, match, insertionPoint) 674 } 675 + 676 + if (operation === Operation.EqualNode) { 677 + } else if (operation === Operation.SameElement) { 678 + // this.#morphMatchingElements(match as Element, node as Element) 679 + this.#morphMatchingElements(match as Element, node as Element) 680 + } else { 681 + this.#morphOneToOne(match, node) 682 + } 683 + 684 insertionPoint = match.nextSibling 685 } else { 686 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {