티스토리 뷰

728x90
SMALL

배경

라이브러리없이 밑바닥부터 개발하는 경험은 정말 소중하다. 1년전 우아한테크캠프를 통해서 이를 배웠으며 한번씩 기회가 될때마다 공부해왔다. 그리고 이제 좀 쓸만한? 리액트 비스무리한 라이브러리를 만든것 같아서 글을 적는다.(참고로 컴포넌트에 관한 3번째 글이다...)

 

사전지식

리액트를 만들어야하는데 리액트의 동작방식을 몰라서는 안된다. 최소한 공식문서는 정독하고 혼자서 가볍게라도 만들어보는 걸 추천한다. 이미 나와있는 자료들을 보고 내가 새롭게 만들어보는것도 좋은 방법이지만 아무것도 머리속에 넣지 않고서 머리를 쥐어뜯으면서 생각해보는 것이 더 도움이 된다고 생각한다. best practive를 찾아서 공부하는 건 좋은 방법이지만 그것만 쫓아서는 좋은 개발자가 될 수 없다고 생각한다. 혼자서 코딩해보고 남의 코드, 좋은 코드도 봐야지 비로소 실력이 오른다고 생각한다.

 

왜?? 리액트를 왜쓸까?? 그리고 나는 왜 리액트를 만들려고 하는걸까?? 남들이 쓰니까 취업이 잘되니까 쓰는걸까?? 맞는말이다. 근데 이것보다 조금 더 근본적인 이유를 따져보자.(spa 라이브러리 프레임워크 장단점을 비교하는건 아니다. 뷰, 앵귤러, 스벨트 얘기는 하지 않겠다)

 

리액트는 사용자 인터페이스를 만들기위한 JavaScript 라이브러리다. 사용자 인터페이스 즉 view를 위한 라이브러리다. 다들 리액트 리액트하는데 겨우 view만 지원해주는 라이브러리네? 라고 생각할 수 있다. 근데 view를 지원해주는게 엄청 중요하다. 그러기위해서는 먼저 백엔드와 프론트 개발자가 하는 역할에 대해서 생각해봐야 한다. 현대 웹 개발자를 기준으로 설명하겠다.

백엔드개발자는 주로 db정보를 가공해서 api를 만든다.(db에 관한 정보만 있지는 않다.) 프론트개발자는 백엔드로부터 데이터를 받아서 화면에 뿌려준다. 정말 간단하게 설명하면 이렇다. 백엔드는 db를 다루긴하지만 코드적인 부분을 많이 다룬다. 하지만 프론트는 비개발자가 알고있는 프로그래밍만 다루는 것이 아니라 html, css로 유저에게 보여주는 일을 한다.

 

그 전에... 현대 웹 개발자와 과거 웹 개발자가 무엇이 다른지 설명하고 리액트가 나오게 된 배경까지 설명하겠다. 과거에는 백엔드에서 데이터도 관리하고 html,css,js까지 전부 관리해서 웹에 내려줬다. ex) express pug. 

이게 가능한 이유는 과거에는 프론트에서 정말 간단한 작업만 했기 때문이다. 요즘은 여러 파일들을 하나로 묶어주는 번들러를 이용한다.(사실 하나가 아닐 수 도 있다. 묶어주는 것 뿐만 아니라 다양한 역할을 한다.) webpack, vite등이 여기에 속한다.

번들러를 이용해서 프론트에서 백엔드 도움없이 새로고침 없이 화면을 이동한다. 그리고 필요한 데이터만 api통신을 통해 가져온다. 그런데 어떻게 새로고침 없이 화면을 이동할 수 있을까?? 그건 하나의 html파일을 두고 자바스크립트로 관리하기 때문이다. 이때 react는 virtual dom과 diff 알고리즘을 이용해서 효율적으로 화면이 변경되게 도와준다.  

 

만들어보기

추상 클래스 만들기

너무 어렵다고 생각하지 말고 차근차근 생각해보자. 그리고 어떤 기능을 구현할 것이고 필요한 클래스, 메서드, 함수, 인터페이스는 뭔지 생각을 해보자. 

일단 리액트의 핵심 기능만 생각해보자. props, state, render, setState 이정도만 구현해보자. 리액트는 상태기반으로 동작하기 때문에 이는 필수적이다.

 

abstract class Component<P = {}, S = {}> {
  $target: HTMLElement;
  props: P;
  state!: S;

  constructor($target: HTMLElement, props: P) {
    this.$target = $target;
    this.props = props;
    this.setup();
    this.render();
    this.componentDidMount();
  }

  setup() {}
  render() {
    this.$target.innerHTML = this.markup();
  }
  abstract markup(): string;
  setState() {}
  componentDidMount() {}
  componentDidUpdate() {}
}

클래스형 컴포넌트를 만들어야 하기 때문에 추상 클래스로 만들었다. 그리고 props와 state의 타입이 필요하기 때문에 제네릭으로 정의해줬다. 또한 constructor에는 target과 props를 넣어줬다. jsx의 xml형태로 html 태그를 사용할 수 없기 때문에 감싸주는 태그가 필요하다. 이제 setup에서 state를 정의해주고 render를 실행시켜서 markup에 있는 string데이터를 html정보로 변환해줄 것 이다. 그리고 componentDidMount를 실행한다. 또한 setState를 실행시키면 componentDidUpdate를 실행시키고 render를 실행시킬 것이다.

 

