yunwoo-yu 님의 상세페이지[2팀 유윤우] Chapter 1-2. 프레임워크 없이 SPA 만들기

과제 체크포인트

배포 링크

https://yunwoo-yu.github.io/front_6th_chapter1-2/

기본과제

가상돔을 기반으로 렌더링하기

  • createVNode 함수를 이용하여 vNode를 만든다.
  • normalizeVNode 함수를 이용하여 vNode를 정규화한다.
  • createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
  • 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.

이벤트 위임

  • 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
  • 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
  • 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다

심화 과제

Diff 알고리즘 구현

  • 초기 렌더링이 올바르게 수행되어야 한다
  • diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
  • 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
  • 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
  • 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다

과제 셀프회고

이번엔 Jsx가 추가 된 환경에서 SPA에서 Virtual DOM을 다루는 코어 코드를 학습했습니다. 테스트 코드가 존재해 순서대로 진행할 수 있었지만 만약 테스트 코드가 없는 환경에서 vNode를 생성하고 정규화한 뒤 Element를 생성하는 과정을 생각해낼 수 있었을까? 라고 한다면 시작하기가 굉장히 어렵지 않았을까 생각이 드는 과제였습니다.

AI는 최소한으로 사용하고자 했고 이해하지 못한 코드는 사용하지 않으려 노력했습니다! 개인적으로는 diff, event쪽 구현을 했지만 아직 꽤 어려운 부분인 것 같습니다.😅

전체적인 구조를 이해하려고 Jsx 플러그인 문서를 보며 커스텀 함수로 트랜스파일링되어 실행이 되는 부분부터 차근차근 되짚어보며 복기했고 개인적으로 한번 더 살펴보며 리팩토링을 진행해봤지만 반복 코드 한곳밖에 개선한 게 없는 것 같아 아쉬움이 조금 남는 것 같습니다.

이제 또 블로그에 기록하며 한번 더 살펴봐서 이해도를 높일 생각입니다. 유익하고 재밌는 과제 감사합니다. 🙇‍♂️

기술적 성장

WeakMap 활용

// WeakMap의 Key는 객체이며 객체를 참조하는 곳이 없다면 객체는 메모리에서 제거된다.
let john = { name: "John" };

let weakMap = new WeakMap();
weakMap.set(john, "...");

john = null; // 참조를 덮어씀

// john을 나타내는 객체는 이제 메모리에서 제거

Virtual DOM + JSX 구조의 전체적인 실행 플로우

function main() {
  initRender();
  router.start();
}

main 함수 호출로 initRender 실행

/**
 * 렌더링 초기화 - Store 변화 감지 설정
 */
export function initRender() {
  // 각 Store의 변화를 감지하여 자동 렌더링
  productStore.subscribe(render);
  cartStore.subscribe(render);
  uiStore.subscribe(render);
  router.subscribe(render);
}
  1. 각 Store를 구독, 렌더함수 전달
  2. 이후 router.start() 실행하여 현재 url에 해당하는 router를 찾아 알림 전송 (notify 함수 실행)
/** @jsx createVNode */
import { createVNode } from "../lib";

const HomePage = withLifecycle(
  return (/* JSX 문법...*/)
)

export const render = withBatch(() => {
  const rootElement = document.getElementById("root");
  if (!rootElement) return;

  const PageComponent = router.target;

  // App 컴포넌트 렌더링
  renderElement(<PageComponent />, rootElement);
});

// /** @jsx createVNode */ JSX 커스텀 함수 이용으로 인해 컴포넌트는 트랜스파일링 된다.
renderElement(<PageComponent />, rootElement); -> renderElement(createVNode(<PageComponent />), rootElement);

  1. notify 함수 실행으로 첫 PageComponent인 HomePage render 실행
  2. 컴포넌트는 createVNode(<HomePage />) 형태로 변환되어 renderElement 함수의 vNode로 전달된다.
  3. JSX 플러그인 문서
