바닐라JS로 useState() 구현 도전해보기

조민호·2023년 5월 18일
0

TS스터디에서 매주마다 간단한 페이지의 로직을 바닐라로만 구현하는 과제를 진행하고 있었습니다

모두 각자의 방식대로 자신만의 로직으로 구현을 해 갔었지만

사실 , 바닐라로 웹을 구현하는 것은 처음 JS를 배울때 이벤트 로직만 몇개 추가하며 만들어 본 기억 뿐이기에
저는 이번 과제들을 진행하면서 단순한 기능 구현만을 바라보고 로직을 작성하기 보다
지금껏 해보지 못한 고차원적인 로직을 도입해 보고 싶었습니다


그러던 도중 문득 리액트의 주요 기능 중 하나인 '상태를 기반으로 한 브라우저 리렝더링'을 를 한번 바닐라에도 적용을 시켜보고 싶었습니다

마침 TypeScript에서도 실제 리액트의 useState()의 타입이 튜플로써 사용된다고 하는 것을 보고 뭔가 흥미가 생겨서 한번 해볼까? 라는 생각도 들었고

  1. 브라우저의 상태를 다루는 로직을 작성하고

  2. 상태관리와 리렌더링을 하는 로직을 연계 하게 되면다면

매우 조잡하겠지만 , 바닐라로 상태를 기반으로 한 브라우저 리렝더링을 구현할 수 있을 것만 같았습니다


그러므로 아래의 기록들은 실제로 제가 바닐라 JS로 useState()를 비슷하게 구현해보기 위한
과정들을 기록 했습니다

  • TypeScript를 통해 타입이 적용된 예시들은 [TS 과제 챌린지] 블로그에 각 과제별 구현 상황에 맞게 사용된 로직으로써 필기해 놓았습니다

  • 상태를 바탕으로 리렌더링까지 연계된 예시들 또한 [TS 과제 챌린지] 의 페이지에 각 과제별 구현 상황에 맞게 사용된 로직으로써 필기해 놓았습니다

  • 여기선 순수히 useState()라는 함수 자체의 설명에만 집중합니다



최초 시도

  	  const tagetObj = {
        name: 'aaa',
      };
    
      const useState = (input) => {
        let initialState = input;
    
        const setState = (newState) => {
          initialState = newState;
        };
    
        return [initialState, setState];
      };
    
      const [value, setValue] = useState(tagetObj);
    
      console.log(value); // { name: 'aaa' }
    
      setValue({
        name: 'bbb',
      });
      console.log(value); // { name: 'aaa' }

useState라는 함수를 호출하면 , [인자로 넣어준 초기 상태값 , 이를 수정하는 함수] 를 리턴한다

라는 아주 기본적인 개념들을 가지고 구현해 본 것입니다

그렇지만 위의 방법은 제대로 동작하지 않습니다

setState안에서 아무리 initialState 의 값을 수정한다고 한들,

이미 initialState는 ‘값으로써’ 리턴이 됐고 , value의 변수에 담겨버렸기 때문입니다

두번째 시도

      const tagetObj = {
        name: 'aaa',
      };
    
      function useState(input) {
        let initialState = input;
    
        const setState = (newState) => {
          for (let i in value) {
            value[i] = newState[i];
          }
        };
    
        return [initialState, setState];
      }
    
      const [value, setValue] = useState(tagetObj);
    
      console.log(value); // { name: 'aaa' }
    
      setValue({
        name: 'bbb',
      });
    
      console.log(value); // { name: 'bbb' }

첫번째 시도의 실패 이유가 이미 값으로써 리턴되어버린 상태를 변경할 수 없기 때문이라고 했으므로 , 그 상태 자체를 다시 가져와서 바꿔봤습니다

즉, 외부에 존재하는 상태를 직접 가져와서 수정하는 것입니다

동작은 제대로 합니다 그렇지만 다들 느꼈듯이 이건 애초에 매우 잘못된 로직입니다

  1. useState안에서 외부에 있는 value라는 상태를 불러오는 것 자체가 말이 되지 않습니다. 실제로 useState는 외부에서 현재 상태값으로 관리되고 있는 변수 이름을 알리가 없기 때문입니다

  2. 또한 외부에 존재하는 상태를 직접 참조하는 것은 매우 위험하며 수많은 부수효과를 유발할 수 있습니다




어쩔 수 없이 블로그의 힘을 빌렸습니다

