Skip to content

Commit ea506db

Browse files
committed
[add] DOM Reference callback property
[optimize] merge VNode interface & VDOMNode class [optimize] Type & Logic details of VDOM & JSX
1 parent 5293b6d commit ea506db

File tree

7 files changed

+112
-83
lines changed

7 files changed

+112
-83
lines changed

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.0.0-rc.4",
3+
"version": "2.0.0-rc.5",
44
"license": "LGPL-3.0-or-later",
55
"author": "[email protected]",
66
"description": "A light-weight DOM Renderer supports Web components standard & TypeScript language",
@@ -35,7 +35,7 @@
3535
"jest-environment-jsdom": "^29.6.2",
3636
"lint-staged": "^13.2.3",
3737
"open-cli": "^7.2.0",
38-
"prettier": "^3.0.0",
38+
"prettier": "^3.0.1",
3939
"ts-jest": "^29.1.1",
4040
"typedoc": "^0.24.8",
4141
"typedoc-plugin-mdn-links": "^3.0.3",

pnpm-lock.yaml

Lines changed: 4 additions & 4 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: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ import {
88
toHyphenCase
99
} from 'web-utility';
1010

11-
import { DataObject, VDOMNode, VNode } from './VDOM';
11+
import { DataObject, VNode } from './VDOM';
1212

