Deconstructing React (리액트 만들어보기)

최익준·2023년 8월 25일

Deconstructing React

디렉토리 만들고, 타입스크립트 설치

mkdir deconstructing-react
npm install -d typescript
npm install parcel-bundler

yarn add typescript parcel -D

타입스크립트 초기화

tsc --init

tsconfig.json 파일에서 몇 가지 수정

"jsx": "react"
"strict": false

index.html, index.tsx 파일 생성

<script src="index.tsx"></script>

parcel환경에서 서버 실행

parcel index.html

index.tsx에 본격적으로 jsx 넣어보기

const a = <div>hello</div>;

const React = {};
const a = <div>hello</div>;

let React = {
	createElement: () => {},
};
const a = <div>hello</div>;

이렇게만 해도 에러는 사라진다!

그럼 이 함수가 받아오고 있는 argument에 한 번 접근해보자

const React = {
  createElement: (...args) => {
    console.log(args);
  },
};
const a = <div>hello</div>;

div는 태그 이름이고, hello는 요소 안의 값 이름이다. 그럼 null은 뭘까?

const React = {
  createElement: (...args) => {
    console.log(args);
  },
};
const a = <div className="good">hello</div>;

클래스 리스트였군!

그럼 이제 본격적으로 요소를 반환하는 함수를 작성해보자!

const React = {
  createElement: (tag, props, ...children) => {
    var element = { tag, props: { ...props, children } };
    console.log(element);
    return element;
  },
};
const a = <div className="good">hello</div>;

적당히 a변수 잘 수정해주면

const React = {
  createElement: (tag, props, ...children) => {
    var element = { tag, props: { ...props, children } };
    console.log(element);
    return element;
  },
};
const a = (
  <div className="good">
    <h1>hello</h1>
    <p>
      Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
      deleniti harum quibusdam inventore!
    </p>
  </div>
);

이런식으로 잘 나오는 것을 확인할 수 있음.

다음단계는 이 element를 화면에 보여주는 것!

먼저 지금은 그냥 jsx 변수인 a를 함수 컴포넌트로 바꾸어보자

→ 이제 a는 진짜 react component이다!

  • 이참에 변수 이름도 App으로 바꿔보자
const React = {
  createElement: (tag, props, ...children) => {
    if (typeof tag === "function") {
      return tag(props);
    }
    var element = { tag, props: { ...props, children } };
    console.log(element);
    return element;
  },
};
const App = () => (
  <div className="good">
    <h1>hello</h1>
    <p>
      Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
      deleniti harum quibusdam inventore!
    </p>
  </div>
);

<App />;

이러면 일단 Poor man’s react가 만들어졌다.

react is just a part of the puzzle you need to have something that takes your virtual Dom tree puts it somewhere.

본격적으로 이를 화면에 표시해보자.

→ we need to create our render function

index.html에 app이라는 id를 가진 태그를 만들어준다.

<div id="app"></div>
<script src="index.tsx"></script>

그리고 index.tsx에서는 App이라는 함수를 렌더하는 함수를 작성해준다.

render(<App />, document.getElementById("app"));

1차 시도

const React = {
  createElement: (tag, props, ...children) => {
    if (typeof tag === "function") {
      return tag(props);
    }
    var element = { tag, props: { ...props, children } };
    console.log(element);
    return element;
  },
};
const App = () => (
  <div className="good">
    <h1>hello</h1>
    <p>
      Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
      deleniti harum quibusdam inventore!
    </p>
  </div>
);

const render = (reactElement, container) => {
  const actualDomElement = document.createElement(reactElement.tag);
  if (reactElement.props) {
    Object.keys(reactElement.props)
      .filter((p) => p !== "children")
      .forEach((p) => (actualDomElement[p] = reactElement[p]));
  }
  if (reactElement.props.children) {
    reactElement.props.children.forEach((child) => render(child, actualDomElement));
  }
  container.appendChild(actualDomElement);
};

render(<App />, document.getElementById("app"));

왜 이러냐?

저 위에 써놓은 jsx의 자식 태그 안의 hello와 같은 문자는 react element가 아닌 그저 문자열이기 때문이다.

solution → reactElement를 reactElementOrStringOrNumber로 변환

→ TypeScript의 필요성 한 번 강조해주면 좋을 듯

변경 사항 적용