기본 메소드 구현하기

위에서 만든 추상 클래스의 메소드를 구현해보자. 

abstract class Component<P = {}, S = {}> {
  $target: HTMLElement;
  props: P;
  state!: S;

  constructor($target: HTMLElement, props: P) {
    this.$target = $target;
    this.props = props;
    this.setup();
    this.render();
    this.componentDidMount();
  }

  protected setup() {}
  private render() {
    this.$target.innerHTML = this.markup();
  }
  protected abstract markup(): string;
  protected setState<K extends keyof S>(newState: Pick<S, K> | S | null) {
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...newState });
    this.state = { ...this.state, ...newState };
    this.render();
  }
  protected componentDidMount() {}
  protected componentDidUpdate(state: S, nextState: S) {}
}

 

코드를 보면 알겠지만 간단한다. setState를 실행하게 되면 componentDidUpdate에 현재 상태와 다음 상태를 넣어주고 state를 변경한 후 render 함수를 실행하면 된다.

 

이를 바탕으로 간단하게 실행해보자

class App extends Component<{}, { id: number }> {
  protected setup(): void {
    this.state = { id: 1 };
  }
  protected componentDidMount(): void {
    setTimeout(() => this.setState({ id: 10 }), 1000);
  }
  protected markup(): string {
    return `<div>id: ${this.state.id}</div>`;
  }
}

new App(document.querySelector('#root') as HTMLElement, {});

 

이벤트 구현하기

기본 이벤트 구현

이제 기본적인 것들이 동작하는 것을 확인했다. 그 다음 단계로는 이벤트를 구현해보자. setEvents라는 메서드를 만들고 생성자에서 실행시켜주면 될것같다.

 

abstract class Component<P = {}, S = {}> {
  // 생략
  constructor($target: HTMLElement, props: P) {
    this.$target = $target;
    this.props = props;
    this.setup();
    this.render();
    this.componentDidMount();
    this.setEvents();
  }
  protected setEvents() {}
  // 생략
}

class App extends Component<{}, { id: number }> {
  protected setup(): void {
    this.state = { id: 1 };
  }
  protected componentDidMount(): void {
    setTimeout(() => this.setState({ id: 10 }), 1000);
  }
  protected setEvents(): void {
    this.$target.querySelector('.js-increase')?.addEventListener('click', () => {
      this.setState({ id: this.state.id + 1 });
    });
    this.$target.querySelector('.js-decrease')?.addEventListener('click', () => {
      this.setState({ id: this.state.id - 1 });
    });
  }
  protected markup(): string {
    return `
    <div>id: ${this.state.id}</div>
    <button class="js-increase">increase</button/>
    <button class="js-decrease">decrease</button/>
    `;
  }
}

new App(document.querySelector('#root') as HTMLElement, {});

 

그런데 제대로 동작하지 않는다. 왜일까?? 지금 render가될때마다 target의 내부값들이 전부 변경된다. 그렇기 때문에 이벤트를 렌더링될때마다 추가해줘야한다. target에 직접적으로 이벤트를 걸지 않는한 이벤트의 중복은 발생하지 않기 때문에 일단 이벤트 해제는 생각하지 말자.

abstract class Component<P = {}, S = {}> {
  // 생략
  constructor($target: HTMLElement, props: P) {
    this.$target = $target;
    this.props = props;
    this.setup();
    this.render();
    this.componentDidMount();
  }
  protected setEvents() {}
  protected render() {
    this.$target.innerHTML = this.markup();
    this.setEvents();
  }
  // 생략
}

 

이벤트 추상화

this.$target.querySelecotr('') 부분이 앞으로 계속 중복될것이다. 이걸 추상화시켜보자. 

  addEvent(eventType: keyof DocumentEventMap, selector: string, callback: (e: Event) => void) {
    this.$el.querySelector(selector)?.addEventListener(eventType, callback);
  }

 

이벤트 위임

map으로 리스트들을 렌더링하고 리스트들에 이벤트를 걸어줘야하는 경우를 생각해보자. 100개의 리스트가 있다면 100개의 이벤트를 걸어줘야한다. 이럴때는 이벤트 위임을 걸어서 해결할 수 있다. 리액트에서는 그냥 편하게 이벤트를 map 내부의 컴포넌트에서 걸지만 리액트에서 이벤트 위임 처리를 해준다. virtual dom이 있기 때문에 가능한거고 나는 직접 dom에 접근하기 때문에 따로 구현해줘야 한다.

아래와 같이 이벤트 위임처리를 하게되면 매번 이벤트를 새롭게 걸어줄 필요가 없다. 즉 render함수의 setEvent를 없애고 constructor에서 한번만 실행시킬수도 있다.

 