1313
export class DOMRenderer {
1414
eventPattern = /^on[A-Z]/;
1515
ariaPattern = /^aira[A-Z]/;
1616

17+
protected treeCache = new WeakMap<Node, VNode>();
18+
1719
protected keyOf = ({ key, text, props, selector }: VNode, index?: number) =>
18-
key || props?.id || text || (selector && selector + index);
20+
key?.toString() || props?.id || text || (selector && selector + index);
1921

20-
protected vNodeOf = (list: VNode[], key: string) =>
21-
list.find((vNode, index) => this.keyOf(vNode, index) + '' === key);
22+
protected vNodeOf = (list: VNode[], key?: VNode['key']) =>
23+
list.find(
24+
(vNode, index) => `${this.keyOf(vNode, index)}` === String(key)
25+
);
2226

2327
protected propsKeyOf = (key: string) =>
2428
key.startsWith('aria-')
@@ -27,12 +31,12 @@ export class DOMRenderer {
2731
? key.toLowerCase()
2832
: key;
2933

30-
protected updateProps<T extends DataObject>(
31-
node: T,
32-
oldProps: DataObject = {},
33-
newProps: DataObject = {},
34-
onDelete?: (node: T, key: string) => any,
35-
onAdd?: (node: T, key: string, value: any) => any
34+
protected updateProps<N extends DataObject, P extends DataObject>(
35+
node: N,
36+
oldProps = {} as P,
37+
newProps = {} as P,
38+
onDelete?: (node: N, key: string) => any,
39+
onAdd?: (node: N, key: string, value: any) => any
3640
) {
3741
const { group } = diffKeys(
3842
Object.keys(oldProps),
@@ -58,8 +62,19 @@ export class DOMRenderer {
5862
? document.createElement(vNode.tagName, { is: vNode.is })
5963
: document.createDocumentFragment();
6064

61-
return this.patch({ tagName: vNode.tagName, node: vNode.node }, vNode)
62-
.node;
65+
const { node } = this.patch(
66+
{ tagName: vNode.tagName, node: vNode.node },
67+
vNode
68+
);
69+
if (node) vNode.ref?.(node);
70+
71+
return node;
72+
}
73+
74+
deleteNode({ node, children }: VNode) {
75+
if (node instanceof DocumentFragment)
76+
children?.forEach(this.deleteNode);
77+
else (node as ChildNode)?.remove();
6378
}
6479

6580
protected updateChildren(
@@ -73,7 +88,7 @@ export class DOMRenderer {
7388
);
7489

7590
for (const [key] of group[DiffStatus.Old] || [])
76-
(this.vNodeOf(oldList, key)?.node as ChildNode)?.remove();
91+
this.deleteNode(this.vNodeOf(oldList, key));
7792

7893
const newNodes = newList.map((vNode, index) => {
7994
const key = this.keyOf(vNode, index);
@@ -101,7 +116,7 @@ export class DOMRenderer {
101116
: node.removeAttribute(
102117
this.ariaPattern.test(key)
103118
? toHyphenCase(key)
104-
: VDOMNode.propsMap[key] || key
119+
: VNode.propsMap[key] || key
105120
),
106121
(node, key, value) => {
107122
// @ts-ignore
@@ -127,9 +142,13 @@ export class DOMRenderer {
127142
}
128143

129144
render(vNode: VNode, node: Element = document.body) {
130-
const root = new VDOMNode(node);
145+
var root = this.treeCache.get(node) || VNode.fromDOM(node);
146+
147+
root = this.patch(root, { ...root, children: [vNode] });
148+
149+
this.treeCache.set(node, root);
131150

132-
return this.patch(root, { ...root, children: [vNode] });
151+
return root;
133152
}
134153

135154
renderToStaticMarkup(tree: VNode) {

source/dist/VDOM.ts

Lines changed: 45 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,85 +2,91 @@ import { HTMLProps, IndexKey } from 'web-utility';
22

33
export type DataObject = Record<string, any>;
44

5-
export interface VNode {
5+
export class VNode {
66
key?: IndexKey;
7+
ref?: (node: Node) => any;
78
text?: string;
89
selector?: string;
910
tagName?: string;
1011
is?: string;
1112
props?: DataObject;
12-
style?: DataObject;
13+
style?: HTMLProps<HTMLElement>['style'];
1314
children?: VNode[];
1415
node?: Node;
15-
}
1616

17-
export class VDOMNode implements VNode {
18-
text?: string;
19-
tagName?: string;
20-
is?: string;
21-
props?: DataObject;
22-
style?: DataObject;
23-
children?: VNode[];
17+
constructor(meta: VNode) {
18+
Object.assign(this, meta);
19+
20+
const { tagName, is, props } = meta;
21+
22+
if (!tagName && !props?.className && !is) return;
2423

25-
static selectorOf = (tagName: string, is?: string, className?: string) =>
26-
[
27-
tagName.toLowerCase(),
28-
className && `.${className.trim().replace(/\s+/, '.')}`,
24+
this.selector = [
25+
tagName?.toLowerCase(),
26+
props?.className &&
27+
`.${props.className.trim().replace(/\s+/, '.')}`,
2928
is && `[is="${is}"]`
3029
]
3130
.filter(Boolean)
3231
.join('');
33-
34-
get selector() {
35-
const { tagName, is, props } = this;
36-
37-
return tagName && VDOMNode.selectorOf(tagName, is, props?.className);
3832
}
3933

40-
static propsMap: DataObject = { className: 'class', htmlFor: 'for' };
34+
static propsMap: Partial<
35+
Record<keyof HTMLProps<HTMLLabelElement>, string>
36+
> = {
37+
className: 'class',
38+
htmlFor: 'for'
39+
};
4140

42-
static attrsMap = Object.fromEntries(
43-
Object.entries(this.propsMap).map(item => item.reverse())
44-
);
41+
static attrsMap: Record<string, keyof HTMLProps<HTMLLabelElement>> =
42+
Object.fromEntries(
43+
Object.entries(this.propsMap).map(item => item.reverse())
44+
);
4545

46-
constructor(public node: Node) {
47-
if (node instanceof Text) {
48-
this.text = node.nodeValue;
49-
return;
50-
}
51-
if (!(node instanceof Element)) return;
52-
53-
const { tagName, attributes, style, childNodes } = node as HTMLElement;
46+
static fromDOM(node: Node) {
47+
if (node instanceof Text)
48+
return new VNode({ node, text: node.nodeValue });
5449

55-
this.tagName = tagName.toLowerCase();
56-
this.is = node.getAttribute('is');
50+
if (!(node instanceof Element)) return new VNode({ node });
5751

52+
const { tagName, attributes, style, childNodes } = node as HTMLElement;
53+
const vNode: VNode = {
54+
node,
55+
tagName: tagName.toLowerCase(),
56+
is: node.getAttribute('is')
57+
};
5858
const props = Array.from(
5959
attributes,
6060
({ name, value }) =>
61-
name !== 'style' && [VDOMNode.attrsMap[name] || name, value]
61+
name !== 'style' && [this.attrsMap[name] || name, value]
6262
).filter(Boolean);
6363

64-
if (props[0]) this.props = Object.fromEntries(props);
64+
if (props[0]) vNode.props = Object.fromEntries(props);
6565

6666
const styles = Array.from(style, key => [key, style[key]]);
6767

68-
if (styles[0]) this.style = Object.fromEntries(styles);
68+
if (styles[0]) vNode.style = Object.fromEntries(styles);
69+
70+
const children = Array.from(childNodes, node => VNode.fromDOM(node));
6971

70-
const children = Array.from(childNodes, node => new VDOMNode(node));
72+
if (children[0]) vNode.children = children;
7173

72-
if (children[0]) this.children = children;
74+
return new VNode(vNode);
7375
}
7476
}
7577

7678
declare global {
79+
/**
80+
* @see {@link https://www.typescriptlang.org/docs/handbook/jsx.html}
81+
*/
7782
namespace JSX {
7883
type Element = VNode;
7984

8085
type IntrinsicElements = {
8186
[tagName in keyof HTMLElementTagNameMap]: HTMLProps<
8287
HTMLElementTagNameMap[tagName]
83-
>;
88+
> &
89+
Pick<VNode, 'is' | 'key' | 'ref'>;
8490
};
8591
}
8692
}

source/jsx-runtime.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { IndexKey, isHTMLElementClass, tagNameOf } from 'web-utility';
22

3-
import { DataObject, VDOMNode, VNode } from './dist/VDOM';
3+
import { DataObject, VNode } from './dist/VDOM';
44

55
/**
66
* @see {@link https://github.com/reactjs/rfcs/blob/createlement-rfc/text/0000-create-element-changes.md}
77
* @see {@link https://babeljs.io/docs/babel-plugin-transform-react-jsx}
88
*/
99
export function jsx(
1010
type: string | Function,
11-
{ is, style, children, ...props }: DataObject,
11+
{ ref, is, style, children, ...props }: DataObject,
1212
key?: IndexKey
1313
): VNode {
1414
if (typeof type === 'function' && isHTMLElementClass(type))
@@ -20,20 +20,14 @@ export function jsx(
2020
node instanceof Object
2121
? node
2222
: node === 0 || node
23-
? { text: node + '' }
24-
: { text: '' }
23+
? new VNode({ text: node.toString() })
24+
: new VNode({ text: '' })
2525
);
26+
const commonProps: VNode = { key, ref, is, style, children };
27+
2628
return typeof type === 'string'
27-
? {
28-
key,
29-
selector: VDOMNode.selectorOf(type, is, props.className),
30-
tagName: type,
31-
is,
32-
props,
33-
style,
34-
children
35-
}
36-
: type({ is, style, children, ...props });
29+
? new VNode({ ...commonProps, tagName: type, props })
30+
: type({ ...commonProps, ...props });
3731
}
3832

3933
export const jsxs = jsx;
@@ -42,11 +36,11 @@ export const jsxs = jsx;
4236
* @see {@link https://babeljs.io/docs/babel-plugin-transform-react-jsx#react-automatic-runtime-1}
4337
*/
4438
export const Fragment = ({
39+
key,
40+
ref,
41+
is,
4542
style,
4643
children,
4744
...props
48-
}: VNode['props'] & Pick<VNode, 'style' | 'children'>): VNode => ({
49-
props,
50-
style,
51-
children
52-
});
45+
}: VNode['props'] & Omit<VNode, 'props'>) =>
46+
new VNode({ key, ref, is, props, style, children });

test/DOMRenderer.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DOMRenderer, VDOMNode, VNode } from '../source/dist';
1+
import { DOMRenderer, VNode } from '../source/dist';
22

33
describe('DOM Renderer', () => {
44
const renderer = new DOMRenderer(),
@@ -23,7 +23,7 @@ describe('DOM Renderer', () => {
2323
it('should update DOM styles', () => {
2424
const newVNode = renderer.patch(root, {
2525
...root,
26-
style: { margin: 0 }
26+
style: { margin: '0' }
2727
});
2828
expect(document.body.style.margin).toBe('0px');
2929

@@ -53,7 +53,7 @@ describe('DOM Renderer', () => {
5353
});
5454

5555
it('should transfer a DOM node to a Virtual DOM node', () => {
56-
const { tagName, selector, node } = new VDOMNode(document.body);
56+
const { tagName, selector, node } = VNode.fromDOM(document.body);
5757

5858
expect({ tagName, selector, node }).toEqual(root);
5959
});

test/jsx-runtime.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ describe('JSX runtime', () => {
9191
expect(onClick).toBeCalledTimes(1);
9292
});
9393

94+
it('should pass a real DOM Node by a callback', () => {
95+
const ref = jest.fn();
96+
97+
renderer.render(jsx('b', { ref }));
98+
99+
expect(document.body.innerHTML).toBe('<b></b>');
100+
101+
expect(ref).toBeCalledWith(document.body.firstChild);
102+
});
103+
94104
it('should render to a Static String', () => {
95105
expect(renderer.renderToStaticMarkup(jsx('i', {}))).toBe('<i></i>');
96106
});

0 commit comments

Comments
 (0)