Vanilla JS로 React 구현하기

SangHyeon Lee·2024년 10월 26일
0

React 톺아보기

목록 보기
1/1

시작하면서...

최근, 동아리에서 React기초 세미나를 개최하게 됐다.

React를 사용하는데 있어, 여러가지 제약이나 규칙이 존재하는데,
이들이 '왜 필요한가'에 대해서는 알려주지 못했다.

너무나도 자연스럽게 사용하고 있던 React의 비밀을 파헤치기 위해
React의 동작을 Vanilla JS로 구현하며 파헤쳐보자..!

(개발자 황준일블로그를 많이 참조했다.)


실습


세팅

참조 글을 따라, 함수형 컴포넌트를 구현한다.

import renderDebounce from './renderDebounce';

function MyReact() {
  const options = {
    state: [],
    currentStateKey: 0,
    root: null,
    rootComponent: null,
  };
  
  // _render에서의 renderDebounce는 batch 동작 수행하는 역할.
  // requestAnimationFrame을 통한 debounce.
  const _render = renderDebounce(() => { 
    if (!options.root || !options.rootComponent) return;
    options.root.innerHTML = options.rootComponent();
    options.currentStateKey = 0; 
  });

  function render(root, rootComponent) {
    options.root = root;
    options.rootComponent = rootComponent;
    _render();
  }
  function setState(newState) {
    options.state[options.currentStateKey] = newState;
    _render();
  }
  function useState(initState) {
    const { state, currentStateKey } = options; 
    if (state.length === currentStateKey) {
      state.push(initState);
    }
    options.currentStateKey += 1; 
    return [state[currentStateKey], setState]; 
  }
  return { useState, render };
}

export const { useState, render } = MyReact();

목표

참조글에는 위의 React라이브러리를 사용해 함수형 컴포넌트를 동작시킨다.

글 말미에도 나와있듯이, eventListener를 사용할 때 전역 변수를 이용한다.

이는 버그 가능성을 높이고 사용에 불편한 방식이므로,
이 부분을 먼저 해결하고자 했다.


추가적으로 세운 목표는 다음과 같다.

  • Diff알고리즘을 적용
  • setState실행이 관련 컴포넌트에서 re-render를 트리거
  • useEffect제작 및 render이후 동작 수행