이벤트 위임의 장점

  1. 동적인 엘리먼트에 대한 이벤트 처리가 수월하다. 예를 들면 상위 엘리먼트에서만 이벤트 리스너를 관리하기 때문에 하위 엘리먼트는 자유롭게 추가 삭제할 있다.
  2. 동일한 이벤트에 대해 곳에서 관리하기 때문에 각각의 엘리먼트를 여러 곳에 등록하여 관리하는 것보다 관리가 수월하다.
  3. 메모리 사용량이 줄어든다. 동적으로 추가되는 이벤트가 없어지기 때문에 당연한 결과이다. 1000건의 각주를 등록한다고 생각해보면 고민할 필요로 없는 일이다.
  4. 메모리 누수 가능성도 줄어든다. 등록 핸들러 자체가 줄어들기 때문에 메모리 누수 가능성도 줄어든다.
  protected addEventDelegation(eventType: keyof DocumentEventMap, selector: string, callback: (e: Event) => void) {
    const children = [...this.$el.querySelectorAll(selector)];
    const isTarget = (target: HTMLElement) => children.includes(target) || target.closest(selector);
    this.$el.addEventListener(eventType, (e) => {
      if (!isTarget(e.target as HTMLElement)) return false;
      callback(e);
    });
  }

 

setState를 한 단계 깊게 생각해보기

state가 바뀌었을 때만 state 변경하기

state가 같은값으로 변경되는 경우가 있다. 이때는 굳이 불필요한 리렌더링을 할 필요가 없다. 그래서 새로운 상태와 현재 상태를 비교해서 같다면 리렌더링을 막아준다. 이때 JSON으로 변환해서 비교할 수도 있지만 Object.is를 이용해서 비교해주겠다. JSON으로 변환하는 과정은 비용이 들기 때문에 리액트에서도 Object.is를 사용해서 비교하고 있다. 즉 동일한 객체값을 setState한다면 리렌더링이 일어난다. 이는 유의하자.

abstract class Component<P = {}, S = {}> {
  // 생략
  protected setState<K extends keyof S>(newState: Pick<S, K> | S | null) {
    if (!this.checkNeedUpdate(newState)) return;
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...newState });
    this.state = { ...this.state, ...newState };
    this.render();
  }
  private checkNeedUpdate(newState: any) {
    for (const key in newState) {
      // @ts-ignore
      if (!Object.is(newState[key], this.state[key])) return true;
    }
    return false;
  }
  // 생략
}

debounce 적용하기

state가 연속되서 변경되었을 때 계속해서 리렌더링이 발생한다. 잠깐의 시간동안 setState가 10번 실행된다고 가정해보자. reflow, repaint가 찰나의 순간에 10번 발생하게 된다. 이는 굉장히 비효율적이다. 렌더링 함수에만 디바운스를 걸면 어떨까? 특정 시간을 거는 것보다는 1프레임뒤에 실행시키자. 1프레임뒤에 렌더링을 시키면 유저입장에서는 전혀 불편함을 느끼지 않는다.

const debounceFrame = (callback: () => void) => {
  let currentCallback: number;
  return () => {
    if (currentCallback) cancelAnimationFrame(currentCallback);
    currentCallback = requestAnimationFrame(callback);
  };
};

abstract class Component<P = {}, S = {}> {
  // 생략
  protected setState<K extends keyof S>(newState: Pick<S, K> | S | null) {
    if (!this.checkNeedUpdate(newState)) return;
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...newState });
    this.state = { ...this.state, ...newState };
    debounceFrame(() => {
      this.render();
    })();
  }
  // 생략
}

callback 처리하기

setState를 한 뒤에 콜백함수를 실행시켜야하는 경우가 있다. 이럴때는 그냥 setState의 두번째 인자로 콜백 함수를 넘겨서 처리하자.

  protected setState<K extends keyof S>(newState: Pick<S, K> | S | null, callback?: Function) {
    if (!this.checkNeedUpdate(newState)) return;
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...newState });
    this.state = { ...this.state, ...newState };
    debounceFrame(() => {
      this.render();
      callback?.();
    })();
  }

 

하위 컴포넌트 추가하기

지금은 하위 컴포넌트를 추가하는 메소드가 없다. 어렵지 않으니 바로 추가해주자. 그런데 문제가 하나 있다. render함수에서 appendComponent를 통해 새로운 컴포넌트 클래스 인스턴스를 만들기 때문에 하위 컴포넌트의 state는 상위 컴포넌트가 리렌더링될때마다 초기화된다. 해결할 수 있는 방법은 여러가지가 있지만 일단은 넘어가자.

abstract class Component<P = {}, S = {}> {
  private render() {
    this.$target.innerHTML = this.markup();
    this.appendComponent();
    this.setEvents();
  }
  protected appendComponent() {}
}

class App extends Component<{}, { id: number }> {
  // 생략
  protected markup(): string {
    return `
    <div>id: ${this.state.id}</div>
    <button class="js-increase">increase</button/>
    <button class="js-decrease">decrease</button/>
    <div class="child-wrapper"></div>
    `;
  }
}

class Child extends Component {
  protected markup(): string {
    return `<div>child component</div>`;
  }
}

 

Diff 알고리즘

리액트처럼 diff 알고리즘을 적용해보자. 세부 구현은 나중에 생각하고 컴포넌트에서 어떤 작업을 해야할지부터 생각하자. 어렵고 세부적인 내용의 코드를 먼저 작성할필요는 없다. 구현하길 원하는 최종형태를 먼저 생각하자. 