export function renderElement(vNode, container) {
  const currentNodeTree = currentNodeMap.get(container);
  const progressWorkInNodeTree = normalizeVNode(vNode);

  if (!currentNodeTree) {
    container.appendChild(createElement(progressWorkInNodeTree));
  } else {
    updateElement(container, progressWorkInNodeTree, currentNodeTree);
  }

  currentNodeMap.set(container, progressWorkInNodeTree);
  setupEventListeners(container);
}
  1. 이후 전달된 vNode를 normalize 하고 기존 Virtual DOM Tree가 있다면 updateElement를 (diff)
  2. 없다면 container에 appendChild를 통해 Tree대로 Element를 생성하여 붙여준다.
  3. 이후 새로 EventListeners를 Setup한다.
  4. 만약 기존 트리가 존재한다면 updateElement를 실행하여 기존 트리와 비교하며 변경 된 부분만 업데이트를 진행한다.

코드 품질

특히 만족스러운 구현

createElement, updateElement 에서 반복되지만 따로 만들어져있던 AttributeUpdate 함수를 하나로 합쳐 재사용해봤습니다.

export function setAttribute(target, key, value) {
  if (key.startsWith("on") && typeof value === "function") {
    const eventType = key.slice(2).toLowerCase();

    addEvent(target, eventType, value);
  } else if (key === "className") {
    target.setAttribute("class", value);
  } else if (key === "style") {
    Object.assign(target.style, value);
  } else if (value === true) {
    target[key] = true;
  } else if (value === false) {
    target[key] = false;
  } else {
    target.setAttribute(key, value);
  }
}

export function createElement(vNode) {

  if (vNode.props) {
    Object.entries(vNode.props).forEach(([key, value]) => {
      setAttribute($element, key, value);
    });
  }

  // ...etc
}
function updateAttributes(target, newProps, oldProps) {
  // newProps, oldProps가 없을경우 빈 객체로 초기화
  const safeNewProps = newProps || {};
  const safeOldProps = oldProps || {};

  // 기존 속성 중 새 속성에 없는 것들 제거
  Object.keys(safeOldProps).forEach((key) => {
    if (!(key in safeNewProps)) {
      removeAttribute(target, key, safeOldProps[key]);
    }
  });

  // 새 속성 추가/업데이트
  Object.keys(safeNewProps).forEach((key) => {
    if (key.startsWith("on")) {
      const eventType = key.slice(2).toLowerCase();
      if (safeOldProps[key]) {
        removeEvent(target, eventType, safeOldProps[key]);
      }

      if (safeNewProps[key]) {
        addEvent(target, eventType, safeNewProps[key]);
      }
    } else {
      setAttribute(target, key, safeNewProps[key]);
    }
  });
}

export function updateElement(parentElement, newNode, oldNode, index = 0) {

  updateAttributes(currentElement, newNode.props, oldNode.props);
  // ...etc
}

리팩토링이 필요한 부분

  • 가독성이 좋지않은 조건문들을 한눈에 들어오게 만들기
  • updateElement 시 조건별로 돌아가는 for문 개선

학습 효과 분석

  • 가장 큰 배움이 있었던 부분
    • Virtual DOM을 이용한 SPA의 전체적인 흐름과 구현방식
  • 추가 학습이 필요한 영역
    • 더 많은 문제들을 해결한 React의 Fiber 아키텍처

과제 피드백

  • 과제에서 좋았던 부분
    • 테스트를 보며 하나씩 코어 함수들을 만들어가며 어떤 부분이 부족한지 빠졌는지 적절한 고민과 확인이 가능해서 너무 좋았습니다!

리뷰 받고 싶은 내용

  • 현재 updateElement, createElement 모두 재귀를 이용해 구현했습니다! 선택해볼법한 더 좋은 알고리즘이 있을까요?
  • 조건문이 많아져서 가독성이 많이 떨어지게 되었는데 if, else if 를 줄이거나 혹은 다른 선택지가 있을까요? 그리고 if(조건) { ... 내용 return } 이런 여러개의 if return 형태와 if else if 중 뭐가 더 나은 코드라고 생각하시나요?
  • eventManager 쪽에서 "이벤트 위임" 은 작성했지만 while 루프를 돌며 부모 엘리먼트를 재할당하며 이벤트가 있는 요소까지 찾아가는 이벤트 버블링 형태로 처리를 했습니다. 정상적으로 동작은 하지만 이게 맞는건지 잘 모르겠습니다.. 매번 이벤트마다 while 루프가 실행된다고 생각하면 성능이 좋지 않은 것 같다는 생각이 듭니다!
  • updateElement에서 children을 처리할 때 조건별로 돌아가는 for문이 3개 있습니다. 아무래도 3개의 for문이 거부감이 좀 드는데 적절한 코드가 맞을까요?
  • 이벤트가 제거됐는지 확인하는 방법이 궁금합니다! 테스트는 통과했는데 현재 코드레벨에서 확인해볼 방법이 있을까요? (메모리가 누수되는지 체크)

