Precise DOM morphing
morphing typescript dom

Use specific operations for better performance

+71 -28
+9 -1
benchmark/index.html
··· 383 383 import morphdom from "https://unpkg.com/morphdom@2.7.7/dist/morphdom-esm.js" 384 384 // Try loading nanomorph from jsdelivr with ESM 385 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 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() 387 394 388 395 const sandbox = document.getElementById("sandbox") 389 396 let isRunning = false ··· 558 565 { name: "idiomorph", fn: (from, to) => Idiomorph.morph(from, to) }, 559 566 { name: "morphdom", fn: (from, to) => morphdom(from, to.cloneNode(true)) }, 560 567 { name: "nanomorph", fn: (from, to) => nanomorph(from, to.cloneNode(true)) }, 568 + { name: "alpine-morph", fn: (from, to) => Alpine.morph(from, to) }, 561 569 ] 562 570 563 571 for (const lib of libraries) {
+62 -27
src/morphlex.ts
··· 3 3 const TEXT_NODE_TYPE = 3 4 4 const PARENT_NODE_TYPES = [false, true, false, false, false, false, false, false, false, true, false, true] 5 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 + 6 14 const candidateNodes: Set<number> = new Set() 7 15 const candidateElements: Set<number> = new Set() 8 16 const unmatchedNodes: Set<number> = new Set() ··· 322 330 if (from === to) return 323 331 if (from.isEqualNode?.(to)) return 324 332 325 - if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 326 - 327 333 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 328 334 if ((from as Element).localName === (to as Element).localName) { 329 335 this.#morphMatchingElements(from as Element, to as Element) ··· 333 339 } else { 334 340 this.#morphOtherNode(from, to) 335 341 } 336 - 337 - this.#options.afterNodeVisited?.(from, to) 338 342 } 339 343 340 344 #morphMatchingElements(from: Element, to: Element): void { 345 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 346 + 341 347 if (from.hasAttributes() || to.hasAttributes()) { 342 348 this.#visitAttributes(from, to) 343 349 } ··· 347 353 } else if (from.hasChildNodes() || to.hasChildNodes()) { 348 354 this.visitChildNodes(from, to) 349 355 } 356 + 357 + this.#options.afterNodeVisited?.(from, to) 350 358 } 351 359 352 360 #morphNonMatchingElements(from: Element, to: Element): void { 361 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 362 + 353 363 this.#replaceNode(from, to) 364 + 365 + this.#options.afterNodeVisited?.(from, to) 354 366 } 355 367 356 368 #morphOtherNode(from: ChildNode, to: ChildNode): void { 369 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 370 + 357 371 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 358 372 from.nodeValue = to.nodeValue 359 373 } else { 360 374 this.#replaceNode(from, to) 361 375 } 376 + 377 + this.#options.afterNodeVisited?.(from, to) 362 378 } 363 379 364 380 #visitAttributes(from: Element, to: Element): void { ··· 454 470 unmatchedElements.clear() 455 471 whitespaceNodes.clear() 456 472 457 - const seq: Array<number | undefined> = [] 458 - const matches: Array<number | undefined> = [] 473 + const seq: Array<number> = [] 474 + const matches: Array<number> = [] 475 + const op: Array<Operation> = [] 459 476 460 477 for (let i = 0; i < fromChildNodes.length; i++) { 461 478 const candidate = fromChildNodes[i]! ··· 492 509 493 510 if (candidate.isEqualNode(element)) { 494 511 matches[unmatchedIndex] = candidateIndex 512 + op[unmatchedIndex] = Operation.EqualNode 495 513 seq[candidateIndex] = unmatchedIndex 496 514 candidateElements.delete(candidateIndex) 497 515 unmatchedElements.delete(unmatchedIndex) ··· 512 530 candidateLoop: for (const candidateIndex of candidateElements) { 513 531 const candidate = fromChildNodes[candidateIndex] as Element 514 532 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 - } 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 + } 523 543 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 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 + } 536 558 } 537 559 } 538 560 } ··· 558 580 ) { 559 581 matches[unmatchedIndex] = candidateIndex 560 582 seq[candidateIndex] = unmatchedIndex 583 + op[unmatchedIndex] = Operation.SameElement 561 584 candidateElements.delete(candidateIndex) 562 585 unmatchedElements.delete(unmatchedIndex) 563 586 break ··· 580 603 } 581 604 matches[unmatchedIndex] = candidateIndex 582 605 seq[candidateIndex] = unmatchedIndex 606 + op[unmatchedIndex] = Operation.SameElement 583 607 candidateElements.delete(candidateIndex) 584 608 unmatchedElements.delete(unmatchedIndex) 585 609 break ··· 595 619 const candidate = fromChildNodes[candidateIndex]! 596 620 if (candidate.isEqualNode(node)) { 597 621 matches[unmatchedIndex] = candidateIndex 622 + op[unmatchedIndex] = Operation.EqualNode 598 623 seq[candidateIndex] = unmatchedIndex 599 624 candidateNodes.delete(candidateIndex) 600 625 unmatchedNodes.delete(unmatchedIndex) ··· 613 638 const candidate = fromChildNodes[candidateIndex]! 614 639 if (nodeType === candidate.nodeType) { 615 640 matches[unmatchedIndex] = candidateIndex 641 + op[unmatchedIndex] = Operation.SameNode 616 642 seq[candidateIndex] = unmatchedIndex 617 643 candidateNodes.delete(candidateIndex) 618 644 unmatchedNodes.delete(unmatchedIndex) ··· 641 667 const matchInd = matches[i] 642 668 if (matchInd !== undefined) { 643 669 const match = fromChildNodes[matchInd]! 670 + const operation = op[matchInd]! 644 671 645 672 if (!shouldNotMove[matchInd]) { 646 673 moveBefore(parent, match, insertionPoint) 647 674 } 648 - this.#morphOneToOne(match, node) 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 + 649 684 insertionPoint = match.nextSibling 650 685 } else { 651 686 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {