Skip to content

🤔 목표

이 펫 프로젝트의 목표는 다음과 같다.

  • 최종적으로 라이트 한 프로젝트에 사용할 수준으로 제작한다.
  • 컴포넌트 정의 기능을 간단하게 만들 수 있어야 한다.
    • 아키텍처 레벨의 코드는 난이도가 높을 가능성이 크기 때문이다.
  • 컴포넌트 사용을 쉽게 사용 가능한 형태로 제작해야 한다.
  • 지원 기능
    • 상태 변경 시, 다시 렌더링 되는 기능
    • 부모-자식 관계를 맺을 수 있는 기능
    • 공유상태를 사용할 수 있는 기능

📄 컴포넌트 사용법

기본 컴포넌트

js
import {component} from './core/component.js';

export const BasicComponent = component(({html}) => {
  const render = () => {
    return html(`<div>
      <h2>Basic Component</h2>
      Hello World!
    </div>`);
  };

  return render
});
  • component로 컴포넌트를 선언한다.
  • component 인자에 함수를 전달하는 데, 해당 함수를 createComponent로 명명한다.
  • createComponent 함수는 항상 함수를 반환해야 하는 데. 해당 함수는 render로 명명한다.
  • createComponent는 첫번째 인자에 html가 전달된다.
    • htmltemplate을 인자로 받아, DOM을 반환한다.

컴포넌트 내부 스토어 사용

js
import {events, query} from './core/helper/dom.js';
import {component} from './core/component.js';

export const CounterComponent = component(({store, html}) => {
  const state = store.useState({
    count: 0,
  });
  const actions = {
    upCount: () => {
      state.count.set(state.count.get() + 1);
    },
    downCount: () => {
      state.count.set(state.count.get() - 1);
    }
  };
  const render = () => {
    const dom = html(`<div>
      <h2>Counter Component</h2>
      <button type="text" class="up">Up</button>
      <button type="text" class="down">Down</button>
      <div>${state.count.get()}</div>
    </div>`);

    events(query(dom, '.up'), {
      click: actions.upCount
    });
    events(query(dom, '.down'), {
      click: actions.downCount
    });

    return dom;
  };

  return render
});
  • createComponent는 첫번째 인자에 html과 함께, store가 전달된다.
  • store컴포넌트 내부 스토어다.
  • store.useState컴포넌트 상태를 등록한다.
    • 등록된 상태는 get으로 조회, set으로 수정 할 수 있다.
    • set이 실행되면 render다시 렌더링한다.
  • DOM 이벤트는 DOM API로 등록한다.

리스트 렌더링

js
import {events, query} from './core/helper/dom.js';
import {component} from './core/component.js';

export const ListComponent = component(({store, html}) => {
  const state = store.useState({
    inputText: '',
    todoList: []
  });
  const actions = {
    addItem: () => {
      if (!state.inputText.get()) {
        return;
      }
      state.todoList.set([
        ...state.todoList.get(),
        state.inputText.get()
      ])
      state.inputText.set('', false);
    },
    changeInput: (inputText) => {
      // 렌더링을 하고 싶지 않을 때, set 두번째 인자에 false처리
      state.inputText.set(inputText, false);
    }
  };
  const render = () => {
    const dom = html(`<div>
      <h2>List Rendering</h2>
      <input type="text">
      <button type="button">Add</button>
      <ol>
       ${state.todoList.get().map((item) => {
          return `<li>${item}</li>`
        }).join('')}
      </ol>
    </div>`)

    const input = query(dom, 'input');
    const button = query(dom, 'button');

    events(button, {
      click: () => {
        actions.addItem();
        input.value = '';
      }
    });
    events(input, {
      input: (event) => {
        actions.changeInput(event.target.value)
      }
    });

    return dom;
  };

  return render;
});
  • 리스트 렌더링은 Array API를 사용한다.
  • set 함수의 두번째 인자에 false를 전달하면, 해당 상태를 전파 하지 않는다.

컨디션 렌더링

js
import {events, query} from './core/helper/dom.js';
import {component} from './core/component.js';

export const ConditionComponent = component(({store, html}) => {
  const state = store.useState({
    toggle: false,
  });
  const actions = {
    toggle: () => {
      state.toggle.set(!state.toggle.get())
    }
  };

  const render = () => {
    const dom = html(`<div>
      <h2>Condition Rendering</h2>
      <button type="button">Toggle</button>
      ${state.toggle.get() ? '<div>Hello World</div>' : ''}
    </div>`);

    events(query(dom, 'button'), {
      click: actions.toggle
    });

    return dom;
  };

  return render
});
  • 컨디션 렌디링은 연산자를 사용한다.

부모-자식 관계

js
import {events, query, replaceWith} from './core/helper/dom.js';
import {component} from './core/component.js';

export const ParentButton = component(({html, store}) => {
  const state = store.useState({
    count: 0
  });
  const actions = {
    upCount: () => {
      state.count.set(state.count.get() + 1)
    }
  };
  const render = () => {
    const dom = html(`<div>
      <h2>Parent-Child</h2>
      <div>${state.count.get()}</div>
      <child-button></child-button>
     </div>`);
    
    const props = {
      buttonName: 'Up Count'
    };
    const emit = {
      upCount: actions.upCount
    };

    replaceWith(
      query(dom, 'child-button'),
      ChildButton({props, emit})
    );

    return dom;
  };

  return render;
});