const React = {
  createElement: (tag, props, ...children) => {
    if (typeof tag === "function") {
      return tag(props);
    }
    var element = { tag, props: { ...props, children } };
    console.log(element);
    return element;
  },
};
const App = () => (
  <div className="good">
    <h1>hello</h1>
    <p>
      Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
      deleniti harum quibusdam inventore!
    </p>
  </div>
);

const render = (reactElementOrStringOrNumber, container) => {
  if (["string", "number"].includes(typeof reactElementOrStringOrNumber)) {
    container.appendChild(document.createTextNode(String(reactElementOrStringOrNumber)));
    return;
  }
  const actualDomElement = document.createElement(reactElementOrStringOrNumber.tag);
  if (reactElementOrStringOrNumber.props) {
    Object.keys(reactElementOrStringOrNumber.props)
      .filter((p) => p !== "children")
      .forEach((p) => (actualDomElement[p] = reactElementOrStringOrNumber.props[p]));
  }
  if (reactElementOrStringOrNumber.props.children) {
    reactElementOrStringOrNumber.props.children.forEach((child) => render(child, actualDomElement));
  }
  container.appendChild(actualDomElement);
};

render(<App />, document.getElementById("app"));

키야 이게 되네?

🥳🥳🥳🥳🥳

하지만 이런 건 필요없다! 왜냐? state가 존재하지 않기 때문이다!

이럴 바엔 그냥 HTML 쓰지;;

일단 그 전에 App 컴포넌트를 저런 식으로 쓰지 말고 return으로 jsx를 뱉어내는 식으로 수정해보자

그리고 input 창을 하나 만들자

const App = () => {
  return (
    <div className="good">
      <h1>hello</h1>
      <input type="text" placeholder="name" />
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
        deleniti harum quibusdam inventore!
      </p>
    </div>
  );
};

그냥 우리가 원래 썼던 방식 그대로 useState의 기능을 코드로 작성해보자

(주의할 점 : 우리는 React에서 onChange의 형태로 이벤트 코드를 작성하지만, 이는 React에서 사용되는 방법이고, 실제 input 태그에서의 변화를 가지고 코드를 실행하기 위한 것은 onchange이다.)

const App = () => {
  const [name, setName] = useState("ikjun");
  return (
    <div className="good">
      <h1>Hello, {name}</h1>
      <input type="text" placeholder="name" value={name} onchange={(e) => setName(e.target.value)} />
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
        deleniti harum quibusdam inventore!
      </p>
    </div>
  );
};

당연하게도, useState라는 건 없다고 한다.

App 컴포넌트 위에 useState라는 것을 선언하자

const useState = (initialState) => {
  let state = initialState;
  let setState = (newState) => (state = newState);
  return [state, setState];
};

초기 상태를 받아오고, setState라는 함수를 통해 state를 업데이트 해주는 코드

name이라는 변수를 사용할 수는 있게 되었다. 하지만 input창의 값을 변경했을 때 변수 값이 변하지는 않는다.

왜 안될까?

→ 실제 React가 그렇듯이, 값이 바뀌었다면 재랜더링 해주어야한다.

그럼 rerender함수를 만들어보자.

(render 함수 아래에 선언)

const rerender = () => {
  render(<App />, document.getElementById("app"));
}

이런 식으로 rerender함수를 작성하고, state가 변할때마다 호출하는 식으로 구현하면 될 것 같은 느낌이 든다.

const useState = (initialState) => {
  let state = initialState;
  let setState = (newState) => {
    state = newState;
    rerender();
  };
  return [state, setState];
};

쉣

rerender가 발생할 때 이전에 있던 모든 element를 삭제해주면 될 듯

const rerender = () => {
  document.getElementById("app").firstChild.remove();
  render(<App />, document.getElementById("app"));
};

그래도 ikjun이라는 초기 값은 변화하지 않는 것을 확인할 수 있다.

콘솔에 찍어보면서 무슨 일이 일어나고 있는 것인지 확인해보자

const useState = (initialState) => {
  console.log("initial : ", initialState);
  let state = initialState;
  let setState = (newState) => {
    console.log("setState : ", newState);
    state = newState;
    rerender();
  };
  return [state, setState];
};

그럼 이제 여기서 우리는 훅이 어떤 식으로 동작하는지에 대해 생각해보아야 한다.

어떻게 고칠까?

여기서 사용되는 개념이 바로 클로저(Closure)이다.

(클로저에 관한건 나중에 따로 다루기로 하자.)

