본 게시글의 코드는 리액트 16버전을 기준으로 하고있습니다.
이전 게시글에서 reconciler 패키지의 rednerWithHooks함수를 찾았고, 거기서 훅을 주입한다는 것을 알게되었다.
그럼 실제 코드를 확인하며 어떻게 훅을 주입하고 있는지 확인해보자.
renderWithHooks
우선 함수의 인자들을 확인해보자.
current와 workInProgress, Component, props, refOrContext, nextRenderExpirationTime을 받아서 사용한다.
자... 하나씩보자.
일단 current와 workInProgress는 넘어가자.
Component는 실행시켜야할 함수를 말한다. (ReactElement의 tag 프로퍼티)
props는 ReactElement의 props를 뜻한다.
refOrContext는 말그대로 ref요소 또는 Context를 뜻한다.
nextRenderExpirationTime은 렌더된 시간과 관련된 인자이다.
하지만 여기서 가장 중요한 것은 넘겨버렸던 current와 workInProgress이다.
이들에 대해 알아보자.
current와 workInProgress
current와 workInProgress에 대해 알아보려면 리액트의 Virtual DOM에 대해 이야기해야한다.
리액트의 VDOM노드는 Fiber객체로 이뤄진다.
Fiber 객체 타입 알아보기
이쯤에서 Fiber객체의 타입을 살펴보자.
Fiber 객체가 담고있는 정보가 매우 매우 매우 많은 것을 확인할 수 있다.
export type Fiber = {
tag: WorkTag, //fiber를 식별하는데에 사용되는 tag다.종류별 (함수형, 클래스형, html태그, string 등)로 달라지며enum형태로 관리된다.
key: null | string, //리액트 태그에 달아주는 그 키다.
elementType: any,
type: any, //react Element의 type(내코드에서는 tagName)
stateNode: any, //DOM에 마운트된 HTML element다.
return: Fiber | null, //부모fiber
child: Fiber | null, //자식 fiber의 첫번째 요소
sibling: Fiber | null, //형제요소
index: number, //형제들 사이에서의 자기 위치
ref: null | RefObject,
pendingProps: any, // workInProgress Fiber의 경우 작업이 안끝나서 아직 안붙은 props들임
memoizedProps: any, // current Fiber의 경우 작업이 끝나서 붙은 props들
updateQueue: UpdateQueue<any> | null, // 컴포넌트의 종류에 따라 element의 변경점 또는 라이프사이클을 저장함
memoizedState: any, //함수형 컴포넌트의 hook이 저장됨
dependencies: Dependencies | null,
mode: TypeOfMode,
effectTag: SideEffectTag,
nextEffect: Fiber | null,
firstEffect: Fiber | null,
lastEffect: Fiber | null,
expirationTime: ExpirationTime,
childExpirationTime: ExpirationTime,
alternate: Fiber | null, // current인경우 workInProgress를 workInProgress인 경우 current를 반환함
actualDuration?: number,
actualStartTime?: number,
selfBaseDuration?: number,
treeBaseDuration?: number,
_debugID?: number,
_debugSource?: Source | null,
_debugOwner?: Fiber | null,
_debugIsCurrentlyTiming?: boolean,
_debugNeedsRemount?: boolean,
_debugHookTypes?: Array<HookType> | null,
};
이런 다양한 프로퍼티를 통해 파악할 수 있는 점을 요약해보자면
- Fiber노드는 자신의 부모(return프로퍼티), 자식(child프로퍼티), 형제 Fiber(sibling프로퍼티) 노드에 접근할 수 있다, 이때 자식에 접근할때는 첫번째 요소에 접근한다.
- Fiber노드는 자신이 렌더링한 HTML요소를 갖고있을 수 있다.
- 함수형 컴포넌트는 memoizedState를 통해 hook을 관리한다. (이게 뭔소린가 싶겠지만 일단 넘어가자.)
- 함수형 컴포넌트의 경우 type에 함수 그 자체가 등록되기에 type()형태로 호출해서 해당 컴포넌트를 재실행 시킬 수 있다.
자 이제 이 Fiber로 이뤄진 virtual DOM의 구성을 보자.
이전에 내가 작성했던 코드에서는 prev와 updateElement 두가지 ReactElement를 관리하면서 두 VDOM의 차이점을 updateDOM을 통해 반영해주었었다.
이처럼 리액트도 두가지 VDOM을 관리하면서 Reconcile을 진행한다.
current
current가 내 코드에서의 prev에 해당하는 VDOM이다.
DOM에 이미 마운트 된 VDOM인 것이다.
workInProgress
마운트가 된 후, 업데이트시의 VDOM이다.
하지만, 내 코드의 VDOM과의 차이점이 있다면 workInProgress는 current에서 복사되어 만들어진다는 점이다.
이게 복사될 수 있는 이유는 함수형 컴포넌트의 경우, 호출할 함수를 type프로퍼티로 가지고 있기 때문이다.
필요한 경우(내용이 바뀐 경우) type을 call해주면 바뀐 컴포넌트와 그 하위에 대해서만 재실행이 가능해진다!
백문이불여일견이라고 Fiber가 동작하는 방식은 [콴다 팀블로그] React Deep Dive — Fiber 선언형 UI 라이브러리의 동시성 렌더링 기술에서 가져온 codepen자료를 보면 훨씬 이해하기 쉽다.
See the Pen fiber reconciliation animation by Yeji Lee (Bailey) (@ejilee) on CodePen.
이처럼 workInProgress가 완료되면 current로 싹 바꿔버린다.
좋은 자료가 나온 김에 기존의 내 방식과 리액트 방식의 차이점을 비교해보며, stack방식과 Fiber방식의 차이점을 다시 한번 알아보자.
내방식
내 방식으로 구현하면 tagName이 함수인경우 재귀적으로 실행시키기에 A1컴포넌트를 실행시키는순간, E3까지 확인하는 과정을 멈출수가 없다.
물론, 재귀함수에 조건을 걸수는 있지만, 재귀함수를 조건부로 분기시키는건 여간 쉬운일이 아니다.
Fiber
하지만, Fiber로 구성하면 재귀보다는 우리가 컨트롤 할 수 있는 것들이 많아진다.
Fiber노드의 탐색은 dfs형태를 따르나 명시적으로 child, sibling을 통해 탐색시키기 때문에 컨트롤할 수 있는 여지가 생기는 것이다.
예컨데 A1-> B1까지 진행했는데 특정 조건에 부합하는 경우 임의로 종료시키기가 가능해진다.
다시 renderWIthHooks
자, 그럼 인자를 알았으니 renderWithHooks를 다시 살펴보자.
알아보기전에, 우리는 이제 이 함수가 어느 시점에 호출되는 것인지 유추해볼 수 있다.
VDOM중 workInProgress 트리가 순회될때, 특정 Fiber가 렌더(Fiber의 tag가 호출되는 것)되어야 하는 상황인 것이다.
그렇다면 여기서 인자로 들어오는current와 workInProgress는 렌더가 필요한 Fiber노드임을 알 수 있다.
그럼 다시 코드를 보자.
우선, 빨간색으로 네모를 친부분을 보면 선언된 적 없는 변수들을 쓰고있다.
얘네는 이 파일(ReactFiberHooks.js)에서 쓰이는 전역변수다.
그래서 파일초반부를 보면 선언과 초기화를 하는부분을 찾을 수 있다.
또한 renderWithHooks의 모든 일이 끝나면, 하나의 fiber를 render한 것(함수컴포넌트의 호출)이므로 모든 전역변수를 초기화시키는 코드가 실행된다.
코드를 다시보면, currentlyRenderFiber에 입력받은 workInProgress를 할당하고 있다.
여기서 workInProgress는 재호출되어야하는 하나의 Fiber노드이다.
오케이 여기까진 잘 이해했다.
그리고 nextCurrentHook을 current가 없는경우 current의 memoizedStatte로 설정하고있다.
currentHook이 뭔데? 이게 당최 무슨 말이당가?
함수형 컴포넌트의 Hook
함수형 컴포넌트의 state는 도대체 어떻게 저장되는걸까?
사실 이 시리즈를 시작하게 된 원인이기도한데, 이제 정확하게 알아보자.
함수형 컴포넌트의 state는 hook에 저장된다. 그리고 hook은Fiber객체에 저장된다.
이런 느낌인 것이다. (지금은 느낌이다.)
const fiber = {
...
hook: [{state: 1}],
...
};
음... 하지만 fiber에서 이걸 hook이라는 이름으로 저장하진 않는다.
fiber에서는 hook이라는 이름 대신 memoizedState프로퍼티에 hook을 저장한다.
const fiber={
...
memoizedState:[
{
state:1,
},
]
...
}
그리고 잘 보면 hook 여러개가 배열형태로 되어있는 것을 볼 수 있다.
useState와같은 훅이 여러개 쓰이는 경우, 여기에 쌓이는 것이다.
const App=()=>{
const [count,setCount]=useState(0);
const [foo, setFoo]=useState(false);
...
}
이런경우 아래처럼 Fiber가 구성될거라고 예측해볼 수 있다.
const fiber={
...
memoizedState:[
{
state:0,
},
{
state: false,
}
]
...
}
이렇게 해서 한 Fiber내부에서 hook의 실행 순서가 보장되게 된다.
사실 훅도 이런 state만 딸랑 있는게 아니다.
hook은 사실 이렇게 생겼다.
const hook: Hook = {
memoizedState: null, //state다.
baseState: null, //얘는 setState시에 사용되는 state다.
queue: null, //업데이트 요소가 쌓이는 큐다.
baseUpdate: null, //업데이트에 사용되는 요소다.
next: null, //다음 요소를 가리킨다... 어?
};
이 요소를 잘 보면 next라는 프로퍼티가 있다는 것을 알 수 있다.
엥? 이거 어디서 본 기억이 있지 않은가?
그렇다. hook객체는 링크드 리스트 노드다.
hook을 인덱스로 접근할 일은 없으니 링크드 리스트로 관리하는 것이 훨씬 합리적이기 때문이다.
그래서 실제 fiber객체의 memoizedState에는 hook하나만 담겨있고, 그 다음 hook을 가리키는 형태로 구현이 되어있다.
const fiber={
...
memoizedState:[
{
memoizedState:0,
...
next:{
memoizedState: false,
...
}
},
]
...
}
자 그럼 이렇게 저장된 hook을 다음 렌더시에 어디서 받아올까?
아까 VDOM은 current 와 workInProgress 두개의 VDOM으로 구성된다고 했었다.
현재 renderWithHooks가 실행되는 시점은 workInProgress가 처리되는 시점이다.
이전의 상태를 받아오려면 이미 있는 VDOM인 current에서 받아오면된다!
그래서 이런 코드가 있는 것이다.
nextCurrentHook = current !== null ? current.memoizedState : null;
이 코드는 사실 이전에 렌더링된 current가 있는지 확인하고, 있다면 이전의 첫번째 훅을 받아오는 코드였던 것이다.
만약 없다면 null이 들어간다.
그리고 DEV부분을 싹날리고 다음 부분을 보자.
여기가 바로 ReactCurrentDispatcher.current,즉 useState의 주입부다!
여기도 역시 조건부로 주입이 되고있다.
이곳의 조건을 살펴보자.
- nextCurrentHook이 null인경우
nextCurrentHook,그러니까 기존 current VDOM요소의 hook이 없는 경우다.
이런 경우는 current가 없는 경우라고 할 수 있다.
바로 VDOM이 처음으로 DOM에 마운트되는 시점인 것이다.
따라서 HookDispatcherOnMount 객체를 가져온다. - nextCurrentHook이 null이 아닌경우
nextCurrentHook, 기존 current VDOM요소의 hook이 있는 경우다.
이제 기존에 마운트 된 VDOM인 current의 hook을 업데이트를 해야하는 상황이니 HooksDispatcherOnUpdate 객체를 불러온다.
여기서 ReactCurrentDispatcher.current에 주입되는 객체를 보면 각 훅들이 어떻게 생겼는지를 볼 수 있다!
각 훅들이 mountHook 또는 updateHook에 연결된 것을 볼 수 있다.
current의 여부에 따라 훅함수가 달라지는 것이다.
그림으로 표현해보면 이렇다.
나의 관심사는 useState이므로, mountState와 updateState만 확인해볼 것이다.
mountState
자 그럼 마운트 시점에 불러와지는 useState인 mounState를 확인해보자.
여기서 mountWorkInProgressHook함수를 통해 hook 객체를 받아오는 것을 볼 수 있다.
useState건, useEffect건 무엇이됐건 hook은 hook이다.
그래서 기본적인 hook틀을 가져온 뒤에 각 훅에서 객체의 값들을 바꿔주는데, 이건 mount시점의 workInProgress에서 쓰는 훅이므로 해당 함수를 통해 가져온다.
자 그럼 mountWorkInProgressHook함수도 살펴보자.
기본 적인 훅을 만든뒤에, 전역 변수인 workInProgressHook, firstWorkInProgressHook을 사용해서 뭔가를 해주고있다.
이 친구들도 전역변수이므로, 초기화되는 부분이 있으며, 초기에는 값이 무조건 null이다.
그러면 mountWorkInProgressHook이 처음불리는 시점에는 무조건 firstWorkInProgressHook과 workInProgressHook이 여기서 만든 기본 hook객체로 할당된다.
firstWorkInProgressHook은 정말 첫번째 훅, 링크드 리스트의 시작부를 뜻하는 것이다!
그러면 두번째 호출부터는 workInProgressHook이 있으므로, 가장 최근에 만들어진 workInProgressHook다음에 hook을 붙여주는 형태로 hook을 링크드리스트로 만들어줄 수 있다!
그럼 이렇게 만들어진 firstWorkInProgressHook을 Fiber객체에 붙이는 부분이 분명 있을 것이다.
이부분은 컴포넌트를 호출한 후에 renderWithHooks에서 해주고 있었다.
자 그럼 hook이 Fiber에 어떻게 붙는지 확인했으니 다시 mounState로돌아가자.
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState; //hook의 state를 initialValue로 만들어줌
const queue = (hook.queue = {
last: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
}); //업데이트 대기열이다.
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
// Flow doesn't know this is non-null, but we do.
((currentlyRenderingFiber: any): Fiber), //무조건 현재의 fiber에 대해 dispatch함수를 실행시키겠다.
queue, //위에서 만든 업데이트 대기열을 넘겨준다.
): any));
return [hook.memoizedState, dispatch]; //여기가 우리가 실제로 쓰는 useState의 반환값이다!
}
일단 주석에 다 남겨놨긴했는데, 하나씩 다시 살펴보자.
initialState설정
인자로 받은 initialState의 타입에 따라 다르게 처리된다.
함수인 경우, 이걸 실행시킨 반환값을 initialState로 만든다.
그리고 만든 hook의 memoizedState에 저장한다.(Fiber의 hook을 담은 memoizedState와는 다르다!!! 이건 훅의 상태다.)
queue 설정
useState의 setState를 여러번 호출해도 한번에 처리된다는 사실을 알고있는가?
그렇게 할 수 있는 이유가 바로 이 큐의 존재때문이다.
이게 바로 업데이트 대기열인 것이다.
dispatch(setState)
이게 바로 setState의 본체다.
아까 만든 queue.dispatch값에 dispatchAction(currentRenderingFiber,queue)라는 함수를 저장시켰다.(bind는 인자를 고정시킨 함수객체를 반환한다.)
그니까 지금 작업하고 있는 fiber를 넘겨주고, 업데이트 대기열을 넘겨서 대충 업데이트시킨다는 것을 알 수 있다.
그리고 그걸 setState로 반환해서 우리가 사용하고 있는 것이다!
dispatchAction이 어떤 함수고, 어떻게 일어나는지는 다음 게시글에서 확인해보자.
그럼 mountState에 대한 분석이 끝났다.이번엔 updateState도 한번 보자.
이 훅도 마찬가지로, update가 어떻게 일어나는지보다는 어떻게 useState형태로 우리에게 오는지를 먼저 살펴보고, 이후에 업데이트 로직을 살펴볼 것이다.
updateState
오잉?
updateState는 updateReducer로 구현되어있다.
그전에 인자로 넘어가는 함수인 basicStateReducer를 살펴보자.
음 그냥 action이 함수면 state를 인자로 넣어서 실행시키고, 아니면 action을 반환하는 함수다
그럼 updateState의 본체인 updateReducer를 살펴보자.
이친구는 hook객체를 updateWorkInProgressHook함수를 통해 얻어오고 있다.
updateWorkInProgressHook을 보자.
상당히 길다....
문단별로 살펴보자.
먼저 첫번째 if문 이다.
이 경우는 꽤 특별한 경우다. render가 되는 도중에 rerender가 일어난 상황이다.
그래서 기존의 updateWorkInProgressHook을 workInProgress로 설정해서 재사용하는 것이다.
이런 경우가 있을 수 있다.
cosnt App=()=>{
const [count,setCount]=useState(0);
if(state===1) setState(2);
return <div onClick={()=>setState(1)}>클릭</div>;
}
이런 경우를 처리하기 위한 부분인 것이다.
그럼 이런 경우가 아닌 보통의 경우는 else문에서 확인할 수 있을 것이다.
우선 nextCurrentHook은 renderWithHooks에서 받아온 그 값이다.(current Fiber의 memoizedState값)
그리고 이 값들을 토대로 새로운 훅인 newHook을 만드는 것을 확인할 수 있다.
이후에 if문은 mountWorkInProgressHook과 동일하다.
첫번째 훅을 설정하거나, 훅에 훅을 붙이는 것이다.
다시 updateReducer로 돌아오자.
요 부분은 state를 업데이트 하는 부분이다.
queue에서 업데이트할 요소를 꺼내와서 기존의 baseState와 memoizedState를 적절히 사용해 모든 업데이트를 처리하는 과정이다.
이 과정에서 인자로 넘겨준 basicStateReducer가 사용된다.
이 부분에 대해서는 다음 게시글에서 좀 더 알아보자.
그리고 업데이트가 끝난 memoizedState와 queue.dispatch함수를 배열에 감싸 반환함으로서 useState를 우리가 사용할 수 있게된다.
결론
우선 그림으로 표현해보면 이렇다.
- hook은 Fiber객체에 memoizedState프로퍼티 키값으로 저장된다.
- hook은 여러개를 사용할 수 있으므로, 여러가지 값을 가지도록 관리되어야하는데, 링크드리스트로 구현되어있다.
- current (이미 DOM에 그려진 VDOM) 여부에 따라 useState의 구현체가 달라지게된다.
- 이걸 매개해주는 친구가 ReactCurrentDispatcher.current다.
다음에는 어떻게 업데이트가 이뤄지는지 mountState의 dispatchAction과 updateState의 업데이트로직을 살펴보며 setState가 어떻게 이뤄지는지 확인해보자.