React - useState 구현하기
이전 구현 사항들
지금까지 Virtual DOM, Router, Reconcilation을 구현했다. 가상돔을 통해 업데이트한 내용을 브라우저 DOM에 적용하고, SPA를 구현하기 위해 router를 구현했다. 그리고 그 과정에서 모든 요소들이 리렌더링 되지 않고 리렌더링이 필요한 부분만 업데이트하는 Reconcilation 기능을 구현했다.
이제 리액트에서 가장 자주 사용하는 Hook을 만들어보겠다.
Hook이란?
기존 클래스 컴포넌트에서는 생명 주기 메서드를 통해 컴포넌트를 조작하고 업데이트 했다. 하지만 리액트 16이후 함수 컴포넌트의 역할이 확대되면서 생명주기 메서드와 비슷한 기능을 하는 Hook을 만들었다. 특히 리렌더링을 발생시키는 조건인 state를 정의하고 업데이트하는 hook인 useState이 있다.
... constructor(props: Props) { super(props); this.state = { count: 1, }; } private handleClick = () => { this.setState({ count: 1 }); }; ...
클래스 컴포넌트는 React.Component를 상속받아 생성되고, state를 객체 형태로 직접 초기화하고 setState를 통해 state를 업데이트 한다. 이렇게 해서 State가 변경되었을때 리렌더링이 이뤄진다.
... const [count, setCount] = useState(1); const handleClick = () => { setCount(2); }; ...
함수형 컴포넌트에서는 더욱 간편하게 state를 생성하고, 업데이트하는 setter함수를 관리할 수 있다. 기존 클래스 컴포넌트에서는 직접 React.Component의 this.state와 this.setState() 활용해서 업데이트 과정이 명확하게 보인다. 하지만 hook은 내부 로직에 의해 관리되기 때문에 어떻게 state가 관리되고 업데이트 및 리렌더링되는지 알기 어렵다. 그래서 직접 구현해보겠다.
useState 구현하기
사실상 우리는 준비를 전부 마쳤다. useState의 핵심은 state가 변경되면 필요한 부분만 리렌더링 시켜주는 것이다. 이 기능을 구현하기 위해 render와 reconcilation를 구현했다. 그래서 적절한 타이밍에 필요한 부분만 업데이트 시켜주고, state를 관리해주기만 하면 된다.
구조 설정
우선 useState는 state를 저장해야한다. 그리고 여러개의 state를 사용할 수 있기 때문에 배열 형식으로 저장하면 용이할 것이다.
const options: IOptions = { states: [], stateHook: 0, };
인덱스와 state를 각각 저장하면, 상태를 개별적으로 관리하기 편리할 것이다.
const useState = <T>(initialState?: T) => { const { stateHook: index, states } = options; const state = (states[index] ?? initialState) as T; const setState = (newState) => { states[index] = newState; _render(); // 리렌더링 함수 } options.stateHook += 1; return [state, setState] as const; };
useState를 통해 state를 추가하는 상황을 가정해보자. 인덱스에 해당하는 배열 위치에 state를 저장하고 관리하게 된다. state를 추가할 때마다 index를 증가시켜 개별적으로 state를 관리할 수 있게 된다. 그리고 setState는 인덱스에 해당하는 값을 새로운 값으로 변경해주는 기능을 하도록 만들어준다. 이렇게 만들어진 state와 setState를 배열로 리턴해주면 우리가 평소에 사용하던 방법으로 useState를 사용할 수 있게 된다.
상태 데이터가 유지되는 이유
지금 구현된 useState는 실행된 이후에도 state가 초기화되지 않고 유지된다. state가 함수 내부에서 작성되고 관리된다면 당연히 함수가 종료되면서 초기화가 될것이다. 하지만 state와 index를 관리하는 option은 useState 외부에서 관리되고 있다. 그 이유는 자바스크립트부터 천천히 공부했다면 예상될 것이다. Clouser를 활용해서 변수에 할당한 값이 사라지지 않고 유지시켜 주는 것이다.
Clouser : 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우, A의 실행 컨택스트가 종료된 이후에도 변수 a가 사라지지 않는 현상
그래서 useState를 통해 리턴된 state와 setState가 컴포넌트에서 사용되고 있기 때문에 상태가 초기화되지 않고 유지되는 것이다.
useState 플로우
컴포넌트에서 useState를 통해 상태값을 생성할때 어떤 순서로 진행되는지 알아보겠다.
우선 현재 route에 따라 컴포넌트를 render함수를 통해 렌더링한다.
const options: IOptions = { states: [], stateHook: 0, }; const renderInfo: IRenderInfo = { $root: null, component: null, currentVDOM: null, }; const resetOptions = () => { options.states = []; options.stateHook = 0; }; const render = (root: HTMLElement, component: Component) => { resetOptions(); // options를 전부 리셋 renderInfo.$root = root; renderInfo.component = component; _render(); };
이 함수를 통해 state의 값을 전부 초기화하고, route에 해당하는 경로와 컴포넌트를 저장해준다. 그리고 _render함수를 실행한다.
const _render = () => { const { $root, currentVDOM, component } = renderInfo; if (!$root || !component) return; const newVDOM = component(); updateElement($root, newVDOM, currentVDOM); renderInfo.currentVDOM = newVDOM; options.stateHook = 0; // stateHook을 0으로 초기화 };
_render함수는 render함수를 통해 저장된 경로와 컴포넌트를 렌더링시켜준다. 그리고 상태를 저장할 index인 options.stateHook을 0으로 초기화 해준다. 그래야지 렌더링이 일어날때마다 state를 순서대로 저장할 수 있게 된다. 이제 컴포넌트에서 useState를 실행해 상태를 저장한다고 가정해보자.
const [count, setCount] = useState(0); const [count2, setCount2] = useState(2);
그러면 우리가 위에서 작성한 코드가 실행되면서 상태와 setState를 얻을 수 있다.
const useState = <T>(initialState?: T) => { const { stateHook: index, states } = options; const state = (states[index] ?? initialState) as T; const setState = (newState: T) => { states[index] = newState; _render(); }; options.stateHook += 1; return [state, setState] as const; };
먼저 실행한 count의 경우 기본값(initialState)이 0이다. 그리고 먼저 실행했기 때문에 index는 0이다. 이후에 해당 인덱스를 가지고 데이터를 불러온다. 지금은 아무런 데이터가 없기 때문에 기본값이 들어간다. 그리고 만들어진 값으로 setState함수를 만들어준다. 그리고 다음에 생성될 state를 위해 index를 1 증가시켜준다. 그러면 다음 state인 count2는 index 1로 인해 개별 상태값을 유지할 수 있게 되는 것이다.
여기에서 state값을 변경 해보겠다.
setCount2(4);
Clouser에 의해 index와 state가 유지되고 있기 때문에 setState를 호출하면 해당 상태에 계속 접근할 수 있다. setCount2는 index 1에 해당하는 상태이기 때문에 state배열의 1번 index에 접근한다. 그리고 그 값을 새로운 값으로 변경해, _render를 실행하고, _render에 의해 DOM업데이트가 된다.
const _render = frameRunner(() => { const { $root, currentVDOM, component } = renderInfo; if (!$root || !component) return; const newVDOM = component(); updateElement($root, newVDOM, currentVDOM); renderInfo.currentVDOM = newVDOM; options.stateHook = 0; // stateHook을 0으로 초기화 });
_render로직을 다시보면 현재 DOM과 새로운 DOM을 비교해서 변경이 이뤄진 부분만 업데이트를 시키는데 우리가 업데이트한 부분은 state뿐이다. 그래서 리렌더링을 하지만 페이지 전체가 리렌더링 되지 않고 변경이 일어난 state를 사용한 부분만 리렌더링이 이뤄지는 것이다.
리렌더링 방지
위의 로직은 한가지 부족한 부분이 있다. 기존 리액트는 새로운 값이 아니라 동일한 값으로 변경하면 리렌더링되지 않는다. 그래서 객체나 배열의 경우 새로운 객체나 배열을 생성해서 변경해줘야한다. 하지만 내가 지금 만든 로직은 얕은 비교는 수행하지 않는다. 그래서 얕은 비교를 하는 로직을 작성해서 setState에 추가해준다.
export const shallowEqual = <T>(objA: T, objB: T): boolean => { if (Object.is(objA, objB)) { return true; } if ( typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null ) { return false; } const keysA = Object.keys(objA) as Array<keyof T>; const keysB = Object.keys(objB) as Array<keyof T>; if (keysA.length !== keysB.length) { return false; } for (let i = 0; i < keysA.length; i++) { if ( !Object.hasOwnProperty.call(objB, keysA[i]) || !Object.is(objA[keysA[i]], objB[keysA[i]]) ) { return false; } } return true; };
기존 state와 새로운 state, 두개의 값을 받아 비교한다. 단순히 객체나 배열의 얕은 비교만 하는 것이 아니라 기본형 데이터도 비교가 가능하다. 그래서 기존과 같다면 true, 다른 값이라면 false를 반환한다.
Object.is란?
Object.is()는 JavaScript의 === 연산자와 비슷하게 동작하면서, 두 값이 같은지 비교한다. 비슷하지 같은 것은 아니다. NaN === NaN은
false지만, Object.is(NaN, NaN)은true가 되고, -0 === 0은true지만, Object.is(-0, 0)은false이다.
const useState = <T>(initialState?: T) => { const { stateHook: index, states } = options; const state = (states[index] ?? initialState) as T; const setState = (newState: T) => { if (shallowEqual(state, newState)) return; states[index] = newState; _render(); }; options.stateHook += 1; return [state, setState] as const; };
이제 setState에서 업데이트 하기전에 얕은 비교를 수행해준다. 만약 기존 state와 새로운 state의 비교 결과가 true라면 리렌더링을 실행하지 않고 리턴한다. 반대로 결과가 false라면 리렌더링을 실행한다. 이로서 같은 값으로 업데이트하면 리렌더링이 일어나지 않는 useState가 구현되었다.
비동기 동작
useState가 비동기로 동작한다는 말을 들어봤을것이다.
const [count, setCount] = useState(0); console.log(2); const handleClick = () => { setCount(1); console.log(1); };
버튼을 클릭하면 setState가 실행되고, 리렌더링이 일어나게 될것이다. 여기에서 console은 어떻게 될까? 우리가 작성한 코드를 그대로 사용하면 setState가 실행되면서 아래에 있는 console은 동작되지 못한체 리렌더링이 일어나고, 콘솔창에는 2 다음 1이 출력될 것이다. 하지만 리액트에서는 다르다. setState가 실행되어도 아래에 있는 console은 동작해 1이 먼저 출력되고, 리렌더링이 발생한 뒤 2가 출력된다. 그 이유는 상태 업데이트는 비동기적으로 실행되고, 전부 동기 동작이 실행된 이후에 상태 업데이트를 진행하기 때문이다.
이런 점때문에 함수에서 setState한 이후에 state 값을 확인하면 새로 변경된 state값이 아닌 변경 전 state가 확인되는 것이다. 이 함수를 실행하는 시점에서는 상태 업데이트가 완료되지 않았기 때문이다.
추가적으로 지금은 setState할때마다 리렌더링을 한다.
const [count, setCount] = useState(0); const handleClick = () => { setCount(1); setCount(2); setCount(3); };
결과적으로는 3으로 한번만 바뀌면 되지만 3번의 리렌더링을 거쳐 3으로 변경된다. 그래서 비동기 동작과 엮어서 요청을 하나로 모아 최소한으로 리렌더링을 시켜주도록 하겠다.
const frameRunner = (callback: () => void) => { let requestId: ReturnType<typeof requestAnimationFrame>; return () => { requestId && cancelAnimationFrame(requestId); requestId = requestAnimationFrame(callback); }; };
현대 모니터들은 보통 초당 60회 화면 갱신을 한다. 화면 갱신후 다음 갱신까지 16ms(1/60 * 1000)가 걸리는 것을 알수가 있고, 16ms에 한 번 변경된 상태에 의한 화면을 보여줘도 사용자는 자연스러운 화면 갱신을 느낄 수 있다. 그래서 requestAnimationFrame를 활용해 브라우저가 효율적으로 다음 화면 렌더링 타이밍에 콜백을 호출하도록 해준다. 그러면 아무리 많은 setState 요청을 해도 렌더링 주기에 맞춰서 실행되기 때문에 기능과 성능 면에서 동일하지만 효율적으로 동작이 가능하다.
그리고 이렇게 만들어진 frameRunner의 requestAnimationFrame는 Macrotask Queue에서 실행되기 때문에 비동기 적으로 동작하게 되고, 우리가 원했던 비동기 상태 변경이 가능해진다.
비동기 이벤트 Queue 종류
- Microtask Queue : 현재 이벤트 루프 사이클이 끝나기 전에 반드시 처리해야 하는 작업, Macrotask보다 먼저 실행
- Macrotask Queue : 각 이벤트 루프 사이클마다 처리되는 작업, Microtask가 완료되어야 실행
const [count, setCount] = useState(0); console.log('rerender'); const handleButtonClick = () => { setCount(1); setCount(2); setCount(3); };
이렇게 코드를 작성했다고 가정했을때, 리렌더링을 확인하기 위해 console을 넣어두었다. 이제 버튼을 눌러보면
매번 리렌더링이 일어나지 않고 한번만 일어나는 모습이다. 추가적을 상태가 잘 변경되는지 확인해보면
기존에 만들어둔 update로직과 reconcilation 기능을 통해 변경이 일어난 부분만 리렌더링을 해주고 있다.
마무리
useState의 기능을 비슷하게 구현해보았다. 하지만 실제 useState는 이것보다 훨씬 복잡한 구조로 이루어져 있다. 그냥 단순히 비슷한 기능을 하도록 만든 것 뿐이다. 그래서 리액트 소스 코드를 분석하는 글을 읽어보았는데 아직 제대로 이해가 안된다...언제쯤 나는 이 모든 것을 이해하고 활용할 수 있을지 의문이다. 그래도 조금씩이라도 성장해보자..!
개의 댓글
1
이전 구현 사항들
Hook이란?
useState 구현하기
구조 설정
상태 데이터가 유지되는 이유
useState 플로우
리렌더링 방지
비동기 동작
마무리