항해 플러스 프론트엔드 2주차 회고

항해 플러스 프론트엔드 5기 2주차 그 과정에 대한 회고글입니다.

16
단어: 1,577
게시글 썸네일
정보

항해 플러스 프론트엔드 5기 2주차 그 과정에 대한 회고글입니다.
( 바닐라 자바스크립트로 구현된 SPA에 가상돔 적용하기 )

🧿 프라그마

정보

프라그마(pragma)는 프로그래밍 언어에서 컴파일러나 인터프리터에게 특별한 지시를 내리는 메타데이터입니다. 이번 과제에서는 jsx를 변환해주는거라서 트랜스파일러에서 지시를 내리는 목적으로 사용한 것 같아요

이번 과제를 진행하려고 코드를 봤는데 대부분의 파일 최상단에 /** @jsx createVNode */ 이런 구문이 있었어요.
처음 느낌으로는 아마 jsx를 변환할때 createVNode()를 사용하는게 아닐까 생각만하고 과제부터 진행하려고 넘어갔어요.

과제를 어느정도 진행하고 정리하면서 해당 부분에 대해서 알아봤는데 pragma라고 부르고 vite에서 설정해주면 jsx를 변환할때 사용할 함수를 트랜스파일러에게 알려주는 역할을 해요.

유용한 팁

옛날에 공부용으로 babel을 이용해서 jsx를 직접 변환해본적이 있는데 그때 변환했을때 /*#__PURE__*/ 이런 주석이 붙어있었어요.
그 당시에 찾아봤을때 순수 컴포넌트임을 컴파일러한테 알려주는 역할이라고 이해했었는데 이런것을 부르는 명칭이 있다는걸 이제 알았네요..!

🔮 기본 테스트 코드

아래는 설명을 위한 예시 코드입니다.

const UnorderedList = ({ children, ...props }) => ( <ul {...props}>{children}</ul> ); const ListItem = ({ children, className, ...props }) => ( <li {...props} className={`list-item ${className ?? ""}`}> - {children} </li> ); const TestComponent = () => ( <UnorderedList> <ListItem id="item-1">Item 1</ListItem> <ListItem id="item-2">Item 2</ListItem> <ListItem id="item-3" className="last-item"> Item 3 </ListItem> </UnorderedList> );

0️⃣ createVNode

정보

자세한 코드는 GitHub을 참고해주세요

jsx를 받아서 특정 형태의 객체로 반환하는 함수에요.
( type, props, children을 가진 객체를 반환하는 함수 )

유용한 팁

여기서 typeHTMLTagName이거나 컴포넌트 함수 자체일 수 있어요.

jsx에서 falsy한 값이나 boolean은 화면에 렌더링하지 않아서 해당 부분은 제외하고 반환하게 만들어줘야해요.
그리고 의문이 하나 있는데 테스트 코드에 있어서 구현은 했지만 children을 평탄화하는 이유를 모르겠어요.

