Skip to content

코드

https://github.com/angrymusic/mini-vue

reactive-core.ts

ts
// reactive-core.ts
export const REF_BRAND: unique symbol = Symbol("refBrand");

export type Ref<T> = { value: T; [REF_BRAND]?: true };
export type ComputedRef<T> = Readonly<Ref<T>>;
export type UnwrapRef<T> = T extends Ref<infer V> ? V : T;
export type ShallowUnwrapRefs<T extends object> = {
  [K in keyof T]: UnwrapRef<T[K]>;
};

// --- Effect / Dep graph ---

type EffectFn = (() => any) & {
  deps?: Array<Set<EffectFn>>;
  active?: boolean;
  scheduler?: () => void;
};

let activeEffect: EffectFn | null = null;
const effectStack: EffectFn[] = [];
const targetMap = new WeakMap<object, Map<PropertyKey, Set<EffectFn>>>();

// 값을 읽을 때
function track(target: object, key: PropertyKey) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    (activeEffect.deps ||= []).push(dep);
  }
}

// 값을 쓸 때
function trigger(target: object, key: PropertyKey) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;
  const effects = new Set(dep);
  for (const e of effects) {
    if (e === activeEffect) continue;
    e.scheduler ? e.scheduler() : queueJob(e);
  }
}

// effect가 다시 실행되기 전에 기존에 등록된 의존성을 정리
function cleanup(effect: EffectFn) {
  const deps = effect.deps;
  if (!deps) return;
  for (const dep of deps) dep.delete(effect);
  deps.length = 0;
}

//
export function effect(
  fn: () => any,
  options?: { scheduler?: () => void; lazy?: boolean }
) {
  const runner: EffectFn = function () {
    cleanup(runner);
    try {
      activeEffect = runner;
      effectStack.push(runner);
      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1] || null;
    }
  };

  runner.active = true;
  runner.scheduler = options?.scheduler;
  if (!options?.lazy) runner();
  return runner;
}

// --- 마이크로태스크 배치 ---

const jobQueue = new Set<() => any>();
let isFlushing = false;

function queueJob(job: () => any) {
  jobQueue.add(job);
  if (!isFlushing) {
    isFlushing = true;
    Promise.resolve().then(flushJobs);
  }
}

function flushJobs() {
  try {
    for (const job of jobQueue) job();
  } finally {
    jobQueue.clear();
    isFlushing = false;
  }
}

// --- reactive ---

const reactiveCache = new WeakMap<object, any>();

export function reactive<T extends object>(target: T): T {
  if (reactiveCache.has(target)) return reactiveCache.get(target);
  const proxy = new Proxy(target, {
    get(t, key, recv) {
      const res = Reflect.get(t, key, recv);
      track(t, key);
      return isObject(res) ? reactive(res) : res;
    },
    set(t, key, val, recv) {
      const oldVal = (t as any)[key];
      const ok = Reflect.set(t, key, val, recv);
      if (ok && !Object.is(oldVal, val)) trigger(t, key);
      return ok;
    },
  });
  reactiveCache.set(target, proxy);
  return proxy;
}

function isObject(val: unknown): val is object {
  return val !== null && typeof val === "object";
}

// --- ref ---

class RefImpl<T> implements Ref<T> {
  [REF_BRAND] = true as const;
  private _value: T;
  public dep?: Set<EffectFn>;
  constructor(v: T) {
    this._value = v;
  }
  get value(): T {
    trackRefValue(this);
    return this._value;
  }
  set value(next: T) {
    if (!Object.is(this._value, next)) {
      this._value = next;
      triggerRefValue(this);
    }
  }
}

function trackRefValue(ref: RefImpl<any>) {
  if (!activeEffect) return;
  (ref.dep ||= new Set()).add(activeEffect);
  (activeEffect.deps ||= []).push(ref.dep);
}

function triggerRefValue(ref: RefImpl<any>) {
  const dep = ref.dep;
  if (!dep) return;
  const effects = new Set(dep);
  for (const e of effects) e.scheduler ? e.scheduler() : queueJob(e);
}