/*useState가 사용된 컴포넌트부터 리렌더(실행)시키도록.
ㄴ> 구현이 어렵..
diff알고리즘 적용
이벤트 리스너에 달기.
useEffect만들기.
ㄴrender이후에 mounted과정에서 실행.
컴포넌트 반환으로 인한 처리랑 일반 노드 처리랑 동일하게 만들어서, 이벤트 리스너와 props에 대해 동일하게 처리할 수 있도록 함.
render 사이클에 hook을 넣기 어려웠음.. 함수형 컴포넌트에서 이대로 사용한다면.

과정

1. Event 핸들링

jsx 사용경험을 최대한 보존하기 위해, 참조글에서 사용한 innerHTML형식을 그대로 이용하려고 시도했다.

이 방법은 innerHTML이 결국 string이기 때문에, '값'을 넣어야 제대로 동작한다는 한계가 있었다. 문자열 내에서는 함수를 정의할 수 없기 때문.

그래서 참조글에서도 이름을 가진 함수를 전역변수로 정의하고, 이를 참조하도록 한 것이다.


이러한 한계를 극복하기 위해, 참조 글 이전 포스트의 h함수를 사용하기로 했다.

여기서 h함수란, Virtual DOM을 Real DOM으로 바꿔주는 함수이다.

사용이 불편하기에 JSX를 사용하게 되었으나,
babel을 통해 이 방식으로 트랜스파일한다 가정하에 진행했다.

완성된 innerHTML이 아니라,
정보들을 props로 받아들이기 때문에 후처리가 편하다는 이유에서였다.

적용한 코드는 다음과 같다.

// core/MyJsxConvertion.js

function isEventProp(propKey) {
  // 'onClick'처럼 react에서 사용하는 event handling props 사용에 맞춰 처리.
  // 'on'으로 시작하고 뒤에 대문자가 오는 경우 event handling props로 인지.
  return (
    propKey.slice(0, 2) === 'on' &&
    'A' <= propKey.slice(2, 3) &&
    propKey.slice(2, 3) <= 'Z'
  );
}
function setProps(node, props) {
  if (props !== null) {
    Object.keys(props).forEach((propKey) => {
      if (isEventProp(propKey)) {
        // 이벤트 핸들링
        node.addEventListener(propKey.slice(2).toLowerCase(), props[propKey]);
      } else {
        node.setAttribute(propKey, props[propKey]);
      }
    });
  }
}

function setChildren (node,children){
  children.forEach((child) => {
    if (typeof child === 'string') {
      node.innerHTML += child;
    } else {
      // child는 MyJsxConvertion함수의 반환인 element
      node.appendChild(child);
    }
  });
}

function MyJsxConvertion(type, props, ...children) {
  const node = document.createElement(type);

  // props붙이기
  setProps(node, props);

  //children관리
  setChildren(node, children);

  return node;
}

export default MyJsxConvertion;

이를 사용한 함수형 컴포넌트의 모습은 다음과 같다.

// Component/Mix.js

import h from '../core/MyJsxConvertion.js';

const Mix = () => {
  return (
    h('div',null,
		h('ul',null,
			h('li', { key: 1 }, '리스트1'),
      		h('li', { key: 2 }, '리스트2'),
      		MixChild()
		)
	)
  );
};
export default Mix;

// Component/MixChild.js
import h from '../core/MyJsxConvertion.js';

export default MixChild(){
	return (
    	h(
          'button',
          {onClick:()=>{console.log("클릭")}},
          '버튼'
        );
    );
}

이처럼 h함수를 사용한다면, 우리의 MyReact라이브러리에도 수정이 필요하다.

반환이 innerHTML이 아닌 element이기 때문!


렌더링의 핵심인 _render함수만 수정해주었다.
const _render = renderDebounce(() => {
    if (!options.root || !options.rootComponent) return;
	
  	// 이 밑의 부분이 수정된 부분이다.
    if (options.root.childNodes.length !== 0) {
      options.root.removeChild(options.root.childNodes[0]);
    }
    options.root.appendChild(options.rootComponent());
  	// 이 윗 부분이 수정된 부분이다.
  
    options.currentStateKey = 0; 
  });

여기서 render함수가 root에서만 사용되므로, childNode[0]만을 고려했다.

렌더마다 새롭게 생성된 element들을 root element에 교체하는 방식이다.


이제 이벤트 핸들링을 간편하게 할 수 있게 됐다..!

이 밑의 과정은 추가 목표를 달성하기 위해 삽질했던 경험이다.

나름의 고뇌가 들어갔다 생각하지만, 결과적으로 useEffect제작 이외엔 달성에 실패했기에 참고하여 읽길 바란다.


2. Diff알고리즘을 적용

참조 글의 이전 포스트에 Diff알고리즘을 클래스형 컴포넌트에 적용하는 내용이 있었다.

처음엔 이 방법을 함수형 컴포넌트에 이식하고자 했다.


먼저, 클래스형 컴포넌트에 있던 Diff알고리즘에 대한 코드를 살펴보자.
// core/Component.js
class Component{
	...
    render(){
      // 이 부분을 통해 기존의 노드(target)과 새로운 노드(newNode)를 
      // Diff알고리즘 코드(updateElement)에 넣는다.
      const newNode = this.$target.cloneNode(true);
      newNode.innerHTML = this.template(); // 새로운 템플릿 넣기.

      const oldChildNodes = [...this.$target.childNodes];
      const newChildNodes = [...newNode.childNodes];
      const maxIter = Math.max(oldChildNodes.length, newChildNodes.length);
      for (let i = 0; i < maxIter; i++) {
        updateElement(this.$target, newChildNodes[i], oldChildNodes[i]);
      }
	
      // 밑은 EventListener를 다시 설정하는 과정이다.
      requestAnimationFrame(() => {
        this.setEvent();
      });
      
      ...
    }
    ...
}

    
// core/vdom.js
export function updateElement(parent, newNode, oldNode) {
// parent랑 oldNode가 실제 document에 메달린 노드들이 됨.

  // 1. oldNode만 => 얘 제거
  if (!newNode && oldNode) {
    oldNode.remove();
    return;
  }
  // 2. newNode만 => 얘 추가
  if (newNode && !oldNode) {
    parent.appendChild(newNode);
    return;
  }
  // 3. 모두 text타입 => nodeValue보고 판단
  if (newNode instanceof Text && oldNode instanceof Text) {
    if (oldNode.nodeValue === newNode.nodeValue) return;
    oldNode.nodeValue = newNode.nodeValue;
    return;
  }

  // 4. old와 new 태그이름이 다를 경우
  if (newNode.nodeName !== oldNode.nodeName) {
    const idx = [...parent.childNodes].indexOf(oldNode);
    oldNode.remove();
    parent.insertBefore(newNode, parent.children[idx] || null);
    return;
  }

  // 5. 태그 이름은 같은 경우.=>속성 비교해야 함.
  updateAttributes(oldNode, newNode);

  // 6. 자식들에 대해 1~5과정 반복
  const newChildren = [...newNode.childNodes];
  const oldChildren = [...oldNode.childNodes];
  const maxLength = Math.max(newChildren.length, oldChildren.length);
  for (let i = 0; i < maxLength; i++) {
    updateElement(oldNode, newChildren[i], oldChildren[i]);
  }
}

function updateAttributes(oldNode, newNode) {
  const oldProps = [...oldNode.attributes];
  const newProps = [...newNode.attributes];

  for (const { name, value } of newProps) {
    if (value === oldNode.getAttribute(name)) continue;
    oldNode.setAttribute(name, value);
  }

  for (const { name, value } of oldProps) {
    if (newNode.getAttribute(name) !== undefined) continue;
    oldNode.removeAttribute(name);
  }
}

좀 길긴 하지만 이해에 무리가 가지는 않을 것이다.

중요한 것은, 이 방법은 기존의 노드와 새로운 노드를 비교하며
변경점을 기존 노드에 적용하는 방식이라는 것이다.

이는 EventListener를 수정할 때 문제가 된다.

DOM API에서는 특정 노드의 EventLisetner들을 조회할 수 없다.
이들을 참조할 유일한 방법은 EventListener에 등록된 함수 자체를 알고 있는 것이다.

즉, 각 노드마다의 정보(EventListener를 포함한)를 알고 있어야 할 것이다.

이는 Component class로 컴포넌트 내 사용된 eventListener정보를 저장하여 해결할 수 있을 것이므로,

"클래스형 컴포넌트 + 함수형 컴포넌트"를 목표로 잡았다.

3. setState실행이 관련 컴포넌트에서 re-render를 트리거

이를 달성하기 위해서도 "클래스형 컴포넌트 + 함수형 컴포넌트"를 목표로 잡았다.

useState가 실행되는 곳의 컴포넌트를 render시켜야 했기 때문에,
해당 컴포넌트의 정보를 알고 있어야 했다.

가장 간단한 방법은, setState를 통해 해당 컴포넌트의 render를 실행하는 것이라 생각해 위의 목표에 도전했다.

⭐특수목표⭐ - render 수정하기

⚒️시도 1

함수형 컴포넌트에서 사용되는 setState가
클래스형 컴포넌트의 메서드인 render를 실행시키기 위해선,

MyReact라이브러리 내부 useState안에 인스턴스 정보를 전달해야 한다.

먼저, 함수형 컴포넌트 내부에 인스턴스 정보를 전달하기 위해 다음의 방법을 사용했다.

// Component/ParentComponent.js
function ParentComponent(){
	return (
    	h('div',null,
          new Component(ChildComponent.bind(this))
    );
}

// core/Componenet.js
class Component{
	constructor($target){
    	this.$target = $target.bind(this);
    }
}

/*
// core/MyJsxConvertion.js
import Component from './Component.js';
function MyJsxConvertion(){
	...
    //children관리
    if (child instanceof Component){
      child = 
    } else if (typeof child === 'string'){
      // string타입 처리
    }else{
      // 다른 기본 node들 대한 처리
    }
}*/