모든 블로그에서 공통적으로 언급하는 방법은 바로 클로저를 이용하는 것입니다

	const tagetObj = {
    name: 'aaa',
	 };

  const useState = (input) => {
    let initialState = input;

    const state = () => initialState; // 클로저 함수

    const setState = (newState) => {
      initialState = newState;
    };

    return [**state**, setState];
  };

  const [value, setValue] = useState(tagetObj);

  console.log(value()); // { name: 'aaa' }

  setValue({
    name: 'bbb',
  });
  console.log(value()); // { name: 'bbb' }

initialState를 state라는 함수의 리턴값으로 사용합니다

state 함수는 클로저 이므로, 상위에 있는 initialState변수를 스크린샷으로 가지고 있게 되므로 계속 initialState변수를 참조하게 됩니다


그렇지만 실제 useState()의 상태는 함수가 아닌 변수입니다

그러므로 이 로직 역시 완벽하지 않습니다


💡 위의 useState 에서 return [state(), setState] 형태로 값을 리턴하면 안 됩니다
이렇게 되면 일반 변수자체를 리턴하는 것이므로 클로저를 사용하는 의미가 없기 때문입니다




<body>
    <div id="app"></div>
    <script src="./test.js"></script>
</body>

기본적인 useState 구현

let state = undefined; // 반드시 useState상위에 선언해야 한다 (클로저)
function useState(initState) {

	// state를 정의한다.
  //  let state = initState  (x); 
  if (!state) state = initState;  // 최초로 선언할 때만 할당
						       	  // 이때가 아니면 initState은 아예 무시됨

  const setState = (newState) => {
    state = newState; // 새로운 state를 할당한다
    render(); // render를 실행한다.
  };
  return [state, setState];
}

function Counter() {
  const [count, setCount] = useState(1);

  window.increment = () => setCount(count + 1);

  return `
    <div>
      <strong>count: ${count} </strong>
      <button id='btn'>증가</button>
    </div>
  `;
}

const render = () => {
  app.innerHTML = `
      <div>${Counter()}</div>
    `;
};

render();
  • 상태로 사용하는 state는 클로저 특성을 이용해야 하기 때문에 외부에 선언 합니다

  • setState에서 리렌더링이 돼서 useState를 재사용해도 어차피 state는 초기값인 경우에만 할당하기에 문제가 없습니다ㅑ

    업데이트된 상태값을 바탕으로 Counter()에서 리턴하는 HTML을 통째로 다시 사용한다 (비효율적이긴 함)


여기서 눈여겨 봐야할 것은 2개가 있습니다

  1. 증가함수인 increment를 윈도우 전역객체에 등록한 것입니다

    • 왜냐하면 Counter컴포넌트가 HTML을 문자열 형태로 리턴하는데

    • 문자열 형태로 onclick속성에 함수를 등록해 줄 수가 없습니다

      템플릿 리터럴도 불가능합니다

    • 그러므로 윈도우 전역객체로 등록해 놓으면 어디서든 참조가 가능하기에 나중에 Counter()가 호출돼서 HTML이 실제로 등록이 될때 , 해당 함수를 전역객체에서 알아서 참조할 수 있게 한 것입니다

    해당 컴포넌트에 대한 로직은 해당 컴포넌트 안에다 작성해야 하기에 이렇게라도 진행하는 것입니다

    • 물론 아래 처럼 작성해도 가능은 합니다. button을 참조해야 하니까 최소한 Counter()가 호출된 다음에 동적으로 이벤트를 추가하는 것입니다
      그렇지만 이렇게 하는것은 컴포넌트의 이벤트 로직을 외부에다가 걸어두는 것이므로 굉장히 지양되는 방법입니다
        const render = () => {
          app.innerHTML = `
              <div>${Counter()}</div>
            `;
          let btn = document.getElementById('btn');
          if (btn) {
            btn.addEventListener('click', increment);
          }
        };

  1. useState는 계속해서 호출이 일어나야 한다!!!

    setState()가 실행돼서 리렌더링이 발생할때를 보면 계속해서 Counter()컴포넌트를 호출하고 있고 , 이때 useState() 또한 계속해서 다시 사용이 됩니다

    이처럼 , useState() 에서 아래와 같이 상태 업데이트가 된 값으로 다시 리턴받아야
    비로소 업데이트 된 상태를 사용할 수 있는 것입니다

    return [state, setState];



    하나씩 다시 살펴 봅시다

    a. 최초 렌더링이 될 때는 Counter()가 호출되고 useState()또한 최초로 호출이 됩니다

    그땐 아래의 부분이 실행 됩니다

     let state = undefined; // 반드시 useState상위에 선언해야 한다 (클로저)
     function useState(initState) {
     
       **if (!state) state = initState;  // 최초로 선언할 때만 할당
    										// 이때가 아니면 initState은 아예 무시됨
     
       const setState = (newState) => {
         state = newState; // 새로운 state를 할당한다
         render(); // render를 실행한다.
       };
     
       return [state, setState]; // 리턴
     
     }

    b. setCount(count + 1); 가 일어났을 땐 아래의 부분이 실행되고

     let state = undefined; // 반드시 useState상위에 선언해야 한다 (클로저)
     function useState(initState) {
     
       if (!state) state = initState;  // 최초로 선언할 때만 할당
     												// 이때가 아니면 initState은 아예 무시됨
     
       const setState = (newState) => {
         state = newState; // 새로운 state를 할당한다
         render(); // render를 실행한다.
       };
       return [state, setState];
     }

    render() 가 발생해서 다시 Counter()가 호출되고 useState()가 또다시 호출될 땐

    아래의 부분만 실행이 됩니다

    let state = undefined; // 반드시 useState상위에 선언해야 한다 (클로저)
    function useState(initState) {
    
    (((((((((( 이 스코프 안에서 사용되고 있는 state(=setState에 의해 변경 됐었음)를 
       다시 리턴해 줘야 하는 것
    
      if (!state) state = initState;  // 최초로 선언할 때만 할당
    								 // 어차피 useState()가 다시 호출되도 initState은 무시됨
    
      const setState = (newState) => {
        state = newState; // 새로운 state를 할당한다
        render(); // render를 실행한다.
      };
    
      return [state, setState];
    
    ))))))))))
    
    }

    이때 , useState() 함수 안에서 사용하고 있는 state라는 상태변수는

    직전에 사용된 setState()에 의해 값에 변경이 일어났고,

    setState()에서 사용하는 state변수와 useState()에서 사용하는 변수는

    둘 다 전역 state변수를 클로저로 사용하고 있는, 서로 같은 변수이므로

    이 변경된 값을 다시 리턴해줘서 [count, setCount] 로 받아야

    비로소 count라는 변수로 변경된 상태를 사용할 수 있는 것입니다