export function ref<T>(v: T): Ref<T> {
  return new RefImpl<T>(v);
}

export function isRef(r: unknown): r is Ref<unknown> {
  return typeof r === "object" && r !== null && REF_BRAND in (r as any);
}

export function unref<T>(r: T | Ref<T>): T {
  return isRef(r) ? (r as any).value : (r as any);
}

export function proxyRefs<T extends object>(obj: T): ShallowUnwrapRefs<T> {
  return new Proxy(obj as any, {
    get(t, k, rcv) {
      return unref(Reflect.get(t, k, rcv));
    },
    set(t, k, v, rcv) {
      const old = Reflect.get(t, k, rcv);
      if (isRef(old) && !isRef(v)) {
        (old as any).value = v;
        return true;
      }
      return Reflect.set(t, k, v, rcv);
    },
  }) as any;
}

// --- computed ---

export function computed<T>(getter: () => T): ComputedRef<T> {
  let cached!: T;
  let dirty = true;
  const holder: any = {};
  (holder as any)[REF_BRAND] = true; // ref 브랜드 → proxyRefs/unref 대상

  const runner = effect(getter, {
    lazy: true,
    scheduler: () => {
      if (!dirty) {
        dirty = true;
        trigger(holder, "value");
      }
    },
  });

  Object.defineProperty(holder, "value", {
    get() {
      if (dirty) {
        cached = runner();
        dirty = false;
      }
      track(holder, "value");
      return cached;
    },
  });

  return holder as ComputedRef<T>;
}

// --- watch ---

type WatchSource<T> = (() => T) | object;

export function watch<T>(
  source: WatchSource<T>,
  cb: (newVal: T, oldVal: T, onCleanup: (fn: () => void) => void) => void,
  options?: { immediate?: boolean; flush?: "pre" | "post" | "sync" }
) {
  const getter: () => T =
    typeof source === "function"
      ? (source as any)
      : () => traverse(source as object) as any;
  let oldVal!: T;
  let cleanup: (() => void) | undefined;
  const onCleanup = (fn: () => void) => (cleanup = fn);

  const job = () => {
    const newVal = runner();
    if (!Object.is(newVal, oldVal)) {
      cleanup?.();
      cb(newVal, oldVal, onCleanup);
      oldVal = newVal;
    }
  };
  const scheduler =
    options?.flush === "sync"
      ? job
      : options?.flush === "pre"
      ? () => queuePreJob(job)
      : () => queueJob(job);
  const runner = effect(getter, { lazy: true, scheduler });

  if (options?.immediate) job();
  else oldVal = runner();
}

const preQueue = new Set<() => void>();
let preFlushing = false;
function queuePreJob(job: () => void) {
  preQueue.add(job);
  if (!preFlushing) {
    preFlushing = true;
    Promise.resolve().then(() => {
      for (const j of preQueue) j();
      preQueue.clear();
      preFlushing = false;
    });
  }
}

function traverse(value: any, seen = new Set<any>()) {
  if (!isObject(value) || seen.has(value)) return value;
  seen.add(value);
  for (const k in value) traverse((value as any)[k], seen);
  return value;
}

mini-vue.ts

ts
// mini-vue.ts
import { effect, proxyRefs, type ShallowUnwrapRefs } from "./reactive-core";

// 스타일/속성 타입
export type StyleValue = Record<string, string | number>;
export type Props = Record<string, unknown> & {
  key?: any;
  class?: string;
  style?: StyleValue;
};

// 이벤트 인보커 저장용 (엘리먼트에 심볼 속성 사용)
const INVOKERS_KEY = Symbol("vei");

type Invoker = ((e: Event) => void) & { value: (e: Event) => void };

type ElWithInvokers = Element & {
  [INVOKERS_KEY]?: Record<string, Invoker | undefined>;
};