문제는 함수형 컴포넌트 안에서 useState안으로 this를 전달할 때에 있었다.
최대한 React의 함수형 컴포넌트 사용 경험을 유지하려 했기에,

함수형 컴포넌트 안에서 useState에 this를 직접 바인딩해 사용하진 않으려 했다.

fuction MyComponent(){
  	// babel적용 시 밑의 코드가 된다는 방법은 쫌... 
  	const [state,setState] = useState.bind(this)(initState);
	return ;
}

이렇게 가정한다면, 일관성에 문제가 생긴다.

custom Hooks에도 똑같이 바벨 적용 후 .bind(this)가 추가될 것이라 가정해야 하는데, 이는 옳지 못하다.
babel을 통해 'use로 시작하는 함수 뒤에는 .bind(this)를 붙여라'를 수행할 수 있지만, React에서 customHook 이름을 use로 시작하도록 하는 것은 Convention일 뿐이다.

개발에 혼란이 없도록 하는 Naming Convention일 뿐, 메커니즘적 오류를 일으키지 않으므로 이 방법은 제외하기로 했다.


⚒️시도 2

useState가 Component클래스를 통해 정의된다면 컴포넌트 정보를 얻을 수 있으니, MyReact를 수정하기로 했다.

MyReact안에 Component클래스를 정의하면 가능할 듯 했다.

