본문 바로가기

Web

Vanilla Javascript Component Core 구현

※ 전반적인 코드는 황준일님의 코드를 참고하여 구현하였습니다.

 

1. 컴포넌트 생명주기

 (1)  constructor(컴포넌트 생성)

   - 상태변화를 감지할 observer에게 상태 등록

   - props 설정

   - event 설정

 

 (2)  created(엘리먼트 생성 전)

   - 엘리먼트를 그리기전 필요한 로직 수행

 

 (3)  render(엘리먼트 생성)

   - 엘리먼트 생성

 

 (4)  mounted(엘리먼트 생성 후)

   - 자식 컴포넌트 마운팅

 

 

2. 상태관리

 (1)  컴포넌트에 종속되는 상태

   - getState(키, 초기값)으로 상태 호출

   - setState(키, 값)으로 상태 변경

 

 (2) 전역으로 관리되는 상태

   - store생성시 observer에게 상태 등록

   - observer에 등록한 상태를 호출할때 컬렉션에 존재하지 않을 경우 해당 값 컬렉션에 추가

   - 컬렉션에 새로운 값이 추가될경우 observer에 등록된 함수 호출

 

 

3. 최적화

 (1)  requestAnimationFrame을 이용한 호출 횟수 최적화

   - requestAnimationFrame을 이용한 1프레임(60초)에 1회 호출

 

 (2)  가상돔을 이용한 엘리먼트 생성 최적화

   - 가상 엘리먼트를 실제엘리먼트와 비교하여 바뀐 부분만 랜더링 수행

 

 

4. 구현

 (1)  index.html 

<!DOCTYPE html>
<html>
<head>
    <title>Core</title>
</head>
<body>
    <div id="app"></div>
    <script type="text/javascript" src="/dist/bundle.js"></script>
</body>
</html>

 

 

 (2)  utils/debounceFrame.ts

export function debounceFrame(callback) {
  // 1프레임(60초)에 1회 호출
  let nextFrameCallback = -1;
  return () => {
    cancelAnimationFrame(nextFrameCallback);
    nextFrameCallback = requestAnimationFrame(callback);
  };
}

 

 

(3)  utils/diff.ts

function updateAttributes(oldNode, newNode) {
  // oldNode와 newNode의 태그 이름(type)이 똑같을 경우(oldNode.type === newNode.type)
  // newNode와 oldNode의 attribute를 비교하여 변경된 부분만 반영한다.
  // oldNode의 attribute 중 newNode에 없는 것은 모두 제거한다.
  // newNode의 attribute에서 변경된 내용만 oldNode의 attribute에 반영한다.
  for (const {name, value} of [...newNode.attributes]) {
    if (value === oldNode.getAttribute(name)) continue;
    oldNode.setAttribute(name, value);
  }
  for (const {name} of [...oldNode.attributes]) {
    if (newNode.getAttribute(name) !== undefined) continue;
    oldNode.removeAttribute(name);
  }
}

export function updateElement(parent, newNode, oldNode) {
  // 1. oldNode만 있는 경우
  // oldNode를 parent에서 제거한다.
  if (!newNode && oldNode) return oldNode.remove();

  // 2. newNode만 있는 경우
  // newNode를 parent에 추가한다.
  if (newNode && !oldNode) return parent.appendChild(newNode);

  // 3. oldNode와 newNode 모두 text 타입일 경우
  // oldNode의 내용과 newNode의 내용이 다르다면, oldNode의 내용을 newNode의 내용으로 교체한다.
  if (newNode instanceof Text && oldNode instanceof Text) {
    // Text일 경우 nodeValue로 값 비교가 가능하다.
    if (oldNode.nodeValue === newNode.nodeValue) return;
    oldNode.nodeValue = newNode.nodeValue;
    return;
  }

  // 4. oldNode와 newNode의 태그 이름이 다를 경우
  // 둘 중에 하나가 String일 경우에도 해당
  // oldNode를 제거하고, 해당 위치에 newNode를 추가한다.
  if (newNode.nodeName !== oldNode.nodeName) {
    const index = [...parent.childNodes].indexOf(oldNode);
    oldNode.remove();
    parent.appendChild(newNode, index);
    return;
  }

  // 5. oldNode와 newNode의 태그 이름(type)이 같을 경우
  // 가상돔(VirtualDOM)의 props를 넘기는게 아니기 때문에 oldNode와 newNode를 직접 넘긴다.
  updateAttributes(oldNode, newNode);

  // 6. newNode와 oldNode의 모든 자식 태그를 순회하며 1 ~ 5의 내용을 반복한다.
  // 일단 childNodes를 배열로 변환해야한다.
  const newChildren = [...newNode.childNodes];
  const oldChildren = [...oldNode.childNodes];
  const maxLength = Math.max(newChildren.length, oldChildren.length);
  for (let i = 0; i < maxLength; i++) {
    updateElement(oldNode, newChildren[i], oldChildren[i]);
  }
}

 

 

