diff, renderIntoRealDom, dynamic count of elements rendering (without 'key'), useState hook only

This commit is contained in:
Kiril Burlaka 2025-12-04 12:51:30 +01:00
parent 110906370b
commit 5ca8f7e1c0
9 changed files with 451 additions and 220 deletions

View File

@ -1,24 +1,42 @@
import {useState} from "../keact/hooks"; import {useState} from "../keact/hooks";
import {Toggle} from "./Toggle"; import {Toggle} from "./Toggle";
import {CheckEmptyFragment} from "./CheckEmptyFragment";
import {ChooseBg} from "./ChooseBg";
import {TextComponent} from "./TextComponent";
import {NamesList} from "./namesList";
export const App = () => { export const App = () => {
let [clicked, setClicked] = useState(0); // let [clicked, setClicked] = useState(0);
const text = "Hello world!"; // const text = "Hello world!";
function clickHandler() { // function clickHandler() {
const newClicked = +clicked + 1; // const newClicked = +clicked + 1;
setClicked(newClicked); // setClicked(newClicked);
console.log(`clicked ${newClicked}-th time`); // console.log(`clicked ${newClicked}-th time`);
} // }
return <div>
<NamesList/>
</div>
// let [isSwap, setIsSwap] = useState(false);
// return <div>
// <div>
// <span>swapped: </span><span>{isSwap ? 'true' : 'false'}</span>
// </div>
// <div>
// <button onClick={() => {setIsSwap(!isSwap)}}>swap</button>
// </div>
// {isSwap ? [<Toggle/>, <TextComponent/>] : [<TextComponent/>, <Toggle/>]}
// </div>
// return <div> // return <div>
// {text} // {text}
// </div> // </div>
return <div> // return <div>
<div className="text_holder" // <div className="text_holder"
onClick={clickHandler} // onClick={clickHandler}
> // >
<span>{text}</span> // <span>{text}</span>
<span>clicked {clicked} times</span> // <span>clicked {clicked} times</span>
</div> // </div>
<Toggle clicked={clicked}/> // <Toggle clicked={clicked}/>
</div> // </div>
} }

View File

@ -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 <span>component will return false</span>
}
return false;
}

View File