공부를 아주 간단하게 해보았음.

요약해보자면,

클로저를 사용하지 않으면 그 상태가 이전에 어떤 상태였는지 기억할 수 없음.

왜? 그 함수가 실행될 때마다 초기화될거니까?

이 예제 코드가 가장 대표적인 예라고 할 수 있을 듯. 이러면 increase 함수가 호출될 때마다 counter가 0으로 초기화 되어서 결국에는 상태변화를 구현할 수 없음

그럼 가장 기본적으로 생각해볼 수 있는 방법이 바로 전역변수를 활용하는 방법.

근데 전역변수의 사용은 그닥 추천되지 않음.

전역변수는 언제든지 누구나 접근할 수 있고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야 한다.

그래 그럼 우리도 클로저를 사용해서 상태를 관리해보자.

일단 이름 바꾸는 것 보다, count를 관리함으로써 보다 직관적인 구현이 가능하도록 방향을 잡았다.

그래서 일단 count를 보여주고 이를 증가 혹은 감소시키는 버튼을 컴포넌트에 집어넣었다.

const App = () => {
  const [name, setName] = useState("ikjun");
  const [count, setCount] = useState(0);
  return (
    <div className="good">
      <h1>Hello, {name}</h1>
      <input type="text" placeholder="name" value={name} onchange={(e) => setName(e.target.value)} />
      <h2>The count is : {count}</h2>
      <button onclick={() => setCount(count + 1)}>+</button>
      <button onclick={() => setCount(count - 1)}>-</button>
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
        deleniti harum quibusdam inventore!
      </p>
    </div>
  );
};

이러면 state가 바뀌려고 할 때마다 리렌더링은 정상적으로 되는 것 같지만 state 값을 확인해보면 계속 다시 초기화되는 모습을 볼 수 있다.

그래서 일단은 외부 스코프에 store을 만들어서 그곳에 값을 저장하는 방식을 구현해보자.

state를 보관 할 states와 어떤 state에 대해 다루는지를 알려줄 stateCursor를 useState를 선언한 부분 위에 각각 선언.

(stateCursor는 states의 몇 번째 인덱스에 그 값이 있는지를 나타낼 변수?)

const states = [];
let stateCursor = 0;

일단 useState에 있는 콘솔 로그 부터 지우고,

forzenCursor라는 변수를 만들어 어떤 state에 대해서 다룰지 나타낸다.

원래 state라는 변수로 관리하던 값을 외부 변수인 states의 값을 참조하는 식으로 변경함.

initialState가 없는 경우에는 그냥 states[FROZENCURSOR]를 넣어줌

states[FROZENCURSOR]에 값이 없을 경우에는 initialState를 넣어줌

로직을 마무리할 때 stateCursor++를 해줌으로써 자동으로 다음 상태로 이동

const states = [];
let stateCursor = 0;

const useState = (initialState) => {
  const FROZENCURSOR = stateCursor;
  states[FROZENCURSOR] = states[FROZENCURSOR] || initialState;
  const setState = (newState) => {
    states[FROZENCURSOR] = newState;
    rerender();
  };
  stateCursor++;
  return [states[FROZENCURSOR], setState];
};

그리고 컴포넌트가 리렌더링 되었을 때는 stateCursor를 0으로 초기화

const rerender = () => {
  stateCursor = 0;
  document.getElementById("app").firstChild.remove();
  render(<App />, document.getElementById("app"));
};

미쳤다

마지막으로 구현해볼 것은 React 측에서 밀고있는 Concurrent Mode이다.

번역하자만 동시성 모드? 라고 할 수 있음.

가장 쉽게 말하자면 일단 비동기 처리를 구현한다고 생각하면됨.

근데 그런 비동기 작업의 우선순위를 React에서 부여하여 부분적인 DOM Tree 렌더링을 가능하게 하는 걸 Concurrent Mode라고 말하는 듯 함.

실습에서는 api를 통해 사진 파일의 url을 불러오고 이를 화면에 표시하도록 구성해보겠음.

App컴포넌트에 api를 통해 dogPhotoUrl을 받아오는 코드를 작성하고 이를 화면에 표시하도록 함

