이번 주차는 raect의 다양한 hooks들을 만들고 사용해보며 동작원리를 체득해보는 시간이었다.

useRef, useMemo, useCallback, memo를 직접 구현해보면서 메모이제이션에 대해 더 깊게 생각해볼 수 있었고,

useContext로 랜더링을 최적화 하는 과정을 통해 보다 응집도 있는 코드 작성하는 법에 대해 배울수 있었다.

 

1. useRef

렌더링 사이에 값을 유지하는 ref 객체를 생성

export function useRef<T>(initialValue: T): { current: T } {
  const [ref] = useState({ current: initialValue });
  return ref;
}

 

처음엔 아래처럼 object에 값을 넣어도 동일하게 동작할 줄 알았다

const ref = { currnet: initialValue };

그런데 이렇게하면 렌더링이 될때마다 값이 초기화돼버렸다.

새로고침 하기 전까지는 값을 유지하는 useState 내부 저장소에 값을 저장했다

useState에 저장한 값을 변경하는 방법으로 리랜더링하지 않고 랜더링 사이에도 값을 유지하는 useRef를 구현했다. 

ref.current = "값 변경";

 

 


 

그런데 useRef를 구현하고보니 useState에 궁금증이 생겼다.

useState는 어디에 값을 저장하길래 object처럼 초기화되지 않는거지? 

 

React는 특정 공간에 state 값을 저장하고있다.