실제 React라이브러리를 사용할 때도 React.Component로 사용하므로,
가능성이 보였다.

// core/MyReact.js
function MyReact(){
  const options={
  	...
    root:null,
    rootComponent:null
  };
  
  const _render=renderDebouncer(()=>{
  	...
  });
    
  function render(root, rootComponent){
  	options.root = root;
    options.rootComponent = rootComponent;
    _render();
  }
    
  function functionUseState(initState, thisBinded){
  	...
    function setState(newState){
      	...
    	state = newState;
      
      	// 넘겨받은 thisBinded를 활용한다.
      	render(thisBinded.$parent, thisBinded.$target);
      	
    };
    return [state, setState.bind(thisBinded)];
  }
    
  class Component{
    $parent;
  	constructor($target){
    	this.$target = $target;
      	...
    }
    render(){
    	this.$parent.appendChild(this.$target());
    }
    setup($parent){
    	this.$parent = $parent;
    }
      
    // 여기가 핵심이다.
    static classUseState=(initState)=>{
    	functionUseState(initState, this);
    }
  }
    
  const useState = Component.classUseState;
  return {useState, render, Component};
}
export default {useState, render, Component} = MyReact();

// core/MyJsxConvertion.js
function MyJsxConvertion(type, props, ...children){
  const node = document.createElement(type);
  
  ...
  
  // children관리
  children.forEach(child=>{
  	if (child instanceof Component){
      
      // 부모 노드를 인스턴스에 넘기기
      child.setup(node);
      child.render();
    } else if (typeof child === 'string'){
      // string타입 처리
      ...
    }else{
      // 다른 기본 node들 대한 처리
      ...
    }
  });
}

이 방법 역시 실패했다.

핵심은 static 메서드를 화살표 함수로 정의해, this를 미리 바인딩하는 것이었는데,
이는 불가능 했다.

클래스 관련한 객체들의 생성 순서는 다음과 같이 때문이다.

Class 객체 -> 클래스.prototype -> 클래스 인스턴스.

따라서 아직 만들어지지도 않은 클래스 인스턴스를 참조할 순 없었다.

이쯤 되니, 실제로 React는 어떻게 만들어졌는지 궁금해진다...


4. useEffect제작 및 render이후 동작 수행

useEffect제작은 간단했다.

