From 325e8088e9b5ee837550832c6e9dbdee8bed92e3 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Sat, 5 Aug 2023 03:25:43 +0800 Subject: [PATCH] [add] VNode unRef() callback [optimize] reuse similar Deleting DOM nodes instead of Creating ones [fix] Empty & Text children updating [migrate] replace David-DM.org with Libraries.io for Dependency badge --- ReadMe.md | 31 +++++++++++------- package.json | 4 +-- pnpm-lock.yaml | 64 +++++++++++++++++++------------------- source/dist/DOMRenderer.ts | 46 +++++++++++++++++++-------- source/dist/VDOM.ts | 1 + source/jsx-runtime.ts | 8 ++--- test/jsx-runtime.spec.ts | 41 ++++++++++++++++++++++-- tsconfig.json | 2 +- 8 files changed, 130 insertions(+), 67 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 530a7b8..2783cb6 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -2,12 +2,13 @@ A light-weight DOM Renderer supports [Web components][1] standard & [TypeScript][2] language. -[![CI & CD](https://github.com/EasyWebApp/DOM-Renderer/actions/workflows/main.yml/badge.svg)][3] - -[![Open in GitPod](https://img.shields.io/badge/GitPod-dev--now-blue?logo=gitpod)][4] +[![NPM Dependency](https://img.shields.io/librariesio/github/EasyWebApp/DOM-Renderer.svg)][3] +[![CI & CD](https://github.com/EasyWebApp/DOM-Renderer/actions/workflows/main.yml/badge.svg)][4] [![NPM](https://nodei.co/npm/dom-renderer.png?downloads=true&downloadRank=true&stars=true)][5] +[![Open in GitPod](https://img.shields.io/badge/GitPod-dev--now-blue?logo=gitpod)][6] + ## Usage ### JavaScript @@ -38,7 +39,7 @@ console.log(newVNode); ### TypeScript -[![Edit DOM Renderer example](https://codesandbox.io/static/img/play-codesandbox.svg)][6] +[![Edit DOM Renderer example](https://codesandbox.io/static/img/play-codesandbox.svg)][7] #### `tsconfig.json` @@ -65,21 +66,29 @@ const newVNode = new DOMRenderer().render( console.log(newVNode); ``` +## Framework + +### Web components + +[![Edit MobX Web components](https://codesandbox.io/static/img/play-codesandbox.svg)][8] + ## Original ### Inspiration -[![SnabbDOM](https://github.com/snabbdom.png)][7] +[![SnabbDOM](https://github.com/snabbdom.png)][9] ### Prototype -[![Edit DOM Renderer](https://codesandbox.io/static/img/play-codesandbox.svg)][8] +[![Edit DOM Renderer](https://codesandbox.io/static/img/play-codesandbox.svg)][10] [1]: https://www.webcomponents.org/ [2]: https://www.typescriptlang.org/ -[3]: https://github.com/EasyWebApp/DOM-Renderer/actions/workflows/main.yml -[4]: https://gitpod.io/#https://github.com/EasyWebApp/DOM-Renderer +[3]: https://libraries.io/npm/dom-renderer +[4]: https://github.com/EasyWebApp/DOM-Renderer/actions/workflows/main.yml [5]: https://nodei.co/npm/dom-renderer/ -[6]: https://codesandbox.io/s/dom-renderer-example-pmcsvs?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.tsx&theme=dark -[7]: https://github.com/snabbdom/snabbdom -[8]: https://codesandbox.io/s/dom-renderer-pglxkx?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.ts&theme=dark +[6]: https://gitpod.io/#https://github.com/EasyWebApp/DOM-Renderer +[7]: https://codesandbox.io/s/dom-renderer-example-pmcsvs?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.tsx&theme=dark +[8]: https://codesandbox.io/s/mobx-web-components-pvn9rf?autoresize=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2FWebComponent.ts&moduleview=1&theme=dark +[9]: https://github.com/snabbdom/snabbdom +[10]: https://codesandbox.io/s/dom-renderer-pglxkx?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.ts&theme=dark diff --git a/package.json b/package.json index 7811068..efe18f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dom-renderer", - "version": "2.0.0-rc.5", + "version": "2.0.0", "license": "LGPL-3.0-or-later", "author": "shiy2008@gmail.com", "description": "A light-weight DOM Renderer supports Web components standard & TypeScript language", @@ -29,7 +29,7 @@ }, "devDependencies": { "@types/jest": "^29.5.3", - "@types/node": "^18.17.1", + "@types/node": "^18.17.2", "husky": "^8.0.3", "jest": "^29.6.2", "jest-environment-jsdom": "^29.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74725a9..f6b64be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,14 +17,14 @@ devDependencies: specifier: ^29.5.3 version: 29.5.3 '@types/node': - specifier: ^18.17.1 - version: 18.17.1 + specifier: ^18.17.2 + version: 18.17.2 husky: specifier: ^8.0.3 version: 8.0.3 jest: specifier: ^29.6.2 - version: 29.6.2(@types/node@18.17.1) + version: 29.6.2(@types/node@18.17.2) jest-environment-jsdom: specifier: ^29.6.2 version: 29.6.2 @@ -412,7 +412,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 chalk: 4.1.2 jest-message-util: 29.6.2 jest-util: 29.6.2 @@ -433,14 +433,14 @@ packages: '@jest/test-result': 29.6.2 '@jest/transform': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.8.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.5.0 - jest-config: 29.6.2(@types/node@18.17.1) + jest-config: 29.6.2(@types/node@18.17.2) jest-haste-map: 29.6.2 jest-message-util: 29.6.2 jest-regex-util: 29.4.3 @@ -468,7 +468,7 @@ packages: dependencies: '@jest/fake-timers': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-mock: 29.6.2 dev: true @@ -495,7 +495,7 @@ packages: dependencies: '@jest/types': 29.6.1 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-message-util: 29.6.2 jest-mock: 29.6.2 jest-util: 29.6.2 @@ -528,7 +528,7 @@ packages: '@jest/transform': 29.6.2 '@jest/types': 29.6.1 '@jridgewell/trace-mapping': 0.3.18 - '@types/node': 18.17.1 + '@types/node': 18.17.2 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -616,7 +616,7 @@ packages: '@jest/schemas': 29.6.0 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 '@types/yargs': 17.0.24 chalk: 4.1.2 dev: true @@ -718,7 +718,7 @@ packages: /@types/graceful-fs@4.1.6: resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} dependencies: - '@types/node': 18.17.1 + '@types/node': 18.17.2 dev: true /@types/istanbul-lib-coverage@2.0.4: @@ -747,7 +747,7 @@ packages: /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: - '@types/node': 18.17.1 + '@types/node': 18.17.2 '@types/tough-cookie': 4.0.2 parse5: 7.1.2 dev: true @@ -756,8 +756,8 @@ packages: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} dev: true - /@types/node@18.17.1: - resolution: {integrity: sha512-xlR1jahfizdplZYRU59JlUx9uzF1ARa8jbhM11ccpCJya8kvos5jwdm2ZAgxSCwOl0fq21svP18EVwPBXMQudw==} + /@types/node@18.17.2: + resolution: {integrity: sha512-wBo3KqP/PBqje5TI9UTiuL3yWfP6sdPtjtygSOqcYZWT232dfDeDOnkDps5wqZBP9NgGgYrNejinl0faAuE+HQ==} dev: true /@types/normalize-package-data@2.4.1: @@ -1454,7 +1454,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/expect-utils': 29.6.2 - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-get-type: 29.4.3 jest-matcher-utils: 29.6.2 jest-message-util: 29.6.2 @@ -1845,7 +1845,7 @@ packages: '@jest/expect': 29.6.2 '@jest/test-result': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.1 @@ -1866,7 +1866,7 @@ packages: - supports-color dev: true - /jest-cli@29.6.2(@types/node@18.17.1): + /jest-cli@29.6.2(@types/node@18.17.2): resolution: {integrity: sha512-TT6O247v6dCEX2UGHGyflMpxhnrL0DNqP2fRTKYm3nJJpCTfXX3GCMQPGFjXDoj0i5/Blp3jriKXFgdfmbYB6Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -1883,7 +1883,7 @@ packages: exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.1.0 - jest-config: 29.6.2(@types/node@18.17.1) + jest-config: 29.6.2(@types/node@18.17.2) jest-util: 29.6.2 jest-validate: 29.6.2 prompts: 2.4.2 @@ -1895,7 +1895,7 @@ packages: - ts-node dev: true - /jest-config@29.6.2(@types/node@18.17.1): + /jest-config@29.6.2(@types/node@18.17.2): resolution: {integrity: sha512-VxwFOC8gkiJbuodG9CPtMRjBUNZEHxwfQXmIudSTzFWxaci3Qub1ddTRbFNQlD/zUeaifLndh/eDccFX4wCMQw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -1910,7 +1910,7 @@ packages: '@babel/core': 7.22.9 '@jest/test-sequencer': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 babel-jest: 29.6.2(@babel/core@7.22.9) chalk: 4.1.2 ci-info: 3.8.0 @@ -1976,7 +1976,7 @@ packages: '@jest/fake-timers': 29.6.2 '@jest/types': 29.6.1 '@types/jsdom': 20.0.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-mock: 29.6.2 jest-util: 29.6.2 jsdom: 20.0.3 @@ -1993,7 +1993,7 @@ packages: '@jest/environment': 29.6.2 '@jest/fake-timers': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-mock: 29.6.2 jest-util: 29.6.2 dev: true @@ -2009,7 +2009,7 @@ packages: dependencies: '@jest/types': 29.6.1 '@types/graceful-fs': 4.1.6 - '@types/node': 18.17.1 + '@types/node': 18.17.2 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -2060,7 +2060,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-util: 29.6.2 dev: true @@ -2115,7 +2115,7 @@ packages: '@jest/test-result': 29.6.2 '@jest/transform': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -2146,7 +2146,7 @@ packages: '@jest/test-result': 29.6.2 '@jest/transform': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -2198,7 +2198,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 chalk: 4.1.2 ci-info: 3.8.0 graceful-fs: 4.2.11 @@ -2223,7 +2223,7 @@ packages: dependencies: '@jest/test-result': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 18.17.1 + '@types/node': 18.17.2 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -2235,13 +2235,13 @@ packages: resolution: {integrity: sha512-l3ccBOabTdkng8I/ORCkADz4eSMKejTYv1vB/Z83UiubqhC1oQ5Li6dWCyqOIvSifGjUBxuvxvlm6KGK2DtuAQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 18.17.1 + '@types/node': 18.17.2 jest-util: 29.6.2 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true - /jest@29.6.2(@types/node@18.17.1): + /jest@29.6.2(@types/node@18.17.2): resolution: {integrity: sha512-8eQg2mqFbaP7CwfsTpCxQ+sHzw1WuNWL5UUvjnWP4hx2riGz9fPSzYOaU5q8/GqWn1TfgZIVTqYJygbGbWAANg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -2254,7 +2254,7 @@ packages: '@jest/core': 29.6.2 '@jest/types': 29.6.1 import-local: 3.1.0 - jest-cli: 29.6.2(@types/node@18.17.1) + jest-cli: 29.6.2(@types/node@18.17.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -3300,7 +3300,7 @@ packages: '@babel/core': 7.22.9 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.6.2(@types/node@18.17.1) + jest: 29.6.2(@types/node@18.17.2) jest-util: 29.6.2 json5: 2.2.3 lodash.memoize: 4.1.2 diff --git a/source/dist/DOMRenderer.ts b/source/dist/DOMRenderer.ts index b8bc2f0..cd39d64 100644 --- a/source/dist/DOMRenderer.ts +++ b/source/dist/DOMRenderer.ts @@ -2,6 +2,7 @@ import { diffKeys, DiffStatus, elementTypeOf, + groupBy, isDOMReadOnly, templateOf, toCamelCase, @@ -17,7 +18,7 @@ export class DOMRenderer { protected treeCache = new WeakMap(); protected keyOf = ({ key, text, props, selector }: VNode, index?: number) => - key?.toString() || props?.id || text || (selector && selector + index); + key?.toString() || props?.id || (text || selector) + index; protected vNodeOf = (list: VNode[], key?: VNode['key']) => list.find( @@ -54,16 +55,20 @@ export class DOMRenderer { else Reflect.set(node, key, newProps[key]); } - protected createNode(vNode: VNode) { + protected createNode(vNode: VNode, reusedVNodes?: Record) { if (vNode.text) return (vNode.node = document.createTextNode(vNode.text)); + const reusedVNode = + vNode.selector && reusedVNodes?.[vNode.selector]?.shift(); + vNode.node = vNode.tagName - ? document.createElement(vNode.tagName, { is: vNode.is }) + ? reusedVNode?.node || + document.createElement(vNode.tagName, { is: vNode.is }) : document.createDocumentFragment(); const { node } = this.patch( - { tagName: vNode.tagName, node: vNode.node }, + reusedVNode || { tagName: vNode.tagName, node: vNode.node }, vNode ); if (node) vNode.ref?.(node); @@ -71,10 +76,14 @@ export class DOMRenderer { return node; } - deleteNode({ node, children }: VNode) { + deleteNode({ unRef, node, children }: VNode) { if (node instanceof DocumentFragment) children?.forEach(this.deleteNode); - else (node as ChildNode)?.remove(); + else if (node) { + (node as ChildNode).remove(); + + unRef?.(node); + } } protected updateChildren( @@ -86,19 +95,30 @@ export class DOMRenderer { oldList.map(this.keyOf), newList.map(this.keyOf) ); - - for (const [key] of group[DiffStatus.Old] || []) - this.deleteNode(this.vNodeOf(oldList, key)); - + const deletingGroup = + group[DiffStatus.Old] && + groupBy( + group[DiffStatus.Old].map(([key]) => + this.vNodeOf(oldList, key) + ), + ({ selector }) => selector + '' + ); const newNodes = newList.map((vNode, index) => { const key = this.keyOf(vNode, index); - if (map[key] === DiffStatus.Same) - return this.patch(this.vNodeOf(oldList, key)!, vNode).node; + if (map[key] !== DiffStatus.Same) + return this.createNode(vNode, deletingGroup); + + const oldVNode = this.vNodeOf(oldList, key)!; - return this.createNode(vNode); + return vNode.text != null + ? (vNode.node = oldVNode.node) + : this.patch(oldVNode, vNode).node; }); + for (const selector in deletingGroup) + for (const vNode of deletingGroup[selector]) this.deleteNode(vNode); + node.append(...newNodes); } diff --git a/source/dist/VDOM.ts b/source/dist/VDOM.ts index aa69c6a..90b2bad 100644 --- a/source/dist/VDOM.ts +++ b/source/dist/VDOM.ts @@ -5,6 +5,7 @@ export type DataObject = Record; export class VNode { key?: IndexKey; ref?: (node: Node) => any; + unRef?: (node: Node) => any; text?: string; selector?: string; tagName?: string; diff --git a/source/jsx-runtime.ts b/source/jsx-runtime.ts index e2ac137..d7b2bf4 100644 --- a/source/jsx-runtime.ts +++ b/source/jsx-runtime.ts @@ -8,22 +8,20 @@ import { DataObject, VNode } from './dist/VDOM'; */ export function jsx( type: string | Function, - { ref, is, style, children, ...props }: DataObject, + { ref, unRef, is, style, children, ...props }: DataObject, key?: IndexKey ): VNode { if (typeof type === 'function' && isHTMLElementClass(type)) type = tagNameOf(type); - children = ( - children instanceof Array ? children : children && [children] - )?.map(node => + children = (children instanceof Array ? children : [children])?.map(node => node instanceof Object ? node : node === 0 || node ? new VNode({ text: node.toString() }) : new VNode({ text: '' }) ); - const commonProps: VNode = { key, ref, is, style, children }; + const commonProps: VNode = { key, ref, unRef, is, style, children }; return typeof type === 'string' ? new VNode({ ...commonProps, tagName: type, props }) diff --git a/test/jsx-runtime.spec.ts b/test/jsx-runtime.spec.ts index ff1dd38..a29f38f 100644 --- a/test/jsx-runtime.spec.ts +++ b/test/jsx-runtime.spec.ts @@ -91,14 +91,49 @@ describe('JSX runtime', () => { expect(onClick).toBeCalledTimes(1); }); - it('should pass a real DOM Node by a callback', () => { + it('should pass a real DOM Node by callbacks', () => { const ref = jest.fn(); - renderer.render(jsx('b', { ref })); + renderer.render(jsx('b', { ref, unRef: ref })); + + const { firstChild } = document.body; expect(document.body.innerHTML).toBe(''); - expect(ref).toBeCalledWith(document.body.firstChild); + expect(ref).toBeCalledWith(firstChild); + + renderer.render(jsx('a', {})); + + expect(document.body.innerHTML).toBe(''); + + expect(ref).toBeCalledWith(firstChild); + }); + + it('should reuse similar DOM nodes', () => { + const renderList = (offset = 0) => + renderer.render( + jsx('ul', { + children: Array.from(new Array(2), (_, index) => { + const key = String.fromCodePoint( + 'a'.charCodeAt(0) + index + offset + ); + return jsx('li', { children: [key] }, key); + }) + }) + ); + renderList(); + + expect(document.body.innerHTML).toBe('
  • a
  • b
'); + + const { children } = document.body.firstElementChild!; + + renderList(2); + + expect(document.body.innerHTML).toBe('
  • c
  • d
'); + + expect([...document.body.firstElementChild!.children]).toEqual([ + ...children + ]); }); it('should render to a Static String', () => { diff --git a/tsconfig.json b/tsconfig.json index 7c33a63..964b4ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "esModuleInterop": true, "importHelpers": true, - "lib": ["ES2023", "DOM"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "skipLibCheck": true, "declaration": true, "outDir": "dist"