wkd2ev

밑바닥부터 구현해보는 UI라이브러리 만들기1 (상태)

예제링크

npm 링크: https://www.npmjs.com/package/reona

프로젝트 링크: https://github.com/dkpark10/reona

해당 게시글에 관한 링크: https://github.com/dkpark10/reona-track/tree/master/track/01-state

아키텍처 설계#

인터페이스는 react와 동일하게 함수형 컴포넌트로 작성한다. 데이터 상태 변경에 따른 간단한 카운터 예제를 작성해본다.

1import { html, state } from '../core';
2
3export default function Counter() {
4  const data = state({
5    count: 0,
6  });
7
8  const increase = () => {
9    data.count += 1;
10  }
11
12  const decrease = () => {
13    data.count -= 1;
14  }
15
16  return html`
17    <div id="app">
18      <button type="button" @click=${increase}>increase</button>
19      <button type="button" @click=${decrease}>decrease</button>
20      <div>${data.count}</div>
21    </div>
22  `;
23}

함수형 컴포넌트의 경우 렌더링 시 매번 함수를 호출하기에 해당 컴포넌트의 상태, props, vdom, 라이프 사이클 등

이를 관리해야 할 별도의 인스턴스가 필수적이다. 어찌보면 react class component와 비슷하다 볼 수 있다. 본 프로젝트에서는 이를 ComponentInstance 객체로 구현한다. 아래 이미지는 해당 프로젝트의 전체 구성도이다.

architecture

전체 순서#

11. 함수형 컴포넌트를 가지고와 인스턴스를 생성한다.
2
32. 함수형 컴포넌트의 반환값을 가지고 Vdom을 생성한다.
4
53. Vdom을 가지고 실제 dom을 생성한다.
6
74. 실제 dom을 브라우저에 부착한다.
8
95. 상태 업데이트 시 이전 Vdom과 현재 VDom을 가지고 비교하여 실제 dom을 업데이트 한다.

컴포넌트가 실제 dom에 반영되려면 루트 엘리먼트로부터 컴포넌트가 부착되어야 한다. 루트 엘리먼트와 해당 컴포넌트를 rootRender 함수에 넘긴다.

1rootRender(document.getElementById('root'), Counter);

runtime-dom.js

1// 부착할 엘리먼트와 함수형 컴포넌트를 파라미터로 넘김
2function rootRender(
3  container,
4  component,
5) {
6  const instance = new ComponentInstance(component, 0); // 컴포넌트를 관리할 인스턴스 생성
7  instance.render(container);
8  return instance;
9}

ComponentInstance를 생성하기 위한 생성자 옵션으로 컴포넌트 함수와 html 트리에서의 고유 위치 넘버링을 넘긴다. 루트 엘리먼트에 첫 부착되므로 0으로 넘긴다. 함수 컴포넌트 반환값의 경우 html 함수와 함께 렌더링할 선언적 dom 데이터를 넘겨주는데 html 함수는 문자열과 동적인 값을 넘겨주고 marker를 표시한채 그대로 넘긴다.

이는 웹 컴포넌트 기반 라이브러리 lit에서 컨셉을 차용했다.

https://github.com/lit/lit/blob/f243134b226735320b61466cebdaf0c1e574bfa7/packages/labs/ssr/src/lib/server-template.ts#L44

즉 html 함수의 역할은 추후 가상돔을 생성할 때 문자열에 표시된 marker에 동적값을 주입할 문자열 템플릿을 반환하는 것이다.

1function html(strings, ...values) {
2  let idx = 0;
3  const rawString = strings
4    .join('%%identifier%%')
5    .replace(/%%identifier%%/g, () => `__marker_${idx++}__`);
6
7  return { template: rawString, values };
8}

html 함수의 반환 값

1html`
2  <div id="app">
3    <button type="button" @click=${increase}>increase</button>
4    <button type="button" @click=${decrease}>decrease</button>
5    <div>${data.count}</div>
6  </div>
7`;
8
9// 결과
10
11{
12  template: 
13    <div id="app">
14      <button type="button" data-testid="increase" @click=__marker_0__>increase</button>
15      <button type="button" data-testid="decrease" @click=__marker_1__>decrease</button>
16      <div id="value">__marker_2__</div>
17    </div>,
18
19  values: [f, f, 0],
20}

컴포넌트 인스턴스#

