From 9b344342f2ae4ec8b72581b284e389523cf83108 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Fri, 13 Mar 2020 17:18:40 +0800 Subject: [PATCH 1/4] Initial --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71c7963 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +package-lock.json +node_modules/ \ No newline at end of file From 6d9291bbfcbbe559b6478f3948898aa1c0185ff2 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Fri, 13 Mar 2020 17:25:56 +0800 Subject: [PATCH 2/4] [add] Core methods of PoC --- package.json | 49 ++++++++++++++++++++++++++++++ source/factory.ts | 19 ++++++++++++ source/index.ts | 3 ++ source/renderer.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++ source/type.ts | 12 ++++++++ test/polyfill.ts | 6 ++++ tsconfig.json | 12 ++++++++ 7 files changed, 175 insertions(+) create mode 100644 package.json create mode 100644 source/factory.ts create mode 100644 source/index.ts create mode 100644 source/renderer.ts create mode 100644 source/type.ts create mode 100644 test/polyfill.ts create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..7db9ad4 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "dom-renderer", + "version": "2.0.0-alpha.0", + "license": "LGPL-3.0", + "author": "shiy2008@gmail.com", + "description": "DOM Renderer based on TSX & DOM-compatible virtual DOM", + "keywords": [ + "DOM", + "render", + "TypeScript", + "JSX", + "vDOM" + ], + "homepage": "https://web-cell.dev/DOM-Renderer/", + "repository": { + "type": "git", + "url": "git+https://github.com/EasyWebApp/DOM-Renderer.git" + }, + "bugs": { + "url": "https://github.com/EasyWebApp/DOM-Renderer/issues" + }, + "source": "source/index.ts", + "devDependencies": { + "@types/jest": "^25.1.4", + "@types/jsdom": "^16.1.0", + "jest": "^25.1.0", + "jsdom": "^16.2.1", + "lint-staged": "^10.0.8", + "prettier": "^1.19.1", + "ts-jest": "^25.2.1", + "typescript": "^3.8.3" + }, + "prettier": { + "singleQuote": true, + "tabWidth": 4 + }, + "lint-staged": { + "*.{json,ts,tsx}": [ + "prettier --write" + ] + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node" + }, + "scripts": { + "test": "lint-staged && jest" + } +} diff --git a/source/factory.ts b/source/factory.ts new file mode 100644 index 0000000..a2f64c0 --- /dev/null +++ b/source/factory.ts @@ -0,0 +1,19 @@ +import { CustomElementClass, VChild, VNode } from './type'; + +export function createCell( + tag: string | Function | CustomElementClass, + data?: any, + ...childNodes: VChild[] +): VNode { + if (typeof tag === 'function') { + try { + const node = new (tag as CustomElementClass)(); + + if (node instanceof HTMLElement) tag = node.tagName.toLowerCase(); + } catch {} + + if (typeof tag === 'function') return (tag as Function)(data); + } + + return { ...data, tagName: tag, childNodes }; +} diff --git a/source/index.ts b/source/index.ts new file mode 100644 index 0000000..375002c --- /dev/null +++ b/source/index.ts @@ -0,0 +1,3 @@ +export * from './type'; +export * from './renderer'; +export * from './factory'; diff --git a/source/renderer.ts b/source/renderer.ts new file mode 100644 index 0000000..c802f41 --- /dev/null +++ b/source/renderer.ts @@ -0,0 +1,74 @@ +import { VChild } from './type'; + +const { slice } = Array.prototype; + +export function create(vNode: VChild) { + if (typeof vNode === 'string') return document.createTextNode(vNode); + + const { tagName, childNodes, ...props } = vNode; + + return Object.assign(document.createElement(tagName), props); +} + +const cache = new WeakMap(); + +function save(root: Node, id: string, child: Node) { + var map = cache.get(root); + + if (!map) cache.set(root, (map = {})); + + map[id] = child; +} + +export function update(node: Element, vNode: VChild) { + if (typeof vNode === 'string') return node.replaceWith(vNode); + + const { tagName, childNodes, ...props } = vNode; + + if (node.tagName?.toLowerCase() !== tagName) { + const tag = document.createElement(tagName); + + node.replaceWith(tag); + + node = tag; + } + + const prop_map = Object.entries(props); + + for (const { name } of node.attributes) { + const [key] = + prop_map.find(([key]) => key.toLowerCase() === name) || []; + + if (!key) node.removeAttribute(name); + } + + for (const [key, value] of prop_map) + if (value !== node[key]) node[key] = value; + + const children = node.childNodes; + + vNode.childNodes.forEach((child, index) => { + var old = children[index]; + + if (!old) { + old = create(child); + + if (child.id) save(node, child.id, old); + + node.append(old); + } else { + const cached = cache.get(node)?.[child.id]; + + if (cached) { + old.before(cached); + + old = cached; + } + } + + update(old as Element, child); + }); + + for (const child of slice.call(children, vNode.childNodes.length)) + child.remove(); +} diff --git a/source/type.ts b/source/type.ts new file mode 100644 index 0000000..ffd29d4 --- /dev/null +++ b/source/type.ts @@ -0,0 +1,12 @@ +export interface VNode { + tagName: string; + childNodes: VNode[]; + id?: string; + [key: string]: any; +} + +export type VChild = string | VNode; + +export interface CustomElementClass { + new (): HTMLElement; +} diff --git a/test/polyfill.ts b/test/polyfill.ts new file mode 100644 index 0000000..c8893fc --- /dev/null +++ b/test/polyfill.ts @@ -0,0 +1,6 @@ +import { JSDOM } from 'jsdom'; + +const { window } = new JSDOM(); + +for (const key of ['window', 'document', 'customElements']) + global[key] = window[key]; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cd848dc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES5", + "module": "ES6", + "moduleResolution": "Node", + "esModuleInterop": true, + "downlevelIteration": true, + "jsx": "react", + "jsxFactory": "createCell", + "lib": ["ES2019", "DOM", "DOM.Iterable"] + } +} From 0d30a1b150027d5182d292eb2af0d8904febf084 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Fri, 13 Mar 2020 18:43:00 +0800 Subject: [PATCH 3/4] [add] Test scripts --- .gitignore | 3 ++- package.json | 13 ++++++++++++- source/factory.ts | 27 ++++++++++++++++++++------- source/global.d.ts | 5 +++++ test/factory.spec.tsx | 37 +++++++++++++++++++++++++++++++++++++ test/polyfill.ts | 3 ++- test/renderer.spec.tsx | 41 +++++++++++++++++++++++++++++++++++++++++ test/tsconfig.json | 7 +++++++ 8 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 source/global.d.ts create mode 100644 test/factory.spec.tsx create mode 100644 test/renderer.spec.tsx create mode 100644 test/tsconfig.json diff --git a/.gitignore b/.gitignore index 71c7963..54cd362 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ package-lock.json -node_modules/ \ No newline at end of file +node_modules/ +.vscode/ \ No newline at end of file diff --git a/package.json b/package.json index 7db9ad4..1e240c6 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ }, "source": "source/index.ts", "devDependencies": { + "@types/core-js": "^2.5.3", "@types/jest": "^25.1.4", "@types/jsdom": "^16.1.0", + "core-js": "^3.6.4", + "husky": "^4.2.3", "jest": "^25.1.0", "jsdom": "^16.2.1", "lint-staged": "^10.0.8", @@ -41,9 +44,17 @@ }, "jest": { "preset": "ts-jest", - "testEnvironment": "node" + "testEnvironment": "node", + "globals": { + "ts-jest": { + "tsConfig": "test/tsconfig.json" + } + } }, "scripts": { "test": "lint-staged && jest" + }, + "husky": { + "pre-commit": "npm test" } } diff --git a/source/factory.ts b/source/factory.ts index a2f64c0..a00dd24 100644 --- a/source/factory.ts +++ b/source/factory.ts @@ -1,19 +1,32 @@ import { CustomElementClass, VChild, VNode } from './type'; +const custom_cache = new WeakMap(); + export function createCell( tag: string | Function | CustomElementClass, data?: any, - ...childNodes: VChild[] + ...children: VChild[] ): VNode { + children = children.flat(Infinity); + if (typeof tag === 'function') { - try { - const node = new (tag as CustomElementClass)(); + let name = custom_cache.get(tag); + + if (name) tag = name; + else + try { + const node = new (tag as CustomElementClass)(); + + if (node instanceof HTMLElement) { + name = node.tagName.toLowerCase(); - if (node instanceof HTMLElement) tag = node.tagName.toLowerCase(); - } catch {} + custom_cache.set(tag, (tag = name)); + } + } catch {} - if (typeof tag === 'function') return (tag as Function)(data); + if (typeof tag === 'function') + return (tag as Function)({ ...data, children }); } - return { ...data, tagName: tag, childNodes }; + return { ...data, tagName: tag, childNodes: children }; } diff --git a/source/global.d.ts b/source/global.d.ts new file mode 100644 index 0000000..eb61ea8 --- /dev/null +++ b/source/global.d.ts @@ -0,0 +1,5 @@ +declare namespace JSX { + interface ElementChildrenAttribute { + children: any; + } +} diff --git a/test/factory.spec.tsx b/test/factory.spec.tsx new file mode 100644 index 0000000..ccb497a --- /dev/null +++ b/test/factory.spec.tsx @@ -0,0 +1,37 @@ +import './polyfill'; +import { createCell } from '../source'; + +describe('JSX Factory method', () => { + it('should accept a Standard HTML Tag', () => { + expect(test).toEqual( + expect.objectContaining({ + tagName: 'a', + childNodes: ['test'] + }) + ); + }); + + it('should accept a Function Component', () => { + const Test = ({ children }) => {children}; + + expect(test).toEqual( + expect.objectContaining({ + tagName: 'a', + childNodes: ['test'] + }) + ); + }); + + it('should accept a Class Component', () => { + class Test extends HTMLElement {} + + customElements.define('x-test', Test); + + expect().toEqual( + expect.objectContaining({ + tagName: 'x-test', + childNodes: [] + }) + ); + }); +}); diff --git a/test/polyfill.ts b/test/polyfill.ts index c8893fc..9f57474 100644 --- a/test/polyfill.ts +++ b/test/polyfill.ts @@ -1,6 +1,7 @@ +import 'core-js/es/array/flat'; import { JSDOM } from 'jsdom'; const { window } = new JSDOM(); -for (const key of ['window', 'document', 'customElements']) +for (const key of ['window', 'document', 'HTMLElement', 'customElements']) global[key] = window[key]; diff --git a/test/renderer.spec.tsx b/test/renderer.spec.tsx new file mode 100644 index 0000000..ff66dc5 --- /dev/null +++ b/test/renderer.spec.tsx @@ -0,0 +1,41 @@ +import './polyfill'; +import { update, createCell } from '../source'; + +describe('Renderer methods', () => { + document.body.innerHTML = ''; + + it('should update a Standard HTML Tag', () => { + update(document.body.firstElementChild, test); + + expect(document.body.innerHTML).toBe('test'); + }); + + it('should relpace an old HTML Tag', () => { + update( + document.body.firstElementChild, +
    +
  1. 1
  2. +
  3. 0
  4. +
+ ); + + expect(document.body.innerHTML).toBe( + '
  1. 1
  2. 0
' + ); + }); + + it('should relpace an old HTML Tag', () => { + const root = document.body.firstElementChild; + const last = root.lastElementChild; + + update( + document.body.firstElementChild, +
    +
  1. 0
  2. +
+ ); + + expect(root.outerHTML).toBe('
  1. 0
'); + expect(root.lastElementChild).toBe(last); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..505d44d --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ES6" + }, + "include": ["../source/*.ts", "*.ts", "*.tsx"] +} From 9b471be897bbdf20039bdd8352ca2303d769c3d8 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Mon, 1 Jun 2020 23:14:30 +0800 Subject: [PATCH 4/4] [refactor] rewrite to Async Rendering inspired by Crank.js --- ReadMe.md | 7 ++ package.json | 45 +++++------ source/AsyncComponent.ts | 37 +++++++++ source/creator.ts | 72 +++++++++++++++++ source/factory.ts | 32 -------- source/global.d.ts | 5 -- source/index.ts | 8 +- source/renderer.ts | 154 +++++++++++++++++++------------------ source/type.ts | 12 --- source/updater.ts | 111 +++++++++++++++++++++++++++ source/utility.ts | 16 ++++ test/factory.spec.tsx | 37 --------- test/polyfill.ts | 7 -- test/renderer.spec.tsx | 161 +++++++++++++++++++++++++++++---------- test/tsconfig.json | 14 ++-- test/updater.spec.ts | 48 ++++++++++++ test/utility.spec.ts | 19 +++++ tsconfig.json | 25 +++--- 18 files changed, 558 insertions(+), 252 deletions(-) create mode 100644 ReadMe.md create mode 100644 source/AsyncComponent.ts create mode 100644 source/creator.ts delete mode 100644 source/factory.ts delete mode 100644 source/global.d.ts delete mode 100644 source/type.ts create mode 100644 source/updater.ts create mode 100644 source/utility.ts delete mode 100644 test/factory.spec.tsx delete mode 100644 test/polyfill.ts create mode 100644 test/updater.spec.ts create mode 100644 test/utility.spec.ts diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..d38d473 --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,7 @@ +# DOM Renderer + +DOM Renderer with [Async Generator][1] support (inspired by [Crank.js][2]), which is based on [TSX][3] & **DOM-compatible virtual DOM**. + +[1]: https://tc39.es/ecma262/#sec-asyncgeneratorfunction-objects +[2]: https://crank.js.org/ +[3]: https://www.typescriptlang.org/docs/handbook/jsx.html diff --git a/package.json b/package.json index 1e240c6..58dfd9a 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,18 @@ { "name": "dom-renderer", - "version": "2.0.0-alpha.0", + "version": "3.0.0-alpha.0", "license": "LGPL-3.0", "author": "shiy2008@gmail.com", - "description": "DOM Renderer based on TSX & DOM-compatible virtual DOM", + "description": "DOM Renderer with Async Generator support (inspired by Crank.js), which is based on TSX & DOM-compatible virtual DOM.", "keywords": [ "DOM", "render", + "async", + "generator", "TypeScript", "JSX", - "vDOM" + "vDOM", + "crank" ], "homepage": "https://web-cell.dev/DOM-Renderer/", "repository": { @@ -20,41 +23,39 @@ "url": "https://github.com/EasyWebApp/DOM-Renderer/issues" }, "source": "source/index.ts", + "dependencies": { + "web-utility": "1.5.0" + }, "devDependencies": { - "@types/core-js": "^2.5.3", - "@types/jest": "^25.1.4", - "@types/jsdom": "^16.1.0", - "core-js": "^3.6.4", - "husky": "^4.2.3", - "jest": "^25.1.0", - "jsdom": "^16.2.1", - "lint-staged": "^10.0.8", - "prettier": "^1.19.1", - "ts-jest": "^25.2.1", - "typescript": "^3.8.3" + "@types/jest": "^25.2.3", + "husky": "^4.2.5", + "jest": "^26.0.1", + "lint-staged": "^10.2.7", + "prettier": "^2.0.5", + "ts-jest": "^26.1.0", + "typescript": "^3.9.3" }, "prettier": { "singleQuote": true, + "trailingComma": "none", + "arrowParens": "avoid", "tabWidth": 4 }, "lint-staged": { - "*.{json,ts,tsx}": [ + "*.{md,json,yml,ts,tsx}": [ "prettier --write" ] }, "jest": { "preset": "ts-jest", - "testEnvironment": "node", - "globals": { - "ts-jest": { - "tsConfig": "test/tsconfig.json" - } - } + "transformIgnorePatterns": [] }, "scripts": { "test": "lint-staged && jest" }, "husky": { - "pre-commit": "npm test" + "hooks": { + "pre-commit": "npm test" + } } } diff --git a/source/AsyncComponent.ts b/source/AsyncComponent.ts new file mode 100644 index 0000000..bf37de8 --- /dev/null +++ b/source/AsyncComponent.ts @@ -0,0 +1,37 @@ +import { Props, GeneratorNode, AsyncGeneratorNode, VChild } from './creator'; +import { isEmpty } from 'web-utility/source/data'; +import { clearList } from './utility'; + +export class AsyncComponent { + function: Function; + props: Props; + generator: GeneratorNode | AsyncGeneratorNode; + + lastNodes: VChild[] = []; + realNodes: HTMLElement[] = []; + + constructor(func: Function, props: Props) { + this.function = func; + this.props = props; + this.generator = func.call(this, props); + } + + *[Symbol.iterator]() { + while (true) yield this.props; + } + + async *[Symbol.asyncIterator]() { + while (true) yield this.props; + } + + async render() { + const { realNodes } = this; + + const { value } = await this.generator.next( + realNodes[1] ? realNodes : realNodes[0] + ); + if (!isEmpty(value)) this.lastNodes = value; + + return clearList(this.lastNodes); + } +} diff --git a/source/creator.ts b/source/creator.ts new file mode 100644 index 0000000..8628e8c --- /dev/null +++ b/source/creator.ts @@ -0,0 +1,72 @@ +import { HTMLProps } from 'web-utility/source/DOM-type'; +import { clearList } from './utility'; +import { AsyncComponent } from './AsyncComponent'; + +export interface VElement { + tagName: string; + childNodes: VChildNode[]; + [key: string]: any; +} +export type VChild = string | number | boolean | VElement; +export type VChildNode = VChild | AsyncComponent; +export type VChildren = VChildNode | VChildNode[]; + +export type GeneratorNode = Generator; +export type AsyncGeneratorNode = AsyncGenerator; + +export type AsyncNode = Promise | GeneratorNode | AsyncGeneratorNode; + +export type RenderNode = VChildren | AsyncNode; + +export interface Props extends HTMLProps { + children?: VChildren; + [key: string]: any; +} + +export type FunctionComponent = (props: Props) => RenderNode; + +export type Component = string | FunctionComponent; + +declare global { + namespace JSX { + interface IntrinsicElements { + [tagName: string]: Props; + } + interface ElementChildrenAttribute { + children: VChildren; + } + } +} + +export function createElement( + tagName: Component, + props: Props | null, + ...childNodes: VChildNode[] +) { + childNodes = clearList(childNodes); + + if (typeof tagName === 'string') + return { + ...props, + tagName: tagName as string, + childNodes + }; + + props = { ...props, children: childNodes }; + + const result = tagName(props); + + if (typeof (result as Promise).then === 'function') + return new AsyncComponent(async function* () { + yield await (result as Promise); + }, props); + + if (typeof (result as Generator).next === 'function') + return new AsyncComponent(tagName, props); + + return result; +} + +export function Fragment({ children }: Props) { + return children; +} diff --git a/source/factory.ts b/source/factory.ts deleted file mode 100644 index a00dd24..0000000 --- a/source/factory.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CustomElementClass, VChild, VNode } from './type'; - -const custom_cache = new WeakMap(); - -export function createCell( - tag: string | Function | CustomElementClass, - data?: any, - ...children: VChild[] -): VNode { - children = children.flat(Infinity); - - if (typeof tag === 'function') { - let name = custom_cache.get(tag); - - if (name) tag = name; - else - try { - const node = new (tag as CustomElementClass)(); - - if (node instanceof HTMLElement) { - name = node.tagName.toLowerCase(); - - custom_cache.set(tag, (tag = name)); - } - } catch {} - - if (typeof tag === 'function') - return (tag as Function)({ ...data, children }); - } - - return { ...data, tagName: tag, childNodes: children }; -} diff --git a/source/global.d.ts b/source/global.d.ts deleted file mode 100644 index eb61ea8..0000000 --- a/source/global.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare namespace JSX { - interface ElementChildrenAttribute { - children: any; - } -} diff --git a/source/index.ts b/source/index.ts index 375002c..7c774e7 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,3 +1,5 @@ -export * from './type'; -export * from './renderer'; -export * from './factory'; +export * from './utility'; +export * from './creator'; +export * from './AsyncComponent'; +export * from './renderer'; +export * from './updater'; diff --git a/source/renderer.ts b/source/renderer.ts index c802f41..064b346 100644 --- a/source/renderer.ts +++ b/source/renderer.ts @@ -1,74 +1,80 @@ -import { VChild } from './type'; - -const { slice } = Array.prototype; - -export function create(vNode: VChild) { - if (typeof vNode === 'string') return document.createTextNode(vNode); - - const { tagName, childNodes, ...props } = vNode; - - return Object.assign(document.createElement(tagName), props); -} - -const cache = new WeakMap(); - -function save(root: Node, id: string, child: Node) { - var map = cache.get(root); - - if (!map) cache.set(root, (map = {})); - - map[id] = child; -} - -export function update(node: Element, vNode: VChild) { - if (typeof vNode === 'string') return node.replaceWith(vNode); - - const { tagName, childNodes, ...props } = vNode; - - if (node.tagName?.toLowerCase() !== tagName) { - const tag = document.createElement(tagName); - - node.replaceWith(tag); - - node = tag; - } - - const prop_map = Object.entries(props); - - for (const { name } of node.attributes) { - const [key] = - prop_map.find(([key]) => key.toLowerCase() === name) || []; - - if (!key) node.removeAttribute(name); - } - - for (const [key, value] of prop_map) - if (value !== node[key]) node[key] = value; - - const children = node.childNodes; - - vNode.childNodes.forEach((child, index) => { - var old = children[index]; - - if (!old) { - old = create(child); - - if (child.id) save(node, child.id, old); - - node.append(old); - } else { - const cached = cache.get(node)?.[child.id]; - - if (cached) { - old.before(cached); - - old = cached; - } - } - - update(old as Element, child); - }); - - for (const child of slice.call(children, vNode.childNodes.length)) - child.remove(); -} +import { + RenderNode, + GeneratorNode, + VChildNode, + VElement, + VChildren +} from './creator'; +import { AsyncComponent } from './AsyncComponent'; +import { clearList } from './utility'; +import { updateTree } from './updater'; + +export function isAsync(node: RenderNode) { + return ( + node instanceof Promise || + (node as GeneratorNode).next instanceof Function + ); +} + +export function* updateComponentTree( + next: VChildNode[], + prev: VChildNode[] = [] +) { + for (const node of next) + if (node instanceof AsyncComponent) { + const index = prev.findIndex( + item => + item instanceof AsyncComponent && + item.function === node.function + ); + + if (index < 0) yield node; + else { + const current = prev.splice(index, 1)[0] as AsyncComponent; + + const { children, ...rest } = node.props; + + Object.assign(current.props, rest); + + current.props.children = [ + ...updateComponentTree( + children as VChildNode[], + current.props.children as VChildNode[] + ) + ]; + yield current; + } + } else if (typeof node === 'object') { + const index = prev.findIndex( + (item: VElement) => item.tagName === node.tagName + ); + + if (index < 0) yield node; + else { + const current = prev.splice(index, 1)[0] as VElement; + + const { childNodes, ...rest } = node; + + Object.assign(current, rest); + + current.childNodes = [ + ...updateComponentTree(childNodes, current.childNodes) + ]; + yield current; + } + } else yield node; +} + +const vTreeMap = new WeakMap(); + +export function render(children: VChildren, root = document.body) { + children = [ + ...updateComponentTree( + clearList(children as VChildNode[]), + vTreeMap.get(root) + ) + ]; + vTreeMap.set(root, children); + + return updateTree(children, root); +} diff --git a/source/type.ts b/source/type.ts deleted file mode 100644 index ffd29d4..0000000 --- a/source/type.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface VNode { - tagName: string; - childNodes: VNode[]; - id?: string; - [key: string]: any; -} - -export type VChild = string | VNode; - -export interface CustomElementClass { - new (): HTMLElement; -} diff --git a/source/updater.ts b/source/updater.ts new file mode 100644 index 0000000..b835122 --- /dev/null +++ b/source/updater.ts @@ -0,0 +1,111 @@ +import { VChild, Props, VChildNode, VElement } from './creator'; +import { find } from './utility'; +import { AsyncComponent } from './AsyncComponent'; + +export function updateProps(node: HTMLElement, props: Props) { + const { style = {}, ...data } = props; + const keys = Object.keys(data); + + for (const { name } of [...node.attributes]) + if (!keys.find(key => name === key.toLowerCase())) + (node as Element).removeAttribute(name); + + for (const name of [...node.style].filter(name => !(name in style))) + node.style.removeProperty(name); + + Object.assign(node.style, style); + + const [attr_list, prop_list] = keys.reduce( + ([attr_list, prop_list], key) => { + if (/\W/.test(key)) attr_list.push(key); + else prop_list.push(key); + + return [attr_list, prop_list]; + }, + [[], []] as string[][] + ); + + for (const key of attr_list) + node.setAttribute(key.toLowerCase(), data[key]); + + Object.assign( + node, + Object.fromEntries( + prop_list.map(key => [ + /^on[A-Z]\w+$/.test(key) ? key.toLowerCase() : key, + data[key] + ]) + ) + ); +} + +export function insertChild( + root: HTMLElement, + node: string | Node, + index = root.childNodes.length +) { + const old = root.childNodes[index]; + + if (old) old.before(node); + else root.append(node); +} + +export function updateChild(root: HTMLElement, node: VChild, index: number) { + var old: Node; + + if (typeof node === 'object') { + const { tagName, childNodes, ...props } = node; + + old = + find( + root.childNodes, + ({ nodeName }) => nodeName.toLowerCase() === tagName, + index + ) || document.createElement(tagName); + + if (root.childNodes[index] !== old) insertChild(root, old, index); + + updateProps(old as HTMLElement, props); + } else { + node = node + ''; + + old = + find( + root.childNodes, + ({ nodeValue }) => nodeValue === node, + index + ) || document.createTextNode(node); + + if (root.childNodes[index] !== old) insertChild(root, old, index); + } + + return old; +} + +export async function updateTree(children: VChildNode[], root: HTMLElement) { + var cursor = 0; + + for (const vNode of children) + if (vNode instanceof AsyncComponent) { + const realNodes = []; + + for (const item of await vNode.render()) { + const node = updateChild(root, item, cursor); + cursor++; + + realNodes.push(node); + + if (node instanceof HTMLElement) + await updateTree(item.childNodes, node); + } + vNode.realNodes = realNodes; + } else { + const node = updateChild(root, vNode, cursor); + cursor++; + + if (node instanceof HTMLElement) + await updateTree((vNode as VElement).childNodes, node); + } + + for (const node of [...root.childNodes].slice(cursor)) node.remove(); +} diff --git a/source/utility.ts b/source/utility.ts new file mode 100644 index 0000000..e0db95a --- /dev/null +++ b/source/utility.ts @@ -0,0 +1,16 @@ +import { isEmpty } from 'web-utility/source/data'; + +export function find( + list: ArrayLike, + callback: (item: T, index: number) => boolean, + offset = 0 +) { + const { length } = list; + + for (let i = offset; i < length; i++) + if (callback(list[i], i)) return list[i]; +} + +export function clearList(data: any[]): T[] { + return [data].flat(Infinity).filter(node => !isEmpty(node)); +} diff --git a/test/factory.spec.tsx b/test/factory.spec.tsx deleted file mode 100644 index ccb497a..0000000 --- a/test/factory.spec.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import './polyfill'; -import { createCell } from '../source'; - -describe('JSX Factory method', () => { - it('should accept a Standard HTML Tag', () => { - expect(test).toEqual( - expect.objectContaining({ - tagName: 'a', - childNodes: ['test'] - }) - ); - }); - - it('should accept a Function Component', () => { - const Test = ({ children }) => {children}; - - expect(test).toEqual( - expect.objectContaining({ - tagName: 'a', - childNodes: ['test'] - }) - ); - }); - - it('should accept a Class Component', () => { - class Test extends HTMLElement {} - - customElements.define('x-test', Test); - - expect().toEqual( - expect.objectContaining({ - tagName: 'x-test', - childNodes: [] - }) - ); - }); -}); diff --git a/test/polyfill.ts b/test/polyfill.ts deleted file mode 100644 index 9f57474..0000000 --- a/test/polyfill.ts +++ /dev/null @@ -1,7 +0,0 @@ -import 'core-js/es/array/flat'; -import { JSDOM } from 'jsdom'; - -const { window } = new JSDOM(); - -for (const key of ['window', 'document', 'HTMLElement', 'customElements']) - global[key] = window[key]; diff --git a/test/renderer.spec.tsx b/test/renderer.spec.tsx index ff66dc5..e862213 100644 --- a/test/renderer.spec.tsx +++ b/test/renderer.spec.tsx @@ -1,41 +1,120 @@ -import './polyfill'; -import { update, createCell } from '../source'; - -describe('Renderer methods', () => { - document.body.innerHTML = ''; - - it('should update a Standard HTML Tag', () => { - update(document.body.firstElementChild, test); - - expect(document.body.innerHTML).toBe('test'); - }); - - it('should relpace an old HTML Tag', () => { - update( - document.body.firstElementChild, -
    -
  1. 1
  2. -
  3. 0
  4. -
- ); - - expect(document.body.innerHTML).toBe( - '
  1. 1
  2. 0
' - ); - }); - - it('should relpace an old HTML Tag', () => { - const root = document.body.firstElementChild; - const last = root.lastElementChild; - - update( - document.body.firstElementChild, -
    -
  1. 0
  2. -
- ); - - expect(root.outerHTML).toBe('
  1. 0
'); - expect(root.lastElementChild).toBe(last); - }); -}); +import { + Props, + render, + createElement, + Fragment, + GeneratorNode +} from '../source'; + +const { body } = document; + +describe('Render methods', () => { + const log = jest.fn(); + + it('should render HTML nodes', async () => { + await render( + + 01 + + ); + + expect(body.innerHTML).toBe('01'); + }); + + function Sync({ children }: Props) { + return {children}; + } + + it('should render a Sync Function Component', async () => { + await render(2); + + expect(body.innerHTML).toBe('2'); + }); + + async function Async({ children }: Props) { + await new Promise(resolve => setTimeout(resolve)); + + return {children}; + } + + it('should render an Async Function Component', async () => { + await render(3); + + expect(body.innerHTML).toBe('3'); + }); + + function* Generator({ children }: Props): GeneratorNode { + for (const { children } of this) { + const element = (yield

{children}

) as Element; + + log(element.outerHTML); + } + } + + it('should render a Generator Function Component', async () => { + await render(4); + + expect(body.innerHTML).toBe('

4

'); + }); + + async function* AsyncGenerator({ children }: Props) { + for await (const { children } of this) { + const element = (yield {children}) as Element; + + log(element.outerHTML); + } + } + + it('should render an Async Generator Function Component', async () => { + await render(5); + + expect(body.innerHTML).toBe('5'); + }); + + it('should render kinds of Function Components in a Tree', async () => { + await render( + + 01 + 2 + 3 + 4 + 5 +
+ + 67 + +
+
+ ); + + expect(body.innerHTML).toBe( + `0123

4

5

67

` + ); + expect(log).toBeCalledTimes(1); + expect(log).lastCalledWith('5'); + }); + + it('should update kinds of Function Components in a Tree', async () => { + await render( + + 01 + 2 + 3-1 + 4-1 + 5-1 +
+ + 6-1 + 7-1 + +
+
+ ); + + expect(body.innerHTML).toBe( + `0123-1

4-1

5-1

6-17-1

` + ); + expect(log).toBeCalledTimes(5); + expect(log).lastCalledWith('7'); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 505d44d..06dc298 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,7 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "target": "ES6" - }, - "include": ["../source/*.ts", "*.ts", "*.tsx"] -} +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ES2018" + }, + "include": ["../source/*.ts", "*.ts", "*.tsx"] +} diff --git a/test/updater.spec.ts b/test/updater.spec.ts new file mode 100644 index 0000000..d118286 --- /dev/null +++ b/test/updater.spec.ts @@ -0,0 +1,48 @@ +import { updateProps, insertChild, updateChild } from '../source/updater'; + +const { body } = document; + +describe('DOM updater', () => { + it('should update properties of a DOM Element', () => { + body.innerHTML = ''; + + const tag = body.firstElementChild as HTMLElement, + onClick = () => {}; + + updateProps(tag, { + className: 'sample', + style: { width: '100%' }, + 'data-index': '0', + hidden: true, + onClick + }); + + expect(tag.outerHTML).toBe( + '' + ); + expect(tag.onclick).toBe(onClick); + }); + + it('should insert a Node to DOM tree based on Index', () => { + const { childNodes } = body; + + insertChild(body, 'test'); + + expect(childNodes[1].nodeValue).toBe('test'); + + insertChild(body, document.createElement('a'), 1); + + expect(childNodes[1].nodeName).toBe('A'); + }); + + it('should update a Child Node based on Virtual Node', () => { + const link = body.childNodes[1]; + + updateChild(body, { tagName: 'a', href: '#', childNodes: [] }, 0); + + expect(body.childNodes[0]).toBe(link); + expect(body.innerHTML).toBe( + 'test' + ); + }); +}); diff --git a/test/utility.spec.ts b/test/utility.spec.ts new file mode 100644 index 0000000..be4eb33 --- /dev/null +++ b/test/utility.spec.ts @@ -0,0 +1,19 @@ +import { find, clearList } from '../source/utility'; + +describe('Utility methods', () => { + it('should find an Item from Array-like objects with Index Offset', () => { + expect( + find( + document.documentElement.childNodes, + ({ nodeName }) => nodeName.toLowerCase() === 'body', + 1 + ) + ).toBe(document.body); + }); + + it('should clear & flatten an Array', () => { + expect( + clearList([1, [null, [2, [NaN, 3]]]]) + ).toEqual(expect.arrayContaining([1, 2, 3])); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index cd848dc..4735303 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,13 @@ -{ - "compilerOptions": { - "target": "ES5", - "module": "ES6", - "moduleResolution": "Node", - "esModuleInterop": true, - "downlevelIteration": true, - "jsx": "react", - "jsxFactory": "createCell", - "lib": ["ES2019", "DOM", "DOM.Iterable"] - } -} +{ + "compilerOptions": { + "target": "ES5", + "module": "ES6", + "moduleResolution": "Node", + "esModuleInterop": true, + "downlevelIteration": true, + "jsx": "react", + "jsxFactory": "createElement", + "lib": ["ES2019", "DOM", "DOM.Iterable"] + }, + "include": ["source/*.ts"] +}