// --- VNode & 컴포넌트 타입 ---
export type VNode = {
  type: string | Component<any, any>;
  props: Props | null;
  children: string | VNode[] | null;
  key?: any;
  el?: Node | null;
  component?: ComponentInstance | null;
};

export type ComponentInstance = {
  vnode: VNode;
  isMounted: boolean;
  subTree: VNode | null;
  state: Record<string, unknown>;
  props: Props;
  update: (() => void) | null;
  el: Node | null;
};

// 제네릭 컴포넌트: P(props), S(setup 반환)
export type Component<P extends object = {}, S extends object = {}> = {
  setup?: (props: Readonly<P>) => S | void;
  render: (ctx: ShallowUnwrapRefs<P & S>) => VNode;
};

// --- h(): VNode 생성 ---
export function h(
  type: VNode["type"],
  props: Props | null = null,
  children?: string | VNode | VNode[] | null
): VNode {
  const key = props && "key" in props ? (props as any).key : undefined;
  const normChildren: string | VNode[] | null =
    children == null
      ? null
      : typeof children === "string"
      ? children
      : Array.isArray(children)
      ? children
      : [children];
  return {
    type,
    props,
    children: normChildren,
    key,
    el: null,
    component: null,
  };
}

// --- 렌더 진입점 ---
export function render(vnode: VNode, container: Element) {
  patch(null, vnode, container, null);
}

// --- 패치 (diff) ---
function patch(
  n1: VNode | null,
  n2: VNode,
  container: Element,
  anchor: Node | null
) {
  if (typeof n2.type === "string") {
    n1 ? patchElement(n1, n2, container) : mountElement(n2, container, anchor);
  } else {
    n1
      ? updateComponent(n1, n2, container, anchor)
      : mountComponent(n2, container, anchor);
  }
}

// --- Element 처리 ---
function mountElement(vnode: VNode, container: Element, anchor: Node | null) {
  const el = (vnode.el = document.createElement(vnode.type as string));
  patchProps(null, vnode.props, el as Element);
  mountChildren(vnode, el as Element);
  container.insertBefore(el as Node, anchor);
}

function patchElement(n1: VNode, n2: VNode, _container: Element) {
  const el = (n2.el = n1.el)! as Element;
  patchProps(n1.props, n2.props, el);
  patchChildren(n1, n2, el);
}

function mountChildren(vnode: VNode, el: Element) {
  const c = vnode.children;
  if (typeof c === "string") (el as HTMLElement).textContent = c;
  else if (Array.isArray(c))
    for (const child of c) patch(null, child, el, null);
}

function patchChildren(n1: VNode, n2: VNode, el: Element) {
  const c1 = n1.children,
    c2 = n2.children;
  if (typeof c2 === "string") {
    if (Array.isArray(c1)) for (const old of c1) unmount(old);
    (el as HTMLElement).textContent = c2;
    return;
  }
  if (Array.isArray(c2)) {
    if (typeof c1 === "string" || c1 == null) {
      (el as HTMLElement).textContent = "";
      for (const child of c2) patch(null, child, el, null);
    } else {
      const common = Math.min(c1.length, c2.length);
      for (let i = 0; i < common; i++) patch(c1[i], c2[i], el, null);
      if (c2.length > c1.length)
        for (let i = common; i < c2.length; i++) patch(null, c2[i], el, null);
      else if (c1.length > c2.length)
        for (let i = common; i < c1.length; i++) unmount(c1[i]);
    }
  } else {
    if (Array.isArray(c1)) for (const old of c1) unmount(old);
    else if (typeof c1 === "string") (el as HTMLElement).textContent = "";
  }
}

function unmount(vnode: VNode) {
  if (typeof vnode.type === "string") {
    const node = vnode.el as Node | null;
    node?.parentNode?.removeChild(node);
  } else if (vnode.component?.subTree) {
    unmount(vnode.component.subTree);
  }
}

// --- Props & 이벤트 패치 ---
const onRE = /^on[A-Z]/;