함수형 컴포넌트를 관리할 컴포넌트 인스턴스를 만든다. 인스턴스가 하는일은 다음과 같다.

11. 함수형 컴포넌트 원본과 상태를 보관
22. 렌더링 시 현재 인스턴스를 전역에 할당 (훅이 접근할 수 있도록)
33. VNode 생성 → 실제 DOM 생성 → 부모에 삽입
44. 리렌더링 시 새 DOM으로 교체

component-instance.js

1export default class ComponentInstance {
2  states = [];        // 상태를 관리할 훅 배열
3  parentElement;      // DOM이 삽입될 부모 엘리먼트
4  component;          // 함수형 컴포넌트 원본
5  prevVnodeTree;      // 이전 렌더링의 VNode
6  nextVnodeTree;      // 현재 렌더링의 VNode
7  currentDom;         // 실제 마운트된 DOM
8  sequence;           // 트리에서의 위치 (전위순회 기준)
9  hookIndex = 0;      // 훅 포인터 (마운트 전)
10  hookLimit = 0;      // 훅 최대 개수 (마운트 후 고정)
11  stateHookIndex = 0; // 상태 훅 포인터
12  isMounted = false;  // 마운트 여부
13
14  constructor(component, options) {
15    this.component = component;
16    this.sequence = options.sequence;
17  }
18}

render 메서드 - 초기 마운트

컴포넌트를 처음 DOM에 부착할 때 호출된다.

1render(parentElement, isRerender) {
2  if (isRerender) {
3    this.stateHookIndex = 0;
4  }
5
6  // 1. 현재 렌더링 중인 인스턴스를 전역에 할당
7  currentInstance = this;
8
9  // 2. 컴포넌트 함수 실행 → 템플릿 반환
10  const template = this.component();
11
12  this.parentElement = parentElement;
13
14  // 3. 템플릿 → VNode 트리로 파싱
15  this.prevVnodeTree = parse(template, this.sequence + 1);
16
17  // 4. VNode → 실제 DOM 생성
18  this.currentDom = createDOM(this.prevVnodeTree, parentElement);
19
20  // 5. 부모에 DOM 삽입
21  parentElement.insertBefore(this.currentDom, null);
22  this.isMounted = true;
23}

reRender 메서드 - 상태 변경 시 재렌더링

상태가 변경되면 호출되어 DOM을 교체한다.

1reRender() {
2  this.stateHookIndex = 0;
3
4  // 1. 현재 렌더링 중인 인스턴스를 전역에 할당
5  currentInstance = this;
6
7  // 2. 컴포넌트 함수 재실행
8  const template = this.component();
9
10  // 3. 새로운 VNode 트리 생성
11  this.nextVnodeTree = parse(template, this.sequence + 1);
12
13  // 4. 새 DOM 생성
14  const newDom = createDOM(this.nextVnodeTree, this.parentElement);
15
16  // 5. 기존 DOM을 새 DOM으로 일괄 교체
17  this.currentDom.replaceWith(newDom);
18
19  // 6. 참조 업데이트
20  this.currentDom = newDom;
21  this.prevVnodeTree = this.nextVnodeTree;
22}

핵심: currentInstance 전역 할당#

여기서 가장 중요한 로직은 현재 렌더링 중인 인스턴스를 전역에 할당하는 것이다. 이를 통해 훅 함수 내부 실행 시 현재 렌더링 진행중인 컴포넌트를 확인할 수 있다.

1currentInstance = this;
2const template = this.component();

React 역시 렌더링 중인 Fiber 정보를 전역 변수에 등록하여 훅이 현재 실행 중인 컴포넌트를 추적한다.

https://github.com/facebook/react/blob/2d8e7f1ce358e8cddc3aae862007269b6bac04ba/packages/react-reconciler/src/ReactFiberHooks.js#L261 currentInstance

hook 로직 작성하기#

state 훅이 하는 일은 다음과 같다.

11. 현재 렌더링 중인 인스턴스 가져오기 (어떤 컴포넌트에서 호출되었는지 파악)
22. 훅 유효성 검사 (조건부 호출 방지)
33. 리렌더링 시 이전 상태값 반환 (초기값이 아닌 저장된 값)
44. Proxy로 반응형 상태 생성 (값 변경 시 자동 리렌더링)

Proxy vue의 반응성 컨셉을 사용하였으며 구현 단순화를 위해 Proxy 기반 반응성을 사용한다.

