From 5ca8f7e1c0fe75545dff5d2dbf65354244bd78b5 Mon Sep 17 00:00:00 2001 From: Kiril Burlaka Date: Thu, 4 Dec 2025 12:51:30 +0100 Subject: [PATCH] diff, renderIntoRealDom, dynamic count of elements rendering (without 'key'), useState hook only --- src/components/App.tsx | 50 +++-- src/components/CheckEmptyFragment.tsx | 15 ++ src/components/ChooseBg.tsx | 17 +- src/components/TextComponent.tsx | 3 + src/components/Toggle.tsx | 5 - src/components/namesList.tsx | 48 +++++ src/keact/hooks.ts | 12 +- src/keact/keact-dom.ts | 280 +++++++++++++++++++++++++- src/keact/keact.ts | 241 +++++----------------- 9 files changed, 451 insertions(+), 220 deletions(-) create mode 100644 src/components/CheckEmptyFragment.tsx create mode 100644 src/components/TextComponent.tsx create mode 100644 src/components/namesList.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index c521adf..4bf3c70 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,24 +1,42 @@ import {useState} from "../keact/hooks"; import {Toggle} from "./Toggle"; +import {CheckEmptyFragment} from "./CheckEmptyFragment"; +import {ChooseBg} from "./ChooseBg"; +import {TextComponent} from "./TextComponent"; +import {NamesList} from "./namesList"; export const App = () => { - let [clicked, setClicked] = useState(0); - const text = "Hello world!"; - function clickHandler() { - const newClicked = +clicked + 1; - setClicked(newClicked); - console.log(`clicked ${newClicked}-th time`); - } + // let [clicked, setClicked] = useState(0); + // const text = "Hello world!"; + // function clickHandler() { + // const newClicked = +clicked + 1; + // setClicked(newClicked); + // console.log(`clicked ${newClicked}-th time`); + // } + + return
+ +
+ // let [isSwap, setIsSwap] = useState(false); + // return
+ //
+ // swapped: {isSwap ? 'true' : 'false'} + //
+ //
+ // + //
+ // {isSwap ? [, ] : [, ]} + //
// return
// {text} //
- return
-
- {text} - clicked {clicked} times -
- -
+ // return
+ //
+ // {text} + // clicked {clicked} times + //
+ // + //
} \ No newline at end of file diff --git a/src/components/CheckEmptyFragment.tsx b/src/components/CheckEmptyFragment.tsx new file mode 100644 index 0000000..f3fb957 --- /dev/null +++ b/src/components/CheckEmptyFragment.tsx @@ -0,0 +1,15 @@ +import {useState} from "../keact/hooks"; + +export function CheckEmptyFragment(props) { + // return <> + // const [isShow, setIsShow] = useState(false) + // function toggleIsShow() { + // setIsShow(!isShow); + // } + + debugger; + if (props.isShow) { + return component will return false + } + return false; +} \ No newline at end of file diff --git a/src/components/ChooseBg.tsx b/src/components/ChooseBg.tsx index b7bcebf..17c5688 100644 --- a/src/components/ChooseBg.tsx +++ b/src/components/ChooseBg.tsx @@ -1,13 +1,22 @@ import {useState} from "../keact/hooks"; export function ChooseBg() { - const [bgColor, setBgColor] = useState('#ff0000'); + const [bgColor, setBgColor] = useState('#ffffff'); + document.body.style.background = '#ffffff'; + + function setNewBgColor(newColor: string) { + setBgColor(newColor); + document.body.style.background = newColor; + } + + function resetBgColor() { + setNewBgColor('#ffffff'); + } function changeBgColor(e: any) { const value = e.target.value; if ([4, 7].includes(value.length)) { - setBgColor(e.target.value); - document.body.style.background = e.target.value; + setNewBgColor(value); } } @@ -16,8 +25,10 @@ export function ChooseBg() { input your own bg color: + {bgColor === '#ffffff' ? [now it's , white] : } current bg color is {bgColor} diff --git a/src/components/TextComponent.tsx b/src/components/TextComponent.tsx new file mode 100644 index 0000000..5247a33 --- /dev/null +++ b/src/components/TextComponent.tsx @@ -0,0 +1,3 @@ +export function TextComponent() { + return "simple text component" +} \ No newline at end of file diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx index 0bc180a..852725c 100644 --- a/src/components/Toggle.tsx +++ b/src/components/Toggle.tsx @@ -7,11 +7,6 @@ export const Toggle = (props) => { function toggle() { const newVal = !isOn; setIsOn(newVal); - if (newVal) { - document.body.style.background = '#ff0000'; - } else { - document.body.style.background = ''; - } } return
{ diff --git a/src/components/namesList.tsx b/src/components/namesList.tsx new file mode 100644 index 0000000..a764597 --- /dev/null +++ b/src/components/namesList.tsx @@ -0,0 +1,48 @@ +import {useState} from "../keact/hooks"; + +export function NamesList() { + const [namesList, setNamesList] = useState([ + "Greg", + "Elon", + "Mark", + "Olaph" + ]); + + const [newName, setNewName] = useState(""); + + function addNewName() { + namesList.push(newName); + setNewName(""); + setNamesList(namesList); + } + + function deleteName(index: number) { + namesList.splice(index, 1); + setNamesList(namesList); + } + + return
+

Names list:

+
+ {namesList.map((n, i) => +
+ #{i + 1} - {n} +
+ )} +
+
+

add to list:

+ { + //@ts-ignore + setNewName(e.target.value) + }} + /> + +
+
+} \ No newline at end of file diff --git a/src/keact/hooks.ts b/src/keact/hooks.ts index 2d9a463..ba73392 100644 --- a/src/keact/hooks.ts +++ b/src/keact/hooks.ts @@ -1,6 +1,6 @@ -import {reRenderElement, VirtualElementContext} from "./keact"; +import {ComponentContext, reRenderComponent} from "./keact-dom"; -let currentInstance: VirtualElementContext; +let currentInstance: ComponentContext; let cursor = 0; export function useState(initial: any): [any, Function] { @@ -11,18 +11,18 @@ export function useState(initial: any): [any, Function] { return result; } const setFunc = setState.bind(this, currentInstance, cursor); - state.set(cursor, initial) setStateFuncs.set(cursor, setFunc) + state.set(cursor, initial) cursor++; return [initial, setFunc]; } -function setState(currentInstance: VirtualElementContext, cursor: number, newValue: any) { +function setState(currentInstance: ComponentContext, cursor: number, newValue: any) { currentInstance.state.set(cursor, newValue); - reRenderElement(currentInstance); + reRenderComponent(currentInstance.element); } -export const setContext = (context: VirtualElementContext) => { +export const setContext = (context: ComponentContext) => { currentInstance = context; cursor = 0; } \ No newline at end of file diff --git a/src/keact/keact-dom.ts b/src/keact/keact-dom.ts index 8b46bfc..8d7eefd 100644 --- a/src/keact/keact-dom.ts +++ b/src/keact/keact-dom.ts @@ -1,9 +1,285 @@ -import {VirtualElement} from "./keact"; +import { + createElement, FC, + Props, resolveChildrenIntoVirtualElements, + VirtualElement, + VirtualElementComponent, + VirtualElementEmpty, + VirtualElementFragment, + VirtualElementTag, + VirtualElementText +} from "./keact"; +import {setContext} from "./hooks"; + +const childrenByHtml: Map = new Map() + +export type ComponentContext = { + state: Map, + setStateFuncs: Map + element: VDOMComponent, +}; +export type VDOMElement = VDOMComponent | VDOMElementTag | VDOMElementSimple | VDOMElementFragment; +export type VDOMComponent = VirtualElementComponent & { + //TODO: delete context, use instance as key in map + context: ComponentContext; + childrenDomElements: VDOMElement[]; + parent: HTMLElement; +}; +export type VDOMElementTag = VirtualElementTag & { + childrenDomElements: VDOMElement[]; + node: HTMLElement, + parent: HTMLElement; + funcProps: Record; +} +export type VDOMElementFragment = VirtualElementFragment & { + childrenDomElements: VDOMElement[]; + parent: HTMLElement; +} +export type VDOMElementSimple = (VirtualElementText | VirtualElementEmpty) & { + node: Node, +} + +function runComponentsFunction(generate: FC, props: Props): VirtualElement[] { + const result = generate(props); + if (typeof result === 'object') { + if (Array.isArray(result)) { + const toReturn = []; + for (const c of result) { + if (typeof c === 'object') { + toReturn.push(c); + } else { + toReturn.push(...resolveChildrenIntoVirtualElements(c)) + } + } + } else { + return [result]; + } + } + return resolveChildrenIntoVirtualElements(result) +} + +export function reRenderComponent(component: VDOMComponent) { + setContext(component.context) + const newChildren = runComponentsFunction(component.generate, component.props); + + diff(component.childrenDomElements, newChildren, component.parent); +} function render($element: VirtualElement | undefined, root: HTMLElement) { if (!$element) return; - root.append($element.node); + if (!childrenByHtml.has(root)) { + childrenByHtml.set(root, { + children: [], + }) + } + + const { children } = childrenByHtml.get(root); + diff(children, [$element], root); +} + +function diff(oldChildren: VDOMElement[], newChildren: VirtualElement[], parentNode: HTMLElement) { + for (let i = 0, length = Math.max(newChildren.length, oldChildren.length); i < length; i++) { + const oldChild = oldChildren[i] as VDOMElement | undefined; + const newChild = newChildren[i] as VirtualElement | undefined; + if (!newChild && !oldChild) continue; + if (newChild && !oldChild) { + oldChildren[i] = renderIntoRealDom(newChild, parentNode); + continue; + } else if (!newChild && oldChild) { + removeFromDom(oldChild); + oldChildren[i] = undefined; + continue; + } else { + if (oldChild.type === 'empty' && newChild.type === 'empty') { + continue; + } else if (oldChild.type === 'text' && newChild.type === 'text') { + if (oldChild.text !== newChild.text) { + console.warn(`updateText in real dom ${oldChild.text} -> ${newChild.text}`); + oldChild.node.textContent = newChild.text; + oldChild.text = newChild.text; + } + } else if (oldChild.type === 'component' && newChild.type === 'component') { + if (oldChild.generate !== newChild.generate) { + let obj: VDOMElement = oldChild; + while(obj.type === "fragment" || obj.type === 'component') { + obj = obj.childrenDomElements[0]; + } + const insertBefore = obj.node; + + const newComponentDom = renderIntoRealDom(newChild, oldChild.parent, insertBefore) + removeFromDom(oldChild) + oldChildren[i] = newComponentDom; + } + else if (JSON.stringify(oldChild.props) !== JSON.stringify(newChild.props)) { + setContext(oldChild.context); + const newChildren = runComponentsFunction(newChild.generate, newChild.props) + + oldChild.props = newChild.props; + diff(oldChild.childrenDomElements, newChildren, oldChild.parent) + } + } else if (oldChild.type === 'fragment' && newChild.type === 'fragment') { + // let obj: VDOMElement = oldChild.childrenDomElements[0]; + // while(obj.type === "fragment" || obj.type === 'component') { + // obj = obj.childrenDomElements[0]; + // } + // const insertBefore = obj.node; + diff(oldChild.childrenDomElements, newChild.children, oldChild.parent); + } else if (oldChild.type === 'tag' && newChild.type === 'tag') { + if (oldChild.tag !== newChild.tag) { + const newVDOMTag = renderIntoRealDom(newChild, oldChild.parent, oldChild.node) as VDOMElementTag; + removeFromDom(oldChild); + oldChildren[i] = newVDOMTag; + } else if (JSON.stringify(oldChild.props) !== JSON.stringify(newChild.props)) { + clearPropsFromHtmlElement(oldChild.node, oldChild.props) + applyPropsToHtmlElement(oldChild.node, newChild.props, oldChild.funcProps); + oldChild.props = newChild.props; + } else { + updateFuncObjFromProps(newChild.props, oldChild.funcProps) + diff(oldChild.childrenDomElements, newChild.children, oldChild.node); + } + } else { + let obj: VDOMElement = oldChild; + while(obj.type === "fragment" || obj.type === 'component') { + obj = obj.childrenDomElements[0]; + } + const insertBefore = obj.node; + oldChildren[i] = renderIntoRealDom(newChild, insertBefore.parentElement, insertBefore); + removeFromDom(oldChild); + } + } + } +} + +function updateFuncObjFromProps(props: Props, funcObj: Record) { + for (const [propKey, propVal] of Object.entries(props)) { + if (typeof propVal === 'function') { + funcObj[propKey] = propVal; + } + } +} + +function clearPropsFromHtmlElement(el: HTMLElement, props: Props) { + for (const [propKey, propVal] of Object.entries(props)) { + if (propKey === 'children') continue; + if (typeof propVal === 'function') { + el[propKey.toLowerCase()] = undefined; + } else { + el[propKey] = undefined; + } + } +} + +function applyPropsToHtmlElement(el: HTMLElement, props: Props, funcPropsObj: Record){ + for (const [propKey, propVal] of Object.entries(props)) { + if (propKey === 'children') continue; + if (typeof propVal === 'function') { + funcPropsObj[propKey] = propVal; + el[propKey.toLowerCase()] = (...args: any[]) => {funcPropsObj[propKey](...args)}; + } else if (typeof propVal === 'object' && el[propKey]) { + Object.assign(el[propKey], propVal); + } else { + el[propKey] = propVal; + } + } +} + +function renderIntoRealDom(el: VirtualElement, parent: HTMLElement, insertBefore?: Node): VDOMElement { + if (el.type === 'empty') { + const node = document.createTextNode(" ") + appendOrReplace(node, parent, insertBefore); + return { + node, + ...el, + }; + } else if (el.type === 'text') { + const node = document.createTextNode(el.text) + appendOrReplace(node, parent, insertBefore); + return { + node, + ...el, + }; + } else if (el.type === 'tag') { + const node = document.createElement(el.tag); + const newChildren: VDOMElement[] = []; + for (let i = 0; i < el.children.length; i++) { + newChildren.push(renderIntoRealDom(el.children[i], node)); + } + + const funcProps: Record = {}; + applyPropsToHtmlElement(node, el.props, funcProps) + appendOrReplace(node, parent, insertBefore); + return { + node, + parent, + childrenDomElements: newChildren, + funcProps, + ...el, + }; + } else if (el.type === 'component') { + const component: VDOMComponent = { + context: { + state: new Map(), + setStateFuncs: new Map(), + element: null, + }, + parent, + childrenDomElements: [], + // parent, + ...el + }; + component.context.element = component; + setContext(component.context); + const children = runComponentsFunction(el.generate, el.props) + component.childrenDomElements = []; + for (const c of children) { + component.childrenDomElements.push(renderIntoRealDom(c, parent)); + } + return component; + } else if (el.type === 'fragment') { + const node = document.createDocumentFragment(); + const newChildren: VDOMElement[] = []; + for (let i = 0; i < el.children.length; i++) { + newChildren.push(renderIntoRealDom(el.children[i], node as undefined as HTMLElement)); + } + appendOrReplace(node, parent, insertBefore); + newChildren.forEach(child => { + if ('parent' in child) { + child.parent = parent; + } + }); + return { + parent, + childrenDomElements: newChildren, + ...el, + }; + } +} + +function removeFromDom(el: VDOMElement) { + console.warn('removeFromDom:', el); + if (el.type === "fragment" || el.type === 'component') { + const stack = [el.childrenDomElements]; + while(stack.length) { + stack.pop().forEach(c => { + if (c.type === "fragment" || c.type === 'component') { + stack.push(c.childrenDomElements); + } else { + c.node.parentElement.removeChild(c.node); + } + }) + } + } else { + el.node.parentElement.removeChild(el.node); + } +} + +function appendOrReplace(node: Node, parent: HTMLElement | DocumentFragment, insertBefore?: Node) { + console.warn('appendOrReplace:', node); + if (insertBefore) { + parent.insertBefore(node, insertBefore); + } else { + parent.append(node); + } } const KeactDOM = { diff --git a/src/keact/keact.ts b/src/keact/keact.ts index 78b4152..20e8cc5 100644 --- a/src/keact/keact.ts +++ b/src/keact/keact.ts @@ -1,238 +1,103 @@ -import {setContext} from "./hooks"; import {Fragment} from "./jsx-runtime"; export type Props = Record; -export type VirtualElementContext = { - state: Map, - setStateFuncs: Map - element: any, -} export type VirtualElement = VirtualElementTag | VirtualElementText | VirtualElementComponent | VirtualElementEmpty | VirtualElementFragment; export type VirtualElementComponent = { type: 'component', - node: Node, - context: VirtualElementContext, + props: Props, generate: FC, - children: VirtualElement[], + name: string, + instance: { + element: VirtualElementComponent; + }, }; export type VirtualElementTag = { - node: HTMLElement; type: 'tag'; tag: string; //html tag + props: Props; children: VirtualElement[]; - otherProps: [string, any][]; - propsFunctions: [string, Function][]; // context: VirtualElementContext }; export type VirtualElementText = { type: 'text', - node: Node, text: string, }; export type VirtualElementEmpty = { type: 'empty', - node: Node, }; export type VirtualElementFragment = { - node: Node, type: 'fragment', children: VirtualElement[]; }; export type VirtualElementValid = VirtualElementComponent | VirtualElementTag | VirtualElementFragment; -export type FC = (props: Props) => VirtualElement +type VirtualElementChildrenSingular = false | string | VirtualElement; +type VirtualElementChildren = VirtualElementChildrenSingular | (VirtualElementChildrenSingular)[]; +export type FC = (props: Props) => VirtualElementChildren; -export function reRenderElement(context: VirtualElementContext) { - setContext(context); - createElement(context.element.generate, { - componentToUpdate: context.element, - ...context.element.props - }); +function createElementFragment(props: Props): VirtualElementFragment { + return { + type: 'fragment', + children: resolveChildrenIntoVirtualElements(props.children || [" "]), + } +} + +function createElementComponent(generateFunc: FC, props: Props): VirtualElementComponent { + const component: VirtualElementComponent = { + type: 'component', + generate: generateFunc, + props: props, + name: generateFunc.name, + instance: undefined as {element: VirtualElementComponent}, + } + component.instance = { + element: component, + } + return component; +} + +function createElementTag(tag: string, props: Props): VirtualElementTag { + return { + type: 'tag', + tag, + props: Object.assign({}, props, {children: null}), + children: props.children ? resolveChildrenIntoVirtualElements(props.children) : [], + } } export function createElement(type: string | FC, props: Props): VirtualElementValid { - // type="div" - // props = { - // children: "Hello world!" - // } if (type === Fragment) { - const element: VirtualElementFragment = { - type: 'fragment', - node: document.createTextNode(" "), - children: [], - } - resolveChildren(props.children, element) - return element; + return createElementFragment(props); } - const isComponent = typeof type === 'function'; - - //create component - if (isComponent) { - console.log('createElement() component') - const component: VirtualElementComponent = props.componentToUpdate || { - type: 'component', - node: null, - generate: type, - children: [], - context: { - state: new Map(), - setStateFuncs: new Map(), - element: null, - }, - props: props, - } - component.context.element = component; - - setContext(component.context); - const c= type(props); - if (c.type === 'fragment') { - throw new Error('first element of component can\'t be fragment'); - } - if (!component.children.length) { - component.children.push(c); - component.node = c.node; - } else { - //@ts-ignore - replaceChanged(component, {children: [c]}); - } - - return component; + if (typeof type === 'function') { + return createElementComponent(type, props); } - - // if (!element.node) { - // const node = document.createElement(type); - // element.node = node; - // } else if (element.tag !== type) { - // const node = document.createElement(type); - // console.warn(`element.node.parentElement.replaceChild, tag: ${type}`); - // element.node.parentElement.replaceChild(node, element.node); - // element.node = node; - // element.tag = type; - // } - - console.log(`createElement() tag, ${type}`); - const element: VirtualElementTag = { - node: document.createElement(type), - // node: null, - type: 'tag', - tag: type, - children: [], - otherProps: [], - propsFunctions: [], - }; - - for (const [propKey, propVal] of Object.entries(props)) { - if (propKey === 'children') { - resolveChildren(propVal, element); - } else if (typeof propVal === 'function') { - element.propsFunctions.push([propKey.toLowerCase(), propVal]); - element.node[propKey.toLowerCase()] = propVal; - } else { - element.otherProps.push([propKey, propVal]); - element.node[propKey] = propVal; - } - } - // if (className) { - // element.node.className = className; - // } - // if (onClick) { - // element.node.onclick = onClick; - // } - return element; + return createElementTag(type, props); } -type VirtualElementChildren = false | string | VirtualElement | (false | VirtualElement | string)[]; - -function replaceChanged(oldHead: VirtualElementValid, newHead: VirtualElementValid) { - if (oldHead.children.length !== newHead.children.length) { - alert('replaceChanged: oldHead.children.length !== newHead.children.length') - throw new Error('replaceChanged: oldHead.children.length !== newHead.children.length'); - } - for(let i = 0; i < oldHead.children.length; i++) { - const oldChild = oldHead.children[i]; - const newChild = newHead.children[i]; - - if ( - oldChild.type !== newChild.type || - ( - oldChild.type === 'tag' && - newChild.type === 'tag' && - ( - oldChild.tag !== newChild.tag || - JSON.stringify(oldChild.otherProps) !== JSON.stringify(newChild.otherProps) - ) - ) || - ( - oldChild.type === 'text' && - newChild.type === 'text' && - oldChild.text !== newChild.text - ) - ) { - console.warn(`replace old node with new. old node:`, oldChild.node, 'new node:', newChild.node) - - if (oldChild.type === 'fragment') { - for (const c of oldChild.children) { - oldChild.node.parentElement.removeChild(c.node); - } - } - if (newChild.type === 'fragment') { - for (const c of newChild.children) { - oldChild.node.parentElement.insertBefore(c.node, oldChild.node); - } - } - - oldChild.node.parentElement.replaceChild(newChild.node, oldChild.node); - oldHead.children[i] = newChild; - continue; - } - - if (oldChild.type === 'tag' && newChild.type === 'tag') { - for (const [propKey, propVal] of newChild.propsFunctions) { - oldChild.node[propKey.toLowerCase()] = propVal; - } - } - //@ts-ignore - if (oldChild?.children?.length) { - //@ts-ignore - replaceChanged(oldChild, newChild) - } - if (oldChild.type === 'component' && newChild.type === 'component') { - newChild.node = oldChild.node; - newChild.children = oldChild.children; - } - } -} - -function resolveChildren(list: VirtualElementChildren, parent: VirtualElementValid) { +export function resolveChildrenIntoVirtualElements(list: VirtualElementChildren): VirtualElement[] { const array = Array.isArray(list) ? list : [list]; + const result: VirtualElement[] = []; for (let i = 0; i < array.length; i++) { const child = array[i]; if (typeof child === 'boolean') { - parent.children[i] = { - // node: null, - node: document.createTextNode(" "), + result.push({ type: 'empty', - }; + }); } else if (typeof child !== 'object') { - parent.children[i] = { - // node: null, - node: document.createTextNode(child), + result.push({ type: 'text', - text: child, - }; + text: `${child}`, + }); } else { - if (child.type === 'fragment') { - for (const c of child.children) { - parent.node.appendChild(c.node); - } + if (Array.isArray(child)) { + result.push(...resolveChildrenIntoVirtualElements(child)) + } else { + result.push(child) } - parent.children[i] = child; - } - - if (parent.type !== 'fragment') { - parent.node.appendChild(parent.children[i].node); } } + return result; } \ No newline at end of file