function patchProps(
  oldProps: Props | null,
  newProps: Props | null,
  el: Element
) {
  const prev = oldProps || {};
  const next = newProps || {};
  for (const key in next) {
    const prevVal = prev[key];
    const nextVal = next[key];
    if (prevVal !== nextVal) setProp(el, key, prevVal, nextVal);
  }
  for (const key in prev) {
    if (!(key in next)) setProp(el, key, (prev as any)[key], null);
  }
}

function setProp(el: Element, key: string, prevVal: unknown, nextVal: unknown) {
  if (key === "class") {
    (el as HTMLElement).className = (nextVal as string) ?? "";
    return;
  }
  if (key === "style") {
    const style = (el as HTMLElement).style;
    const prev = (prevVal as StyleValue) || {};
    const next = (nextVal as StyleValue) || {};
    for (const k in next) style.setProperty(k, String(next[k]));
    for (const k in prev) if (!(k in next)) style.removeProperty(k);
    return;
  }
  if (onRE.test(key)) {
    const name = key.slice(2).toLowerCase();
    const host = el as ElWithInvokers;
    const invokers = (host[INVOKERS_KEY] ||= {});
    let inv = invokers[key];
    if (nextVal) {
      if (!inv) {
        inv = ((e: Event) => (inv as Invoker).value(e)) as Invoker;
        inv.value = nextVal as (e: Event) => void;
        invokers[key] = inv;
        el.addEventListener(name, inv);
      } else {
        inv.value = nextVal as (e: Event) => void;
      }
    } else if (inv) {
      el.removeEventListener(name, inv);
      invokers[key] = undefined;
    }
    return;
  }
  if (key === "value" && "value" in (el as any)) {
    (el as any).value = nextVal ?? "";
    return;
  }
  if (key === "checked" && "checked" in (el as any)) {
    (el as any).checked = Boolean(nextVal);
    return;
  }
  if (nextVal == null || nextVal === false) el.removeAttribute(key);
  else el.setAttribute(key, String(nextVal));
}

// --- 컴포넌트 처리 ---
function mountComponent(vnode: VNode, container: Element, anchor: Node | null) {
  const comp = vnode.type as Component<any, any>;
  const instance: ComponentInstance = {
    vnode,
    isMounted: false,
    subTree: null,
    state: {},
    props: vnode.props || {},
    update: null,
    el: null,
  };
  vnode.component = instance;

  const setupState = comp.setup?.(instance.props as any);
  if (setupState && typeof setupState === "object")
    instance.state = setupState as Record<string, unknown>;

  const updateComponent = () => {
    const ctx = proxyRefs({
      ...instance.props,
      ...instance.state,
    }) as ShallowUnwrapRefs<Record<string, unknown>>;
    const nextTree = comp.render(ctx as any);
    if (!instance.isMounted) {
      patch(null, nextTree, container, anchor);
      instance.isMounted = true;
    } else {
      patch(instance.subTree!, nextTree, container, anchor);
    }
    instance.subTree = nextTree;
    vnode.el = instance.el = nextTree.el!;
  };

  instance.update = effect(updateComponent);
}

function updateComponent(
  n1: VNode,
  n2: VNode,
  _container: Element,
  _anchor: Node | null
) {
  const instance = (n2.component = n1.component)!;
  n2.el = n1.el;
  instance.vnode = n2;
  instance.props = n2.props || {};
  instance.update && instance.update();
}

// --- 앱 생성기 ---
export function createApp<P extends object = {}, S extends object = {}>(
  root: Component<P, S>
) {
  return {
    mount(selectorOrEl: string | Element) {
      const container =
        typeof selectorOrEl === "string"
          ? (document.querySelector(selectorOrEl) as Element)
          : selectorOrEl;
      const vnode = h(root as any, null, null);
      render(vnode, container);
    },
  };
}

main.ts

ts
// main.ts
import { reactive, ref, computed, type ComputedRef } from "./reactive-core";
import { h, createApp, type Component, type VNode } from "./mini-vue";