1단계: 현재 인스턴스 가져오기#

컴포넌트 함수 실행 전에 전역에 할당된 currentInstance를 가져온다. 이것이 React에서 훅이 컴포넌트 내부에서만 호출되어야 하는 이유다.

hooks.js

1import  { getCurrentInstance } from './component-instance';
2
3function checkInvalidHook(currentInstance) {
4  if (currentInstance.isMounted && currentInstance.hookIndex > currentInstance.hookLimit) {
5    throw new Error('훅은 함수 최상단에 선언해야 합니다.');
6  }
7
8  if (!currentInstance.isMounted) {
9    currentInstance.hookIndex += 1;
10  }
11}
12
13export function state(initial) {
14  const currentInstance = getCurrentInstance();
15
16  // 컴포넌트 외부에서 호출하면 에러
17  if (currentInstance === null) {
18    throw new Error('state 함수는 컴포넌트 내에서 선언해야 합니다.');
19  }
20  // ...생략
21}

2단계: 훅 유효성 검사#

마운트 시점에 훅 개수를 기록해두고, 리렌더링 시 그 개수를 초과하면 에러를 던진다.

이것이 실제 react에서도 훅을 조건부로 호출할 수 없는 이유다.

1function checkInvalidHook(currentInstance) {
2  // 마운트 후 훅 개수가 늘어나면 에러
3  if (currentInstance.isMounted && currentInstance.hookIndex > currentInstance.hookLimit) {
4    throw new Error('훅은 함수 최상단에 선언해야 합니다.');
5  }
6
7  // 마운트 전에는 훅 포인터 증가
8  if (!currentInstance.isMounted) {
9    currentInstance.hookIndex += 1;
10  }
11}

3단계: 이전 상태값 반환#

매 렌더링마다 state()가 호출되지만, 초기값이 아닌 이전에 저장된 상태를 반환해야 한다. stateHookIndex를 포인터로 사용해 states 배열에서 해당 순서의 상태를 가져온다.

1const stateIndex = currentInstance.stateHookIndex++;
2
3// 이미 상태가 있으면 그대로 반환 (리렌더링 시)
4if (currentInstance.states[stateIndex] !== undefined) {
5  return currentInstance.states[stateIndex];
6}

4단계: Proxy로 반응형 상태 생성#

초기 마운트 시에만 실행된다. Proxy의 set trap을 활용해 값 변경 시 자동으로 리렌더링을 트리거한다.

1const data = new Proxy(initial, {
2  get(target, key, receiver) {
3    return Reflect.get(target, key, receiver);
4  },
5
6  set(target, key, value, receiver) {
7    const prevValue = Reflect.get(receiver, key);
8    const result = Reflect.set(target, key, value, receiver);
9
10    // 값이 변경되면 리렌더링
11    if (prevValue !== value) {
12      currentInstance.reRender();
13    }
14    return result;
15  },
16});
17
18// 인스턴스의 states 배열에 저장
19currentInstance.states[stateIndex] = data;
20return data;

훅 포인터가 동작하는 방식#

아래와 같이 훅이 여러 개 있을 때 인스턴스 변수들이 어떻게 동작하는지 살펴보자.

1function Component() {
2  const data1 = state({ foo: 1 }); // stateIndex = 0
3  const data2 = state({ bar: 1 }); // stateIndex = 1
4  return ...
5}

마운트 이전

before-mount

마운트 이전에는 훅을 순회하면서 포인터를 증가시키고, states 배열에 초기값을 세팅한다. 마운트 완료 후 hookLimit에 훅 개수를 저장하여 이후 추가 훅 호출을 방지한다. 마운트 이후 리렌더링 after-mount

리렌더링 시에는 states 배열에 값이 이미 존재하므로 저장된 상태를 그대로 반환한다.

재조정 및 이벤트, 데이터 바인딩을 위한 Vdom 생성#

앞서 html 함수에서 받은 결과물을 이제 실제로 dom에 부착해야 하는 작업이 남았다. Vdom을 생성하는 parser를 작성한다.

파서의 전체 흐름#

파서가 하는 일을 단계별로 요약하면 다음과 같다.

11. template 문자열 → DOM으로 파싱 (브라우저 template 엘리먼트 활용)
22. DOM 노드 순회하면서 VNode로 변환 (convertNode, convertChild)
33. \_\_marker_N\_\_ 을 만나면 values 배열에서 실제 값으로 치환
44. 최종 VNode 트리 반환

