나만의 작은 React 만들어보기

AnSuebin·2024년 7월 6일
0

[리액트] 개념 정리

목록 보기
15/15
post-thumbnail

프론트엔드 멋쟁이들은 이런걸 만들어본다던데
프론트엔드를 진심으로 공부하시는 분들이 해보는 필수 코스가 있습니다.
나만의 리액트 만들어보기....!!!!!!
저도 프론트엔드에 진심이고 React를 꽤나 사랑하기 때문에
직접 만들어보며 리액트의 상세 원리를 코드로 이해해보기로 하였습니다.


render.js 만들기

📌 가상돔

  • html에 변화가 있을 때, 기존 가상돔과 새 가상 돔을 비교하여 변경된 내용만 DOM에 적용합니다.
  • 가상돔은 순수 객체로 추상화 되었기 때문에, 브라우저의 종속적이지 않다는 특징을 가지고 있습니다.

📌 jsx

  • 가상돔은 객체로 추상화 되어있고, html에 변화를 Jsx의 변화를 통해 감지하기 때문에, jsx의 구조를 객체로 반환해주었습니다.
export function jsx(type, props, ...children) {
  return { type, props: props || {}, children };
}

📌 createElemnet

  • Props의 값이 string인 경우와, 노드인 경우 분기처리를 해주었습니다.
  1. string인 경우 바로 node로 만들어줍니다.
  2. 노드인 경우, type으로 element를 만들어주고
  3. 속성을 돌려, setAttribute해주고
  4. 재귀를 통해 Children을 appendChild해줍니다.
export function createElement(node) {
  if (typeof node === "string") {
    return document.createTextNode(node);
  }

  const element = document.createElement(node.type);

  Object.entries(node.props).forEach(([name, value]) => {
    element.setAttribute(name, value);
  });

  node.children.forEach((child) => {
    element.appendChild(createElement(child));
  });
  return element;
}

📌 render

  • props로 parent, newNode, oldNode, index를 받습니다.
  • newNode와 oldNode의 상황에 따라 분기처리가 필요합니다.
  1. newNode가 없고 oldNode만 있는 경우는 요소를 제거합니다.
  2. ewNode만 있고 oldNode가 없는 경우는 새 요소를 추가합니다.
  3. 둘다 없는 경우는 바로 Return합니다.
  4. node가 둘다 문자열인 경우는 삼항연산자로 둘이 다른지 구분할 수 있기 때문에, 분기처리해줍니다.
  5. node의 타입이 다른 경우, oldNode를 newNode로 교체해줍니다.
  6. 둘다 있고 속성이 node인 경우, updateAttributes함수에 넣어 둘의 다른 부분을 체크하고, new와 old가 다르다면, new로 업데이트하고, newProps에 값이 없다면 제거해줍니다.
  7. 마지막으로, new와 old의 children의 length를 비교하여, 긴 길이를 기준으로, render함수를 통해 children node를 재귀적으로 돌려줍니다.
export function jsx(type, props, ...children) {
  return { type, props: props || {}, children };
}

export function createElement(node) {
  if (typeof node === "string") {
    return document.createTextNode(node);
  }

  const element = document.createElement(node.type);

  Object.entries(node.props).forEach(([name, value]) => {
    element.setAttribute(name, value);
  });
  
  node.children.forEach((child) => {
    element.appendChild(createElement(child));
  });
  return element;
}

function updateAttributes(target, newProps, oldProps) {
  Object.entries(oldProps || {}).forEach(([key, _]) => {
    if (oldProps[key] !== newProps[key]) {
      target.setAttribute(key, newProps[key]);
    }

    if (!(key in newProps)) {
      target.removeAttribute(key);
    }
  });
}

export function render(parent, newNode, oldNode, index = 0) {
  if (!newNode && oldNode) {
    parent.removeChild(parent.childNodes);
    return;
  }

  if (newNode && !oldNode) {
    const newElementNode = createElement(newNode);
    parent.appendChild(newElementNode);
    return;
  }

  if (!newNode && !oldNode) {
    return;
  }

  if (typeof newNode === "string" && typeof oldNode === "string") {
    if (newNode !== oldNode) {
      parent.childNodes.nodeValue = newNode;
    }
    return;
  }

  if (newNode.type !== oldNode.type) {
    const newElementNode = createElement(newNode);
    parent.replaceChild(newElementNode, parent.childNodes);
    return;
  }

  updateAttributes(parent.childNodes[index], newNode.props, oldNode.props);

  const newChildren = newNode.children || [];
  const oldChildren = oldNode.children || [];
  const maxLength = Math.max(newChildren.length, oldChildren.length);

  [...new Array(maxLength)].forEach((_, i) => {
    render(parent.childNodes[index], newChildren[i], oldChildren[i], i);
  });
}

📌 myReact

  • 상태관리 및 진입점 역할을 담당합니다.
  • 현재 렌더링 중인 DOM요소와 루트 컴포넌트 함수를 클로저를 통해 저장합니다.
  • 사용자 호출 진입점인 render함수에 root를 사용하여, props로 루트와, 컴포넌트를 받아오고, currentRoot에 루트를 저장하고, rootComponent에 컴포넌트를 저장합니다. 그 후 실제 돔을 업데이트 하는 함수를 실행합니다.
  • _render는 실제 돔을 업데이트하는 역할로, 현재 렌더링 중인 DOM요소와 루트 컴포넌트를 사용하여, vDOM 생성, 실제 돔에 반영합니다.
import { createHooks } from "./hooks";
import { render as updateElement } from "./render";

