init, v1, createElement create nodes and manipulate DOM
This commit is contained in:
commit
110906370b
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
lib
|
||||||
|
.idea
|
||||||
13
babel.config.json
Normal file
13
babel.config.json
Normal 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
6644
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
24
src/components/App.tsx
Normal 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>
|
||||||
|
}
|
||||||
24
src/components/ChooseBg.tsx
Normal file
24
src/components/ChooseBg.tsx
Normal 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
36
src/components/Toggle.tsx
Normal 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
10
src/index.html
Normal 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
12
src/index.tsx
Normal 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
28
src/keact/hooks.ts
Normal 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
14
src/keact/jsx-runtime.ts
Normal 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
12
src/keact/keact-dom.ts
Normal 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
238
src/keact/keact.ts
Normal 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
38
webpack.config.js
Normal 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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user