marker 치환 과정 예시

1// 입력
2html`<button @click=${onClick}>${count}개</button>`
3
4// 1. html 함수 결과
5{
6  template: '<button @click=__marker_0__>__marker_1__개</button>',
7  values: [onClick, 5]
8}
9
10// 2. parse 결과 (VNode)
11{
12  type: 'element',
13  tag: 'button',
14  attr: { '@click': onClick },  // marker_0 → onClick 함수
15  children: [
16    { type: 'text', value: '5개' }  // marker_1 → 5
17  ]
18}

1단계: 템플릿을 DOM으로 변환#

html 함수에서 받은 template 문자열을 브라우저의 template 엘리먼트를 활용해 파싱한다. template 엘리먼트는 innerHTML에 문자열을 넣으면 자동으로 DOM 트리를 생성해준다.

parser.js

1export default function parse(renderResult) {
2  const { template: t } = renderResult;
3  const template = document.createElement('template');
4
5  template.innerHTML = t.trim();
6
7  // 루트 엘리먼트는 1개여야 한다
8  if (template.content.childNodes.length > 1) {
9    throw new Error('루트 엘리먼트는 1개여야 합니다.');
10  }
11
12  const firstChild = template.content.firstChild;
13
14  // 텍스트 노드라면
15  if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
16    return convertChild(firstChild);
17  }
18
19  return convertNode(template.content.firstElementChild);
20}

2단계: 엘리먼트 노드 변환 (convertNode)#

엘리먼트 노드를 VNode로 변환한다. 속성(attributes)을 순회하며 marker가 있으면 values 배열에서 실제 값을 가져온다.

1let valueIndex = 0;
2
3function convertNode(el) {
4  if (!el) {
5    return { type: 'text', value: '' };
6  }
7
8  const attrs = {};
9  for (const attr of el.attributes) {
10    const { values } = renderResult;
11
12    // marker 패턴 매칭
13    const markers = attr.value.match(/__marker_(\d+)__/g);
14    if (markers && markers.length >= 1) {
15      // 함수면 이벤트 핸들러로 할당
16      if (typeof values[valueIndex] === 'function') {
17        attrs[attr.name] = values[valueIndex++];
18      // 객체면 경고와 함께 할당
19      } else if (typeof values[valueIndex] === 'object') {
20        attrs[attr.name] = values[valueIndex++];
21      // 원시값이면 문자열로 치환
22      } else {
23        attrs[attr.name] = attr.value.replace(/__marker_(\d+)__/g, () => {
24          const v = values[valueIndex++];
25          return v !== undefined ? String(v) : '';
26        });
27      }
28    } else {
29      // marker가 없으면 그대로 할당
30      attrs[attr.name] = attr.value;
31    }
32  }
33
34  // 자식 노드들을 재귀적으로 변환
35  const children = [];
36  for (const child of el.childNodes) {
37    const vnode = convertChild(child);
38    if (vnode) {
39      children.push(vnode);
40    }
41  }
42
43  return {
44    type: 'element',
45    tag: el.tagName.toLowerCase(),
46    children,
47    attr: attrs,
48  };
49}

3단계: 텍스트/자식 노드 변환 (convertChild)#

텍스트 노드를 처리하고, marker가 있다면 실제 값으로 치환한다. 값이 또 다른 컴포넌트(renderResult 객체)라면 재귀적으로 parse를 호출한다.

1function convertChild(node) {
2  // 텍스트 노드 처리
3  if (node.nodeType === Node.TEXT_NODE) {
4    let text = node.textContent ?? '';
5    if (/^\s*$/.test(text)) return null; // 공백만 있으면 무시
6
7    const markers = text.match(/__marker_(\d+)__/g);
8    if (markers && markers.length >= 1) {
9      return markers.map(() => {
10        const { values } = renderResult;
11        const value = values[valueIndex];
12
13        // 값이 컴포넌트라면 재귀 파싱
14        if (isRenderResultObject(value)) {
15          return parse(value);
16        }
17
18        // 배열이면 각각 파싱
19        if (Array.isArray(value)) {
20          return values[valueIndex++].map((v) => {
21            if (isRenderResultObject(v)) {
22              return parse(v);
23            }
24          });
25        }
26
27        // 일반 값이면 텍스트로 치환
28        text = replaceMarkers(text);
29        return { type: 'text', value: text };
30      });
31    }
32
33    return { type: 'text', value: text };
34  }
35
36  // 엘리먼트 노드면 convertNode로 위임
37  if (node.nodeType === Node.ELEMENT_NODE) {
38    return convertNode(node);
39  }
40
41  return null;
42}