여러 상태를 가질 수 있는 useState 구현

그렇다면 useState을 여러 컴포넌트에서 사용하게 되면 어떻게 될까요?

let state = undefined;
function useState(initState) {
  if (!state) state = initState;

  const setState = (newState) => {
    state = newState;
    render();
  };
  return [state, setState];
}

function Counter() {

	// 숫자 상태
  const [count, setCount] = useState(1);

  window.increment = () => setCount(count + 1);

  return `
    <div>
      <strong>count: ${count} </strong>
      <button id='btn'>증가</button>
    </div>
  `;
}

function Cat() {

	// 문자열 상태
  const [cat, setCat] = useState('고양이');

  window.meow = () => setCat(cat + ' 야옹!');

  return `
    <div>
      <strong>${cat}</strong>
      <button>고양이의 울음소리</button>
    </div>
  `;
}

const render = () => {
  app.innerHTML = `
    <div>
      ${Counter()}
      ${Cat()}
    </div>
    `;
};

render();

결과가 어떻게 된다고 말하기에는 꼬이는 부분이 제각각이라 일일이 언급하긴 그렇지만

한 개의 state 변수로 두 개의 state를 관리하기 때문에

  • count와 cat이 똑같은 값을 보여주게 되고

  • 상태 자체가 굉장히 꼬이게 되며

  • 심지어 setState로직도 꼬이게 됩니다

위에서 언급한 문제를 해결하기 위해서 외부의 state 갯수를 useState가 실행되는 횟수만큼 만들어주면 됩니다


let currentStateKey = 0; // useState가 실행 된 횟수

const states = []; // state를 보관할 배열

function useState(initState) {
  // initState로 초기값 설정
  
  const key = currentStateKey;

  // 새로 생성된 상태라면 states배열에 추가
  if (states.length === key) {

		**// 이때가 아니면 initState은 아예 무시됨**
    states.push(initState); 
  }

  // state 할당
  const state = states[key];

  const setState = (newState) => {

    // state를 직접 수정하는 것이 아닌, states 내부의 값을 수정
    states[key] = newState;
    render();
  };
  currentStateKey += 1;
  return [state, setState];
}

function Counter() {
  const [count, setCount] = useState(1);

  window.increment = () => setCount(count + 1);

  return `
    <div>
      <strong>count: ${count} </strong>
      <button id='btn'>증가</button>
    </div>
  `;
}

function Cat() {
  const [cat, setCat] = useState('고양이');

  window.meow = () => setCat(cat + ' 야옹!');

  return `
    <div>
      <strong>${cat}</strong>
      <button>고양이의 울음소리</button>
    </div>
  `;
}

