티스토리 뷰

728x90
SMALL

배경

리액트를 클래스형으로는 꽤 많이 구현해 보고 블로그에 글도 적었다. 언젠가 한 번쯤은 함수형을 구현해봐야지라고 생각했는데 생각보다 좀 늦었다. 너무 많은 내용을 적으면 글을 쓰는 사람도 읽는 사람도 힘들 것 같아서 조금씩 나눠서 몇차례에 거쳐 글을 써보려고 한다.

첫번째 목표: 하나의 useState 만들기

1-1.js

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

function Counter() {
  const [count, setCount] = useState(1);
  console.log('count: ', count);
  window.increase = () => setCount(count + 1);
  window.decrease = () => setCount(count - 1);
  return /*html*/ `
      <div>
        count: ${count}
        <button onclick="increase()">increase</button>
        <button onclick="decrease()">decrease</button>
      </div>
      `;
}

function render() {
  const $root = document.getElementById('root');
  $root.innerHTML = Counter();
}
render();

 

일단 useState로 count 예제를 만들어보겠다.

useState라는 함수를 만들고 setState 실행하면 state가 변경되고 리렌더링이 발생한다.

결과는 어떨까?

 

 

count가 변경되지 않는다. 왜 그럴까?

함수형 컴포넌트는 리렌더링이 일어날 때마다 함수가 다시 호출된다.

즉, 버튼을 누르면 count가 증가하지만 `let state = initialState`가 다시 실행되어서 count는 변하지 않는 것이다.

 

1-2.js

let state;

function useState(initialState) {
  if (state === undefined) {
    state = initialState;
  }
  const setState = (nextState) => {
    state = nextState;
    render();
  };
  return [state, setState];
}

// 1-1과 동일해서 생략

 

함수가 재실행되지만 기존의 state는 유지되어야한다. 이를 구현하기 위해 클로저를 사용했다.

 

 

첫번째 목표 완료!

두번째 목표: 여러개의 useState 동작하게 만들기

2-1.js

let state;

function useState(initialState) {
  if (state === undefined) {
    state = initialState;
  }
  const setState = (nextState) => {
    state = nextState;
    render();
  };
  return [state, setState];
}

function Input() {
  const [input, setInput] = useState('');
  console.log('input: ', input);
  window.changeInput = () => setInput(document.querySelector('input').value);
  return /*html*/ `
      <div>
        input: ${input}
        <input oninput="changeInput()"/>
      </div>
      `;
}

function render() {
  const $root = document.getElementById('root');
  $root.innerHTML = `
      ${Counter()}
      ${Input()}
      `;
}

render();

 

하나가 성공을 했으면 두 개를 시도해보자.

 

 

count를 변경했는데 input이 변경되고 input을 변경했는데 count가 변경된다. 즉 두 개의 상태는 항상 같은 값을 가진다.

다시 한번 useState를 살펴보면 이유를 쉽게 알 수 있다. 클로저가 하나의 값으로 이루어져 있다. 그렇기 때문에 매번 변경되는 게 당연하다. useState를 다시 구현해보자.

 

2-2.js

let states = [];
let statesIndex = 0;

function useState(initialState) {
  if (states.length === statesIndex) {
    states.push(initialState);
  }

  const currentIndex = statesIndex;
  const state = states[currentIndex];
  const setState = (nextState) => {
    states[currentIndex] = nextState;
    render();
  };
  // 버그 테스트1
  // const state = states[statesIndex];
  // const setState = (nextState) => {
  //   console.log('setState index', statesIndex);
  //   states[statesIndex] = nextState;
  //   render();
  // };

  statesIndex += 1;
  return [state, setState];
}

function render() {
  const $root = document.getElementById('root');
  $root.innerHTML = `
      ${Counter()}
      ${Input()}
      `;
  statesIndex = 0;
  // 버그 테스트2: statesIndex 재할당하지 않기
}

render();

 

이제 조금씩 어려워진다.

useState는 한 번의 렌더 페이지에 여러 번 호출되어야 한다. 그렇기 때문에 기존의 클로저를 배열로 변경했다.

배열로만 관리를 하면 setState가 발생할 때 몇 번재 index에 접근해야 할지 모르기 때문에 index 변수도 필요해진다.

위 구현에는 크게 두 가지 포인트가 있다.

 

point1. useState 내부에 currentIndex라는 변수를 선언하기