파싱 결과 예시

1const template = html`<div id="app" @click=${onClick}>${text}</div>`;
2const result = parse(template);
3
4// result
5{
6  type: 'element',
7  tag: 'div',
8  children: [ { type: 'text', value: 'text' } ],
9  attr: { id: 'app', '@click': [Function: onClick] }
10}

VNode를 실제 DOM으로 변환#

파싱된 VNode 트리를 실제 브라우저 DOM으로 변환한다.

createDOM이 하는 일#

  1. 텍스트 노드면
    1document.createTextNode()
    로 생성
  2. 엘리먼트 노드면
    1document.createElement()
    로 생성
  3. 속성 처리
    1@click
    같은 이벤트는
    1addEventListener
    , 나머지는
    1setAttribute
  4. 자식 노드 재귀 처리 → children을 순회하며 createDOM 재귀 호출

runtime-dom.js

1export function createDOM(vnode, parentElement) {
2  // 1. 텍스트 노드 처리
3  if (vnode.type === 'text') {
4    return document.createTextNode(vnode.value);
5  }
6
7  // 2. 엘리먼트 생성
8  const el = document.createElement(vnode.tag);
9
10  // 3. 속성 처리
11  if (vnode.attr) {
12    for (const [key, value] of Object.entries(vnode.attr)) {
13      // @click 같은 이벤트 바인딩
14      if (/@([^\s=/>]+)/.test(key) && typeof value === 'function') {
15        const eventName = key.slice(1);
16        el.addEventListener(eventName, value);
17      } else {
18        el.setAttribute(key, value);
19      }
20    }
21  }
22
23  // 4. 자식 노드 재귀 처리
24  vnode.children?.forEach((child) => {
25    const c = createDOM(child, el);
26    if (c) {
27      el.appendChild(c);
28    }
29  });
30
31  return el;
32}

DOM 삽입과 교체#

생성한 DOM은 초기 렌더링 시 부모에 삽입하고, 리렌더링 시 기존 DOM을 교체한다.

1// 초기 렌더링: 부모에 삽입
2this.currentDom = createDOM(this.prevVnodeTree, this.parentElement);
3this.parentElement.insertBefore(this.currentDom, null);
4
5// 리렌더링: 기존 DOM을 새 DOM으로 일괄 교체
6const newDom = createDOM(this.nextVnodeTree, this.parentElement);
7this.currentDom.replaceWith(newDom);

렌더링 최적화#

현재 Proxy의 set trap은 속성이 변경될 때마다 리렌더링을 트리거한다. 여러 속성을 동시에 변경하면 그 횟수만큼 리렌더링이 발생한다.

1const data = state({ a: 0, b: 0 });
2
3const trigger = () => {
4  data.a += 1; // 리렌더링 1번
5  data.b += 1; // 리렌더링 2번 → 비효율!
6}

requestAnimationFrame으로 배치 처리#

대부분의 디스플레이 디바이스는 60Hz 주사율을 사용하므로 1프레임(약 16ms) 안에 발생한 모든 상태 변경을 모아서 한 번만 렌더링하면 된다.

requestAnimationFrame은 다음 프레임 직전에 콜백을 실행하므로, 같은 프레임 내의 여러 업데이트를 배치로 처리할 수 있다.

배치 업데이트 흐름

1data.a += 1  →  큐에 인스턴스 추가, RAF 예약
2data.b += 1  →  이미 큐에 있음, 무시
3─────────────────────────────────────────
4        (다음 프레임)
5
6RAF 콜백 실행 → 큐의 인스턴스들 한 번씩 리렌더링

update 함수 구현