const render = () => {
  app.innerHTML = `
    <div>
      ${Counter()}
      ${Cat()}
    </div>
    `;

  // 이 시점에 currentStateKey는 2가 될 것이다.
  // 그래서 다시 0부터 접근할 수 있도록 값을 초기화 해야 한다.
  currentStateKey = 0;
};

render();
  • 최초에 render()가 실행돼서 Counter() , Cat() 가 호출되며 상태값들이 새로 추가될 때는 states배열에 push를 해주고
  • states배열에 key라는 인덱스로 접근해서 초기 상태들을 리턴 해 줍니다 states배열 길이는 useState를 사용할 때마다 +1이 됩니다
  • 상태 업데이트 역시 클로저로 선언된 key로 접근애서 해당 useState의 상태인 states[key]를 수정한 다음 리렌더링을 진행합니다
  • 렌더링이 될 때마다 currentStateKey를 0으로 해줍니다
    그래야 리렌더링이 될때 컴포넌트 순서인 Counter와 Cat을 차례로
    호출하며 각 인덱스인 0 과 1을 key로 삼아서 해당 useState의 상태를 가져 옵니다



useState 최적화

먼저 고민해볼 수 있는 상황은 setState에 state와 동일한 값을 넣었을 경우 입니다

아래의 경우는 값은 똑같은데 render는 계속 실행하고 있습니다

이럴 때는 렌더링이 되지 않도록 방지해야 합니다

let currentStateKey = 0; // useState가 실행 된 횟수
const states = []; // state를 보관할 배열

function useState(initState) {
  // initState로 초기값 설정
  
  const key = currentStateKey;

  // 새로 생성된 상태라면 states배열에 추가
  if (states.length === key) {

		// 이때가 아니면 initState은 아예 무시됨
    states.push(initState);
  }

  // state 할당
  const state = states[key];
  const setState = (newState) => {

    // state를 직접 수정하는 것이 아닌, states 내부의 값을 수정
    states[key] = newState;
    render();
  };
  currentStateKey += 1;
  return [state, setState];
}

function Counter () {
  const [count, setCount] = useState(1);

	// 클릭을 해도 setState로 같은 값만 사용하므로 최적화가 필요!!
  window.nochange = () => setCount(1); 

  return `
    <div>          //이 coun상태는 계속 1
      <strong>count: ${count} </strong>
      <button>변화없음</button>
    </div>
  `;
}

let renderCount = 0;
const render = () => {
  const $app = document.querySelector('#app');
  $app.innerHTML = `
    <div>
      renderCount: ${renderCount}
      ${Counter()}
    </div>
  `;
  currentStateKey = 0;
  renderCount += 1; // 렌더링이 되는 횟수를 확인하기 위해 선언
}

render();

위의 코드에 대한 해결책은 매우 간단 합니다

setState로 들어온 인자가 기존 state와 동일하면 그냥 render()를 하기전에 함수를 리턴하는 것입니다

그리고 이 역시 setState() 안에서 참조하는 state는 클로저를 통해 기존에 선언된 값을 사용합니다

let currentStateKey = 0; // useState가 실행 된 횟수

const states = []; // state를 보관할 배열

function useState(initState) {
  // initState로 초기값 설정
  const key = currentStateKey;
  if (states.length === key) {

		**// 이때가 아니면 initState은 아예 무시됨**
    states.push(initState);
  }

  // state 할당
  const state = states[key];
  const setState = (newState) => {
    
    **// 값이 똑같은 경우 (일반 값)**
    if (newState === state) return;
    **// 값이 똑같은 경우 (배열 or 객체)**
    if (JSON.stringify(newState) === JSON.stringify(state)) return;

    // 기존 값과 다른 경우에만 값을 변경하고 render()를 실행한다.
    states[key] = newState;
    render();
  };
  currentStateKey += 1;
  return [state, setState];
}
function Counter() {
  const [count, setCount] = useState(1);
  window.nochange = () => setCount(1); // count에 똑같은 값을 삽입한다.
  return `
    <div>
      <strong>count: ${count} </strong>
      <button>변화없음</button>
    </div>
  `;
}

let renderCount = 0;
const render = () => {
  const $app = document.querySelector('#app');
  $app.innerHTML = `
    <div>
      renderCount: ${renderCount}
      ${Counter()}
    </div>
  `;
  currentStateKey = 0;
  renderCount += 1;
};

render();

배열/객체일 때는 JSON.stringify를 통해 간단하게 비교할 수 있지만 Set, Map, WeekMap, Symbol 같은 원시타입의 경우 JSON으로 파싱되지 않습니다





(앞으로도 계속 공부하면서 추가 될 예정입니다)


출처 :

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

profile
할 수 있다

0개의 댓글