// 공통 UI
const Box = (children: VNode[] | string, extra?: Record<string, unknown>) =>
  h(
    "div",
    {
      class: "box",
      style: {
        padding: "12px",
        border: "1px solid #ddd",
        borderRadius: "12px",
        marginBottom: "12px",
        ...(extra?.style as any),
      },
    },
    children
  );

// 문자열을 VNode로 정규화하여 children 타입 충돌 제거
const Row = (children: Array<VNode | string>): VNode =>
  h(
    "div",
    { style: { display: "flex", gap: "8px" } },
    children.map((c) => (typeof c === "string" ? h("span", null, c) : c))
  );

const Button = (label: string, onClick: (e: Event) => void) =>
  h(
    "button",
    {
      onClick,
      style: {
        padding: "6px 10px",
        borderRadius: "8px",
        border: "1px solid #ccc",
        cursor: "pointer",
      },
    },
    label
  );

// --- Counter ---

type CounterState = {
  state: { count: number };
  double: ComputedRef<number>;
  inc: () => void;
  dec: () => void;
};

const Counter: Component<{}, CounterState> = {
  setup() {
    const state = reactive({ count: 0 });
    const double = computed(() => state.count * 2);
    const inc = () => {
      state.count++;
    };
    const dec = () => {
      state.count--;
    };
    const ret: CounterState = { state, double, inc, dec };
    return ret;
  },
  render(ctx) {
    // ctx.double, ctx.state.count 는 언랩/추론됨
    return Box([
      h("h2", null, "Counter"),
      h("p", null, `count = ${ctx.state.count} / double = ${ctx.double}`),
      Row([Button("+1", ctx.inc), Button("-1", ctx.dec)]),
    ]);
  },
};

// --- TodoList ---

interface Todo {
  id: number;
  text: string;
}

type TodoState = {
  input: ReturnType<typeof ref<string>>; // ref<string>
  todos: ReturnType<typeof reactive<Todo[]>>; // Todo[] (Proxy)
  add: () => void;
  remove: (idx: number) => void;
};

const TodoList: Component<{}, TodoState> = {
  setup() {
    const input = ref("");
    const todos = reactive<Todo[]>([]);
    let id = 0;

    const add = () => {
      const v = input.value.trim();
      if (!v) return;
      todos.push({ id: id++, text: v });
      input.value = "";
    };
    const remove = (idx: number) => {
      todos.splice(idx, 1);
    };

    return { input, todos, add, remove };
  },
  render(ctx) {
    // ctx.input 은 string 으로 언랩되어 세터 쓰기 가능 (proxyRefs)
    return Box([
      h("h2", null, "Todos"),
      Row([
        h("input", {
          value: ctx.input,
          onInput: (e: Event) =>
            (ctx.input = (e.target as HTMLInputElement).value),
          placeholder: "할 일을 입력...",
        }),
        Button("추가", ctx.add),
      ]),
      h(
        "ul",
        { style: { paddingLeft: "18px" } },
        ctx.todos.map((t: Todo, i: number) =>
          h(
            "li",
            {
              style: {
                margin: "4px 0",
                display: "flex",
                gap: "8px",
                alignItems: "center",
              },
            },
            [h("span", null, t.text), Button("삭제", () => ctx.remove(i))]
          )
        )
      ),
    ]);
  },
};

// --- Root ---
const App: Component = {
  render() {
    return h(
      "div",
      {
        style: {
          fontFamily: "system-ui, sans-serif",
          maxWidth: "720px",
          margin: "40px auto",
        },
      },
      [
        h("h1", null, "Mini Vue: Reactivity + Renderer"),
        h(
          "p",
          { style: { color: "#666", marginBottom: "16px" } },
          "Typed inference 데모"
        ),
        h("div", { style: { display: "grid", gap: "12px" } }, [
          h(Counter, null, null),
          h(TodoList, null, null),
        ]),
      ]
    );
  },
};

createApp(App).mount("#app");