init
This commit is contained in:
75
src/main.ts
75
src/main.ts
@@ -1,33 +1,50 @@
|
||||
import { Component, type ComponentBlueprint } from "./module/Component";
|
||||
import { HTMLComponent } from "./module/HTMLComponent";
|
||||
import { Component, type Blueprint } from "./module/Component";
|
||||
import { State } from "./module/State";
|
||||
import { TextComponent } from "./module/TextComponent";
|
||||
|
||||
class Counter extends Component<{ initValue: number, children: State<ComponentBlueprint<any>[]> }> {
|
||||
state: { count: State<number> } = {
|
||||
count: new State(0)
|
||||
};
|
||||
|
||||
constructor(props: { initValue: number }) {
|
||||
super({
|
||||
...props,
|
||||
children: [
|
||||
{
|
||||
component: HTMLComponent,
|
||||
props: {
|
||||
children: new State([{
|
||||
component: TextComponent,
|
||||
props: {
|
||||
content: this.state.count
|
||||
}
|
||||
}])
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
import { html } from "./module/html";
|
||||
|
||||
type Props = { initValue?: number }
|
||||
class Counter extends Component<Props, { count: State<number>, double: State<number> }> {
|
||||
init() {
|
||||
this.state.count.set(this.props.initValue);
|
||||
this.states.count = new State(this.propStates.initValue?.value ?? 0);
|
||||
this.states.double = this.derived((value) => value * 2, [this.states.count]);
|
||||
}
|
||||
}
|
||||
|
||||
blueprint(): Blueprint[] {
|
||||
const s = html`
|
||||
<button onclick="${() => this.states.count.update((val) => val + 1)}">
|
||||
${this.states.count}
|
||||
</button>
|
||||
`;
|
||||
return [
|
||||
...s,
|
||||
{
|
||||
type: 'component',
|
||||
component: RedText,
|
||||
props: {
|
||||
content: this.derived((double) => `doulbed: ${double}`, [this.states.double])
|
||||
},
|
||||
children: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
class RedText extends Component<{ content: string }, {}> {
|
||||
init() { }
|
||||
blueprint(): Blueprint[] {
|
||||
return html`
|
||||
<span style="color: red;">
|
||||
${this.propStates.content}
|
||||
</span>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
const counter = new Counter({ initValue: 1 });
|
||||
counter.mount(document.body);
|
||||
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.innerText = "destroy";
|
||||
btn.onclick = () => {counter.destroy()};
|
||||
document.body.appendChild(btn);
|
||||
@@ -1,44 +1,27 @@
|
||||
import { ComponentBase } from "./ComponentBase";
|
||||
import { ComponentFactory } from "./ComponentFactory";
|
||||
import { State } from "./State";
|
||||
import { TextComponent } from "./TextComponent";
|
||||
|
||||
export abstract class Component<Props extends PropsBase> {
|
||||
static propsToPropState<Props extends PropsBase>(props: Props) {
|
||||
const propsState: Record<string, State<any>> = {};
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (value instanceof State) {
|
||||
propsState[key] = value;
|
||||
}
|
||||
else {
|
||||
propsState[key] = new State(value);
|
||||
}
|
||||
export abstract class Component<Props extends PropsBase, States extends StatesBase> extends ComponentBase<Props, States> {
|
||||
declare node: DocumentFragment;
|
||||
|
||||
constructor(props: Props, children: Blueprint[] = []) {
|
||||
super(props);
|
||||
this.node = this.createNode();
|
||||
|
||||
const blueprint = this.blueprint(children);
|
||||
this.children = blueprint.map((bp) => ComponentFactory.blueprintToComponentInstance(bp));
|
||||
this.children.forEach((child) => {
|
||||
child.mount(this.node);
|
||||
});
|
||||
return propsState as PropState<Props>;
|
||||
}
|
||||
|
||||
static blueprintsToComponentInstances(blueprints: Blueprint[]): Component<any>[] {
|
||||
return blueprints.map((blueprint) => this.blueprintToComponentInstance(blueprint));
|
||||
}
|
||||
|
||||
static blueprintToComponentInstance(blueprint: Blueprint) {
|
||||
if (blueprint.type === "text") {
|
||||
return new TextComponent(blueprint.props);
|
||||
}
|
||||
}
|
||||
|
||||
propState: PropState<Props>;
|
||||
children: Component<any>[] = [];
|
||||
mounted: boolean = false;
|
||||
subscriberSymbols: Symbol[] = [];
|
||||
abstract node: Node;
|
||||
|
||||
constructor(props: Props) {
|
||||
this.propState = Component.propsToPropState(props);
|
||||
this.init();
|
||||
}
|
||||
|
||||
abstract init(): void;
|
||||
abstract blueprint(children?: Blueprint[]): Blueprint[];
|
||||
mount(target: HTMLElement) {
|
||||
createNode(): DocumentFragment {
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
|
||||
mount(target: Node) {
|
||||
if (this.mounted) {
|
||||
throw new Error("This component is already mounted");
|
||||
}
|
||||
@@ -46,18 +29,22 @@ export abstract class Component<Props extends PropsBase> {
|
||||
this.mounted = true;
|
||||
}
|
||||
destroy() {
|
||||
|
||||
this.children.forEach((child) => child.destroy());
|
||||
this.subscriberSymbols.forEach(({ state, symbol }) => {
|
||||
state.unsubscribe(symbol);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type StatesBase = Record<string, State<any>>;
|
||||
export type PropsBase = Record<string, any>;
|
||||
export type PropState<Props extends PropsBase> = {
|
||||
[K in keyof Props]: State<Props[K]>
|
||||
}
|
||||
|
||||
export type ComponentBlueprint<Props extends PropsBase> = {
|
||||
export type ComponentBlueprint<Props extends PropsBase, States extends StatesBase> = {
|
||||
type: 'component',
|
||||
component: new (props: Props) => Component<Props>,
|
||||
component: typeof Component<Props, States>,
|
||||
props: Props,
|
||||
children: Blueprint[]
|
||||
}
|
||||
@@ -71,7 +58,4 @@ export type TextBlueprint = {
|
||||
type: 'text',
|
||||
props: { content: State<any> | any }
|
||||
}
|
||||
export type Blueprint = ComponentBlueprint<any> | HTMLBlueprint | TextBlueprint;
|
||||
|
||||
type K = PropState<{ asd: number }>
|
||||
type X = K['asd']
|
||||
export type Blueprint = ComponentBlueprint<any, any> | HTMLBlueprint | TextBlueprint;
|
||||
104
src/module/ComponentBase.ts
Normal file
104
src/module/ComponentBase.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { State, type ValuesOfStateArray } from "./State";
|
||||
|
||||
export abstract class ComponentBase<Props extends PropsBase, States extends StatesBase> {
|
||||
static propsToPropStates<Props extends PropsBase>(props: Props | PropStates<Props>) {
|
||||
const propsState: Record<string, State<any>> = {};
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (value instanceof State) {
|
||||
propsState[key] = value;
|
||||
}
|
||||
else {
|
||||
propsState[key] = new State(value);
|
||||
}
|
||||
});
|
||||
return propsState as PropStates<Props>;
|
||||
}
|
||||
|
||||
states: States = {} as States;
|
||||
propStates: PropStates<Props>;
|
||||
children: ComponentBase<any, any>[] = [];
|
||||
mounted: boolean = false;
|
||||
subscriberSymbols: { state: State<any>, symbol: Symbol }[] = [];
|
||||
abstract node: Node;
|
||||
|
||||
constructor(props: PropStates<Props> | Props) {
|
||||
this.propStates = ComponentBase.propsToPropStates(props);
|
||||
this.init();
|
||||
}
|
||||
|
||||
init(): void { }
|
||||
protected abstract createNode(): Node;
|
||||
|
||||
mount(target: Node) {
|
||||
if (this.mounted) {
|
||||
throw new Error("This component is already mounted");
|
||||
}
|
||||
target.appendChild(this.node);
|
||||
this.mounted = true;
|
||||
this.onMount?.();
|
||||
}
|
||||
destroy() {
|
||||
this.children.forEach((child) => child.destroy());
|
||||
this.subscriberSymbols.forEach(({ state, symbol }) => {
|
||||
state.unsubscribe(symbol);
|
||||
});
|
||||
this.onDestoy?.();
|
||||
};
|
||||
onMount(): void { };
|
||||
onDestoy(): void { };
|
||||
|
||||
effect<const T extends State<any>[]>(callback: (...values: ValuesOfStateArray<T>) => void, states: T) {
|
||||
states.forEach((state) => {
|
||||
const symbol = state.subscribe(() => callback(...states.map(({ value }) => value) as ValuesOfStateArray<T>));
|
||||
this.subscriberSymbols.push({ state, symbol });
|
||||
})
|
||||
}
|
||||
derived<const T extends State<any>[], U>(callback: (...values: ValuesOfStateArray<T>) => U, states: T): State<U> {
|
||||
const derivedState = new State<U>(undefined as U);
|
||||
states.forEach((state) => {
|
||||
const symbol = state.subscribe(() => {
|
||||
const derivedValue = callback(...states.map(({ value }) => value) as ValuesOfStateArray<T>);
|
||||
derivedState.set(derivedValue);
|
||||
});
|
||||
this.subscriberSymbols.push({ state, symbol });
|
||||
});
|
||||
return derivedState;
|
||||
}
|
||||
update<T extends (keyof Props | (string & {})), U>(
|
||||
key: T,
|
||||
value: T extends keyof Props ?
|
||||
Props[T] extends State<U> ? U : Props[T] :
|
||||
U
|
||||
) {
|
||||
let propState: State<any> = this.propStates[key];
|
||||
if (!propState) {
|
||||
propState = new State(value);
|
||||
this.propStates[key] = propState;
|
||||
}
|
||||
propState.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type StatesBase = Record<string, State<any>>;
|
||||
export type PropsBase = Record<string, any>;
|
||||
export type PropStates<Props extends PropsBase> = {
|
||||
[K in keyof Props]: State<Props[K]>
|
||||
}
|
||||
|
||||
export type ComponentBlueprint<Props extends PropsBase, States extends StatesBase> = {
|
||||
type: 'component',
|
||||
component: new (props: Props, children?: Blueprint[]) => ComponentBase<Props, States>,
|
||||
props: Props,
|
||||
children: Blueprint[]
|
||||
}
|
||||
export type HTMLBlueprint = {
|
||||
type: 'html',
|
||||
tag: string;
|
||||
props: Record<string, any>;
|
||||
children: Blueprint[]
|
||||
}
|
||||
export type TextBlueprint = {
|
||||
type: 'text',
|
||||
props: { content: State<any> | any }
|
||||
}
|
||||
export type Blueprint = ComponentBlueprint<any, any> | HTMLBlueprint | TextBlueprint;
|
||||
18
src/module/ComponentFactory.ts
Normal file
18
src/module/ComponentFactory.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Blueprint } from "./Component";
|
||||
import type { ComponentBase } from "./ComponentBase";
|
||||
import { HTMLComponent } from "./HTMLComponent";
|
||||
import { TextComponent } from "./TextComponent";
|
||||
|
||||
export namespace ComponentFactory {
|
||||
export function blueprintToComponentInstance(blueprint: Blueprint): ComponentBase<any, any> {
|
||||
if (blueprint.type === "text") {
|
||||
return new TextComponent(blueprint.props);
|
||||
}
|
||||
else if (blueprint.type === "html") {
|
||||
return new HTMLComponent(blueprint.tag, blueprint.props, blueprint.children);
|
||||
}
|
||||
else {
|
||||
return new blueprint.component(blueprint.props, blueprint.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,49 @@
|
||||
import { Component, type Blueprint, type PropsBase } from "./Component";
|
||||
import { type Blueprint, type PropsBase } from "./Component";
|
||||
import { ComponentBase } from "./ComponentBase";
|
||||
import { ComponentFactory } from "./ComponentFactory";
|
||||
import { State } from "./State";
|
||||
|
||||
export class HTMLComponent<Props extends PropsBase> extends Component<Props> {
|
||||
export class HTMLComponent<Props extends PropsBase> extends ComponentBase<Props, {}> {
|
||||
state: Record<string, State<any>> = {};
|
||||
tag: string;
|
||||
node: HTMLElement;
|
||||
subscriberSymbols: { state: State<any>, symbol: Symbol }[] = [];
|
||||
declare node: HTMLElement;
|
||||
|
||||
constructor(tag: string, props: Props) {
|
||||
constructor(tag: string, props: Props, children: Blueprint[] = []) {
|
||||
super(props);
|
||||
this.tag = tag;
|
||||
this.node = document.createElement(tag);
|
||||
this.node = this.createNode();
|
||||
|
||||
const blueprint = this.blueprint();
|
||||
this.children = Component.blueprintsToComponentInstances(blueprint);
|
||||
const blueprint = this.blueprint(children);
|
||||
this.children = blueprint.map((bp) => ComponentFactory.blueprintToComponentInstance(bp));
|
||||
this.children.forEach((child) => {
|
||||
this.node.appendChild(child.node);
|
||||
child.mount(this.node);
|
||||
});
|
||||
|
||||
Object.entries(this.propStates).forEach(([key, prop]) => {
|
||||
if (prop instanceof State) {
|
||||
prop.subscribe((value) => {
|
||||
this.setNodePropertyOrAttribute(key, value);
|
||||
})
|
||||
}
|
||||
else {
|
||||
this.setNodePropertyOrAttribute(key, prop);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
init() { }
|
||||
private setNodePropertyOrAttribute(key: string, value: any) {
|
||||
if (key in this.node) {
|
||||
(this.node as any)[key] = value;
|
||||
}
|
||||
else {
|
||||
this.node.setAttribute(key, value + "")
|
||||
}
|
||||
}
|
||||
|
||||
createNode() {
|
||||
return document.createElement(this.tag);
|
||||
}
|
||||
blueprint(children?: Blueprint[]): Blueprint[] {
|
||||
return children ?? [];
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,5 @@ export class State<T> {
|
||||
}
|
||||
}
|
||||
|
||||
type SubscribeCallback<T> = (value: T) => any
|
||||
type SubscribeCallback<T> = (value: T) => any
|
||||
export type ValuesOfStateArray<T extends State<any>[]> = { [K in keyof T]: T[K] extends State<infer V> ? V : never };
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Component, type TextBlueprint } from "./Component";
|
||||
import { ComponentBase } from "./ComponentBase";
|
||||
|
||||
export class TextComponent extends Component<{ content: any }> {
|
||||
export class TextComponent extends ComponentBase<{ content: any }, {}> {
|
||||
init() { };
|
||||
node: Text;
|
||||
subscribeSymbol: Symbol;
|
||||
|
||||
constructor(props: { content: any }) {
|
||||
super(props);
|
||||
this.node = document.createTextNode(this.propState.content.value + "");
|
||||
this.subscribeSymbol = this.propState.content.subscribe((value) => {
|
||||
this.node.textContent = value = "";
|
||||
});
|
||||
this.node = this.createNode();
|
||||
this.effect((value) => {
|
||||
this.node.textContent = value + "";
|
||||
}, [this.propStates.content])
|
||||
}
|
||||
|
||||
protected createNode(): Text {
|
||||
return document.createTextNode(this.propStates.content.value + "");
|
||||
}
|
||||
|
||||
blueprint() {
|
||||
return []
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.propState.content.unsubscribe(this.subscribeSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
113
src/module/html.ts
Normal file
113
src/module/html.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { type Blueprint } from "./Component";
|
||||
import { State } from "./State";
|
||||
|
||||
const MARKER_PREFIX = "__GEMINI_MARKER_";
|
||||
// DOMParser는 태그 이름을 소문자로 변환하므로 소문자 버전도 준비합니다.
|
||||
const LOWER_MARKER_PREFIX = MARKER_PREFIX.toLowerCase();
|
||||
const MARKER_REGEX = new RegExp(`${MARKER_PREFIX}(\\d+)__`, 'g');
|
||||
const TAG_MARKER_REGEX = new RegExp(`${LOWER_MARKER_PREFIX}(\\d+)__`);
|
||||
|
||||
export function html(strings: TemplateStringsArray, ...values: any[]): Blueprint[] {
|
||||
const htmlString = strings.reduce((acc, str, i) => {
|
||||
return acc + str + (i < values.length ? `${MARKER_PREFIX}${i}__` : "");
|
||||
}, "");
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlString, "text/html");
|
||||
|
||||
const blueprints: Blueprint[] = [];
|
||||
Array.from(doc.body.childNodes).forEach(node => {
|
||||
const result = nodeToBlueprint(node, values);
|
||||
if (Array.isArray(result)) {
|
||||
blueprints.push(...result);
|
||||
} else if (result) {
|
||||
blueprints.push(result);
|
||||
}
|
||||
});
|
||||
return blueprints;
|
||||
}
|
||||
|
||||
function nodeToBlueprint(node: Node, values: any[]): Blueprint | Blueprint[] | null {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || "";
|
||||
const matches = [...text.matchAll(MARKER_REGEX)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
if (text.trim() === "") return null;
|
||||
return { type: 'text', props: { content: text } };
|
||||
}
|
||||
|
||||
if (matches.length === 1 && text.trim() === matches[0][0]) {
|
||||
const index = parseInt(matches[0][1]);
|
||||
const val = values[index];
|
||||
if (Array.isArray(val)) return val;
|
||||
if (val && typeof val === 'object' && 'type' in val) return val;
|
||||
return { type: 'text', props: { content: val } };
|
||||
}
|
||||
|
||||
let content: any = text;
|
||||
matches.forEach(match => {
|
||||
const index = parseInt(match[1]);
|
||||
const val = values[index];
|
||||
|
||||
// 컴포넌트 클래스(함수)가 텍스트 노드에 포함된 경우 치환하지 않고 건너뜁니다.
|
||||
if (typeof val === 'function' && !(val instanceof State)) {
|
||||
content = content.replace(match[0], "");
|
||||
return;
|
||||
}
|
||||
|
||||
const replacement = val instanceof State ? val.value : val;
|
||||
content = content.replace(match[0], replacement);
|
||||
});
|
||||
return { type: 'text', props: { content } };
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
|
||||
// 태그 자체가 마커인지 확인 (컴포넌트 케이스)
|
||||
const tagMatch = tagName.match(TAG_MARKER_REGEX);
|
||||
|
||||
const props: Record<string, any> = {};
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const match = attr.value.match(new RegExp(`${MARKER_PREFIX}(\\d+)__`));
|
||||
if (match) {
|
||||
const index = parseInt(match[1]);
|
||||
props[attr.name] = values[index];
|
||||
} else {
|
||||
props[attr.name] = attr.value;
|
||||
}
|
||||
});
|
||||
|
||||
const children: Blueprint[] = [];
|
||||
Array.from(el.childNodes).forEach(child => {
|
||||
const result = nodeToBlueprint(child, values);
|
||||
if (Array.isArray(result)) {
|
||||
children.push(...result);
|
||||
} else if (result) {
|
||||
children.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
if (tagMatch) {
|
||||
const index = parseInt(tagMatch[1]);
|
||||
const componentClass = values[index];
|
||||
return {
|
||||
type: 'component',
|
||||
component: componentClass,
|
||||
props,
|
||||
children
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'html',
|
||||
tag: tagName,
|
||||
props,
|
||||
children
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"erasableSyntaxOnly": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user