const vNode = createVNode(<TestComponent />); { "props": null, "children": [], // TestComponent "type": () => { // ... } }

1️⃣ normalizeVNode

정보

자세한 코드는 GitHub을 참고해주세요

createVNode()를 통해서 jsx가 변환된 vNode를 정규화하는 함수에요.
정규화란 jsx를 통해 입력된 값들을 정해진 형식의 객체로 변환하는 과정을 말해요.
vNode의 타입에 따라서 처리하는 방식이 달라지고, 재귀적으로 돌아서 모든 children을 정규화해요.

const nomarlizedVNode = normalizeVNode(vNode); // 정규화 결과 ( nomarlizedVNode ) // { // type: "ul", // props: {}, // children: [ // { // type: "li", // children: ["- ", "Item 1"], // props: { // className: "list-item ", // id: "item-1", // }, // }, // { // type: "li", // children: ["- ", "Item 2"], // props: { // className: "list-item ", // id: "item-2", // }, // }, // { // type: "li", // children: ["- ", "Item 3"], // props: { // className: "list-item last-item", // id: "item-3", // }, // }, // ], // };

2️⃣ createElement

정보

자세한 코드는 GitHub을 참고해주세요

normalizeVNode로 정규화된 vNode를 실제 노드로 변환하는 함수에요.
여기도 마찬가지로 type마다 처리가 달라지고, 재귀적으로 돌아서 모든 children을 변환해요.
( 실제 노드로 변환해야하기 때문에 document.createElement()setAttribute()같은 메서드를 사용해야해요. )

const $el = createElement(nomarlizedVNode); // <ul> // <li id="item-1" class="list-item "> // - Item 1 // </li> // <li id="item-2" class="list-item "> // - Item 2 // </li> // <li id="item-3" class="list-item last-item"> // - Item 3 // </li> // </ul>

그리고 실제 노드로 변환하는 과정에서 이벤트 리스너를 특정 노드에 등록하지않고, 이벤트 매니저를 통해서 최상위에서 관리하는 방식으로 구현해야해서 아래처럼 처리했어요.

Object.entries(vNode.props).forEach(([key, value]) => { if (key === "className") { $el.setAttribute("class", value); } // 이벤트 속성 처리 else if (key.startsWith("on")) { - $el.addEventListener(key.replace("on", "").toLowerCase(), value); + addEvent($el, key.replace("on", "").toLowerCase(), value); } // 일반 속성 처리 else { $el.setAttribute(key, value); } });

3️⃣ eventManager

정보

자세한 코드는 GitHub을 참고해주세요

이벤트를 통합적으로 관리해주는 함수에요.
일반적으로 버튼에 이벤트를 등록한다면 button.addEventListener("click", () => {}) 이런식으로 등록하죠.
근데 이벤트 매니저는 이벤트들 모두 모아서 최상위 root 엘리먼트에 등록하고 버블링을 이용해서 특정 엘리먼트의 이벤트를 실행해요.
따라서 특정 페이지에 onClick 이벤트가 100개 있더라도 하나의 eventListener로 관리할 수 있게 돼요.

제가 사용한 방식은 아마 정석은 아닌 것 같아요.
일반적으로 이벤트가 등록/삭제될때마다 루트에 이벤트 리스너가 등록/삭제되어야하는데 저는 시작할때 모든 이벤트 리스너를 등록해두고 그 이벤트 리스트너가 참조하는 값에 이벤트 함수를 계속 추가하는 방식으로 구현했어요.
따라서 setupEventListeners()라는 함수를 최초 1회만 실행하도록 처리했어요.

const event = { root: null, map: new Map(), // ["click", "submit", "mouseover", "focus", "keydown"] types: new Set(EVENT_TYPES), }; /** * @param {HTMLElement} root */ export function setupEventListeners(root) { const currentRoot = event.root; if (currentRoot !== root) { event.root = root; } event.types.forEach((eventType) => { root.addEventListener(eventType, (e) => { event.map.get(e.target)?.get(eventType)?.(e); }); }); }

4️⃣ renderElement

정보

자세한 코드는 GitHub을 참고해주세요

이전에 작성했던 함수들을 통해 가상 노드를 실제 노드로 만들고 루트 엘리먼트에 추가하는 함수에요.
최초 렌더링 이후 업데이트하는 로직은 미완성이라 변경되면 모든 노드를 다시 렌더링하도록 임시로 처리해뒀어요.

  • 실행 순서
  1. createVNode(): jsx를 받아서 vNode로 변환 ( 프라그마(?)를 통해 이미 처리된 값을 받음 )
  2. normalizeVNode(): vNode를 정규화
  3. createElement(): 정규화된 vNode를 실제 노드로 변환
  4. renderElement(): 실제 노드를 HTML에 렌더링
  5. setupEventListeners(): 최상위 엘리먼트에 이벤트 리스너 등록
import { setupEventListeners } from "./eventManager"; import { createElement } from "./createElement"; import { normalizeVNode } from "./normalizeVNode"; import { updateElement } from "./updateElement"; const elementMap = new Map(); /** * @param {VNode} vNode * @param {HTMLElement} container */ export function renderElement(vNode, container) { // 정규화 const normalizedVNode = normalizeVNode(vNode); const isFirstRender = container.children.length === 0; // 최초 렌더링시에는 createElement로 DOM을 생성하고 if (isFirstRender) { const newElement = createElement(normalizedVNode); container.appendChild(newElement); setupEventListeners(container); } // 이후에는 updateElement로 기존 DOM을 업데이트한다. else { const newElement = createElement(normalizedVNode); container.appendChild(newElement); } elementMap.set("vNode", normalizedVNode); }

🎁 심화 테스트 코드

0️⃣ updateElement

정보

자세한 코드는 GitHub을 참고해주세요

이 함수는 리랜더링 시 변환된 부분만 업데이트하는 함수에요.
만약 아래처럼 엘리먼트가 바뀌는 경우라면 <ul>이나 Item 3는 변경사항이 없고 Item 1Item 2만 변경돼요.
그리고 Item 2처럼 속성이 변경되는 경우는 속성만 변경되고 엘리먼트 자체는 변경되지 않아요.

const UnorderedList = ({ children, ...props }) => ( <ul {...props}>{children}</ul> ); const ListItem = ({ children, className, ...props }) => ( <li {...props} className={`list-item ${className ?? ""}`}> - {children} </li> ); const TestComponent = () => ( <UnorderedList> - <ListItem id="item-1">Item 1</ListItem> + <ListItem id="item-1">Item 11</ListItem> - <ListItem id="item-2" className="bg-indigo-500">Item 2</ListItem> + <ListItem id="item-2" className="bg-red-500">Item 2</ListItem> <ListItem id="item-3" className="last-item"> Item 3 </ListItem> </UnorderedList> );

updateElement

1️⃣ renderElement 수정

정보

자세한 코드는 GitHub을 참고해주세요

기본 과제에서 작성한 부분에서 초기 렌더링 여부 판단하는 값과 이전 렌더링에 사용한 가상노드를 저장하는 로직을 추가했어요.
그리고 리랜더링 시 변환된 부분만 업데이트하는 updateElement()가 추가했어요.

// ... 생략 import { updateElement } from "./updateElement"; /** * @param {VNode} vNode * @param {HTMLElement} container */ export function renderElement(vNode, container) { // 정규화 const normalizedVNode = normalizeVNode(vNode); const isFirstRender = container.children.length === 0; // 최초 렌더링시에는 createElement로 DOM을 생성하고 if (isFirstRender) { const newElement = createElement(normalizedVNode); container.appendChild(newElement); setupEventListeners(container); } // 이후에는 updateElement로 기존 DOM을 업데이트한다. else { - const newElement = createElement(normalizedVNode); - container.appendChild(newElement); + const oldVNode = elementMap.get("vNode"); + updateElement(container, normalizedVNode, oldVNode); } elementMap.set("vNode", normalizedVNode); }

🔥 그 이외의 코드 파악

0️⃣ createObserver

export const createObserver = () => { const listeners = new Set(); const subscribe = (fn) => listeners.add(fn); const notify = () => listeners.forEach((listener) => listener()); return { subscribe, notify }; };

이 함수는 옵저버 패턴을 구현한 함수에요.

  • listeners: 실행할 함수들 보관
  • subscribe: 요청이 오면 실행할 함수 등록
  • notify: 등록된 함수들 실행하도록 요청

해당 방법을 이용해서 url 변경이나 store(변수) 변경을 감지해서 리랜더링을 실행해요.

1️⃣ createStore

import { createObserver } from "./createObserver.js"; export const createStore = (initialState, initialActions) => { const { subscribe, notify } = createObserver(); let state = { ...initialState }; const setState = (newState) => { state = { ...state, ...newState }; notify(); }; const getState = () => ({ ...state }); const actions = Object.fromEntries( Object.entries(initialActions).map(([key, value]) => [ key, (...args) => setState(value(getState(), ...args)), ]), ); return { getState, setState, subscribe, actions }; };

이 함수는 store를 생성하는 함수에요.
리액트에서 useState()를 전역적으로 관리하는 느낌으로 이해하면 될 것 같아요.
여기서 핵심은 setState()를 실행하면, 즉 상태가 변경되면 notify()를 실행하는 것이에요.
그리고 subscribe()를 내보내고 있어서 해당 함수를 통해 등록된 함수가 실행될텐데 아마 랜더링을 처리하는 함수를 등록할거에요.

2️⃣ createRouter

import { createObserver } from "./createObserver"; import { BASE_URL } from "../constants"; export const createRouter = (routes) => { const { subscribe, notify } = createObserver(); const getPath = () => { return window.location.pathname.replace(BASE_URL, "") || "/"; }; const getTarget = () => routes[getPath()]; const push = (path) => { window.history.pushState(null, null, BASE_URL + path); notify(); }; window.addEventListener("popstate", () => notify()); return { get path() { return getPath(); }, push, subscribe, getTarget, }; };

라우터를 만드는 함수인데 이 함수도 push()popState가 발생했을때 notify()를 실행해서 리랜더링을 일으켜요.
따라서 페이지 이동할때 렌더링을 관리하지 않아도 자동으로 실행되게 돼요.

3️⃣ main

router.set( createRouter({ "/": HomePage, // ... 생략 }), ); function main() { router.get().subscribe(render); globalStore.subscribe(render); render(); } main();

핵심은 routerstore 내부에서 createObserver를 사용했고 subscribe()를 내보내고 있어요.
그리고 그 subscribe()render()를 등록해서 store.setState()router.push()를 실행할때 render()가 실행되게 돼요.
화면을 다시 그린다는 의미이고 render() 내부에서 updateElement()를 통해 변경된 부분만 업데이트하는 방식으로 구현되어있어요.

🕹️ 정리

전체적인 흐름을 마지막으로 정리해볼게요.

  1. createStore()를 통해서 전역 store를 생성
  2. createRouter()를 통해서 전역 router를 생성
  3. main()에서 routerstorerender() 함수를 등록하고 최초 렌더링 실행
  4. render() 함수 내부에서 아래와 같이 동작
    1. createVNode()를 통해서 jsx를 받아서 vNode로 변환
    2. normalizeVNode()를 통해서 vNode를 정규화
    3. createElement()를 통해서 vNode를 실제 노드로 변환
    4. renderElement()를 통해서 실제 노드를 HTML에 렌더링
    5. updateElement()를 통해서 변경된 부분만 업데이트
    6. setupEventListeners()를 통해서 최상위 엘리먼트에 이벤트 리스너 등록하고 버블링을 통해 처리
  5. 페이지가 변경되거나 store의 상태가 변경되면 render()가 실행
연관된 포스트