2. Bingact 고도화

Circlewee·2022년 6월 12일
1

WYLSBingsu

목록 보기
3/8
post-thumbnail

0. 지난번에 이어서

지난번에 이어서 해결해야할 문제들이 아직 많다.

  1. 함수형 컴포넌트가 아직 완벽히 구현되지 않아 props의 전달이나 html의 custom element로 사용이 불가능하다.
<Component props={props} /> // 이것처럼 사용하는 것이 우리의 목표지만
{Component(props)}	// 현재는 이렇게 밖에 사용할 수 없다.
  1. 가상 돔(Virtual DOM)이 미구현이어서 변경사항만 재 반영해 수정할 수 없다.
    이말인 즉슨 사용자와 상호작용이 불가능하고 단방향의 보여주는 서비스만 가능하다.

  2. 컴포넌트 라이브러리의 꽃인 상태관리가 아직 구현되지 않았다.
    useState()를 먼저 구현하고 나면 useEffect()까지도 구현해볼 생각이다.

해야할 것들이 정해져 있기 때문에 다시 Reference들의 힘을 빌려 공부해보려 한다.

1. 재귀적 호출

먼저 작성한 코드중 render를 담당하는 부분에 재귀호출하는 부분이 있다.

function render(element, container) {
  ...
  element.props.children.forEach((child) => render(child, dom)); // <-- 여기!
  container.appendChild(dom);
}

element의 props로 자식(children)이 존재한다면 자식을 createElement()로 렌더링하기 위한 부분인데, 만약 자식의 개수가 성능에 지장을 줄만큼 많거나 중간에 필요한 여러 연산을 수행해야한다면 Bingact는 재귀호출이 끝날때 까지 앞선 동작들을 수행할 수 없다.

이 말인 즉슨 컴포넌트들의 rendering이 끝나기 전까진 할 수 있는 것이 없는 것이다. 이는 사용자 경험적인 측면에서 안 좋은 경우에 해당된다고 할 수 있을 것 같다.

// poly-fill
window.requestIdleCallback =
    window.requestIdleCallback ||
    function(cb) {
        var start = Date.now();
        return setTimeout(function() {
            cb({
                didTimeout: false,
                timeRemaining: function() {
                    return Math.max(0, 50 - (Date.now() - start));
                },
            });
        }, 1);
    };
window.cancelIdleCallback =
    window.cancelIdleCallback ||
    function(id) {
        clearTimeout(id);
    };

...
// 수행해야할 작업
let nextUnitOfWork = null;

function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1; // 작업을 수행하기 위한 충분한 시간이 존재한다면 false, 아니면 true
  }
  window.requestIdleCallback(workLoop); // IdleCallback을 재등록한다.
}

// 초기 IdleCallback 등록
window.requestIdleCallback(workLoop);

// TODO
function performUnitOfWork(nextUnitOfWork) {}

requestIdleCallback()이라는 Web API를 사용한다. 이것은 Event loop에서 동작한다. 여기서 메인 스레드가 대기 상태가 되면 우리가 지정한 workLoop 함수를 실행하게 할 것이다.
workLoop 함수에 대해서 간략히 정리하자면

  1. callback 함수는 IdleDeadline객체를 인자로 전달 받는다. 이 객체를 통해 timeout이 만료되어 우리가 준 callback이 실행되었는지 확인할 수 있다.
  2. deadline.timeRemaining()은 남은 유휴 시간을 확인해 유휴 시간이 존재하면 조건문에서 false를 반환해 다음 작업을 수행할 수 있게 해준다.

이제 해당 작업을 수행하고 다음 작업 단위를 반환하는 performUnitOfWork()를 구현해야 한다.

다만, requestIdleCallback()은 IE, Safari에서 사용할 수 없는 기능이기 때문에 poly-fill할 수 있는 코드를 추가해 주었다.

2. fiber tree 구현

React에서는 재 렌더링이 발생할 때 변화된 부분을 감지하여 변화된 부분만 렌더링한다.
이런 방식으로 동작하기위해 이전 DOM tree와 변경된 DOM tree의 차이를 알아내야할 알고리즘이 필요했고 이를 Reconciliation(재조정)이라 명명했다.

그리고 fiber라는 개념이 등장한다. 전에 createElement()를 구현했을 때 방식인

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === 'object' ? child : createTextElement(child)
      ),
    },
  };
}

이 코드에서 type, props의 key를 가진 하나의 object를 fiber라고 할 수 있다. 만약 자식이 존재한다면 부모 fiber, 자식 fiber라고도 부를 수 있는 것이다.

하나의 fiber에서 수행하는 작업은 다음과 같다.

  1. DOM에 엘리먼트를 추가하기
  2. 각 엘리먼트의 children에 대해 fiber를 생성하기
  3. 다음 작업 단위를 선택하기
    자식 fiber가 존재하면 이것을 먼저 선택하고 존재하지 않으면 형제 fiber, 이것도 없으면 부모의 형제 fiber를 선택한다.
    더 이상 존재하지 않아 root에 도달하면 이는 렌더링이 종료되었음을 의미한다.