reconcilation 함수에 target과 새로운 node를 넘기고 target을 node로 비교하면서 변경시켜주게 만들면 될 것 같다. 

  private render() {
    const $newNode = this.$target.cloneNode(true) as HTMLElement;
    $newNode.innerHTML = this.markup();
    reconciliation(this.$target, $newNode);
    this.appendComponent();
  }

 

이제 diff 알고리즘을 어떻게 구현할지 생각해보자.  쉽게쉽게 생각하자. 현재 element와 새로운 element가 들어온다면 각각 element의 childNodes를 for문으로 비교해주면 된다. 

export const reconciliation = ($originEl: HTMLElement, $newEl: HTMLElement) => {
  const $originNodes = [...$originEl.childNodes];
  const $newNodes = [...$newEl.childNodes];
  const max = Math.max($originNodes.length, $newNodes.length);
  for (let i = 0; i < max; i++) {
    updateNode($originEl, $originNodes[i], $newNodes[i]);
  }
};

 

이제 node를 업데이트하는 방식을 생각해보자.

1. 만약 originNode가 존재하는데 newNode가 존재하지 않는다면 삭제된것이기 때문에 originNode를 제거해준다.

2. originNode가 존재하지 않고 newNode가 존재한다면 newNode를 append해준다.

3. originNode와 newnode가 모두 text이면 newNode의 텍스트로 변경시킨다.

4. 태그이름이 다르다면 전체를 바꿔준다.(리액트 공식문서 참조)

5. attribute를 업데이트 시킨다.

6. 재귀적으로 실행시킨다.

 

이정도면 충분한 것 같다. 실제 코드로 적어보자

function updateNode($target: HTMLElement, $originNode: ChildNode, $newNode: ChildNode) {
  // Remove origin node
  if ($originNode && !$newNode) return $originNode.remove();

  // Add new node
  if (!$originNode && $newNode) return $target.appendChild($newNode);

  // Change Text node
  if ($originNode instanceof Text && $newNode instanceof Text) {
    if ($originNode.nodeValue === $newNode.nodeValue) return;
    $originNode.nodeValue = $newNode.nodeValue;
    return;
  }

  // Replace html tag
  if ($originNode.nodeName !== $newNode.nodeName) {
    return $target.replaceChild($newNode, $originNode);
  }

  const $originEl = $originNode as HTMLElement;
  const $newEl = $newNode as HTMLElement;

  // Update Attributes
  updateAttributes($originEl, $newEl);

  // Recursion updateNode
  const $originNodes = [...$originEl.childNodes];
  const $newNodes = [...$newEl.childNodes];
  const max = Math.max($originNodes.length, $newNodes.length);
  for (let i = 0; i < max; i++) {
    updateNode($originEl, $originNodes[i], $newNodes[i]);
  }
}

 

이제 attribute를 변경시키는 함수를 만들어보자. 너무 간단한 코드라 설명은 생략하겠다.

const updateAttributes = ($originNode: HTMLElement, $newNode: HTMLElement) => {
  [...$newNode.attributes].forEach(({ name, value }) => {
    if (value === $originNode.getAttribute(name)) return;
    $originNode.setAttribute(name, value);
  });
  [...$originNode.attributes].forEach(({ name }) => {
    if ($newNode.hasAttribute(name)) return;
    $originNode.removeAttribute(name);
  });
};

 

key

리액트는 key를 가지고 있다. 그리고 key를 가지고 diff 알고리즘을 더 효율적으로 동작시킨다. 못할게뭐있어. 나도 해보자. 다만 virtual dom이 아니기 때문에 실제 key값이 real dom에 노출된다. 이는 감안하자.

 

다음과 같이 부모 element에서 자식 element의 attribute에 접근해서 key의 존재 여부를 파악하자. (real dom에서 진행되기 때문에 key를 생략하거나 일부만 등록하면 제대로 동작하지 않는다.)

function updateNode($target: HTMLElement, $originNode: ChildNode, $newNode: ChildNode) {
  // 생략
  // Update Attributes
  updateAttributes($originEl, $newEl);

  // Update By Key
  if (
    ($originNode as HTMLElement).firstElementChild?.getAttribute('key') &&
    ($newNode as HTMLElement).firstElementChild?.getAttribute('key')
  ) {
    return updateByKey($originEl, $newEl);
  }
  // 생략
}

 

본격적으로 key로 업데이트하는 로직을 생각해보자.

만약 originEl의 key가 존재하고 newEl의 key가 존재하지 않는다면 이는 element가 제거된 경우다. element를 제거해주자.

그리고 만약 newEl의 key가 존재하고 orignEl의 key가 존재하지 않는다면  newEl를 추가해주자.

마지막으로 updateNode 함수를 실행시키면 된다. 

이를 바탕으로 코드를 작성해보면 다음과 같다.

type THash = Record<string, HTMLElement>;