const App = () => {
  const [name, setName] = useState("ikjun");
  const [count, setCount] = useState(0);
  const dogPhotoUrl = fetch("https://dog.ceo/api/breeds/image/random")
    .then((r) => r.json())
    .then((playload) => playload.message);
  return (
    <div className="good">
      <h1>Hello, {name}</h1>
      <input type="text" placeholder="name" value={name} onchange={(e) => setName(e.target.value)} />
      <h2>The count is : {count}</h2>
      {dogPhotoUrl}
      <button onclick={() => setCount(count + 1)}>+</button>
      <button onclick={() => setCount(count - 1)}>-</button>
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
        deleniti harum quibusdam inventore!
      </p>
    </div>
  );
};

에러가 뜸.

왜냐면 data를 fetching해오는 것은 promise인데, 그쪽 관련된 작업을 아무 것도 해주지 않음.

따라서 우리는 이 promise를 관리해줄 필요가 있고, 언제 준비되는지 알아야함.

React에서는 이를 createResource로 관리한다고?

createResource에 대한 부가 설명

  • createResource()는 wrapPromise를 호출함.
  • 여기서 promise의 상태에 접근할 수 없었던 문제를 해결함.
  • 클로저로 상태와 결과를 관리하고 suspender를 throw하는 것으로 Suspense 컴포넌트에 promise를 전달할 수 있게 됨.
  • pending상태일 경우에 상태에 접근할 수 있게 result를 throw하고,
  • error상태일 경우에도 에러를 처리하기 위해 result를 throw함.
const App = () => {
  const [name, setName] = useState("ikjun");
  const [count, setCount] = useState(0);
  const dogPhotoUrl = createResource(() => {
    fetch("https://dog.ceo/api/breeds/image/random")
      .then((r) => r.json())
      .then((playload) => playload.message), 'dogPhoto'
  });
  return (
    <div className="good">
      <h1>Hello, {name}</h1>
      <input type="text" placeholder="name" value={name} onchange={(e) => setName(e.target.value)} />
      <h2>The count is : {count}</h2>
      {dogPhotoUrl}
      <button onclick={() => setCount(count + 1)}>+</button>
      <button onclick={() => setCount(count - 1)}>-</button>
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
        deleniti harum quibusdam inventore!
      </p>
    </div>
  );
};

그래서 일단 이런 식으로 수정.

createResource는 데이터 불러오는 로직을 앞에 작성하고, 그 뒤에는 고유 key 값을 파라미터로 넣어주어야 한다.

그럼 이제 createResource를 만들어보자. (App 컴포넌트 바로 위에)

const promiseCache = new Map();
const createResource = (asyncFunc, key) => {
  if(promiseCache.has(key)){
    return promiseCache.get(key);
  }
  throw { promise: asyncFunc(), key };
}

App 컴포넌트는 코드의 상단에 있는 createElement로 부터 만들어짐.

→ 자식 요소를 그냥 만들기만 하면 안됨.

→ 왜? promise 형태인 놈이 있기 때문!

따라서 try, catch문 사용

const React = {
  createElement: (tag, props, ...children) => {
    if (typeof tag === "function") {
      try {
        return tag(props);
      } catch ({ promise, key }) {
        promise.then((data) => {
          promiseCache.set(key, data);
          rerender();
        });
        return { tag: "h1", props: { children: ["Loading..."] } };
      }
    }
    var element = { tag, props: { ...props, children } };
    return element;
  },
};

이런 식으로 promise가 있을 때의 상황을 관리해주면 됨.

아직 pending 상태이면 Loading이라는 문구를 출력

정상 처리된 상태이면 그냥 props 전달

마지막으로 불러온 데이터를 바탕으로 사진을 출력하도록 컴포넌트를 구성한다면

const App = () => {
  const [name, setName] = useState("ikjun");
  const [count, setCount] = useState(0);
  const dogPhotoUrl = createResource(() => fetch("https://dog.ceo/api/breeds/image/random").then((r) => r.json()), "dog");
  return (
    <div className="good">
      <h1>Hello, {name}</h1>
      <input type="text" placeholder="name" value={name} onchange={(e) => setName(e.target.value)} />
      <h2>The count is : {count}</h2>
      <img src={dogPhotoUrl.message} />
      <button onclick={() => setCount(count + 1)}>+</button>
      <button onclick={() => setCount(count - 1)}>-</button>
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus itaque similique, adipisci voluptatum atque ex
        deleniti harum quibusdam inventore!
      </p>
    </div>
  );
};

끝!

🥳🥳🥳🥳🥳🥳🥳🥳🥳🥳🥳🥳

profile
공부하는 개발자 꿈나무

0개의 댓글