만약 stateIndex를 그대로 사용하게 되면 input이 변경되지 않고 count가 변경된다.

이걸 해석해 보면 setState내부에서 statesIndex는 0으로 간주된다는 것을 알 수 있다.(버그 테스트1 주석을 실행해서 테스트 가능)

오래된 클로저

setState 내부의 stateIndex는 함수가 선언될 당시의 값을 기억한다.

useState가 여러번 실행되더라도, 이미 만들어진 클로저 내부에서는 이전 값을 계속 참조하게 된다.

그러면 states는 왜 제대로 동작하냐는 의문이 들 수 있다.

states는 원시값이 아닌 객체다. 그렇기 때문에 동일한 값을 참조하게 된다.(객체는 주소값이니까.)

statesIndex는 원시값이기 때문에 값이 복사되고, 클로저 내부의 sattesIndex는 함수 선언 당시의 값을 계속 유지하게 된다.

 

point2. index 초기화

redner 될 때마다 statesIndex를 0으로 재선언하지 않으면 statesIndex는 계속 늘어나게 된다.

그래서 0으로 초기화가 꼭 필요하다.

 

 

두번째 목표 완료!

세번째 목표: 하나의 state와 useEffect 동작하게 만들기

3.js

let prevDeps;

function useEffect(effect, deps) {
  const hasNoDeps = !deps;
  const hasChangedDeps = !prevDeps || deps.some((dep, i) => !Object.is(dep, prevDeps[i]));

  if (hasNoDeps || hasChangedDeps) {
    effect();
  }
  prevDeps = deps;
}

function Counter() {
  const [count, setCount] = useState(1);
  console.log('count: ', count);
  window.increase = () => setCount(count + 1);
  window.decrease = () => setCount(count - 1);
  window.render = render;
  useEffect(() => {
    console.log('useEffect: count changed', count);
  }, [count]);
  return /*html*/ `
      <div>
        count: ${count}  
        <button onclick="increase()">increase</button>
        <button onclick="decrease(event)">decrease</button>
        <button onclick="render()">rerender</button>
      </div>
      `;
}

 

클로저로 prevDeps를 정의하고 prevDeps와 currentDeps를 비교해서 하나라도 변경이 되었다면 effect를 실행하면 된다.

참고로 리액트에서는 값을 비교할 때 Object.is로 비교한다. 참고링크

 

 

세번째 목표 완료!

네번째 목표: 여러개의 state와 useEffect 동작하게 만들기

4-1.js

function App() {
  const [count, setCount] = useState(1);
  const [input, setInput] = useState('');

  console.log('count: ', count);
  console.log('input: ', input);

  window.increase = () => setCount(count + 1);
  window.decrease = () => setCount(count - 1);
  window.changeInput = () => setInput(document.querySelector('input').value);

  window.render = render;

  useEffect(() => {
    console.log('useEffect: count changed', count);
  }, [count]);
  useEffect(() => {
    console.log('useEffect: input changed', input);
  }, [input]);
  return /*html*/ `
      <div>
        count: ${count}  
        <button onclick="increase()">increase</button>
        <button onclick="decrease(event)">decrease</button>
        <button onclick="render()">rerender</button>
      </div>
      <div>
        input: ${input}
        <input oninput="changeInput()"/>
      </div>
      `;
}

 

이번에는 하나의 컴포넌트에 두개의 state를 넣고 useEffect를 테스트해보자.

 

 

count를 변경했는데 input 의존성을 넣은 useEffect가 실행된다. 실패했다. 

이건 useEffect안에 `console.log(prevDeps, deps);`를 넣어서 테스트해 보면 금방 알 수 있다.

useState와 유사하게 prevDeps가 배열이 아니어서 발생하는 문제다.(deps가 배열이기 때문에 사실 prevDeps도 배열이기는 하다. 다만 이렇게 되면 2차원 배열이 되어야 한다.)

 

4-2.js

let effects = [];
let effectsIndex = 0;

function useEffect(effect, deps) {
  const currentIndex = effectsIndex; // 오래된 클로저문제 방지
  const hasNoDeps = !deps;
  const prevDeps = effects[currentIndex];

  const hasChangedDeps = !prevDeps || deps.some((dep, i) => !Object.is(dep, prevDeps[i]));

  if (hasNoDeps || hasChangedDeps) {
    effect();
    effects[currentIndex] = deps;
  }

  effectsIndex += 1;
}