const updateByKey = ($originEl: HTMLElement, $newEl: HTMLElement) => {
  const $originEls = [...$originEl.children] as HTMLElement[];
  const $newEls = [...$newEl.children] as HTMLElement[];

  const originKeyObj: THash = {};
  const originNodeOrders: string[] = [];
  $originEls.forEach((originNode) => {
    const key = originNode.getAttribute('key');
    if (!key) throw new Error('key is not defined');
    originKeyObj[key] = originNode;
    originNodeOrders.push(key);
  });

  const newKeyObj: THash = {};
  const newNodeOrders: string[] = [];
  $newEls.forEach((newNode) => {
    const key = newNode.getAttribute('key');
    if (!key) throw new Error('key is not defined');
    newKeyObj[key] = newNode;
    newNodeOrders.push(key);
  });

  originNodeOrders.forEach((key) => {
    const originEl = originKeyObj[key];
    const newEl = newKeyObj[key];
    if (!newEl) {
      originEl.remove();
    }
  });

  newNodeOrders.forEach((key, index) => {
    const originEl = originKeyObj[key];
    const newEl = newKeyObj[key];
    if (!originEl) {
      return $originEl.insertBefore(newEl, $originEl.children[index]);
    }
    updateNode($originEl, originEl, newEl);
  });
};

 

불필요한 html tag 태그제거하기

target이라는 html tag가 너무나도 불필요해보인다. jsx를 사용하지 않는다고는 하지만 이것만큼은 고쳤으면 좋겠다. 먼저 간단하게 생각해보자. target이라는 태그안에 innerhtml을 하고 있는데 그냥 태그를 만들어주면 될것같다.  단, 상위 태그가 없는 만큼 감싸는 태그를 만들어야 한다. ex) `<div>123</div>` (fragment를 적용할 수도 있는데 이건 좀 나중에 생각하자.) 

child component는 어디에 넣어줘야할까?? 사실 jsx가 없기 때문에 바벨을 사용하지 않고는 <Child/> 같은 형태로 만들 수 없다. 조금 우회해서 생각해보자. html 태그를 메소드를 통해서 만들어주고 만든 태그를 markup 메소드에 넣어주면 되지 않을까?? 그리고 렌더링하는 과정에서 만들어진 커스텀 컴포넌트를 replace하면 될 것 같다. 해보자. 

 

target 제거하기

먼저 markup을 render로 변경했다. 그리고 getNewEl와 parseHTML을 통해서 target의 firstElementChild를 $el에 저장한다. 또한 render대신 update 메서드를 실행시켜서 업데트를 해준다.

abstract class Component<P = {}, S = {}> {
  protected props: P;
  protected state!: S;

  public $el: HTMLElement;
  private getNewEl() {
    return this.parseHTML(this.render());
  }

  constructor(props: P) {
    this.setup();
    this.addComponents();
    this.props = props;
    this.$el = this.getNewEl(true);
    this.componentDidMount();
    this.setEvents();
  }
  private parseHTML(html: string): HTMLElement {
    const $el = document.createElement('div');
    $el.innerHTML = html;
    return $el.firstElementChild as HTMLElement;
  }
  private update() {
    reconciliation(this.$el, this.getNewEl());
  }
  protected abstract render(): string;
  protected setState<K extends keyof S>(newState: Pick<S, K> | S | null, callback?: Function) {
    if (!this.checkNeedUpdate(newState)) return;
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...newState });
    this.state = { ...this.state, ...newState };
    debounceFrame(() => {
      this.update();
      callback?.();
    })();
  }
  // 생략
}

 

 

target을 제거했는데 그러면 첫번째 최상위 컴포넌트에는 상위 태그가 없다. 그래서 새로운 함수를 만들어줘야한다. 리액트도 이와 유사하게 ReactDom.redner 같은 함수를 사용한다.

const render = (app: Component, root: HTMLElement) => {
  root.appendChild(app.$el);
};

render(new App({}), document.getElementById('root') as HTMLElement);

 

child component를 커스텀 태그로 만들기

public으로 id를 만들어주고 addComponent를 통해서 child component를 return 받는다. 그리고 id를 통해서 커스텀 태그에 접근할 수 있게된다. 아래처럼 코드를 작성하면 커스텀태그가 만들어질 것이다. 이걸 Child component로 바꾸는건 다음단계다.

const TAG = 'C-';

interface IComponents {
  [key: string]: Component;
}

abstract class Component<P = {}, S = {}> {
  private $components: IComponents = {};

  static ID = 0;
  public id = TAG + Component.ID;

  constructor(props: P) {
    Component.ID += 1;
    this.setup();
    this.addComponents();
    // 생략
  }

  protected abstract render(): string;
  protected addComponents() {}
  protected addComponent<PT = {}>(ComponentClass: new (props: PT) => Component, props: PT): Component {
    const newComponent: Component = new ComponentClass(props);
    this.$components[newComponent.id] = newComponent;
    return newComponent;
  }
  // 생략
}

class App extends Component<{}, { id: number }> {
  $child!: Component;

  protected setup(): void {
    this.state = { id: 1 };
  }
  protected componentDidMount(): void {
    setTimeout(() => this.setState({ id: 10 }), 1000);
  }
  protected setEvents(): void {
    this.addEventDelegation('click', '.js-increase', () => {
      this.setState({ id: this.state.id + 1 });
    });
    this.addEventDelegation('click', '.js-decrease', () => {
      this.setState({ id: this.state.id - 1 });
    });
  }
  protected addComponents(): void {
    this.$child = this.addComponent(Child, {});
  }