과제 피드백

안녕하세요 윤우님! 2주차 과제 잘 진행해주셨네요 ㅎㅎ 너무 고생하셨습니다. 바벨 플러그인 문서도 직접 확인해보셨군요!!

현재 updateElement, createElement 모두 재귀를 이용해 구현했습니다! 선택해볼법한 더 좋은 알고리즘이 있을까요?

저도 재귀 말고는 떠오르는 방법이.. 딱히 없네요 ㅋㅋ; 다만 생각해볼 수 있는건, 변경에 대한 커맨드? 라고 해야하나, 그런걸 수집하는 방법이 있을 것 같아요.

insert, update, delete가 있고 이 명령어가 정확히 어떤 구간의 dom을 변화시키는지 정보를 수집할 수 있따면 재귀 방식이 아니라 특정 노드의 일부분만 수정하는 방식으로 코드를 작성할 수도 있을 것 같네요!

그럴라면 component의 정보와 component를 변경하는 코드를 연결시켜야 하는데 그게 사실 굉장히 어렵답니다... ㅎㅎ

조건문이 많아져서 가독성이 많이 떨어지게 되었는데 if, else if 를 줄이거나 혹은 다른 선택지가 있을까요?

새로운 함수로 만들어서 분리하는 방법이 있어요!

eventManager 쪽에서 "이벤트 위임" 은 작성했지만 while 루프를 돌며 부모 엘리먼트를 재할당하며 이벤트가 있는 요소까지 찾아가는 이벤트 버블링 형태로 처리를 했습니다. 정상적으로 동작은 하지만 이게 맞는건지 잘 모르겠습니다.. 매번 이벤트마다 while 루프가 실행된다고 생각하면 성능이 좋지 않은 것 같다는 생각이 듭니다!

다른 방법을 생각해보자면... 이벤트를 등록할 때, 이벤트 타겟의 부모들을 미리 만들어서 관리할 수 있지 않을까 싶어요 ㅎㅎ

const createParentList = e => { let parent = e.parentNode; const parentList = new Set() while(!parent){ parentList.add(parent); parent = parent.parentNode; } return parentList }

const parentMap = new WeakMap(); const addEvent = (el, type, callback) => { if (parentMap.has(el)) { parentMap.set(el, createParentList(el)) }

/// 다른 로직 실행 }

이렇게 렌더링 시점에 미리 만드는 방법도 있고 아니면 최초에 한 번 이벤트가 트리거될 때 부모 목록을 만들어서 재활용하는 방법도 있을 것 같아요 ㅎㅎ

updateElement에서 children을 처리할 때 조건별로 돌아가는 for문이 3개 있습니다. 아무래도 3개의 for문이 거부감이 좀 드는데 적절한 코드가 맞을까요?

일단 문제를 해결하기 위해선 어쩔 수 없겠죠..? 논리적으로 생각해보면, 이걸 for 하나로 만든다고 해도 그 내부에서 분기를 3번 해야 할 수 있어요. 결국 실행해야 하는 코드의 총량은 비슷하죠 ㅎㅎ

진짜로 변경된 attribute에 대한 정보를 렌더링시점에 전달하는 방법이 있다면 좋을텐데... 그런 시스템을 만드는게 더 복잡하고 어렵답니다 ㅎㅎ 맨 위의 답변과 겹쳐요

이벤트가 제거됐는지 확인하는 방법이 궁금합니다! 테스트는 통과했는데 현재 코드레벨에서 확인해볼 방법이 있을까요? (메모리가 누수되는지 체크)

글쎄요.. 코드레벨에서 확인할 수 있는 방법이 있을지.. 코드레벨에서 확인한다는건 결국 값을 조회한다는건데, 값을 조회하려고 시도한다는건 다시 메모리상에 무언가가 있는지 확인하는 과정이라서요.

덤프 떠서 확인해야 하지 않을까 싶어요..!