@ -1,13 +1,22 @@
import {useState} from "../keact/hooks"; import {useState} from "../keact/hooks";
export function ChooseBg() { 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) { function changeBgColor(e: any) {
const value = e.target.value; const value = e.target.value;
if ([4, 7].includes(value.length)) { if ([4, 7].includes(value.length)) {
setBgColor(e.target.value); setNewBgColor(value);
document.body.style.background = e.target.value;
} }
} }
@ -16,8 +25,10 @@ export function ChooseBg() {
<span>input your own bg color:</span> <span>input your own bg color:</span>
<input type="text" <input type="text"
placeholder="#ff0000" placeholder="#ff0000"
style={{color: bgColor !== '#ffffff' ? bgColor : '#000000'}}
onInput={changeBgColor} onInput={changeBgColor}
/> />
{bgColor === '#ffffff' ? [<span>now it's </span>, <span style={{background: '#000', color: '#fff'}}>white</span>] : <button onClick={resetBgColor}>reset</button>}
</div> </div>
<span>current bg color is {bgColor}</span> <span>current bg color is {bgColor}</span>
</div> </div>

View File

@ -0,0 +1,3 @@
export function TextComponent() {
return "simple text component"
}

View File

@ -7,11 +7,6 @@ export const Toggle = (props) => {
function toggle() { function toggle() {
const newVal = !isOn; const newVal = !isOn;
setIsOn(newVal); setIsOn(newVal);
if (newVal) {
document.body.style.background = '#ff0000';
} else {
document.body.style.background = '';
}
} }
return <div> return <div>
{ {

View File

@ -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 <div>
<h1>Names list:</h1>
<div>
{namesList.map((n, i) =>
<div>
<span>#{i + 1} - {n}</span><button onClick={deleteName.bind(this, i)}>x</button>
</div>
)}
</div>
<div>
<h2>add to list:</h2>
<input type="text"
placeholder="Elon"
value={newName}
onInput={(e) => {
//@ts-ignore
setNewName(e.target.value)
}}
/>
<button
disabled={!newName}
onClick={addNewName}>add</button>
</div>
</div>
}

View File

@ -1,6 +1,6 @@
import {reRenderElement, VirtualElementContext} from "./keact"; import {ComponentContext, reRenderComponent} from "./keact-dom";
let currentInstance: VirtualElementContext; let currentInstance: ComponentContext;
let cursor = 0; let cursor = 0;
export function useState(initial: any): [any, Function] { export function useState(initial: any): [any, Function] {
@ -11,18 +11,18 @@ export function useState(initial: any): [any, Function] {
return result; return result;
} }
const setFunc = setState.bind(this, currentInstance, cursor); const setFunc = setState.bind(this, currentInstance, cursor);
state.set(cursor, initial)
setStateFuncs.set(cursor, setFunc) setStateFuncs.set(cursor, setFunc)
state.set(cursor, initial)
cursor++; cursor++;
return [initial, setFunc]; return [initial, setFunc];
} }
function setState(currentInstance: VirtualElementContext, cursor: number, newValue: any) { function setState(currentInstance: ComponentContext, cursor: number, newValue: any) {
currentInstance.state.set(cursor, newValue); currentInstance.state.set(cursor, newValue);
reRenderElement(currentInstance); reRenderComponent(currentInstance.element);
} }
export const setContext = (context: VirtualElementContext) => { export const setContext = (context: ComponentContext) => {
currentInstance = context; currentInstance = context;
cursor = 0; cursor = 0;
} }

View File

@ -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<HTMLElement, { children: VDOMElement[] }> = new Map()
export type ComponentContext = {
state: Map<number, any>,
setStateFuncs: Map<number, Function>
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<string, Function>;
}
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) { function render($element: VirtualElement | undefined, root: HTMLElement) {
if (!$element) return; 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<string, Function>) {
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<string, Function>){
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<string, Function> = {};
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 = { const KeactDOM = {

View File

@ -1,238 +1,103 @@
import {setContext} from "./hooks";
import {Fragment} from "./jsx-runtime"; import {Fragment} from "./jsx-runtime";
export type Props = Record<string, any>; export type Props = Record<string, any>;
export type VirtualElementContext = {
state: Map<number, any>,
setStateFuncs: Map<number, Function>
element: any,
}
export type VirtualElement = VirtualElementTag | VirtualElementText | VirtualElementComponent | VirtualElementEmpty | VirtualElementFragment; export type VirtualElement = VirtualElementTag | VirtualElementText | VirtualElementComponent | VirtualElementEmpty | VirtualElementFragment;
export type VirtualElementComponent = { export type VirtualElementComponent = {
type: 'component', type: 'component',
node: Node, props: Props,
context: VirtualElementContext,
generate: FC, generate: FC,
children: VirtualElement[], name: string,
instance: {
element: VirtualElementComponent;
},
}; };
export type VirtualElementTag = { export type VirtualElementTag = {
node: HTMLElement;
type: 'tag'; type: 'tag';
tag: string; //html tag tag: string; //html tag
props: Props;
children: VirtualElement[]; children: VirtualElement[];
otherProps: [string, any][];
propsFunctions: [string, Function][];
// context: VirtualElementContext // context: VirtualElementContext
}; };
export type VirtualElementText = { export type VirtualElementText = {
type: 'text', type: 'text',
node: Node,
text: string, text: string,
}; };
export type VirtualElementEmpty = { export type VirtualElementEmpty = {
type: 'empty', type: 'empty',
node: Node,
}; };
export type VirtualElementFragment = { export type VirtualElementFragment = {
node: Node,
type: 'fragment', type: 'fragment',
children: VirtualElement[]; children: VirtualElement[];
}; };
export type VirtualElementValid = VirtualElementComponent | VirtualElementTag | VirtualElementFragment; 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) { function createElementFragment(props: Props): VirtualElementFragment {
setContext(context); return {
createElement(context.element.generate, {
componentToUpdate: context.element,
...context.element.props
});
}
export function createElement(type: string | FC, props: Props): VirtualElementValid {
// type="div"
// props = {
// children: "Hello world!"
// }
if (type === Fragment) {
const element: VirtualElementFragment = {
type: 'fragment', type: 'fragment',
node: document.createTextNode(" "), children: resolveChildrenIntoVirtualElements(props.children || [" "]),
children: [],
} }
resolveChildren(props.children, element)
return element;
} }
const isComponent = typeof type === 'function';
//create component function createElementComponent(generateFunc: FC, props: Props): VirtualElementComponent {
if (isComponent) { const component: VirtualElementComponent = {
console.log('createElement() component')
const component: VirtualElementComponent = props.componentToUpdate || {
type: 'component', type: 'component',
node: null, generate: generateFunc,
generate: type,
children: [],
context: {
state: new Map(),
setStateFuncs: new Map(),
element: null,
},
props: props, props: props,
name: generateFunc.name,
instance: undefined as {element: VirtualElementComponent},
} }
component.context.element = component; component.instance = {
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; return component;
} }
// if (!element.node) { function createElementTag(tag: string, props: Props): VirtualElementTag {
// const node = document.createElement(type); return {
// 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', type: 'tag',
tag: type, tag,
children: [], props: Object.assign({}, props, {children: null}),
otherProps: [], children: props.children ? resolveChildrenIntoVirtualElements(props.children) : [],
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;
}
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); export function createElement(type: string | FC, props: Props): VirtualElementValid {
oldHead.children[i] = newChild; if (type === Fragment) {
continue; return createElementFragment(props);
}
if (typeof type === 'function') {
return createElementComponent(type, props);
}
return createElementTag(type, props);
} }
if (oldChild.type === 'tag' && newChild.type === 'tag') { export function resolveChildrenIntoVirtualElements(list: VirtualElementChildren): VirtualElement[] {
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) {
const array = Array.isArray(list) ? list : [list]; const array = Array.isArray(list) ? list : [list];
const result: VirtualElement[] = [];
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
const child = array[i]; const child = array[i];
if (typeof child === 'boolean') { if (typeof child === 'boolean') {
parent.children[i] = { result.push({
// node: null,
node: document.createTextNode(" "),
type: 'empty', type: 'empty',
}; });
} else if (typeof child !== 'object') { } else if (typeof child !== 'object') {
parent.children[i] = { result.push({
// node: null,
node: document.createTextNode(child),
type: 'text', type: 'text',
text: child, text: `${child}`,
}; });
} else { } else {
if (child.type === 'fragment') { if (Array.isArray(child)) {
for (const c of child.children) { result.push(...resolveChildrenIntoVirtualElements(child))
parent.node.appendChild(c.node); } else {
} result.push(child)
}
parent.children[i] = child;
}
if (parent.type !== 'fragment') {
parent.node.appendChild(parent.children[i].node);
} }
} }
} }
return result;
}