function render() {
  const $root = document.getElementById('root');
  $root.innerHTML = `
      ${App()}
      `;
  statesIndex = 0;
  effectsIndex = 0;
}

render();

 

useState와 이전의 useEffect를 이해했다면 위 코드를 충분히 이해할 수 있다.

 

 

네번째 목표 완료!

다섯번째 목표: useRef 구현하기

사실 원래 쓰려고 했던 글을 다썼다. 근데 좀 아쉬워서 useRef까지만 구현해보려고 한다.

 

let states = [];
let statesIndex = 0;

function useState(initialState) {
  if (states.length === statesIndex) {
    states.push(initialState);
  }

  const currentIndex = statesIndex;
  const state = states[currentIndex];
  const setState = (nextState) => {
    states[currentIndex] = nextState;
    render();
  };

  statesIndex += 1;
  return [state, setState];
}

let refs = [];
let refsIndex = 0;

function useRef(initialValue) {
  if (refs.length === refsIndex) {
    refs.push({ current: initialValue });
  }

  const ref = refs[refsIndex];
  refsIndex += 1;
  return ref;
}

function App() {
  const [input, setInput] = useState('');
  const countRef = useRef(0);

  console.log('count: ', countRef.current);
  console.log('input: ', input);

  window.increase = () => (countRef.current = countRef.current + 1);
  window.decrease = () => (countRef.current = countRef.current - 1);
  window.changeInput = () => setInput(document.querySelector('input').value);

  window.render = render;

  useEffect(() => {
    console.log('useEffect: count changed', countRef.current);
  }, [countRef.current]);
  useEffect(() => {
    console.log('useEffect: input changed', input);
  }, [input]);
  return /*html*/ `
      <div>
        count: ${countRef.current}  
        <button onclick="increase()">increase</button>
        <button onclick="decrease(event)">decrease</button>
        <button onclick="render()">rerender</button>
      </div>
      <div>
        input: ${input}
        <input oninput="changeInput()"/>
      </div>
      `;
}

function render() {
  const $root = document.getElementById('root');
  $root.innerHTML = `
      ${App()}
      `;
  statesIndex = 0;
  effectsIndex = 0;
  refsIndex = 0;
}

render();

 

useState를 이해했다면 useRef를 이해하는 건 어렵지 않다.

거의 대부분의 코드가 동일하다.

다른점1. useState는 initialValue를 추가한다면 useRef는 객체를 추가한다.

다른점2. setState 대신 ref.current를 이용해서 상태를 변경한다.

이 몇 줄의 차이로 리렌더링되지 않는 useState(useRef)를 만들 수 있다.

(html에 ref를 적용하는 건 v-dom을 구현해야 할 수 있기 때문에 일단 생략한다.)

 

increase를 여러번 누르고 rerender 버튼 혹은 input 이벤트를 발생시키면 count가 변경되는 것을 볼 수 있다.

 

다섯번째 목표 완료!

이후 목표

위 코드에서 jsx, v-dom, diff 알고리즘, 컴포넌트 생명주기를 추가하고 가벼운 투두리스트를 만들기

참고 글

https://hewonjeong.github.io/deep-dive-how-do-react-hooks-really-work-ko/

 

[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까? — Hewon Jeong

Deep dive: How do React hooks really work?을 저자, Swyx의 허락을 받고 번역한 글입니다. 오타, 오역은 제보해주시면 수정하도록 하겠습니다.👍🏻 클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(L

hewonjeong.github.io

 

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Make-useSate-hook/

 

Vanilla Javascript로 React UseState Hook 만들기 | 개발자 황준일

본 포스트는 React의 useState Hook의 작동방식에 대해 고민해보고, 구현해보고, 최적화하는 내용을 다룹니다. 필자는 React를 사용할 때 hook api들을 보면서 항상 신기했다. function Counter () { const [count, s

junilhwang.github.io

 

전체 코드

https://github.com/yoonminsang/TIL/tree/main/blog/%ED%95%A8%EC%88%98%ED%98%95_%EB%A6%AC%EC%95%A1%ED%8A%B8

 

TIL/blog/함수형_리액트 at main · yoonminsang/TIL

Today I Learned. Contribute to yoonminsang/TIL development by creating an account on GitHub.

github.com

 

728x90
LIST
댓글
공지사항