(4)  core/Observer.ts

import {debounceFrame} from "../utils/debounceFrame";

let currentObserver = null;

export const observe = callback => {
  // observe에 등록한 함수(render)를 모든 컴포넌트에서 실행
  currentObserver = debounceFrame(callback);
  callback();
  currentObserver = null;
};

export const observable = obj => {
  // observable에 등록한 변수(store.state)를 순회
  Object.keys(obj).forEach(key => {
    let _value = obj[key];
    const observers = new Set();

    Object.defineProperty(obj, key, {
      // observable에 등록한 변수(store.state)를 호출할 경우
      get() {
        // 현재 변수가 observer 컬렉션에 존재하지 않을경우 추가
        if (currentObserver) observers.add(currentObserver);
        return _value;
      },
      // observable에 등록한 변수(store.state)가 변경할 경우
      set(value) {
        _value = value;
        // observer 컬렉션에 새로운 값이 추가될경우 observe에 등록한 함수 실행
        // 현재 코드에서는 store.state에 값이 추가될경우
        observers.forEach((callback: any) => callback());
      },
    });
  });
  return obj;
};

 

 

(5)  core/Store.ts

import {observable} from "./Observer";

export const createStore = reducer => {
  // 초기 Store생성시 initState를 리턴받고 그값을 observable에 등록한다.
  const initState = observable(reducer());

  // getState가 실제 state를 반환하는 것이 아니라 proxyState 반환하도록 만들어야 한다.
  const proxyState: any = {};
  Object.keys(initState).forEach(key => {
    Object.defineProperty(proxyState, key, {
      get: () => initState[key], // get만 정의, set은 불가.
    });
  });

  // dispatch로만 state의 값을 변경할 수 있다.
  const dispatch = action => {
    const newState = reducer(initState, action);

    for (const [key, value] of Object.entries(newState)) {
      // state의 key가 아닐 경우 변경을 생략한다.
      if (!initState[key]) continue;
      initState[key] = value;
    }
  };

  const getState = () => proxyState;

  // subscribe는 observe로 대체한다.
  return {getState, dispatch};
};

 

 

(6)  store/index.ts

import {createStore} from "../core/Store";

// 초기 state의 값을 정의해준다.
const initState = {
  isFilter: 0,
  items: [
    {
      seq: 1,
      contents: "item1",
      active: false,
    },
    {
      seq: 2,
      contents: "item2",
      active: true,
    },
  ],
};

// dispatch에서 사용될 type들을 정의해준다.
export const ADD_ITEM = "ADD_ITEM";
export const SET_FILTER = "SET_FILTER";
export const TOGGLE_ITEM = "TOGGLE_ITEM";
export const DELETE_ITEM = "DELETE_ITEM";

// reducer를 정의하여 store에 넘겨준다.
export const store = createStore((state = initState, action: any = {}) => {
  const {items} = state;
  switch (action.type) {
    case ADD_ITEM: {
      const seq = Math.max(0, ...items.map(v => v.seq)) + 1;
      const active = false;
      const contents = action.payload;
      return {...state, items: [...items, {seq, contents, active}]};
    }
    case TOGGLE_ITEM: {
      const seq = action.payload;
      const index = items.findIndex(v => v.seq === seq);
      items[index].active = !items[index].active;
      return state;
    }
    case DELETE_ITEM: {
      const seq = action.payload;
      items.splice(
        items.findIndex(v => v.seq === seq),
        1,
      );
      return state;
    }
    case SET_FILTER: {
      const isFilter = action.payload;
      state.isFilter = isFilter;
      return state;
    }
    default:
      return initState;
  }
});

// reducer에서 사용될 action을 정의해준다.
export const addItem = payload => ({type: ADD_ITEM, payload});
export const setFilter = payload => ({type: SET_FILTER, payload});
export const toggleItem = payload => ({type: TOGGLE_ITEM, payload});
export const deleteItem = payload => ({type: DELETE_ITEM, payload});

 

 

(7)  core/Component.ts

import {store} from "../store";
import {observable, observe} from "./Observer";
import {updateElement} from "../utils/diff";

export default class Component {
  #state = {};
  props;
  $el;

  constructor($el, props = {}) {
    this.$el = $el;
    this.props = props;
    this.setup();
    this.setEvent();
  }

  mounted() {}
  template() {}
  setEvent() {}
  created() {}