function MyReact() {
  let currentRoot = null;
  let rootComponent = null;

  const _render = () => {
    if (currentRoot && rootComponent) {
      resetHookContext();
      const vDom = rootComponent();
      updateElement(currentRoot, vDom, currentRoot._vDom);
      currentRoot._vDom = vDom;
    }
  };

  function render($root, component) {
    currentRoot = $root;
    rootComponent = component;
    _render();
  }

  const {
    useState,
    useMemo,
    resetContext: resetHookContext,
  } = createHooks(_render);

  return { render, useState, useMemo };
}

export default MyReact();

📌 batch기능 추가

  • 여러번 setState가 호출 되더라도, 마지막 상태만 반영되도록 합니다.
  • 플래그와, queueMicrotask를 사용하여 구현하였습니다.
export function createHooks(callback) {
  let states = [];
  let currentIndex = 0;
  let pendingUpdate = false;

  const useState = (initState) => {
    const stateIndex = currentIndex;
    if (states.length === stateIndex) {
      states.push(initState);
    }

    const state = states[stateIndex];

    const setState = (newState) => {
      if (states[stateIndex] === newState) {
        return;
      }
      states[stateIndex] = newState;

      pendingUpdate = true;
      queueMicrotask(() => {
        if (pendingUpdate) {
          pendingUpdate = false;

          callback();
        }
      });
    };

    currentIndex++;
    return [state, setState];
  };

  const areArraysEqual = (arr1, arr2) => {
    if (!arr1 || !arr2 || arr1.length !== arr2.length) return false;
    return arr1.every((value, index) => value === arr2[index]);
  };

  const useMemo = (fn, deps) => {
    const memoIndex = currentIndex++;
    const lastMemo = memos[memoIndex];

    if (!lastMemo || !deps || !areArraysEqual(lastMemo.deps, deps)) {
      const newValue = fn();
      memos[memoIndex] = { value: newValue, deps };

      return newValue;
    }

    return lastMemo.value;
  };

  const resetContext = () => {
    currentIndex = 0;
  };

  return { useState, useMemo, resetContext };
}

📌 트러블 슈팅

  • batch를 만드는 부분에서 queueMicrotask를 사용했고, 여기서 앞에 true면 계속 순환하게 되는 것이 아닌가라는 생각을 했습니다.
	pendingUpdate = true;
   queueMicrotask(() => {
        if (pendingUpdate) {
          pendingUpdate = false;

          callback();
        }
      });
  • 그러나 queueMicrotask와 브라우저 동작 원리를 이해한다면, 쉽게 이해할 수 있습니다.
  • 우선 queueMicrotask는 현재 자바스크립트 작업이 완료된 직후, 그러나 다음 이벤트 루프의 태스크 전에 실행되는 비동기 함수입니다.
  • 즉, stack에 있는 일을 모두 수행한 후, 실행되는 이벤트 루프라는 점입니다. 이벤트 루프 중에선 빠른 우선순위를 가지고 있지만요.
  • 만약 아래와 같이, 클릭이 실행된다면 아래와 같은 순서로 진행됩니다. 즉, 첫번째만 실행되고 다음 것은 실행되지 않습니다.
let count = 0;
const App = () => {
  const [state, setState] = react.useState(0);
 const handleClick = () => {
    setState(state + 1);
    setState(state + 2);
    setState(state + 3);
  };
  return jsx('div', null, 
    jsx('button', { onClick: handleClick }, `Clicked ${count++} times`),
    `State: ${state}`
  );
};
react.render(document.getElementById('root'), App);
  1. handleClick 함수 호출
  2. setState 3번 호출, pendingUpdate 플래그 true
  3. handleClick 함수 종료, 스택 비움
  4. 마이크로태스크 큐의 첫 번째 queueMicrotask 콜백 함수 실행
    • pendingUpdate 플래그 확인, true이므로 리렌더링 함수 호출
    • 리렌더링 함수 실행, 실제 DOM 업데이트
    • pendingUpdate 플래그 false
  5. 마이크로태스크 큐의 두 번째 queueMicrotask 콜백 함수 실행
    • pendingUpdate 플래그 확인, false이므로 실행 X
  6. 마이크로태스크 큐의 세 번째 queueMicrotask 콜백 함수 실행
    • pendingUpdate 플래그 확인, false이므로 실행 X

마무리

무척 어렵지만, 작은 나만의 리액트를 만들어보다니 무척 즐겁습니다.
리액트를 좀 더 세세하게 배울 수 있었습니다.
코드는 알고 보면 쉽지만, 모를 때는 무서운 존재 같습니다.
뭐든 도전하는게 중요하다는 마음으로 앞으로도 임하려 합니다.
화이팅!!!!

참고

profile
고객에게 명료한 의미를 전달하고, 명료한 코드를 통해 생산성 향상에 기여하고자 노력합니다.

2개의 댓글

comment-user-thumbnail
2024년 7월 12일

안녕하세요
직접 구현한 작은 리액트 글 너무 유익하고 재밌게 잘 봤습니다
궁금한점이 있어 댓글을 남깁니다

보통 리액트에서 의미하는 batch update는 효율적인 DOM 변경을 위해 다수의 상태(state)가 변경되었을때 UI를 일괄(동시) 업데이트 하는것을 의미합니다
예를들어 한개의 이벤트 핸들러 안에서 두개의 상태 변경이 동기적으로 호출되었을때, 각각 UI 업데이트(re-render re-paint)를 하는것이 아닌, 한 커밋 페이즈에 UI를 모두 업데이트하는것으로 알고있습니다.
구현하신 batch 기능은 batch update보단 debounce에 가깝지 않나 생각이 되는데 어떻게 생각하실까요?!

1개의 답글