1const renderQueue = new Set(); // 중복 방지를 위해 Set 사용
2let rafId = null;
3
4export function update(instance) {
5  // 1. 렌더링할 인스턴스를 큐에 추가
6  renderQueue.add(instance);
7
8  // 2. 이미 RAF가 예약되어 있으면 추가 예약 안 함
9  if (rafId !== null) return;
10
11  // 3. 다음 프레임에 배치 렌더링 예약
12  rafId = requestAnimationFrame(() => {
13    try {
14      // 큐에 있는 모든 인스턴스 렌더링
15      renderQueue.forEach((instance) => {
16        instance.reRender();
17      });
18    } finally {
19      // 정리
20      renderQueue.clear();
21      rafId = null;
22    }
23  });
24}

Proxy set trap에 적용

기존의 reRender() 직접 호출을 update() 함수로 교체한다.

1set(target, key, value, receiver) {
2  const prevValue = Reflect.get(receiver, key);
3  const result = Reflect.set(target, key, value, receiver);
4
5  if (prevValue !== value) {
6    // reRender() 대신 update()로 배치 처리
7    update(currentInstance);
8  }
9  return result;
10}

이제 같은 프레임 내에서 아무리 많은 상태를 변경해도 1번만 리렌더링된다.

전체 파일#

index.js

1import { state } from './hooks'
2
3/**
4 * @param {string[]} strings 
5 * @param  {any[]} values 
6 */
7function html(strings, ...values) {
8  let idx = 0;
9  const rawString = strings
10    .join('%%identifier%%')
11    .replace(/%%identifier%%/g, () => `__marker_${idx++}__`);
12
13  return { template: rawString, values };
14}
15
16export {
17  state, html
18};

hooks.js

1import  { getCurrentInstance } from './component-instance';
2
3const renderQueue = new Set();
4let rafId = null;
5
6export function update(instance) {
7  renderQueue.add(instance);
8
9  if (rafId !== null) return;
10
11  rafId = requestAnimationFrame(() => {
12    try {
13      renderQueue.forEach((instance) => {
14        instance.reRender();
15      });
16    } finally {
17      renderQueue.clear();
18      rafId = null;
19    }
20  });
21}
22
23function isPrimitive(value) {
24  return value === null || (typeof value !== 'object' && typeof value !== 'function');
25}
26
27function checkInvalidHook(currentInstance) {
28  if (currentInstance.isMounted && currentInstance.hookIndex > currentInstance.hookLimit) {
29    throw new Error('훅은 함수 최상단에 선언해야 합니다.');
30  }
31
32  if (!currentInstance.isMounted) {
33    currentInstance.hookIndex += 1;
34  }
35}
36
37export function state(initial) {
38  const currentInstance = getCurrentInstance();
39  if (currentInstance === null) {
40    throw new Error('state 함수는 컴포넌트 내에서 선언해야 합니다.');
41  }
42
43  checkInvalidHook(currentInstance);
44
45  const stateIndex = currentInstance.stateHookIndex++;
46  if (currentInstance.states[stateIndex] !== undefined) {
47    return currentInstance.states[stateIndex];
48  }
49
50  if (initial && isPrimitive(initial)) {
51    throw new Error('원시객체 입니다. 객체 형식으로 넣으세요.');
52  }
53
54  const data = new Proxy(initial, {
55    get(target, key, receiver) {
56      return Reflect.get(target, key, receiver);
57    },
58
59    set(target, key, value, receiver) {
60      const prevValue = Reflect.get(receiver, key);
61
62      const result = Reflect.set(target, key, value, receiver);
63      if (prevValue !== value) {
64        update(currentInstance);
65      }
66      return result;
67    },
68  });
69  currentInstance.states[stateIndex] = data;
70  return data;
71}

component-instance.js