  getState(key, defalut) {
    if (!this.#state[key]) this.#state[key] = defalut;
    return this.#state[key];
  }

  setState(key, value) {
    this.#state[key] = value;
    this.render();
  }

  setup() {
    observable(store.getState()); // state를 관찰한다.
    observe(() => {
      // state가 변경될 경우, 함수가 실행된다.
      this.render();
    });
  }

  render() {
    this.created();
    const {$el} = this;
    // $el.innerHTML = this.template();

    // 기존 Node를 복제한 후에 새로운 템플릿을 채워넣는다.
    const newNode = $el.cloneNode(true);
    newNode.innerHTML = this.template();

    // diff알고리즘을 적용한다.
    const oldChildNodes = [...$el.childNodes];
    const newChildNodes = [...newNode.childNodes];
    const max = Math.max(oldChildNodes.length, newChildNodes.length);
    for (let i = 0; i < max; i++) {
      updateElement($el, newChildNodes[i], oldChildNodes[i]);
    }
    this.mounted();
  }

  //이벤트 버블링
  addEvent(eventType, selector, callback) {
    const children = [...this.$el.querySelectorAll(selector)];
    // selector에 명시한 것 보다 더 하위 요소가 선택되는 경우가 있을 땐
    // closest를 이용하여 처리한다.
    const isTarget = target =>
      children.includes(target) || target.closest(selector);

    this.$el.addEventListener(eventType, currentEvent => {
      if (!isTarget(currentEvent.target)) return false;
      callback(currentEvent);
    });
  }
}

 

 

(8)  components/Count.ts

import Component from "../core/Component";

export default class Count extends Component {
  key;
  count;
  template() {
    return `
    <div>
        <p>${this.count}</p>
        <button class="increment">증가</button>
        <button class="decrement">감소</button>
    </div>
    `;
  }

  created() {
    this.key = "count";
    this.count = this.getState(this.key, 0);
  }

  setEvent() {
    this.addEvent("click", ".increment", () => {
      this.setState(this.key, this.count + 1);
    });
    this.addEvent("click", ".decrement", () => {
      this.setState(this.key, this.count + 1);
    });
  }
}

 

 

(9)  components/ItemAppender.ts

import Component from "../core/Component";
import {addItem, store} from "../store";

export default class ItemAppender extends Component {
  template() {
    return `<input type="text" class="appender" placeholder="아이템 내용 입력" />`;
  }

  setEvent() {
    this.addEvent("keyup", ".appender", ({key, target}) => {
      if (key !== "Enter") return;
      store.dispatch(addItem(target.value));
      target.value = "";
    });
  }
}

 

(10)  components/Item.ts

import Component from "../core/Component";
import {addItem, store} from "../store";

export default class ItemAppender extends Component {
  template() {
    return `<input type="text" class="appender" placeholder="아이템 내용 입력" />`;
  }

  setEvent() {
    this.addEvent("keyup", ".appender", ({key, target}) => {
      if (key !== "Enter") return;
      store.dispatch(addItem(target.value));
      target.value = "";
    });
  }
}

 

 

(11)  components/ItemFilter.ts

import Component from "../core/Component";
import {setFilter, store} from "../store";

export default class ItemFilter extends Component {
  template() {
    return `
      <button class="filterBtn" data-is-filter="0">전체 보기</button>
      <button class="filterBtn" data-is-filter="1">활성 보기</button>
      <button class="filterBtn" data-is-filter="2">비활성 보기</button>
    `;
  }

  setEvent() {
    this.addEvent("click", ".filterBtn", ({target}) => {
      const isFilter = Number(target.dataset.isFilter);
      store.dispatch(setFilter(isFilter));
    });
  }
}

 

 

(12) App.ts

import Component from "./core/Component";
import Item from "./components/Item";
import ItemAppender from "./components/ItemAppender";
import ItemFilter from "./components/ItemFilter";
import Count from "./components/Count";

export default class App extends Component {
  template() {
    return `
      <header data-component="item-appender"></header>
      <main data-component="items"></main>
      <footer data-component="item-filter"></footer>
      <div data-component="count"></div>
    `;
  }

  mounted() {
    //자식컴포넌트 마운트
    const $itemAppender = this.$el.querySelector(
      '[data-component="item-appender"]',
    );
    const $items = this.$el.querySelector('[data-component="items"]');
    const $itemFilter = this.$el.querySelector(
      '[data-component="item-filter"]',
    );
    const $count = this.$el.querySelector('[data-component="count"]');

    new ItemAppender($itemAppender);
    new Item($items);
    new ItemFilter($itemFilter);
    new Count($count);
  }
}

 

 

(13) Main.ts

import App from "./App";

new App(document.querySelector("#app"));

 

 

5.  전체코드

https://github.com/yg1110/Component/tree/core

 

GitHub - yg1110/Component

Contribute to yg1110/Component development by creating an account on GitHub.

github.com

 

'Web' 카테고리의 다른 글

웹페이지 동작 원리 & 브라우저 랜더링 과정  (0) 2022.03.18
Vanilla Javascript 라우터 구현  (0) 2021.09.27
WebPack을 이용한 Typescript 번들링  (0) 2021.08.09
Class  (0) 2020.05.01
prototype chain  (0) 2020.05.01