프레임워크 없이 DOM 조작해보기 - Diff 알고리즘을 곁들인..

2cham_ny·2025년 3월 11일
0

TIL

목록 보기
9/9

01 WHY

오늘은 '프레임워크 없는 프론트엔드 개발' 책 중 챕터 2를 읽으며 정리했던 부분을 step by step으로 정리해보려고 한다. 그냥 책으로 읽고 넘어가기엔 내용이 중요하다고 생각되어, 이번 기회에 꼼꼼하게 정리하면 좋을 것 같다는 생각이 들었다.


우선 프레임워크 없는 프론트엔드 개발 2장 파트는 `'렌더링'`이다. 적어도 오늘 해당 글을 유심히 읽어본다면, 아래와 같은 목표를 이룰 수 있을 것이라 예상된다.

[GOAL]

  • DOM 조작의 이해: 프레임워크 없이 효과적으로 DOM을 생성하고, 수정하고, 삭제할 수 있다.
  • 상태 기반의 렌더링과 성능 최적화를 이해할 수 있다.
  • 컴포넌트 기반 설계의 로직을 이해할 수 있다.

02 WHAT

해당 책 속에 사용된 예제는 TodoMVC 템플릿이다. 아래 링크를 타고 들어가면 여러 예제들과 라이브 데모를 함께 볼 수 있다.
https://todomvc.com/

그럼 이제 코드 하나하나씩 뜯어보며 DOM 조작 플로우를 따라가보자.
*오늘 뜯어볼 코드는 구성 요소 기반의 애플리케이션으로, 레지스트리를 갖는 렌더링 엔진이라는 것이 가장 메인 포인트이니, 이를 명심하자.

🗂️ index.html

우선 기본적인 HTML 구조를 정의하면 다음과 같다.

TodoMVC의 CSS를 링크하여 스타일을 적용하고, body 태그 내의 .todoapp section을 포함함으로써 해당 섹션이 메인 컨테이너임을 알 수 있다. 그리고 당연하겠지만, index.js를 script 태그를 통해 불러오게 된다.


그리고 코드를 자세히 보면, data-component 속성을 볼 수 있는데, 이는 뷰 함수의 필수 호출을 대체하는 역할을 한다.
구성 요소 라이브러리를 생성하기 위한 또 다른 필수 조건은 바로 registry이다.

  • registry: 애플리케이션에서 사용할 수 있는 모든 구성 요소의 인덱스
    => registry의 키는 data-component 속성값과 일치한다.
    => 이것이 구성요소 기반 렌더링 엔진의 핵심 메커니즘

index.js

우선 애플리케이션의 진입점인 index.js를 먼저 뜯어보자.

일단, registry에 사전에 만들어두었던 view들이 추가된다.

그럼 todosView, counterView, filterView는 어떤 코드들인지 알아보자.

view/todos.js

  • 하나의 투두 항목을 받아서 html 요소를 문자열로 반환하는 로직이다.
  • 반환되는 문자열은 각 투두 항목의 상태와 텍스트가 매핑돼서 반환된다.(newTodoList)

view/counter.js

  • 남은 투두리스트 항목을 카운팅하는 함수 로직이라고 보면 된다.
  • 보다시피 filter 함수를 통해 완료되지 않은 항목의 길이를 체크해서 반환하는 함수이다.

view/filters.js

  • li > a class에 selected 여부를 추가하는 로직이다.
  • 선택된 필터 강조를 표시한다.

*다음과 같은 형태로 레지스트리가 구성된다.

const registry = {
  'todos': todosView,
  'counter': counterView,
  'filters': filtersView
}

초기상태 설정이 완료되면 렌더링이 시작되고, getTodos.js를 통해 초기 투두리스트를 가져와 상태를 정의하게 된다. getTodos.js에서 어떤 값을 반환해서 상태를 구성하는지 계속 알아보자.

getTodos.js 안에서는?

  • 해당 코드는 Faker.js 라이브러리를 사용해 랜덤으로 더미 데이터를 생성하는 코드이다.
  • 이 모듈은 index.js에서 호출되어 초기 상태를 구성하게 된다.

getTodos.js를 뜯어봤으니 다시 index.js로 돌아가보자.


render() 안에선 어떻게 흘러갈까?

사진에..꽤 복잡한 선들이 있지만 하나씩 차근차근 정리해보겠다.
여기서부터 함께 정신차리며 따라가보자.
...사실 나한테 하는 말

우선 render안에서 사용된 requestAnimationFrame은 브라우저의 렌더링 주기에 맞춰 부드러운 애니메이션 효과를 제공할 수 있도록 하는 자바스크립트 내장 API라고 하며, RAF라고도 부른다.

아래는 MDN에서 가져온 정의이다.

window.requestAnimationFrame() 메서드는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트 바로 전에 브라우저가 애니메이션을 업데이트할 지정된 함수를 호출하도록 요청합니다. 이 메서드는 리페인트 이전에 호출할 인수로 콜백을 받습니다.

우리가 봐야할 핵심 포인트는,

  • 콜백 내에서 DOM 작업을 수행하면 더 효율적이라는 점
  • 메인 스레드를 차단하지 않으며 실행될 repaint가 이벤트 루프에서 스케줄링되기 직전에 실행된다는 점