1import parse from './parser';
2import { createDOM } from './runtime-dom';
3
4/** @description 현재 렌더링 되고 있는 컴포넌트 */
5let currentInstance = null;
6export function getCurrentInstance() {
7  return currentInstance;
8}
9
10/** @description 컴포넌트 인스턴스 - 상태, Props, 생명주기, VNode 트리, DOM 참조를 관리 */
11export default class ComponentInstance {
12  /** @type {Array<unknown>} */
13  states = [];
14
15  /** @type {HTMLElement | null} */
16  parentElement;
17
18  /** @type {Function} */
19  component;
20
21  /** @type {Object | null} */
22  prevVnodeTree;
23
24  /** @type {Object | null} */
25  nextVnodeTree;
26
27  /** @type {HTMLElement | null} */
28  currentDom;
29
30  /** @type {Number} html 트리에서의 위치 */
31  sequence;
32
33  hookIndex = 0;
34
35  hookLimit = 0;
36
37  stateHookIndex = 0;
38
39  isMounted = false;
40
41  constructor(component, options) {
42    this.component = component;
43    this.sequence = options.sequence;
44  }
45
46  hookIndexInitialize() {
47    this.stateHookIndex = 0;
48  }
49
50  render(parentElement, isRerender) {
51    // 부모 리렌더링으로 인한 자식 리렌더링이라면
52    if (isRerender) {
53      this.hookIndexInitialize();
54    }
55
56    /** @description 현재 렌더링 되고 있는 컴포넌트를 할당 */
57    currentInstance = this;
58    const template = this.component();
59
60    this.parentElement = parentElement;
61    this.prevVnodeTree = parse(template, this.sequence + 1);
62
63    this.currentDom = createDOM(this.prevVnodeTree, parentElement);
64    parentElement.insertBefore(this.currentDom, null);
65    this.isMounted = true;
66    this.hookLimit = this.hookIndex;
67  }
68
69  reRender() {
70    this.hookIndexInitialize();
71    currentInstance = this;
72    const template = this.component();
73
74    this.nextVnodeTree = parse(template, this.sequence + 1);
75
76    const newDom = createDOM(this.nextVnodeTree, this.parentElement);
77    this.currentDom.replaceWith(newDom);
78
79    // 새로운 값들을 이전 변수에 할당
80    this.currentDom = newDom;
81    this.prevVnodeTree = this.nextVnodeTree;
82  }
83}
84

parser.js

