Skip to content

Commit 6945c1a

Browse files
authored
[refactor] rewrite Patching & Generation with ES 6 Generator (#10)
1 parent 11044cb commit 6945c1a

File tree

6 files changed

+158
-88
lines changed

6 files changed

+158
-88
lines changed

ReadMe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ A light-weight DOM Renderer supports [Web components][1] standard & [TypeScript]
99

1010
[![Open in GitPod](https://gitpod.io/button/open-in-gitpod.svg)][6]
1111

12+
## Feature
13+
14+
- input: **Virtual DOM** object in **JSX** syntax
15+
- output: **DOM** object or **XML** string of **HTML**, **SVG** & **MathML** languages
16+
1217
## Usage
1318

1419
### JavaScript

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dom-renderer",
3-
"version": "2.4.4",
3+
"version": "2.5.0",
44
"license": "LGPL-3.0-or-later",
55
"author": "[email protected]",
66
"description": "A light-weight DOM Renderer supports Web components standard & TypeScript language",
@@ -44,7 +44,7 @@
4444
"prettier": "^3.3.3",
4545
"ts-jest": "^29.2.5",
4646
"typedoc": "^0.26.11",
47-
"typedoc-plugin-mdn-links": "^3.3.6",
47+
"typedoc-plugin-mdn-links": "^3.3.7",
4848
"typescript": "~5.6.3"
4949
},
5050
"prettier": {

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

source/dist/DOMRenderer.ts

Lines changed: 76 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { findShadowRoots, generateHTML } from 'declarative-shadow-dom-polyfill';
21
import { ReadableStream } from 'web-streams-polyfill';
32
import {
43
diffKeys,
@@ -12,6 +11,12 @@ import {
1211

1312
import { DataObject, VNode } from './VDOM';
1413

14+
export interface UpdateTask {
15+
index?: number;
16+
oldVNode?: VNode;
17+
newVNode?: VNode;
18+
}
19+
1520
export class DOMRenderer {
1621
eventPattern = /^on[A-Z]/;
1722
ariaPattern = /^aira[A-Z]/;
@@ -32,6 +37,7 @@ export class DOMRenderer {
3237
: this.eventPattern.test(key)
3338
? key.toLowerCase()
3439
: key;
40+
protected attrsNameOf = (key: string) => VNode.propsMap[key] || key;
3541

3642
protected updateProps<N extends DataObject, P extends DataObject>(
3743
node: N,
@@ -50,20 +56,6 @@ export class DOMRenderer {
5056
else Reflect.set(node, key, newProps[key]);
5157
}
5258

53-
protected createNode(vNode: VNode, reusedVNodes?: Record<string, VNode[]>) {
54-
if (vNode.text) return vNode.createDOM(this.document);
55-
56-
const reusedVNode = vNode.selector && reusedVNodes?.[vNode.selector]?.shift();
57-
58-
vNode.node = reusedVNode?.node || vNode.createDOM(this.document);
59-
60-
const { node } = this.patch(
61-
reusedVNode || new VNode({ tagName: vNode.tagName, node: vNode.node }),
62-
vNode
63-
);
64-
return node;
65-
}
66-
6759
protected deleteNode({ ref, node, children }: VNode) {
6860
if (node instanceof DocumentFragment) children?.forEach(this.deleteNode);
6961
else if (node) {
@@ -73,53 +65,47 @@ export class DOMRenderer {
7365
}
7466
}
7567

76-
protected commitChildren(root: ParentNode, newNodes: ChildNode[]) {
77-
for (const oldNode of [...root.childNodes]) {
78-
const index = newNodes.indexOf(oldNode);
79-
80-
if (index < 0) continue;
81-
else if (index === 0) {
82-
newNodes.shift();
83-
continue;
84-
}
85-
const beforeNodes = newNodes.slice(0, index);
86-
87-
if (!beforeNodes[0]) continue;
68+
protected commitChild(root: ParentNode, node: Node, index = 0) {
69+
const targetNode = root.childNodes[index];
8870

89-
oldNode.before(...beforeNodes);
71+
if (targetNode === node) return;
9072

91-
newNodes = newNodes.slice(index + 1);
92-
}
93-
94-
if (newNodes[0]) root.append(...newNodes);
73+
if (!targetNode) root.append(node);
74+
else targetNode.before(node);
9575
}
9676

97-
protected updateChildren(node: ParentNode, oldList: VNode[], newList: VNode[]) {
98-
const { map, group } = diffKeys(oldList.map(this.keyOf), newList.map(this.keyOf));
77+
protected *diffVChildren(oldVNode: VNode, newVNode: VNode): Generator<UpdateTask> {
78+
newVNode.children = newVNode.children.map(vNode => new VNode(vNode));
79+
80+
const { map, group } = diffKeys(
81+
oldVNode.children!.map(this.keyOf),
82+
newVNode.children!.map(this.keyOf)
83+
);
9984
const deletingGroup =
10085
group[DiffStatus.Old] &&
10186
groupBy(
102-
group[DiffStatus.Old].map(([key]) => this.vNodeOf(oldList, key)),
87+
group[DiffStatus.Old].map(([key]) => this.vNodeOf(oldVNode.children!, key)),
10388
({ selector }) => selector + ''
10489
);
105-
const newNodes = newList.map((vNode, index) => {
106-
const key = this.keyOf(vNode, index);
10790

108-
if (map[key] !== DiffStatus.Same) return this.createNode(vNode, deletingGroup);
91+
for (const [index, newVChild] of newVNode.children!.entries()) {
92+
const key = this.keyOf(newVChild, index);
10993

110-
const oldVNode = this.vNodeOf(oldList, key)!;
94+
let oldVChild =
95+
map[key] === DiffStatus.Same
96+
? this.vNodeOf(oldVNode.children!, key)
97+
: deletingGroup?.[newVChild.selector]?.shift();
11198

112-
return vNode.text != null
113-
? (vNode.node = oldVNode.node)
114-
: this.patch(oldVNode, vNode).node;
115-
});
99+
yield { index, oldVNode: oldVChild, newVNode: newVChild };
116100

117-
for (const selector in deletingGroup)
118-
for (const vNode of deletingGroup[selector]) this.deleteNode(vNode);
101+
if (oldVChild?.children[0] || newVChild.children[0]) {
102+
oldVChild ||= new VNode({ ...newVChild, children: [] });
119103

120-
this.commitChildren(node, newNodes as ChildNode[]);
121-
122-
for (const { ref, node } of newList) ref?.(node);
104+
yield* this.diffVChildren(oldVChild, newVChild);
105+
}
106+
}
107+
for (const selector in deletingGroup)
108+
for (const oldVNode of deletingGroup[selector]) yield { oldVNode };
123109
}
124110

125111
protected handleCustomEvent(node: EventTarget, event: string) {
@@ -139,12 +125,12 @@ export class DOMRenderer {
139125
this.eventPattern.test(key)
140126
? (node[key.toLowerCase()] = null)
141127
: node.removeAttribute(
142-
this.ariaPattern.test(key) ? toHyphenCase(key) : VNode.propsMap[key] || key
128+
this.ariaPattern.test(key) ? toHyphenCase(key) : this.attrsNameOf(key)
143129
);
144130
protected setProperty = (node: Element, key: string, value: string) => {
145131
const isXML = templateOf(node.tagName) && elementTypeOf(node.tagName) === 'xml';
146132

147-
if (isXML || key.includes('-')) node.setAttribute(key, value);
133+
if (isXML || key.includes('-')) node.setAttribute(this.attrsNameOf(key), value);
148134
else
149135
try {
150136
const name = this.propsKeyOf(key);
@@ -154,11 +140,11 @@ export class DOMRenderer {
154140

155141
node[name] = value;
156142
} catch {
157-
node.setAttribute(key, value);
143+
node.setAttribute(this.attrsNameOf(key), value);
158144
}
159145
};
160146

161-
patch(oldVNode: VNode, newVNode: VNode): VNode {
147+
protected patchNode(oldVNode: VNode, newVNode: VNode) {
162148
this.updateProps(
163149
oldVNode.node as Element,
164150
oldVNode.props,
@@ -170,17 +156,44 @@ export class DOMRenderer {
170156
(oldVNode.node as HTMLElement).style,
171157
oldVNode.style,
172158
newVNode.style,
173-
(node, key) => node.removeProperty(toHyphenCase(key)),
174-
(node, key, value) => node.setProperty(toHyphenCase(key), value)
175-
);
176-
this.updateChildren(
177-
oldVNode.node as ParentNode,
178-
oldVNode.children || [],
179-
(newVNode.children = newVNode.children?.map(vNode => new VNode(vNode)) || [])
159+
(style, key) => style.removeProperty(toHyphenCase(key)),
160+
(style, key, value) => style.setProperty(toHyphenCase(key), value)
180161
);
181-
newVNode.node = oldVNode.node;
162+
newVNode.node ||= oldVNode.node;
163+
}
164+
165+
patch(oldVRoot: VNode, newVRoot: VNode) {
166+
if (VNode.isFragment(newVRoot))
167+
newVRoot = new VNode({ ...oldVRoot, children: newVRoot.children });
168+
169+
this.patchNode(oldVRoot, newVRoot);
170+
171+
for (let { index, oldVNode, newVNode } of this.diffVChildren(oldVRoot, newVRoot)) {
172+
if (!newVNode) {
173+
this.deleteNode(oldVNode);
174+
continue;
175+
}
176+
const inserting = !oldVNode;
177+
178+
if (oldVNode) newVNode.node = oldVNode.node;
179+
else {
180+
newVNode.createDOM(this.document);
181+
182+
const { tagName, node, parent } = newVNode;
183+
184+
oldVNode = new VNode({ tagName, node, parent });
185+
}
186+
187+
if (newVNode.text) oldVNode.node.nodeValue = newVNode.text;
188+
else if (!VNode.isFragment(newVNode)) this.patchNode(oldVNode, newVNode);
182189

183-
return newVNode;
190+
if (oldVNode.parent) {
191+
this.commitChild(oldVNode.parent.node as ParentNode, newVNode.node, index);
192+
193+
if (inserting) newVNode.ref?.(newVNode.node);
194+
}
195+
}
196+
return newVRoot;
184197
}
185198

186199
render(vNode: VNode, node: ParentNode = globalThis.document?.body) {
@@ -195,27 +208,11 @@ export class DOMRenderer {
195208
return root;
196209
}
197210

198-
protected buildRenderTree(tree: VNode) {
199-
const { body } = this.document.implementation.createHTMLDocument();
200-
201-
this.render(tree, body);
202-
203-
const shadowRoots = [...findShadowRoots(body)];
204-
205-
return { body, shadowRoots };
206-
}
207-
208211
renderToStaticMarkup(tree: VNode) {
209-
const { body, shadowRoots } = this.buildRenderTree(tree);
210-
211-
return body.getHTML({ serializableShadowRoots: true, shadowRoots });
212+
return [...tree.generateXML()].join('');
212213
}
213214

214215
renderToReadableStream(tree: VNode) {
215-
const { body, shadowRoots } = this.buildRenderTree(tree);
216-
217-
return ReadableStream.from(
218-
generateHTML(body, { serializableShadowRoots: true, shadowRoots })
219-
);
216+
return ReadableStream.from(tree.generateXML());
220217
}
221218
}

source/dist/VDOM.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { HTMLProps, IndexKey, isEmpty, MathMLProps, SVGProps, XMLNamespace } from 'web-utility';
1+
import { findShadowRoots } from 'declarative-shadow-dom-polyfill';
2+
import {
3+
elementTypeOf,
4+
HTMLProps,
5+
IndexKey,
6+
isEmpty,
7+
MathMLProps,
8+
SVGProps,
9+
toHyphenCase,
10+
XMLNamespace
11+
} from 'web-utility';
212

313
export type DataObject = Record<string, any>;
414

@@ -78,6 +88,64 @@ export class VNode extends VNodeMeta {
7888
);
7989
}
8090

91+
protected *generateElementXML(): Generator<string> {
92+
const { tagName, props, style, children, node } = this;
93+
94+
if (tagName.includes('-') && elementTypeOf(tagName) === 'html') {
95+
const { body } = (node?.ownerDocument || document).implementation.createHTMLDocument();
96+
97+
body.innerHTML = `<${tagName}></${tagName}>`;
98+
99+
const shadowRoots = [...findShadowRoots(body)];
100+
101+
yield body.getHTML({ serializableShadowRoots: true, shadowRoots });
102+
} else {
103+
const { innerHTML, ...restProps } = props;
104+
105+
yield `<${tagName}`;
106+
107+
for (const key in restProps) {
108+
yield ` ${VNode.propsMap[key] || key}="${restProps[key]}"`;
109+
}
110+
if (style) {
111+
yield ` style="`;
112+
113+
for (const key in style) {
114+
yield `${toHyphenCase(key)}:${style[key]};`;
115+
}
116+
yield `"`;
117+
}
118+
if (innerHTML) {
119+
yield `>${innerHTML}</${tagName}>`;
120+
} else if (children[0]) {
121+
yield '>';
122+
123+
for (const child of children) {
124+
yield* child.generateXML();
125+
}
126+
yield `</${tagName}>`;
127+
} else {
128+
yield ` />`;
129+
}
130+
}
131+
}
132+
133+
*generateXML(this: VNode): Generator<string> {
134+
if (VNode.isFragment(this)) {
135+
yield '<template';
136+
137+
const { mode } = (this.node || {}) as ShadowRoot;
138+
139+
if (mode) yield ` shadowrootmode="${mode}"`;
140+
141+
yield '>';
142+
} else if (this.text != null) {
143+
yield this.text;
144+
} else {
145+
yield* this.generateElementXML();
146+
}
147+
}
148+
81149
static propsMap: Partial<Record<keyof HTMLProps<HTMLLabelElement>, string>> = {
82150
className: 'class',
83151
htmlFor: 'for'

test/jsx-runtime.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ describe('JSX runtime', () => {
176176
});
177177

178178
it('should render to a Static String', () => {
179-
expect(renderer.renderToStaticMarkup(<i />)).toBe('<i></i>');
179+
expect(renderer.renderToStaticMarkup(<i />)).toBe('<i />');
180180
});
181181

182182
it('should render SVG', () => {

0 commit comments

Comments
 (0)