이 두 가지라고 할 수 있다.

=> 즉, requestAnimationFrame을 사용해 브라우저의 다음 리페인트 전에 함수를 실행하도록 요청하는 함수이며, 보통 성능 최적화를 위해 사용되며 화면 갱신과 자연스럽게 동기화된다는 점!

const render = () => {
  window.requestAnimationFrame(() => {
    const main = document.querySelector(".todoapp");
    const newMain = registry.renderRoot(main, state);
    applyDiff(doucemnt.body, main, newMain);
  });
}

다시, 렌더함수를 해석해보면..

  • 여기서 main은, 클래스가 .todoapp인 요소를 찾아 저장된다.
  • newMain은, 간단히 말하자면 새로운 가상 DOM 트리를 생성하고 저장되는 아이인데...newMain이 만들어지는 과정을 자세히 알아보도록 하자.

newMain은 어떻게 만들어질까

우선 newMain이 선언된 부분을 다시 보자.

const newMain = registry.renderRoot(main, state);

코드에서도 알 수 있듯이, 앞서 index.js에서 추가해뒀던 registry를 활용해 renderRoot를 호출하면 된다.

const renderRoot = (root, state) => {
  const cloneComponent = (root) => {
    	return root.cloneNode(true);
  };
  
  return renderWrapper(cloneComponent)(root, state);
};
  • 위의 코드를 보면 알 수 있듯이, root에 main이 인자로 들어가게 되면서 main을 클론함으로써 renderWrapper을 호출하게 된다.
  • 이후 다시 renderWrapper 내에서 다음과 같이 진행된다.
const renderWrapper = (component) => {
  return (targetElement, state) => {
    // 인자 받아서 컴포넌트를 렌더링
    const element = component(targetElement, state); // 컴포넌트 렌더링
    // 결과로 나온 DOM 요소를 element에 저장

    const childComponents = element.querySelectorAll("[data-component]");
    // element 내부에서 data-component 속성을 가진 모든 자식 컴포넌트를 찾는다.
    // 이 속성은 자식 컴포넌트의 이름을 나타낸다.
    console.log("childComponents", childComponents);
    // 자식 컴포넌트들을 배열로 변환 -> 각각에 대해 반복 처리
    Array.from(childComponents).forEach((target) => {
      const name = target.dataset.component;

      console.log("name", name);
      // registry에서 해당 컴포넌트 렌더링 함수를 찾아 호출
      const child = registry[name];
      if (!child) {
        return;
      }

      // 결과로 나온 DOM 요소로 기존의 자식 컴포넌트를 대체
      target.replaceWith(child(target, state));
    });

    return element;
  };
};

applyDiff 호출

우선 아래 코드를 보기 전에 다음 개념을 알고가면 좋을 것 같다.

[가상 DOM]

  • 리액트에 의해 유명해진 가상 DOM ⇒ 선언적 렌더링 엔진의 성능을 개선시키는 방법
  • UI 표현은 메모리에 유지되고 “실제” DOM과 동기화된다.
  • 실제 DOM은 가능한 적은 작업들을 수행 ⇒ 이 과정을 조정(reconciliation)이라고 부른다.
  • 가상 DOM의 핵심은 diff 알고리즘
  • 실제 DOM을 문서에서 분리된 새로운 DOM element의 사본으로 바꾸는 가장 빠른 방법을 찾아낸다.


⇒ DOM의 변경 사항을 최소화하여 성능을 개선하는 데 도움을 준다.
⇒ 가상 DOM과 실제 DOM 사이의 차이점만을 적용함으로써, 불필요한 DOM 조작을 줄이고 애플리케이션의 반응 속도를 향상시킨다.
⇒ 렌더링 엔진을 최대한 간단하게 유지하는 걸 권장!


파일별 요약 정리

  • index.html: 애플리케이션의 기본 구조를 정의하는 HTML 파일. TodoMVC의 CSS를 활용하여 스타일링하고 있으며, index.js를 메인 스크립트로 로드.
  • index.js: 애플리케이션의 진입점으로, 초기 상태를 설정하고 렌더링을 트리거.
  • getTodos.js: 더미 데이터를 생성하여 초기 투두 리스트를 반환하는 모듈. 더미 데이터 랜덤 생성을 위해 faker.js 라이브러리 활용
  • view.js: 현재 상태를 기반으로 DOM 요소를 생성하고 업데이트하는 렌더링 로직을 담고 있다.
  • registry.js: 컴포넌트와 해당 렌더링 함수를 등록하고 관리하는 레지스트리 모듈
  • applyDiff.js: 기존 DOM과 새로운 DOM을 비교하여 변경된 부분만 실제 DOM에 적용하는 함수.


03 RETROSPECT

책으로 읽기만 하니까 처음에 이해가 잘 안되는 부분도 있었지만, 역시 직접 코드를 치면서 다이어그램으로 정리하니... 이해가 더 잘 되는 것 같다!

profile
😈 기록하며 성장하자!

0개의 댓글

관련 채용 정보