This commit is contained in:
2026-03-15 10:14:58 +09:00
parent 98c9ffbc89
commit 054f8b471d
9 changed files with 352 additions and 98 deletions

View File

@@ -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);

View File

@@ -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
View 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;

View 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);
}
}
}

View File

@@ -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 {
}
}

View File

@@ -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 };

View File

@@ -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
View 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;
}

View File

@@ -18,7 +18,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},