1import ComponentInstance from './component-instance';
2
3/**
4 * @typedef {Object} RenderResult
5 * @property {string} template
6 * @property {any[]} values
7 */
8
9/**
10 * @param {RenderResult} obj
11 * @returns {boolean}
12 */
13function isRenderResultObject(obj) {
14  return (
15    obj !== null &&
16    typeof obj === 'object' &&
17    typeof obj.template === 'string' &&
18    Array.isArray(obj.values)
19  );
20}
21
22/**
23 * @param {Object} obj
24 * @returns {boolean}
25 */
26export function isEmpty(obj) {
27  return Object.keys(obj).length <= 0;
28}
29
30/**
31 * @typedef {Object} VTextNode
32 * @property {"text"} type
33 * @property {string} value
34 * @export
35 */
36
37/**
38 * @typedef {Object} VElementNode
39 * @property {"element"} type
40 * @property {keyof HTMLElementTagNameMap} tag
41 * @property {Props=} attr
42 * @property {VNode[]} children
43 * @export
44 */
45
46/**
47 * @typedef {Object} VComponent
48 * @property {"component"} type
49 * @property {ComponentInstance} instance
50 * @export
51 */
52
53/**
54 * @typedef {VTextNode | VElementNode | VComponent} VNode
55 * @export
56 */
57
58/**
59 * @description 받은 html을 vnode tree로 만듬
60 * @param {RenderResult} renderResult
61 * @param {number} sequence
62 * @returns {VNode}
63 */
64export default function parse(renderResult) {
65  let valueIndex = 0;
66
67  /**
68   * @param {HTMLElement} el
69   */
70  function convertNode(el) {
71    if (!el) {
72      return {
73        type: 'text',
74        value: '',
75      };
76    }
77
78    const attrs = {};
79
80    for (const attr of el.attributes) {
81      const { values } = renderResult;
82
83      const nameMarker = attr.name.match(/^__marker_(\d+)__$/);
84      if (nameMarker) {
85        const value = values[valueIndex++];
86        if (value && typeof value === 'string') {
87          attrs[value] = true;
88        }
89        continue;
90      }
91
92      const markers = attr.value.match(/__marker_(\d+)__/g);
93      if (markers && markers.length >= 1) {
94        if (typeof values[valueIndex] === 'function') {
95          attrs[attr.name] = values[valueIndex++];
96        } else if (typeof values[valueIndex] === 'object') {
97          attrs[attr.name] = values[valueIndex++];
98          console.warn(
99            `${el.tagName.toLowerCase()} 엘리먼트에 ${attr.name} 속성에 ${values[valueIndex - 1]} 객체가 들어가 있습니다. 값이 맞는지 확인하세요.`
100          );
101        } else {
102          attrs[attr.name] = attr.value.replace(/__marker_(\d+)__/g, () => {
103            const v = values[valueIndex++];
104            return v !== undefined ? String(v) : '';
105          });
106        }
107      } else {
108        attrs[attr.name] = attr.value;
109      }
110    }
111
112    const children = [];
113    for (const child of el.childNodes) {
114      const vnode = convertChild(child);
115      if (vnode) {
116        if (Array.isArray(vnode)) {
117          const filteredEmptyTextValue = vnode.filter((node) => !!node).flat();
118          children.push(...filteredEmptyTextValue);
119        } else {
120          children.push(vnode);
121        }
122      }
123    }
124
125    return {
126      type: 'element',
127      tag: el.tagName.toLowerCase(),
128      children,
129      ...(!isEmpty(attrs) && { attr: attrs }),
130    };
131  }
132
133  /**
134   * @param {ChildNode} node
135   */
136  function convertChild(node) {
137    if (node.nodeType === Node.TEXT_NODE) {
138      let text = node.textContent ?? '';
139
140      // 공백을 제거
141      if (/^\s*$/.test(text)) return null;
142
143      // marker가 붙어있는 경우가 있으므로 match
144      const markers = text.match(/__marker_(\d+)__/g);
145      if (markers && markers.length >= 1) {
146        return markers.map(() => {
147          const { values } = renderResult;
148          const value = values[valueIndex];
149
150          if (isRenderResultObject(value)) {
151            const vdom = parse(value);
152            return vdom;
153          }
154
155          // 배열이 들어 왔다면
156          if (Array.isArray(value)) {
157            const result = values[valueIndex++].map((value) => {
158              if (isRenderResultObject(value)) {
159                const vdom = parse(value);
160                return vdom;
161              }
162            });
163            return result;
164          }
165
166          // marker가 있다면 원본 텍스트를 변경한다.
167          if (/__marker_(\d+)__/.test(text)) {
168            text = replaceMarkers(text);
169            return {
170              type: 'text',
171              value: text,
172            };
173          }
174          // marker 가 없으면 빈 텍스트 반환
175          return null;
176        });
177      }
178
179      return {
180        type: 'text',
181        value: text,
182      };
183    }
184
185    if (node.nodeType === Node.ELEMENT_NODE) {
186      return convertNode(node);
187    }
188
189    return null;
190  }
191
192  /**
193   * @param {string} str
194   * @return {string}
195   */
196  function replaceMarkers(str) {
197    const { values } = renderResult;
198    return str.replace(/__marker_(\d+)__/g, () => {
199      const v = values[valueIndex++];
200      return v !== undefined ? String(v) : '';
201    });
202  }
203
204  // 메인 파싱 로직
205  const { template: t } = renderResult;
206  const template = document.createElement('template');
207
208  template.innerHTML = t.trim();
209
210  if (template.content.childNodes.length > 1) {
211    throw new Error('루트 엘리먼트는 1개여야 합니다.');
212  }
213
214  const firstChild = template.content.firstChild;
215
216  /**
217   * 텍스트 노드, 단일 컴포넌트 처리
218   * html`text`, html`<component />`
219   */
220  if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
221    const vDom = convertChild(firstChild);
222    if (Array.isArray(vDom)) {
223      return vDom.filter((v) => !!v)[0];
224    }
225    return vDom;
226  }
227
228  return convertNode(template.content.firstElementChild);
229}

runtime-dom.js

1import ComponentInstance from "./component-instance";
2
3export function rootRender(
4  container,
5  component,
6) {
7  if (!component) {
8    throw new Error('컴포넌트가 없습니다.');
9  }
10
11  const instance = new ComponentInstance(component, 0);
12  instance.render(container);
13  return instance;
14}
15
16export function createDOM(vnode, parentElement) {
17  if (vnode.type === 'text') {
18    return document.createTextNode(vnode.value);
19  }
20
21  const el = document.createElement(vnode.tag);
22  if (vnode.attr) {
23    for (const [key, value] of Object.entries(vnode.attr)) {
24      if (/@([^\s=/>]+)/.test(key) && typeof value === 'function') {
25        const eventName = key.slice(1);
26        el.addEventListener(eventName, value);
27      } else {
28        el.setAttribute(key, value);
29      }
30    }
31  }
32
33  vnode.children?.forEach((child) => {
34    const c = createDOM(child, el);
35    if (c) {
36      el.appendChild(c);
37    }
38  });
39
40  return el;
41}