공식문서에서도 일반 변수는 랜더링하면 값 초기화되니까 렌더링에 사용할 변수는 useState를 사용하라고 강조하고 있다 (https://react.dev/learn/state-a-components-memory#)

 

클로저

React는 state의 값을 저장하기위해 클로저를 사용하고있다.

클로저를 통해 useState가 호출될 때마다 초기화되지 않고 기존 상태를 유지할 수 있게된다.

// 클로저를 통해 useState 구현

const createUseState = (initialValue = 0) => {
  let value = initialValue; // 클로저로 상태를 저장

  const state = () => value; // 상태를 반환하는 함수
  const setState = (newValue) => {
    value = newValue; // 상태를 업데이트
  };

  return [state, setState]; // 반환
};

const customUseState = createUseState(0); // 초기 상태를 0으로 설정
export default customUseState;
// 컴포넌트에서 사용

const [state, setState] = customUseState;
  const onClick = () => {
    setState(state() + 1);
    console.log(state());
  };

 

 

 

useState를 비슷하게 만들어보니 호출할 때마다 useState를 구분해줘야되는 문제점이 있었다.

리액트는 어떻게 해결했나 공식문서를 보니 역시 해답이 나와있었다.

  

useState는 각각의 useState 인덱스를 key 값으로 구분하고있다.

let componentHooks = [];
let currentHookIndex = 0;

function useState(initialState) {
  let pair = componentHooks[currentHookIndex];
  if (pair) {
	// 이미 호출된 useState 쌍이면 return 해서 호출 대기
	currentHookIndex++;
    return pair;
  }

  // 처음 호출된 useState 는 우리가 흔히 사용하는 [state, setState] 쌍을 만든다
  pair = [initialState, setState];

  function setState(nextState) {
    // setState가 변경되면 state 값을 변경하고 DOM을 update한다.
    pair[0] = nextState;
    updateDOM();
  }

  // 이후 렌더링을 위해 쌍을 저장하고 다른 useState 호출 대기를 위해 인덱스 +1
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

 

2. useMemo

react는 useCallback, useMemo, memo 등의 의존성을 판단할 때 얕은 비교를 한다.

만약 깊은 비교를 통해 위 hook들을 사용하고 싶다면 별도의 커스텀 훅을 만들어 진행해야한다.

export function useMemo<T>(
  factory: () => T,
  _deps: DependencyList,
  _equals = shallowEquals,
): T {
  const prevDeps = useRef<null | DependencyList>(null);
  const prevFactory = useRef<null | T>(null);

  const isEqual = shallowEquals(prevDeps.current, _deps); // 얕은 비교
  if (!isEqual) {
    prevDeps.current = _deps;
    // 참조값 _deps 가 변경되면 factory함수를 다시 생성한다.
    prevFactory.current = factory();
  }

  return prevFactory.current as T;
}

 

3. useCallback

usememo는 값을 useCallback은 함수를 메모이제이션하므로 아래와 같이 간단하게 구현 가능하다. 

export function useCallback<T extends Function>(
  factory: T,
  _deps: DependencyList,
) {
  const _factory = useMemo(() => factory, _deps);

  return _factory as T;
}

 

4. memo

memo도 useMemo, useCallback과 동일하게 얕은비교를 하는데 react element를 다시 생성해줘야한다.

이때 넘겨준 props와 children을 return 하는 createElement라는 react 메서드를 사용한다.

export function memo<P extends object>(
  Component: ComponentType<P>,
  _equals = shallowEquals,
) {
  return function MemoizedComponent(props: P) {
    const prevProps = useRef<P | null>(null);
    const prevComponent = useRef<ReactElement | null>(null);

    const isEqual = shallowEquals(prevProps.current, props);
    if (!isEqual) {
      prevProps.current = props;
      prevComponent.current = createElement(Component, props); element 재생성
    }

    return prevComponent.current;
  };
}

 

항해 2주차가 뚝딱 지나갔다.

이번 주차에서는 항해가 커리큘럼을 정말 잘 짰다고 느꼈다. 매주 과제를 통과해야되는데 단계적으로 작성해놓은 테스트코드를 통과하는 과정이 자연스럽게 성장할 수 있는 발판이 된다.

 

1주차에선 자바스크립트로 라우팅과 이벤트 주입, 컴포넌트 기반 설계등을 구현해보면서 기본을 다지는 시간이었고,

2주차는 자바스크립트로 가상돔을 만들고 React처럼 동작하게 하면서 React의 내부 동작 원리와 이벤트 관리에 대해 더 깊게 이해해보는 시간이었다.

 

처음엔 가상돔 만드는 주제 보면서 '잉? 그걸 어떻게 만들지?' 라는 생각이 들었다

 

Hello, Virtual DOM

 

가상돔은 거창한게 아니라 DOM을 복사한 객체 덩어리다.

객체 덩어리 A, B를 만들어서 변경된 부분만 다시 렌더링해줘서 성능을 올리는 컨셉이다.

 

DOM에 변경 사항이 있을 때마다 매번 DOM을 새롭게 그리는게 아닌

가상돔으로 비교해 변경된 부분을 모아서 DOM을 새롭게 그린다.

실제론 DOM tree를 만들어 tree를 비교하게 된다

 

처음엔 그냥 DOM끼리 비교해도 되지 않나 싶었는데 Real DOM도 객체 덩어리인건 맞지만 덩어리 규모차이가 상당하다.

아래 코드처럼 간단한 div를 만들어 콘솔에 찍어보면 매우 많은 객체들이 있는것을 확인할 수 있다.

const $divElement = document.createElement('div'); 
console.log($divElement);


때문에 DOM 자체를 만들어 비교하려면 그만큼 비용이 많이 들고 DOM 자체가 브라우저 기능이어서 런타임 환경에서야 비교가 가능했다. (가상돔은 순수한 객체)  


그리고 가상돔의 다른 이점은 개발자 경험 향상에 있는데

자바스크립트의 명령형 코드로 UI를 만드는 것이 아닌 jsx를 통해 선언적으로 UI를 만들 수 있게 해준다. 

// 선언형
<button onClick={console.log("버튼입니다")}>버튼</button>
const $btn = document.createElement("button");
$btn.textContent = "버튼";
$btn.addEventListener("click", () => console.log("버튼입니다"));

 

 

추가로 가상돔이 항상 성능 향상에 기여하지는 않는다. 애플리케이션 규모나 기능에 따라 Real DOM을 조작하는 방식이 성능이 좋을 수 있으니 'React가 정답이야' 라는 것보다는 조금 객관적으로 트레이드오프를 고려해 볼 필요가 있다.

 


자바스크립트로 가상돔을 만들어 React처럼 동작하게 하는 과정은 크게 5가지 과정을 거친다.

 

1. 가상DOM 생성

RealDOM을 가상돔으로 변환해보자.

// Real DOM
<div id="parent">
  <span className="child">Child</span>
</div>

 

위의 Real DOM을 가상돔으로 구성하면 아래와 같이된다.

function createVDOM(type, props, ...children) {
	return { type, props, children.flat() }
}

createVDOM('div', { id: 'parent' },
	createVDOM('span', { className: 'child' }, 'Child');
);

 

그렇게 변환된 객체 덩어리는 아래와 같이된다.

// 가상돔
{
  type: "div",
  props: { id: "parent" },
  children: [
    {
      type: "span",
      props: { className: "child" },
      children: ["Child"],
    },
  ],
},

 

2. 가상DOM 정규화

 

정규화 함수를 통해 일관된 형식의 가상DOM을 반환하도록 한다


function normalizeVDOM(vDom) {
	// vDom이 null, undefined 또는 boolean 타입일 경우 빈 문자열을 반환합니다.
    if (vDom === null || vDom === undefined || typeof vDom === "boolean") {
        return "";
    }
    
    // vDom 문자열 또는 숫자일 경우 문자열로 변환하여 반환
    if (typeof vDom === "string" || typeof vDom === "number") {
        return String(vDom);
    
    
    // VDom이 함수일 경우 해당 함수를 호출해 반환된 결과를 재귀적으로 정규화 
    if (type vDom.type === "function") {
    	const props = { ...vDom.props, children: vDom:children  }
        return normalizeVDOM(vDom.type(props))
    }
    
    // 그 외의 경우 vDom의 자식 요소들을 재귀적으로 정규화
    const normalizedChildren = vDom.children.map((child) => normalizeVDOM(child))
    
    return { ...vDom, children: normalizedChildren }
}

 

아래 컴포넌트화 시킨 함수를 정규화해보자

  const TestComponent = () => (
    <ul>
      <li id="item-1">Item 1</li>
      <li id="item-2">Item 2</li>
      <li id="item-3" className="last-item">Item 3</li>
    </ul>
  );
  
  const normalized = normalizeVNode(<TestComponent />);

 

이런 결과가 나온다

{
  type: "ul",
  props: {
  },
  children: [
    {
      type: "li",
      props: {
        id: "item-1",
        className: "list-item ",
      },
      children: [
        "- ",
        "Item 1",
      ],
    },
    {
      type: "li",
      props: {
        id: "item-2",
        className: "list-item ",
      },
      children: [
        "- ",
        "Item 2",
      ],
    },
    {
      type: "li",
      props: {
        id: "item-3",
        className: "list-item last-item",
      },
      children: [
        "- ",
        "Item 3",
      ],
    },
  ],
}

 

3. 가상DOM을 Real DOM으로 변환

function createDOM(vDom) {
	// vDom이 null, undefined, boolean일 경우, 빈 텍스트 노드 생성하여 반환
    // vDom이 문자열이나 숫자면 텍스트 노드 생성하여 반환
    // vDom이 배열이면 DocumentFragment 생성 후 각 자식에 대해 createDOM 재귀 호출하여 추가
    
    // 그 외의 경우 (vDom이 컴포넌트인 경우) DOM 생성
    const { type, props, children } = vDom;
    const $el = document.createElement(type);
    // $el에 props 주입
    updateAttributes($el, props);
    
    // children 있는 경우 (부모와 동일하게 DOM생성, props 주입)
    children.forEach((child) => {
        const $child = createElement(child);
        updateAttributes($child, child?.props);
        $el.appendChild($child);
	});
    
    return $el;
}

// DOM에 props 주입
function updateAttributes($el, props) {
	Object.entries(props).forEach(([key, value] => {
    	// 이벤트인 경우 jsx -> js 로 변경 (ex. onClick -> click)
		if (key.startsWith("on") && typeof value === "function") {
			const event = key.toLowerCase().slice(2);
            // 이벤트는 별도로 저장해놨다가 이벤트 위임방식으로 등록
            addEvent($el, event, value);
		} else {
			$el.setAttribute();
		}
	});
}

 

4. 이벤트 주입

createDOM 함수가 실행될 때 이벤트인 경우에 별도로 저장해놨다가 DOM이 생성된 후에  저장해둔 이벤트를

부모 DOM에 등록한다 (이벤트 위임 방식으로 주입)

 

const eventStore = new Map();

function addEvent(element, eventType, handler) {
  let depthMap = eventStore.get(eventType);
  if (!depthMap) {
    depthMap = new Map();
    eventStore.set(eventType, depthMap);
  }
  depthMap.set(element, handler);
}
function setupEventListeners(root) {
  if (eventStore.size < 1) return;

  Array.from(eventStore.entries()).forEach(([eventType]) => {
    const depthMap = eventStore.get(eventType);
    Array.from(depthMap.entries()).forEach(([element, handler]) => {
      if (rootStore.get(element)?.has(handler)) {
        return;
      }
      const eventHandler = (e) => {
        if (element === e.target) {
          handler(e);
        }
      };
      root.addEventListener(eventType, eventHandler);
    });
  });
}

 

5. 렌더링

그동안 작성한 코드를 이용해 SPA 처럼 렌더링시켜준다.

function renderDOM(vDom, container) {.
  const normalizedvDom = normalizeVNode(vDom);
  const $el = createElement(normalizedvDom);
  
  // 최초 렌더링시에는 createElement로 DOM을 생성
  if (!container.__oldVDom) {
    container.appendChild($el); // 최초 렌더링 시 DOM 생성
    
  // 이후에는 변경된 부분만 기존 DOM을 업데이트한다.
  } else {
    container.replaceChild($el); // 대충 퉁쳤는데 변경된 가상돔을 check해서 update하는 부분 필요
  }

  container.__oldVDom = normalizedVDom;

  // 렌더링이 완료되면 container에 이벤트를 등록한다
  setupEventListeners(container);
}

 

 

 

FE 개발자로 일한지 2년정도 되는 요즈음 문득 이런 생각이 들었다

"나 잘하고 있는거 맞나?"

 

일은 하고 있는데 동료 개발자도 거의 없다시피 작은회사에서 쌓은 시간이라 객관적으로 나를 측정하고 싶었다

그렇게 항해플러스를 시작했다

 

2주차가 끝나는 지금 항해플러스에 합류한건 참 잘한 선택이었다. 자바스크립트의 본질에 대해 이해하고 배우는 좋은 시간이고 비슷한 연차에서의 나의 위치를 정확히 알 수 있었다. 비슷한 고민을 하는 개발자들 사이에서 과제 뿐 아니라 생각을 교류하는 좋은 환경과 커리큘럼이라고 생각한다. 이런 것들을 나누면서 자연스레 좋은 개발자, 함께 일하고 싶은 개발자가 뭔지 알듯하다.

 

갓 1년차가 되었을 때부터 항해플러스를 신청할까 말까 고민을 많이했다. 그러다 끝내 신청하지 못했던건 가격적인 측면도 있었지만 '코어 자바스크립트에 대한 것, 아키텍쳐도, TDD도 지금 사용을 안하는데 궂이 해야되나?' 싶었다  

그것도 지금은 잘못 생각했다고 느끼는게 코어 자바스크립트도 아키텍쳐도 내가 모르니까 사용을 안했구나 싶다.

물론, React 환경에서 지금 배우고있는 DOM 조작과 class문법 사용 등은 지양되고 있지만, React 라는 프레임워크 기저에는 이런 것들이 다 녹아져있었고 알면 기술이지만 모르면 마법이라는 생활코딩님의 말이 종종 생각나는 시간을 보내고있다.

 

그래서 요번주에 뭘했냐면 

 

 

자바스크립트로 React만들기

 

자바스크립트로 React처럼 화면전환이 가능하게 route를 구현하고 가상돔과 diff 알고리즘을 만들어 변경되는 node만 업데이트해주는게 기본 골자다.

 

위의 기능을 위해서 아래의 구현이 필요하다

 

1. 라우팅

자바스크립트에서 기본으로 제공하는 history api를 이용해 페이지를 새로고침 하지 않고 URL 을 변경한다.

 

여러 메서드들이 있는데 하나씩 알아보자.

  • pushState

기본 골자는 이렇다. history.pushState(state, title, url);

State : 브라우저 이동  넘겨줄 데이터 (popstate 에서 받아서 원하는 처리를 해줄  있음)

Title : 변경할 브라우저 제목 (변경 원치 않으면 null)

Url : 변경할 주소

 

아래 예시를 보자.

// html
<nav>
  <div id="navi">/</div>
  <div id="navi">/about</a>
</nav>
document.querySelector("nav").addEventListener("click", (e) => {
  if (e.target.id === "navi") {
    history.pushState(null, "", e.target.innerText);
  }
});

 

위와 같이 사용하면 새로고침 없이 "/" 와 "/about" url로 변경할 수 있다.

 

이때 각 링크에 따라 html만 변경해주면 SPA처럼 동작한다.

아래 예시를 보자

// main.js

class Router {
  constructor() {
    this.routes = {};
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  navigateTo(path) {
    history.pushState(null, "", path);
    this.handleRoute(path);
  }


  handleRoute(path) {
    const handler = this.routes[path];
    if (handler) {
      handler();
    } else {
      console.log("404 Not Found");
    }
  }
}

// router 등록
const router = new Router();
router.addRoute("/", () => renderPage(mainPage()));
router.addRoute("/about", () => renderPage(aboutPage()));

// 페이지 이동 시 router.navigateTo() 호출
document.querySelector("nav").addEventListener("click", (e) => {
  if (e.target.id === "navi") {
    router.navigateTo(e.target.innerText);
  }
});

// URL에 따라 html을 갈아준다
function renderPage(page) {
  document.getElementById("app").innerHTML = page;
}

function mainPage() {
  return `
    <h1>메인 페이지입니다</h1>
`;
}

function aboutPage() {
  return `
    <h1>About 페이지입니다</h1>
`;
}

 

요약하면 아래 순서로 진행된다.

1. addRoute 함수로 사용할 route를 등록하고 ("/", "/about")
2. navigateTo함수로 새로고침 없이 url을 변경하고 (history.pushState)

3. 2를통해 url 변경이 일어나면 등록한 route의 콜백함수를 실행해준다

4. 이때 콜백함수는 html을 render 해주는 함수

 

 

여기서 아래 코드를 추가하면 뒤로가기 앞으로가기를 통해 변경되는 url을 대응할 수 있다

class Router {
  constructor() {
    this.routes = {};
    
    // (New!)
    window.addEventListener("popstate", this.handlePopState.bind(this));
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  navigateTo(path) {
    history.pushState(null, "", path);
    this.handleRoute(path);
  }

  // (New!)
  handlePopState() {
    this.handleRoute(window.location.pathname);
  }

  handleRoute(path) {
    const handler = this.routes[path];
    if (handler) {
      handler();
    } else {
      console.log("404 Not Found");
    }
  }
}

 

위의 과정을 통해 SPA 처럼 페이지 이동이되게 했는데 이벤트는 어떻게 관리하지?

 

2. 이벤트 주입 (아키텍쳐를 곁들인)

element가 존재해야(이하 $el) $el에 이벤트를 주입할 수 있다.

그러면 html을 먼저 파싱하고 그 후에 이벤트를 주입해야 되는건데 요 과정에서 아키텍쳐에 대한 중요성을 배웠다

그동안은 react로만 구현해서 몰랐는데 자바스크립트로 구현하자니 코드가 많아지고 관리하기가 힘들었다

(선언형과 명령형의 차이 +exp) 

 

...컴포넌트 기반 구조 설계를 위한 아키텍쳐 구성은 곧 추가하겠습니다!🙌

 

 

 

 

 

+ Recent posts