[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까? 를 참고하여 다음과 같이 구현했다.

// core/MyReact.js
function MyReact(){
	...
    function useEffect(callback, deps) {
      if (options.deps.length === options.currentEffectKey) {
        options.deps.push(deps);
      }
      let depsChanged = false;
      for (let i = 0; i < Math.max(deps.length, options.deps.length); i++) {
        if (deps[i] !== options.deps[options.currentEffectKey][i]) {
          depsChanged = true;
        }
      }
      if (!deps || depsChanged) {
        callback();
        options.deps = deps;
      }
    }
}

사실 주된 목표가 render 이후에 callback을 실행하는 것이었는데,
이를 위해 각 컴포넌트의 render이후에 이에 맞는 useEffect가 실행돼야 했다.

하지만, 컴포넌트 객체를 클래스와 같이 사용할 수 없기에 개별 render 내부에 자신의 useEffect를 실행하기 불가능했고,

MyReact내의 render내에서 useEffect가 적용될 컴포넌트를 특정할 수 없어 이는 포기했다.

결과

click 이벤트에 대해 잘 동작하는 것을 확인할 수 있다!

해당 컴포넌트 코드는 다음과 같다.

import h from '../core/MyJsxConvertion';
import { useState, useEffect } from '../core/MyReact';

export default function MixChild() {
  const [state, setState] = useState(3);
  
  useEffect(() => {
    console.log('클릭함!');
  }, [state]);
  
  return h(
    'div',
    {
      onClick: () => {
        setState(state + 1);
      },
    },
    `${state}`
  );
}

후기

위의 발전과 삽질 과정에서 알게된 점은 다음과 같다.

  • Hooks을 조건문 등의 내부에서 사용하지 않는 규칙의 이유를 느꼈다.

    MyReact 라이브러리 내부에서 hooks를 관리할 때, 각 hook들에 사용되는 자원을 array의 형태로 저장하고, 실행 순서에 맞는 자원을 참조하도록 했다.

    조건문안에 hooks가 사용된다면 참조 대상이 바뀌게 될 것이다!

  • Fiber 사용 이유

    컴포넌트 마다 정보를 가지고 있고 각 렌더링 사이클에 접근할 수 있어야 Hooks가 완벽하게 제 역할이 가능한데,

    이는 Class형 컴포넌트와 결합하여 이뤄낼 순 없다.
    다른 구조가 필요한데, 이는 Fiber가 될 것 같다.

    공부할 명분을 얻었다..!

이외에, 참조 글의 관련 포스팅을 따라오며 알게된 점은 다음과 같다.

  • 클래스형 컴포넌트에 대한 친숙도

    클래스형 컴포넌트 사용시 필요했던 컴포넌트 생명 주기를 공부할 때 도움이 될 것이다.
    this를 이용하기에 state가 항상 최신값이 된다는 것이 렌더링 사이클과 어긋난다는 한계점에 대해 이해했다.

  • Virtual DOM에 대한 이해 (핵심은 repaint-reflow방지=>in memory변화

    정체가 단순하게 type, props, children을 갖는 Object임을 알게 됐다. in-memory의 변화로 batch하게 DOM을 변경하여 reflow,repaint를 방지하는 목표로 사용됨을 느꼈다.

  • Observer pattern이해, 다른 상태관리 방법들 이해 단초

    전역 상태 관리든, Proxy든, Atomic이든, Observer Pattern이 사용될 것임을 알 수 있었다. 이에 대한 공부에 단초가 될 것이다.

  • requestAnimationFrame 친숙도

    setTimeout처럼 편하게 사용할 수 있게 됐다. 다음 프레임 전에 실행이 확정된다는 점이 매우 편리하게 느껴졌다.

  • Class에서 prototype, this에 대한 이해

    인스턴스 메서드, class관련 객체 생성 순서, 클래스 내부에서 this위치에 따른 바인딩에 대해 공부하게 되었다.

  • Diff알고리즘 구현 경험

  • Proxy객체 사용법



사실은 React에 대한 이해를 최우선으로 했는데, 보다 깊은 이해를 위해서는 클래스형 컴포넌트를 직접 공부하고 Fiber구조에 대해 알아보는게 확실해보였다.

그래도 이들에 대한 단초를 얻고, JS를 보다 이해할 수 있어 좋았다.


참조

Vanilla Javascript로 React UseState Hook 만들기
[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까?

profile
회고할 가치가 있는 개발을 하자

0개의 댓글

관련 채용 정보