사전 지식을 정리해봤으니 이제 performUnitOfWork()를 구현해 보자.

2.1 render()의 로직을 createDom()으로 분리

function createDom(fiber) {
  const dom =
    fiber.type === 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(fiber.type);

  const isProperty = (key) => key !== 'children';

  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach((name) => {
      dom[name] = fiber.props[name];
    });

  return dom;
}

// 초기 작업 단위를 설정, 아직 type과 같은 속성은 추가되지 않았다.
function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  };
}

이 코드는 이것을 의미한다.

  1. 기존 render()가 담당하는 logic을 createDom()로 분리하고 인자명을 fiber로 바꿈.
  2. render()에서는 다음 작업의 단위를 설정해주는 역할을 하고 이전에 작성한 window.requestIdleCallback(workLoop); 코드가 실행되면서 여기서 설정한 작업단위로 렌더링을 시작하게 된다.

render()에 초기 작업 단위를 설정하는 로직을 맡기기 위해(개발자가 직접 사용하는 함수) 기존 로직 일부를 createDom()으로 나누었다. 아까 설명한 fiber단위로 인자명을 바꾸고 fiber object 정보로 dom element를 생성해 반환한다.

2.2 performUnitOfWork()

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom);
  }

  const elements = fiber.props.children;
  let index = 0;
  let prevSibling = null;
  
  while (index < elements.length) {
    const element = elements[index]
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
  }
}

간략하게 설명하자면 인자로 주어지는 fiber는 함수를 호출할 때 nextUnitOfWork를 인수로 넣게 된다. 따라서 다음으로 작업해야할 작업 단위를 받는 것이고 이것은 fiber object이기 때문에 fiber라고 명명하였다.

  1. 조건문에서는 DOM node를 생성하고 이를 추가한다.
  2. 아래 변수들과 반복문은 children들의 fiber object를 생성해서 자식 혹은 형제 노드로 추가하는 과정이다.
  ...
  if (index === 0) {
    fiber.child = newFiber;
  } else {
    prevSibling.sibling = newFiber;
  }

  prevSibling = newFiber;
  index++;
  1. 그리고 만들어진 fiber들을 자식, 형제의 경우로 나뉘어 부모 fiber에 등록한다.
  ...
  // 1
  if (fiber.child) {
    return fiber.child;
  }

  let nextFiber = fiber;

  while (nextFiber) {
    // 2
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    // 3
    nextFiber = nextFiber.parent;
  }
  1. 마지막으로 작업은 탐색입니다. 탐색은 위에서 언급한대로 자식, 형제, 부모의 형제 순으로 이루어지게됩니다. (각각 1, 2, 3의 경우)

    자식 fiber가 존재한다면 자식을 그 다음으로 형제를 이도 없다면 부모의 형제를 반환해 다음 작업단위로 설정합니다.

2.3 전체 코드

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  const elements = fiber.props.children; // 자식 fiber들
  let index = 0;
  let prevSibling = null; // 형제 fiber가 존재한다면 이 변수에 담음

  // 모든 children을 새로운 fiber로 생성하는 반복문
  while (index < elements.length) {
    const element = elements[index];

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };

    if (index === 0) {
      fiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }

  if (fiber.child) {
    return fiber.child;
  }

  let nextFiber = fiber;

  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

3. 마무리

이번부터 fiber에 대한 개념, fiber를 탐색하면서 작업을 수행하고 다음 작업의 단위를 반환하는 함수를 작성하고 Web API중 하나인 requestIdleCallback()에 대해 공부하면서 이벤트 루프의 작업 스케쥴링과 UX에 대해서 조금이나마 익힐 수 있던 것 같다.

하지만 아직도 갈길이 먼데 이 라이브러리로 코드를 작성하면 빈화면만 출력된다.
이유는 createDom()에서 fiber.type이 제공되지 않으면 undefined의 element를 생성하기 때문이다.(render()에서 초기 컴포넌트의 type이 지정되지 않음)

그리고 지금은 렌더링 진행 중에 브라우저 엔진이 얼마든지 끼어들 수 있고 만약 이런 일이 발생한다면 화면 렌더링이 해당 작업 처리 중에 멈추게 되는 최악의 UX경험으로 이어질 수 있다.
다음에는 이 부분을 다뤄보려고 한다.

requestIdleCallback에 대하여
이미 한참 전부터 React dev팀은 이 메소드를 사용하지 않는다고 한다. 답변
같은 Web API인 requestAnimationFrame위에 내부적으로 구현한 poly-fill을 사용하고 이는 scheduler package라고 불린다.

Reference
MDN, window.requestIdleCallback()
requesetIdleCallback으로 초기 렌더링 시간 14% 단축하기
pladaria, requestIdleCallback-poly-fill
React Fiber Architecture

profile
공부할 게 너무 많아요

0개의 댓글