  protected render(): string {
    return `
    <div>
      <div>id: ${this.state.id}</div>
      <button class="js-increase">increase</button/>
      <button class="js-decrease">decrease</button/>
      <${this.$child.id}></${this.$child.id}/>
    </div>
    `;
  }
}

 

customTag를 child component로 바꾸기

이제 customTag를 child Component로 바꿔보자. 간단하다. parseHTML 메서드에서 target의 firstElementChild를 return하기전에 child Component를 변경시켜주는 메소드를 실행시켜주면된다. 그리고 이 메소드(dfsForReplaceComponent)는 dfs를 이용해서 반복해준다. 이때 C-로 시작하는 태그라면 실제로 replace해준다. 

이때 isInit이라는 옵션을 줬는데 true인 경우에는 실제 엘리먼트를 넣어주고 그 외의 경우에는 clone 해서 넣어준다. 처음부터 clone해서 넣어버리면 실제 element가 들어가지 않기 때문에 제대로 동작하지 않는다. 

export const getJSONparse = (value: string) => {
  try {
    return JSON.parse(value);
  } catch (err) {
    return value;
  }
};

const TAG = 'C-';

interface IComponents {
  [key: string]: Component;
}

type TProps = Record<string, any>;

abstract class Component<P = {}, S = {}> {
  protected props: P;
  protected state!: S;

  public $el: HTMLElement;
  private getNewEl(isInit?: boolean) {
    return this.parseHTML(this.render(), isInit);
  }

  private $components: IComponents = {};

  static ID = 0;
  public id = TAG + Component.ID;

  constructor(props: P) {
    Component.ID += 1;
    this.setup();
    this.addComponents();
    this.props = props;
    this.$el = this.getNewEl(true);
    this.componentDidMount();
    this.setEvents();
  }

  private async replaceComponent($target: HTMLElement, id: string, isInit?: boolean) {
    const el = isInit
      ? (this.$components[id].$el as HTMLElement)
      : (this.$components[id].$el as HTMLElement).cloneNode(true);
    $target.replaceWith(el);
  }

  private dfsForReplaceComponent($target: HTMLElement, isInit?: boolean) {
    const $children = [...$target.children];
    const { nodeName } = $target;

    if (nodeName.startsWith('C-')) {
      this.replaceComponent($target, nodeName, isInit);
    }

    $children.forEach(($el) => {
      this.dfsForReplaceComponent($el as HTMLElement, isInit);
    });
  }

  private parseHTML(html: string, isInit?: boolean): HTMLElement {
    const $target = document.createElement('div');
    $target.innerHTML = html;

    this.dfsForReplaceComponent($target, isInit);

    return $target.firstElementChild as HTMLElement;
  }
}

 

하위 컴포넌트의 props 업데이트 시켜주기

지금은 하위 컴포넌트의 props가 업데이트 되지 않는다. 당연하다. 그전에는 새로운 인스턴스를 새롭게 만들어줬는데 지금은 한번만 만들고 더이상 만들지 않는다. 따라서 하위 컴포넌트의 state는 초기화되지 않지만 props는 업데이트 되지 않는다. 어떻게할까?? 업데이트 해주면 된다. 어려울게 없다. 아 그리고 실제 리액트처럼 상위 컴포넌트의 render 함수에서 props를 적어줘야한다. 그리고 컴포넌트의 props에 적은 값들은 string으로 들어가기 때문에 JSON parse 과정을 거쳐야한다. 마지막으로 updateProps 메소드에서 props를 업데이트하고 update 메소드를 실행시키면 끝이다.

const getJSONparse = (value: string) => {
  try {
    return JSON.parse(value);
  } catch (err) {
    return value;
  }
};
abstract class Component<P = {}, S = {}> {
  // 생략
  private updateProps(id: string, props: TProps) {
    if (this.$components[id]) {
      this.$components[id].props = props;
      this.$components[id].update();
    }
  }
  private async replaceComponent($el: HTMLElement, id: string, isInit?: boolean) {
    const nextProps: TProps = {};
    [...$el.attributes].forEach(({ name, value }) => {
      // @ts-ignore
      if (this.$components[id].props[name] === undefined) throw new Error(`check props, name: ${name} value: ${value}`);
      nextProps[name] = getJSONparse(value);
    });
    this.updateProps(id, nextProps);
    const el = isInit
      ? (this.$components[id].$el as HTMLElement)
      : (this.$components[id].$el as HTMLElement).cloneNode(true);
    $el.replaceWith(el);
  }
}

class App extends Component<{}, { id: number }> {
  $child!: Component;

  setup() {
    this.state = { id: 1 };
  }

  addComponents() {
    this.$child = this.addComponent(Child, { id: this.state.id });
  }

  setEvents() {
    this.addEvent('click', '.js-increase', () => {
      const { id } = this.state;
      this.setState({ id: id + 1 });
    });
    this.addEventDelegation('click', '.js-decrease', () => {
      const { id } = this.state;
      this.setState({ id: id - 1 });
    });
  }

