diff --git a/ReadMe.md b/ReadMe.md index cc2f56e..acbe76f 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -9,6 +9,11 @@ A light-weight DOM Renderer supports [Web components][1] standard & [TypeScript] [![Open in GitPod](https://gitpod.io/button/open-in-gitpod.svg)][6] +## Feature + +- input: **Virtual DOM** object in **JSX** syntax +- output: **DOM** object or **XML** string of **HTML**, **SVG** & **MathML** languages + ## Usage ### JavaScript diff --git a/package.json b/package.json index 1ac47be..bab978b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dom-renderer", - "version": "2.4.4", + "version": "2.5.0", "license": "LGPL-3.0-or-later", "author": "shiy2008@gmail.com", "description": "A light-weight DOM Renderer supports Web components standard & TypeScript language", @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "ts-jest": "^29.2.5", "typedoc": "^0.26.11", - "typedoc-plugin-mdn-links": "^3.3.6", + "typedoc-plugin-mdn-links": "^3.3.7", "typescript": "~5.6.3" }, "prettier": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0581aea..b089fe0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,8 +55,8 @@ importers: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-mdn-links: - specifier: ^3.3.6 - version: 3.3.6(typedoc@0.26.11(typescript@5.6.3)) + specifier: ^3.3.7 + version: 3.3.7(typedoc@0.26.11(typescript@5.6.3)) typescript: specifier: ~5.6.3 version: 5.6.3 @@ -1566,8 +1566,8 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - typedoc-plugin-mdn-links@3.3.6: - resolution: {integrity: sha512-iz/+UBDEDqtymjgO6AQA7P4A/kiGmHVKSStGFz7rueZClfbpYaJB5T+xePMF4i0N7PxWMPrWzQd1+B6pn40w1w==} + typedoc-plugin-mdn-links@3.3.7: + resolution: {integrity: sha512-iFSnYj3XPuc0wh0/VjU2M/sHtNv5pSEysUXrylHxgd5PqTAOZTUswJAcbB7shg+SfxMCqGaiyA0duNmnGs/LQg==} peerDependencies: typedoc: '>= 0.23.14 || 0.24.x || 0.25.x || 0.26.x' @@ -3483,7 +3483,7 @@ snapshots: type-fest@2.19.0: {} - typedoc-plugin-mdn-links@3.3.6(typedoc@0.26.11(typescript@5.6.3)): + typedoc-plugin-mdn-links@3.3.7(typedoc@0.26.11(typescript@5.6.3)): dependencies: typedoc: 0.26.11(typescript@5.6.3) diff --git a/source/dist/DOMRenderer.ts b/source/dist/DOMRenderer.ts index beac74d..da99a94 100644 --- a/source/dist/DOMRenderer.ts +++ b/source/dist/DOMRenderer.ts @@ -1,4 +1,3 @@ -import { findShadowRoots, generateHTML } from 'declarative-shadow-dom-polyfill'; import { ReadableStream } from 'web-streams-polyfill'; import { diffKeys, @@ -12,6 +11,12 @@ import { import { DataObject, VNode } from './VDOM'; +export interface UpdateTask { + index?: number; + oldVNode?: VNode; + newVNode?: VNode; +} + export class DOMRenderer { eventPattern = /^on[A-Z]/; ariaPattern = /^aira[A-Z]/; @@ -32,6 +37,7 @@ export class DOMRenderer { : this.eventPattern.test(key) ? key.toLowerCase() : key; + protected attrsNameOf = (key: string) => VNode.propsMap[key] || key; protected updateProps( node: N, @@ -50,20 +56,6 @@ export class DOMRenderer { else Reflect.set(node, key, newProps[key]); } - protected createNode(vNode: VNode, reusedVNodes?: Record) { - if (vNode.text) return vNode.createDOM(this.document); - - const reusedVNode = vNode.selector && reusedVNodes?.[vNode.selector]?.shift(); - - vNode.node = reusedVNode?.node || vNode.createDOM(this.document); - - const { node } = this.patch( - reusedVNode || new VNode({ tagName: vNode.tagName, node: vNode.node }), - vNode - ); - return node; - } - protected deleteNode({ ref, node, children }: VNode) { if (node instanceof DocumentFragment) children?.forEach(this.deleteNode); else if (node) { @@ -73,53 +65,47 @@ export class DOMRenderer { } } - protected commitChildren(root: ParentNode, newNodes: ChildNode[]) { - for (const oldNode of [...root.childNodes]) { - const index = newNodes.indexOf(oldNode); - - if (index < 0) continue; - else if (index === 0) { - newNodes.shift(); - continue; - } - const beforeNodes = newNodes.slice(0, index); - - if (!beforeNodes[0]) continue; + protected commitChild(root: ParentNode, node: Node, index = 0) { + const targetNode = root.childNodes[index]; - oldNode.before(...beforeNodes); + if (targetNode === node) return; - newNodes = newNodes.slice(index + 1); - } - - if (newNodes[0]) root.append(...newNodes); + if (!targetNode) root.append(node); + else targetNode.before(node); } - protected updateChildren(node: ParentNode, oldList: VNode[], newList: VNode[]) { - const { map, group } = diffKeys(oldList.map(this.keyOf), newList.map(this.keyOf)); + protected *diffVChildren(oldVNode: VNode, newVNode: VNode): Generator { + newVNode.children = newVNode.children.map(vNode => new VNode(vNode)); + + const { map, group } = diffKeys( + oldVNode.children!.map(this.keyOf), + newVNode.children!.map(this.keyOf) + ); const deletingGroup = group[DiffStatus.Old] && groupBy( - group[DiffStatus.Old].map(([key]) => this.vNodeOf(oldList, key)), + group[DiffStatus.Old].map(([key]) => this.vNodeOf(oldVNode.children!, key)), ({ selector }) => selector + '' ); - const newNodes = newList.map((vNode, index) => { - const key = this.keyOf(vNode, index); - if (map[key] !== DiffStatus.Same) return this.createNode(vNode, deletingGroup); + for (const [index, newVChild] of newVNode.children!.entries()) { + const key = this.keyOf(newVChild, index); - const oldVNode = this.vNodeOf(oldList, key)!; + let oldVChild = + map[key] === DiffStatus.Same + ? this.vNodeOf(oldVNode.children!, key) + : deletingGroup?.[newVChild.selector]?.shift(); - return vNode.text != null - ? (vNode.node = oldVNode.node) - : this.patch(oldVNode, vNode).node; - }); + yield { index, oldVNode: oldVChild, newVNode: newVChild }; - for (const selector in deletingGroup) - for (const vNode of deletingGroup[selector]) this.deleteNode(vNode); + if (oldVChild?.children[0] || newVChild.children[0]) { + oldVChild ||= new VNode({ ...newVChild, children: [] }); - this.commitChildren(node, newNodes as ChildNode[]); - - for (const { ref, node } of newList) ref?.(node); + yield* this.diffVChildren(oldVChild, newVChild); + } + } + for (const selector in deletingGroup) + for (const oldVNode of deletingGroup[selector]) yield { oldVNode }; } protected handleCustomEvent(node: EventTarget, event: string) { @@ -139,12 +125,12 @@ export class DOMRenderer { this.eventPattern.test(key) ? (node[key.toLowerCase()] = null) : node.removeAttribute( - this.ariaPattern.test(key) ? toHyphenCase(key) : VNode.propsMap[key] || key + this.ariaPattern.test(key) ? toHyphenCase(key) : this.attrsNameOf(key) ); protected setProperty = (node: Element, key: string, value: string) => { const isXML = templateOf(node.tagName) && elementTypeOf(node.tagName) === 'xml'; - if (isXML || key.includes('-')) node.setAttribute(key, value); + if (isXML || key.includes('-')) node.setAttribute(this.attrsNameOf(key), value); else try { const name = this.propsKeyOf(key); @@ -154,11 +140,11 @@ export class DOMRenderer { node[name] = value; } catch { - node.setAttribute(key, value); + node.setAttribute(this.attrsNameOf(key), value); } }; - patch(oldVNode: VNode, newVNode: VNode): VNode { + protected patchNode(oldVNode: VNode, newVNode: VNode) { this.updateProps( oldVNode.node as Element, oldVNode.props, @@ -170,17 +156,44 @@ export class DOMRenderer { (oldVNode.node as HTMLElement).style, oldVNode.style, newVNode.style, - (node, key) => node.removeProperty(toHyphenCase(key)), - (node, key, value) => node.setProperty(toHyphenCase(key), value) - ); - this.updateChildren( - oldVNode.node as ParentNode, - oldVNode.children || [], - (newVNode.children = newVNode.children?.map(vNode => new VNode(vNode)) || []) + (style, key) => style.removeProperty(toHyphenCase(key)), + (style, key, value) => style.setProperty(toHyphenCase(key), value) ); - newVNode.node = oldVNode.node; + newVNode.node ||= oldVNode.node; + } + + patch(oldVRoot: VNode, newVRoot: VNode) { + if (VNode.isFragment(newVRoot)) + newVRoot = new VNode({ ...oldVRoot, children: newVRoot.children }); + + this.patchNode(oldVRoot, newVRoot); + + for (let { index, oldVNode, newVNode } of this.diffVChildren(oldVRoot, newVRoot)) { + if (!newVNode) { + this.deleteNode(oldVNode); + continue; + } + const inserting = !oldVNode; + + if (oldVNode) newVNode.node = oldVNode.node; + else { + newVNode.createDOM(this.document); + + const { tagName, node, parent } = newVNode; + + oldVNode = new VNode({ tagName, node, parent }); + } + + if (newVNode.text) oldVNode.node.nodeValue = newVNode.text; + else if (!VNode.isFragment(newVNode)) this.patchNode(oldVNode, newVNode); - return newVNode; + if (oldVNode.parent) { + this.commitChild(oldVNode.parent.node as ParentNode, newVNode.node, index); + + if (inserting) newVNode.ref?.(newVNode.node); + } + } + return newVRoot; } render(vNode: VNode, node: ParentNode = globalThis.document?.body) { @@ -195,27 +208,11 @@ export class DOMRenderer { return root; } - protected buildRenderTree(tree: VNode) { - const { body } = this.document.implementation.createHTMLDocument(); - - this.render(tree, body); - - const shadowRoots = [...findShadowRoots(body)]; - - return { body, shadowRoots }; - } - renderToStaticMarkup(tree: VNode) { - const { body, shadowRoots } = this.buildRenderTree(tree); - - return body.getHTML({ serializableShadowRoots: true, shadowRoots }); + return [...tree.generateXML()].join(''); } renderToReadableStream(tree: VNode) { - const { body, shadowRoots } = this.buildRenderTree(tree); - - return ReadableStream.from( - generateHTML(body, { serializableShadowRoots: true, shadowRoots }) - ); + return ReadableStream.from(tree.generateXML()); } } diff --git a/source/dist/VDOM.ts b/source/dist/VDOM.ts index 01d83f7..de80b93 100644 --- a/source/dist/VDOM.ts +++ b/source/dist/VDOM.ts @@ -1,4 +1,14 @@ -import { HTMLProps, IndexKey, isEmpty, MathMLProps, SVGProps, XMLNamespace } from 'web-utility'; +import { findShadowRoots } from 'declarative-shadow-dom-polyfill'; +import { + elementTypeOf, + HTMLProps, + IndexKey, + isEmpty, + MathMLProps, + SVGProps, + toHyphenCase, + XMLNamespace +} from 'web-utility'; export type DataObject = Record; @@ -78,6 +88,64 @@ export class VNode extends VNodeMeta { ); } + protected *generateElementXML(): Generator { + const { tagName, props, style, children, node } = this; + + if (tagName.includes('-') && elementTypeOf(tagName) === 'html') { + const { body } = (node?.ownerDocument || document).implementation.createHTMLDocument(); + + body.innerHTML = `<${tagName}>`; + + const shadowRoots = [...findShadowRoots(body)]; + + yield body.getHTML({ serializableShadowRoots: true, shadowRoots }); + } else { + const { innerHTML, ...restProps } = props; + + yield `<${tagName}`; + + for (const key in restProps) { + yield ` ${VNode.propsMap[key] || key}="${restProps[key]}"`; + } + if (style) { + yield ` style="`; + + for (const key in style) { + yield `${toHyphenCase(key)}:${style[key]};`; + } + yield `"`; + } + if (innerHTML) { + yield `>${innerHTML}`; + } else if (children[0]) { + yield '>'; + + for (const child of children) { + yield* child.generateXML(); + } + yield ``; + } else { + yield ` />`; + } + } + } + + *generateXML(this: VNode): Generator { + if (VNode.isFragment(this)) { + yield ''; + } else if (this.text != null) { + yield this.text; + } else { + yield* this.generateElementXML(); + } + } + static propsMap: Partial, string>> = { className: 'class', htmlFor: 'for' diff --git a/test/jsx-runtime.spec.tsx b/test/jsx-runtime.spec.tsx index 394ac11..f6626c1 100644 --- a/test/jsx-runtime.spec.tsx +++ b/test/jsx-runtime.spec.tsx @@ -176,7 +176,7 @@ describe('JSX runtime', () => { }); it('should render to a Static String', () => { - expect(renderer.renderToStaticMarkup()).toBe(''); + expect(renderer.renderToStaticMarkup()).toBe(''); }); it('should render SVG', () => {