init, v1, createElement create nodes and manipulate DOM

This commit is contained in:
Kiril Burlaka 2025-12-02 12:52:04 +01:00
commit 110906370b
14 changed files with 7125 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
lib
.idea

13
babel.config.json Normal file
View File

@ -0,0 +1,13 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-typescript",
[
"@babel/preset-react",
{
"runtime": "automatic",
"importSource": "@keact"
}
]
]
}

6644
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "keact",
"version": "1.0.0",
"description": "Experiment to recreate minimal react implementation.",
"license": "ISC",
"author": "",
"type": "commonjs",
"private": true,
"scripts": {
"babel": "babel src --out-dir lib --extensions \".ts,.tsx\"",
"start": "webpack serve",
"build": "webpack --mode production"
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"babel-loader": "^10.0.0",
"html-webpack-plugin": "^5.6.5",
"webpack": "^5.103.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
}
}

24
src/components/App.tsx Normal file
View File

@ -0,0 +1,24 @@
import {useState} from "../keact/hooks";
import {Toggle} from "./Toggle";
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`);
}
// return <div>
// {text}
// </div>
return <div>
<div className="text_holder"
onClick={clickHandler}
>
<span>{text}</span>
<span>clicked {clicked} times</span>
</div>
<Toggle clicked={clicked}/>
</div>
}

View File

@ -0,0 +1,24 @@
import {useState} from "../keact/hooks";
export function ChooseBg() {
const [bgColor, setBgColor] = useState('#ff0000');
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;
}
}
return <div>
<div>
<span>input your own bg color:</span>
<input type="text"
placeholder="#ff0000"
onInput={changeBgColor}
/>
</div>
<span>current bg color is {bgColor}</span>
</div>
}

36
src/components/Toggle.tsx Normal file
View File

@ -0,0 +1,36 @@
import {useState} from "../keact/hooks";
import {ChooseBg} from "./ChooseBg";
export const Toggle = (props) => {
let [isOn, setIsOn] = useState(false);
function toggle() {
const newVal = !isOn;
setIsOn(newVal);
if (newVal) {
document.body.style.background = '#ff0000';
} else {
document.body.style.background = '';
}
}
return <div>
{
!isOn && <>
<span>
off
</span>
click "Toggle"
</>
}
<button onClick={toggle}>
Toggle
</button>
<span>
clicked*2: {props.clicked*2}
</span>
{
isOn && <ChooseBg />
}
</div>
}

10
src/index.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Keact test</title>
</head>
<body id="root">
</body>
</html>

12
src/index.tsx Normal file
View File

@ -0,0 +1,12 @@
import KeactDOM from "./keact/keact-dom";
import {App} from "./components/App";
init();
async function init() {
KeactDOM.render(
// @ts-ignore
<App/>,
document.getElementById('root')
);
}

28
src/keact/hooks.ts Normal file
View File

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

14
src/keact/jsx-runtime.ts Normal file
View File

@ -0,0 +1,14 @@
import {createElement, FC, Props} from "./keact";
export type {JSX} from 'react';
export function jsx(type: any, props: Props, key) {
// type="div"
// props = {
// children: "Hello world!"
// }
return createElement(type, props)
}
export const jsxs = jsx;
export const Fragment = Symbol("Fragment") as undefined as FC;

12
src/keact/keact-dom.ts Normal file
View File

@ -0,0 +1,12 @@
import {VirtualElement} from "./keact";
function render($element: VirtualElement | undefined, root: HTMLElement) {
if (!$element) return;
root.append($element.node);
}
const KeactDOM = {
render,
}
export default KeactDOM;

238
src/keact/keact.ts Normal file
View File

@ -0,0 +1,238 @@
import {setContext} from "./hooks";
import {Fragment} from "./jsx-runtime";
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 VirtualElementComponent = {
type: 'component',
node: Node,
context: VirtualElementContext,
generate: FC,
children: VirtualElement[],
};
export type VirtualElementTag = {
node: HTMLElement;
type: 'tag';
tag: string; //html tag
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
export function reRenderElement(context: VirtualElementContext) {
setContext(context);
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',
node: document.createTextNode(" "),
children: [],
}
resolveChildren(props.children, element)
return element;
}
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 (!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;
}
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) {
const array = Array.isArray(list) ? list : [list];
for (let i = 0; i < array.length; i++) {
const child = array[i];
if (typeof child === 'boolean') {
parent.children[i] = {
// node: null,
node: document.createTextNode(" "),
type: 'empty',
};
} else if (typeof child !== 'object') {
parent.children[i] = {
// node: null,
node: document.createTextNode(child),
type: 'text',
text: child,
};
} else {
if (child.type === 'fragment') {
for (const c of child.children) {
parent.node.appendChild(c.node);
}
}
parent.children[i] = child;
}
if (parent.type !== 'fragment') {
parent.node.appendChild(parent.children[i].node);
}
}
}

38
webpack.config.js Normal file
View File

@ -0,0 +1,38 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development',
entry: './src/index.tsx',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
loader: 'babel-loader',
exclude: /node_modules/,
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'@keact': path.resolve(__dirname, './src/keact'),
}
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html"
})
],
devServer: {
port: 3000, // You can run your app at http://localhost:3000
hot: false, // "Hot Module Replacement" (Updates without full reload)
static: {
directory: path.join(__dirname, 'public'), // If you have static assets (images)
},
},
};