  render(): string {
    return `
    <div class='app'>
      <h1>App</h1>
      <button class='js-increase'>id increase</button>
      <button class='js-decrease'>id decrease</button>
      <div>app state id: ${this.state.id}</div>
      <${this.$child.id} id=${this.state.id}></${this.$child.id}/>
    </div>
    `;
  }
}

export default class Child extends Component<{ id: number }, { id: number }> {
  setup() {
    this.state = { id: 1 };
  }

  setEvents() {
    this.addEvent('click', '.js-increase', () => {
      const { id } = this.state;
      this.setState({ id: id + 1 });
    });
    this.addEventDelegation('click', '.js-decrease', () => {
      const { id } = this.state;
      this.setState({ id: id - 1 });
    });
  }
  render() {
    return `
    <div>
      <div>child props id : ${this.props.id}</div>
      <div>child state id : ${this.state.id}</div>
      <button class='js-increase'>child id increase</button>
      <button class='js-decrease'>child id decrease delegation</button>
    </div>
    `;
  }
}

 

Fragment 적용하기

TODO... 나중에 시간나면 추가하겠다.

VirtualDom

사실 핵심은 diff 알고리즘이다. virtual dom을 적용하면 더욱 좋겠지만 지금도 성능이 나쁘지는 않다. 그리고 VirtualDom을 적용하다 보면 가독성이 매우 나빠진다. 가독성은 성능 만큼이나 중요한 요소다. 리액트는 바벨을 이용해서 이를 해결했다. 물론 바벨을 사용하지 않고 jsx를 비슷하게 직접 만들수도 있다. 이건 나중에 시간적 여유가 나고 하고 싶은 의지가 생기면 추가하겠다.

 

최종코드

component.ts

import { reconciliation } from './diff';
import { debounceFrame, getJSONparse } from './helper';

const TAG = 'C-';

interface IComponents {
  [key: string]: Component;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TProps = Record<string, any>;

abstract class Component<P = {}, S = {}> {
  protected props: P;
  protected state!: S;

  public $el: HTMLElement;
  private getNewEl(isInit?: boolean) {
    return this.parseHTML(this.render(), isInit);
  }

  private $components: IComponents = {};

  static ID = 0;
  public id = TAG + Component.ID;

  constructor(props: P) {
    Component.ID += 1;
    this.setup();
    this.addComponents();
    this.props = props;
    this.$el = this.getNewEl(true);
    this.componentDidMount();
    this.setEvents();
  }

  protected addComponents() {}

  protected addComponent<PT = {}>(ComponentClass: new (props: PT) => Component, props: PT): Component {
    const newComponent: Component = new ComponentClass(props);
    this.$components[newComponent.id] = newComponent;
    return newComponent;
  }

  protected setEvents() {}

  protected addEvent(eventType: keyof DocumentEventMap, selector: string, callback: (e: Event) => void) {
    this.$el.querySelector(selector)?.addEventListener(eventType, callback);
  }

  protected addEventDelegation(eventType: keyof DocumentEventMap, selector: string, callback: (e: Event) => void) {
    const children = [...this.$el.querySelectorAll(selector)];
    const isTarget = (target: HTMLElement) => children.includes(target) || target.closest(selector);
    this.$el.addEventListener(eventType, (e) => {
      if (!isTarget(e.target as HTMLElement)) return false;
      callback(e);
    });
  }

  protected setup() {}

  protected abstract render(): string;

  private update() {
    reconciliation(this.$el, this.getNewEl());
  }

  protected setState<K extends keyof S>(newState: Pick<S, K> | S | null, callback?: Function) {
    if (!this.checkNeedUpdate(newState)) return;
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...newState });
    this.state = { ...this.state, ...newState };
    debounceFrame(() => {
      this.update();
      callback?.();
    })();
  }

  protected componentDidMount() {}

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected componentDidUpdate(state: S, nextState: S) {}

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private checkNeedUpdate(newState: any) {
    // eslint-disable-next-line no-restricted-syntax
    for (const key in newState) {
      // @ts-ignore
      if (!Object.is(newState[key], this.state[key])) return true;
    }
    return false;
  }

  private updateProps(id: string, props: TProps) {
    if (this.$components[id]) {
      this.$components[id].props = props;
      this.$components[id].update();
    }
  }

  private async replaceComponent($target: HTMLElement, id: string, isInit?: boolean) {
    const nextProps: TProps = {};
    [...$target.attributes].forEach(({ name, value }) => {
      // @ts-ignore
      if (this.$components[id].props[name] === undefined) throw new Error(`check props, name: ${name} value: ${value}`);
      nextProps[name] = getJSONparse(value);
    });
    this.updateProps(id, nextProps);
    const el = isInit
      ? (this.$components[id].$el as HTMLElement)
      : (this.$components[id].$el as HTMLElement).cloneNode(true);
    $target.replaceWith(el);
  }

  private dfsForReplaceComponent($target: HTMLElement, isInit?: boolean) {
    const $children = [...$target.children];
    const { nodeName } = $target;

    if (nodeName.startsWith('C-')) {
      this.replaceComponent($target, nodeName, isInit);
    }

    $children.forEach(($el) => {
      this.dfsForReplaceComponent($el as HTMLElement, isInit);
    });
  }

  private parseHTML(html: string, isInit?: boolean): HTMLElement {
    const $target = document.createElement('div');
    $target.innerHTML = html;

    this.dfsForReplaceComponent($target, isInit);

    return $target.firstElementChild as HTMLElement;
  }
}