export const ChildButton = component(({html}, {props, emit}) => {
  const render = () => {
    const dom = html(`<div>
       <button type="button">${props.buttonName}</button>
     </div>`);

    events(query(dom, 'button'), {
      click: emit.upCount
    });

    return dom;
  };

  return render;
});
  • 자식 컴포넌트는 replaceWith으로 DOM을 변경한다.
  • 자식 컴포넌트에 {props, emit}을 전달할 수 있다.
    • props는 자식에게 전달할 상태를 담는다.
    • emit은 자식에게 전달 받을 함수를 담는다.
  • 자식 컴포넌트에 전달된 {props, emit}createComponent의 두번째 인자로 전달된다.
  • props, emit 자료구조는 강제성이 없으나, 인자 네이밍은 강제한다.

공유 상태 사용

js
import {events, query, replaceWith} from './core/helper/dom.js';
import {createStore} from './core/store.js';
import {component} from './core/component.js';

const sharedStore = createStore();
const sharedState = sharedStore.useState({
  count: 0
});

export const CounterButton = component(({html, store}) => {
  store.share(sharedStore);

  const actions = {
    upCount: () => {
      sharedState.count.set(sharedState.count.get() + 1)
    }
  };
  const render = () => {
    const dom = html(`<div>
       <button type="button">Up Count</button>
     </div>`);

    events(query(dom, 'button'), {
      click: actions.upCount
    });

    return dom;
  };

  return render;
});

export const MainComponent = component(({html, store}) => {
  store.share(sharedStore);

  return () => {
    const dom = html(`<div>
      <h2>Shared State</h2>
      ${sharedState.count.get()}
      <counter-button1></counter-button1>
      <counter-button2></counter-button2>
    </div>`);

    replaceWith(
      query(dom, 'counter-button1'),
      CounterButton()
    );
    replaceWith(
      query(dom, 'counter-button2'),
      CounterButton()
    );

    return dom;
  }
});
  • 공유 상태를 사용하려면, store.share로 스토어를 등록한다.
  • 등록된 스토어를 바로 사용하지 않고, 공유 상태를 사용한다.

마운트

js
import {append} from './core/helper/dom.js';
import {BasicComponent} from './BasicComponent.js';
import {CounterComponent} from './CounterComponent.js';
import {ListComponent} from './ListComponent.js';
import {ConditionComponent} from './ConditionComponent.js';
import {ParentButton} from './ParentChild.js';
import {MainComponent} from './SharedState.js';

const app = document.querySelector('#app');

append(app, BasicComponent());
append(app, CounterComponent());
append(app, ListComponent());
append(app, ConditionComponent());
append(app, ParentButton());
append(app, MainComponent());
  • 컴포넌트 함수의 반환값은 DOM임으로 appendChild로 마운트한다.

💻 데모

ESM를 지원하는 브라우저에서만 동작함

📄 코어 코드

헬퍼

mapValues

js
export const mapValues = (obj, f) => Object
  .entries(obj)
  .map(([k, v]) => ({[k]: f(v)}))
  .reduce((acc, obj) => Object.assign(acc, obj));
  • Object의 값을 변경해주는 map 함수다.

html

js
export const html = (template) => {
  const body = document.createElement('body');
  body.innerHTML = template;

  if (body.childNodes.length > 1) {
    console.warn('Wrapper 태그 필요!')
  }
  return body.childNodes[0];
};
  • 템플릿을 DOM으로 변환하는 역할을 한다.

DOM

js
export const append = (parentNode, childNode) => {
  parentNode.appendChild(childNode)
};

export const query = (parentNode, selector) => {
  return parentNode.querySelector(selector)
};

export const events = (node, options) => {
  Object
    .entries(options)
    .forEach(([eventName, listener]) => {
      node.addEventListener(eventName, listener)
    })
};

export const replaceWith = (oldNode, newNode) => {
  oldNode.replaceWith(newNode)
};
  • DOM API 헬퍼 역할을 한다.

observer

js
export const createSubject = () => {
  const set = new Set();

  return {
    notify: (value) => {
      set.forEach(observer => observer(value))
    },
    subscribe: (observer) => {
      set.add(observer)
    },
    unsubscribe: (observer) => {
      set.remove(observer)
    }
  };
};
  • 옵져버 패턴을 구현한 함수다.

스토어

js
import {createSubject} from './helper/observer.js';
import {mapValues} from './helper/map-values.js';

export const createStore = () => {
  const subject = createSubject();
  const useState = (state = {}) => {
    return mapValues(state, (value) => {
      const get = () => value;
      const set = (newValue, shouldNotify = true) => {
        value = newValue;
        shouldNotify && subject.notify()
      };
      return {get, set}
    });
  };
  const share = (store) => {
    store._subscribe(() => {
      subject.notify();
    })
  };

  return {
    useState,
    share,
    _subscribe: subject.subscribe,
    _unsubscribe: subject.unsubscribe,
  }
};
  • createStore 실행 시, subject를 생성한다.
  • useState로 상태를 등록한다.
  • share로 스토어의 상태변경전파를 공유한다.
  • _subscribe, _unsubscribe는 코어레벨에서 사용된다.

컴포넌트

js
import {html} from './helper/html.js';
import {createStore} from './store.js';

export const component = (createComponent) => {
  return ({props, emit} = {props: null, emit: null}) => {
    const store = createStore();
    const render = createComponent({html, store}, {props, emit});
    const state = {
      dom: null
    };
    const observer = () => {
      const newDom = render();
      state.dom && state.dom.replaceWith(newDom);
      state.dom = newDom;
    };

    store._subscribe(observer);
    observer();

    return state.dom;
  };
};
  • createComponent를 인자로 받는다.
  • createComponent를 실행하여 render를 반환 받는다.
  • render를 최초에 실행 후 반환한다.
  • store를 감시하며, 상태 전파을 받으면 render를 재실행하고 DOM을 교체한다.