export default Component;

 

diff.ts

type THash = Record<string, HTMLElement>;

const updateByKey = ($originEl: HTMLElement, $newEl: HTMLElement) => {
  const $originEls = [...$originEl.children] as HTMLElement[];
  const $newEls = [...$newEl.children] as HTMLElement[];

  const originKeyObj: THash = {};
  const originNodeOrders: string[] = [];
  $originEls.forEach((originNode) => {
    const key = originNode.getAttribute('key');
    if (!key) throw new Error('key is not defined');
    originKeyObj[key] = originNode;
    originNodeOrders.push(key);
  });

  const newKeyObj: THash = {};
  const newNodeOrders: string[] = [];
  $newEls.forEach((newNode) => {
    const key = newNode.getAttribute('key');
    if (!key) throw new Error('key is not defined');
    newKeyObj[key] = newNode;
    newNodeOrders.push(key);
  });

  originNodeOrders.forEach((key) => {
    const originEl = originKeyObj[key];
    const newEl = newKeyObj[key];
    if (!newEl) {
      originEl.remove();
    }
  });

  newNodeOrders.forEach((key, index) => {
    const originEl = originKeyObj[key];
    const newEl = newKeyObj[key];
    if (!originEl) {
      return $originEl.insertBefore(newEl, $originEl.children[index]);
    }
    updateNode($originEl, originEl, newEl);
  });
};

const updateAttributes = ($originNode: HTMLElement, $newNode: HTMLElement) => {
  [...$newNode.attributes].forEach(({ name, value }) => {
    if (value === $originNode.getAttribute(name)) return;
    $originNode.setAttribute(name, value);
  });
  [...$originNode.attributes].forEach(({ name }) => {
    if ($newNode.hasAttribute(name)) return;
    $originNode.removeAttribute(name);
  });
};

function updateNode($target: HTMLElement, $originNode: ChildNode, $newNode: ChildNode) {
  // Remove origin node
  if ($originNode && !$newNode) return $originNode.remove();

  // Add new node
  if (!$originNode && $newNode) return $target.appendChild($newNode);

  // Change Text node
  if ($originNode instanceof Text && $newNode instanceof Text) {
    if ($originNode.nodeValue === $newNode.nodeValue) return;
    $originNode.nodeValue = $newNode.nodeValue;
    return;
  }

  // Replace html tag
  if ($originNode.nodeName !== $newNode.nodeName) {
    return $target.replaceChild($newNode, $originNode);
  }

  const $originEl = $originNode as HTMLElement;
  const $newEl = $newNode as HTMLElement;

  // Update Attributes
  updateAttributes($originEl, $newEl);

  // Update By Key
  if (
    ($originNode as HTMLElement).firstElementChild?.getAttribute('key') &&
    ($newNode as HTMLElement).firstElementChild?.getAttribute('key')
  ) {
    return updateByKey($originEl, $newEl);
  }

  // Recursion updateNode
  const $originNodes = [...$originEl.childNodes];
  const $newNodes = [...$newEl.childNodes];
  const max = Math.max($originNodes.length, $newNodes.length);
  for (let i = 0; i < max; i++) {
    updateNode($originEl, $originNodes[i], $newNodes[i]);
  }
}

export const reconciliation = ($originEl: HTMLElement, $newEl: HTMLElement) => {
  const $originNodes = [...$originEl.childNodes];
  const $newNodes = [...$newEl.childNodes];
  const max = Math.max($originNodes.length, $newNodes.length);
  for (let i = 0; i < max; i++) {
    updateNode($originEl, $originNodes[i], $newNodes[i]);
  }
};

 

helper.ts

export const getJSONparse = (value: string) => {
  try {
    return JSON.parse(value);
  } catch (err) {
    return value;
  }
};

export const debounceFrame = (callback: () => void) => {
  let currentCallback: number;
  return () => {
    if (currentCallback) cancelAnimationFrame(currentCallback);
    currentCallback = requestAnimationFrame(callback);
  };
};

 

render.ts

import Component from './component';

const render = (app: Component, root: HTMLElement) => {
  root.appendChild(app.$el);
};

export default render;

 

후기

좀 잘 써볼려고 했는데 단계단계별로 설명하려다보니 코드를 새롭게 다시 짜야했고 글도 길어지다보니 글쓰면서 지쳤다. 나중에 글정리를 해야겠다. 이해가 안가는점이 있으면 댓글 남겨주세요

 

깃헙 레포

https://github.com/yoonminsang/mact

 

GitHub - yoonminsang/mact: 바닐라 타입스크립트로 만드는 리액트

바닐라 타입스크립트로 만드는 리액트. Contribute to yoonminsang/mact development by creating an account on GitHub.

github.com

참고글

우테캠 레포

https://ms3864.tistory.com/380

https://ms3864.tistory.com/409

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Virtual-DOM/#_2-jsx

728x90
LIST
댓글
공지사항