제목 : React 런타임 코드 해부 노트 : Fiber와 Scheduler를 따라간 21일
부제 : setState 를 이해하기 위한 3500줄 추적기
이 글을 작성하는 이유 :
리액트의 상태 관리는 매우 복잡하게 연계되어 있다.
옛날에 리액트를 배울 때, 함수 컴포넌트, 클래스 컴포넌트의 상태 변경 방법을 공부했다.
아마 리액트를 써 보기만 해도 알 텐데, useState 와, setState 이다.
useState 를 이용하여, 컴포넌트에서 사용한 내부 상태를 생성한다.
setState 란, 클래스형 컴포넌트를 사용 할 시, 상태를 변경하기 위한 함수이다.
useState Hook 의 상태 설정 메서드는
setNum, setNumber, setString, setData 등등 원하는 대로 지정할 수 있다.
브라우저에서 변경된 내용에 대해 렌더링을 수행하기 위해 스스로 처음부터 코드를 작성한다면,
이러한 기능들이 필요 할 것이라고 생각한다.
- 특정 객체 혹은 원시값을 저장 할 저장소
- 이 저장소의 변경 사항을 구독하게 해 주는 기능
- 구독된 저장소가 변경되었을 때, 감지하여 리렌더링 해 주는 기능
- 구독한 저장소의 데이터를 하위 데이터에 뿌리고 있을 때,
자신을 포함한 모든 컴포넌트를 리렌더링 하지 않고, 변경된 데이터를 받는 컴포넌트만 재 렌더링
위의 기능들은 이미 리액트에서 지원하며, 더욱 풍부한 기능들을 제공한다.
지역적 데이터 뿐만 아니라, 전역 데이터까지 이를 적용하게 해 준다.
그런데, React 는 이를 어떻게 적용시켜줄까?
궁금증에 대해서 탐구를 시작해 보자.
이 글을 읽을 때 굉장히 중요한 점.
나는 단순히 함수형, 클래스형 컴포넌트의 개인 상태와 변화에 대해서 간단히 설명하려고 하지 않는다.
리액트의 개인, 전역 상태 지정과 변화에 대해서 빠르게 알고자 한다면, 리액트 공식문서를 참조하는 것이
훨씬 낫다는 것을 강력하게 주장한다.
나는 조금 더 원론적으로 궁금증을 해소하고자 한다.
- "리액트 컴포넌트에 개인 state 를 선언하고, 상호작용 시 어떤 과정으로 컴포넌트가 변하는가?"
위와 같은 스스로의 질문에 응답하기 위해 글을 작성한다.
그런데,
사실 앞서서 facebook/react 깃허브 패키지를 뒤지며 해당되는 여러 클래스와 함수를 살펴보았다.
그 결과, 내가 해석 한 내용이 AI 가 답변 할 내용보다 정확할 수 있나? 라는 생각이 든다.
따라서 만약 이 글을 읽으시는 분이 있으시다면,
다시 React 를 깊게 공부하려는 사람이 원론적으로 기반 지식을 공부했을 때 나오는 요약본이라고 생각하시면 됩니다.
가장 단순한 형태의 React.Component
ReactBaseClasses.js - Github (ReactBaseClasses.js)
클래스로 컴포넌트를 선언 할 때,
React.Component 선언 시 불러오는 가장 기본적인 형태의 컴포넌트이다.
// ...
/**
* Base class helpers for the updating state of a component.
*/
function Component(props, context, updater) {
this.props = props;
this.context = context;
// If a component has string refs, we will assign a different object later.
this.refs = emptyObject;
// We initialize the default updater but the real one gets injected by the
// renderer.
this.updater = updater || ReactNoopUpdateQueue;
}
// ...
Component.prototype.forceUpdate = function (callback) {
this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};
// ...
Component.prototype.setState = function (partialState, callback) {
if (
typeof partialState !== 'object' &&
typeof partialState !== 'function' &&
partialState != null
) {
throw new Error(
'takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
}
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
위의 코드는
ReactBaseClasses.js에서 컴포넌트에 해당하는 부분을 가져왔습니다.
먼저 JavaScript 를 정말 깊게 익힌 사람들이라면 이미 알고 있을 테지만,
JS 의 클래스는 JS 함수의 확장판이다. 즉, "문법적 설탕" 이라고 부르는 부분이다.
"Component" 가 왜 함수인가? 할 수 있지만, JavaScript 에서 함수는
클래스와 동등한 객체이다.
물론, 최신 스펙에서 클래스만을 위한 "#" - (private 지원) 기능이 있지만, 넘어가도록 하자!
평소에 사용하던 리액트의 컴포넌트와 다른 점이 많은데?
Component 는 우리가 알고 있는 것과 조금 다르게 생겼다.
props 가 상위 컴포넌트에서 내려오는 것은 알겠지만,
context, updater 는 왜 받는가? 그리고 updater 는 무엇인가?
먼저 알아야 할 것이 있다.
- 여기서 선언된
Component함수는React.Component구현체와 "정확히" 일치한다. updater인수는 컴포넌트 장착 및 처리 과정에서 update Queue 스케쥴러와 연결된다.React.Component에서this.setState실행 시,
할당 된 updater 의 스케쥴러 큐에 넘긴다.
사실 여기까지 알기 위해 깃허브 코드들을 뒤졌는데,
코드 가독력을 좀 더 키워야 하겠다는 생각을 많이 하게 되었다..
왜 Component 에서 render 가 없는데 어떻게 이를 호출하나?
자바스크립트는 타입이 매우 자유롭다. 그리고, 메타프로그래밍을 원활하게 지원한다.
메타프로그래밍의 일부 기능으로서, 특정 함수, 클래스, 변수에 대한 메타 정보를 삽입 및 추출 하는 기능이 있다.
즉, Component 는 리액트 컴포넌트 처리 후 장착 과정에서 render 함수를 인식하는 것이다.
원본 JavaScript 코드에서 render 함수가 없는 이유는, 이러한 예시를 들 수 있을 것 같다.
"아직 가공되지 않은 보석"
React 는 이러한 "가공되지 않은 보석"에 대한 코드,
"보석을 가공하는" 코드, "보석을 전시하는" 코드 등등이 존재한다.
React 의 상태 관리 자체를 자세히 설명하기가 굉장히 까다로운 이유가 이것이다.
리액트 자체가 기능이 굉장히 다양한데, 역할이 굉장히 세분화 되어 있어,
라이브러리 자체의 데이터 흐름을 이해해야 한다는 것이 어려움으로 작용한다.
render 를 인식하지만, 메서드는 개발자가 선언해야 한다.
요즈음 프로그래밍 언어 서버가 매우 발달되고 효율화되면서,
JavaScript 에서 사용되는 클래스를 TypeScript 레퍼런스로 알려준다.
우리는 이를 통해 "아 render 메서드를 통해 하위 컴포넌트를 렌더링 하는구나!" 하고 알 수 있다.
그러나, 우리가 보다시피, Component.prototype.render 이라는 문구는 전혀 없다.
즉, 사용자가 선언 해 주어야 한다.
그렇다면, 리액트에서는 render 를 도대체 어떻게 인식하는 걸까?
위에서 말했듯, JavaScript 는 메타프로그래밍 기법이 굉장히 활발하게 사용된다.
리액트는, 개발자가 선언한 React.Component 가 처리되는 과정에서,
이 컴포넌트가 "함수" 인지, "객체" 인지 판별한다.
만약에 클래스형 함수라면, 전달된 객체 인스턴스에서 .render 를 추출하여 업데이트 큐로 전달한다.
그러나, 만약에 함수형 함수라면, .render 를 추출하지 않고, 반환값을 업데이트 큐로 바로 전달한다.
변경 사항을 "객체" 로 전달하거나, "함수" 로 전달하기
함수, 혹은 객체가 가지고 있는 고유의 상태(state) 가 존재한다.
이 state(상태) 를 변경하기 위해, 2 가지의 방식이 존재한다.
1. 객체로 전달하기
import React from 'react';
class Test extends React.Component {
constructor(props) {
super(props);
this.state = {
count : 1
}
this.increase = this.increase.bind(this);
}
// 여기를 집중!
increase() {
// 객체로 전달하는 것을 볼 수 있음
this.setState({
count : this.state.count + 1
});
}
render() {
return (
<div>
<div>
count : {this.state.count}
</div>
<button onClick={this.increase}>
+1
</button
</div>
)
}
}
위 처럼 클래스형 컴포넌트에서 this.setState 를 사용할 때, 객체를 사용할 수 있다.
코드의 일부분을 보자.
increase() {
this.setState({
count : this.state.count + 1
});
}
이 코드의 경우, 우리가 사용하고 있는 원본 코드의 Component.prototype.setState 를 사용하여
새로운 상태의 적용을 this.updater.enqueueSetState(...) 로 보낸다.
그런데, 동일한 코드를 한 번 더 increase() 내부에 적었을 때,
increase() {
this.setState({
count : this.state.count + 1
});
this.setState({
count : this.state.count + 1
});
}
결국 이 코드는 중복 적용되지 않고, count : this.state.count + 1 의 형태로 종료된다.
왜 그렇냐면, 이 코드가 실행 될 때, this.state.count 는 고정된다.
즉, 만약에 this.state.count 가 현재 0 이라면 :
increase() {
// 마지막만 남음
// this.setState({
// count : this.state + 1
// });
this.setState({
count : this.state + 1
});
}
이러한 형태가 된다.
따라서, 객체로 전달 할 경우, 같은 값을 설정하게 되는 중복 코드는 회피해야 한다.
위 2 개의 this.setState 는 객체에게 할당된 정보를 결과적으로 스케줄러에게 넘어가는데,
FiberUpdate<State>Lane
Component 객체의 정보를 위의 3 개의 정보로 다시 만들고, 스케쥴러에게 넘겨 업데이트한다.
그러나, 실행 순서와는 무관하게,
this.setState 는 우리가 위에서 볼 수 있듯 결국 1 로만 설정 될 것이라는 것이다.
2. 함수로 전달하기
함수로 전달 할 경우, 중복 코드가 가능해진다.
그런데, 이전처럼 this.state 를 사용해서 접근하는 것이 아니라,
함수 자체에 원하는 형태의 인수를 적어서 사용해야 한다.
예시를 들어 보자면,
import React from 'react';
class Test extends React.Component {
constructor(props) {
super(props);
this.state = {
count : 10
}
this.decrease = this.decrease.bind(this);
}
// increase 에 이은, 감소, decrease
decrease() {
// prev 는 뭘까요? 한번 생각 해 봅시다.
this.setState((prev) => ({
count : prev.count - 1
}));
}
render() {
return (
<div>
<p>현재 count : ${this.state.count}</p>
<button onClick={this.decrease}>-1</button
</div>
)
}
}
이번에는 클래스형 컴포넌트에서 this.setState 인수로, function 타입인 "함수" 를 넣었다.
인수인 prev 는 무엇을 의미하는 것일까?
this.setState 는 "객체" 혹은 "함수" 에 따라서 다르게 동작한다.
"함수" 로 작성할 시, 인자 "이름" 을 줄 수 있는데,
이 때, prev 혹은, prevState 로 이름을 마음대로 정할 수 있다.
이 인자가 바로 중첩 가능하도록 만들어주는 역할을 해 준다.
즉, prev 라고 작성한다면, prev 는 업데이트 큐에서 현재 인스턴스의 this.state 를
동기적으로 접근하게 해 주는 인자가 되는 것이다.
decrease() {
this.setState((prev) => ({
count : prev.count - 1 // --> -1
}));
// 한 번 계산된 이후, 다시 계산할 수 있게 해 준다.
this.setState((prev) => ({
count : prev.count - 1 // --> -2
}))
}
만약에 이러한 형태로 중첩 코드를 작성해도,
업데이트 큐에서 차례로 실행될 때, prev 의 상태는 이미 결정된 것이 아니며,
업데이트 시, 계산된다.
따라서, 중첩 상태 관리 코드가 필요 할 경우, 우리는 함수를 인자로 주어
원하지 않는 상태를 예방할 수 있다.
this.setState 함수는 어떻게 생겼을까? (Fiber 클래스 진입)
Fiber 관련 클래스 파일 진입 전,
ReactBaseClasses.js 파일에서 React.Component 의 원형 코드를 살펴보자 :
그런데, 여기에 이런 코드 조각이 있다.
Component.prototype.setState = function (partialState, callback) {
if (
typeof partialState !== 'object' &&
typeof partialState !== 'function' &&
partialState != null
) {
throw new Error(
'takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
}
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
설명하자면
partialState는 위에서 "함수" 혹은 "객체"의 형태로 들어갈 수 있다고 말했다.
callback은 지정한 상태 변화가 끝난 뒤, 무엇을 할 지 콜백 함수를 넣어주는 공간이다.
예를 들어, function () {console.log(....)} 이렇게 넣을 수도 있다.
partialState가 "함수" 나 "객체" 형태가 아닐 경우, 에러를 던진다.
우리가
this.setState(...)할 경우, 최종적으로
this.updater.enqueueSetState메서드가 실행된다.이
updater는, 서로 다른 renderer 에 따라 업데이터가
하나는 기본적으로 주입되어 초기화되어야 한다고 "주석으로" 설명되어 있다.
this.updater 가 뭐지?
function Component(props, context, updater) {
this.props = props;
this.context = context;
// emptyObject 는 {} 이다.
this.refs = emptyObject;
// ReactNoopUpdateQueue 는 아직 마운트 되지 않았을 때만 할당된다. - 아직 신경쓰지 않아도 됨.
// 렌더링 환경에 따라, 다른 업데이터가 들어간다. EX- React Native, or 브라우저용 React 등등
this.updater = updater || ReactNoopUpdateQueue;
}
나는 React.Component 를 사용 할 때, updater 에 대해서 들어 본 적이 없다.
도대체 이건 뭘까?
Github 에는 훌륭한 기능으로, 동일한 메서드 Symbol 을 가지고 있는 모든 파일을 찾아주는 기능이 있다.
이 기능을 이용하여,
this.updater.enqueueSetState 중 enqueueSetState 메서드를 가진 클래스를 찾아냈다.
일단 확실 한 것은, classComponentUpdater 라는 값이
React.Component 의 this.updater 로 들어간다는 것을 확인했다.
this.updater.enqueueSetState(...., "setState")
부분이 있는데, "setState" 는 무시된 상태로 적용됩니다.
도대체 enqueueSetState 에서 무슨일이 일어나고 있는 거지?
ReactFiberClassComponent.js 에서 enqueueSetState 메서드의 행방을 찾았다.
그러나, 이 메서드는 그동안 보지 못했던 수많은 함수와 변수들로 가득 차 있었다.
한번 원본 코드를 보자 :
const classComponentUpdater = {
// inst 는 "this",
// payload 는 "함수" 혹은 "객체" 이며,
// callback 은 작성했거나, 없다. == undefined or null
enqueueSetState(inst: any, payload: any, callback) {
const fiber = getInstance(inst);
const lane = requestUpdateLane(fiber);
const update = createUpdate(lane);
update.payload = payload;
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback);
}
update.callback = callback;
}
const root = enqueueUpdate(fiber, update, lane);
if (root !== null) {
startUpdateTimerByLane(lane, 'this.setState()');
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitions(root, fiber, lane);
}
if (enableSchedulingProfiler) {
markStateUpdateScheduled(fiber, lane);
}
},
// ... 등등 여러 메서드들
}
이 부분부터 매우 당혹스러웠는데, 알 수 있는 것 부터 차근차근 정리하기로 했다.
각 변수의 생성과 데이터 변환 과정을 먼저 정리하기로 결정했다.
flowchart TB
inst -- getInstance --> fiber
fiber -- requestUpdateLane --> lane
lane -- createUpdate --> update
update이후, 우리가 지정한 "상태 객체" or "상태 객체 변환 함수" 를 지정하며,
지정한callback또한update로 지정한다.
중간의 이러한 메서드들이 호출되면서, 처음 보는 변수들을 생성하고 있었다.
그렇다면, 생성된 fiber, update, lane 변수는 무엇을 위해 생겨났냐면,
const root = enqueueUpdate(fiber, update, lane) 으로 사용되기 위해서이다.
inst, fiber, lane, update 변수를 생성하는 메서드 코드를 살펴보자.
리액트는 상태 변화를 적용 할 때, 단순하게 컴포넌트에 선언된 상태를 즉시 변환시키는 것이 아니라,
상태가 변환된 DOM 객체, 변화시킬 "함수" 혹은 "객체"를 받아서 적용하고 있었다.
그러한 과정을, enqueueSetState 메서드로 실행하고 있었다.
그러나, enqueueSetState 메서드 내부에는 처음 보는 단어들과 객체들이 존재했다.
Fiber 는 무엇이고, Lane 은 또 무엇인가?
한번 찾아보자.
먼저, classComponentUpdater 변수에 존재하던 enqueueSetState 메서드의
원본 코드를 다시 살펴보자.
// inst : React.Component
// payload : this.setState 에 들어간 객체 혹은 함수
// 상태 업데이트 후 실행하고 싶은 개발자의 코드
enqueueSetState(inst: any, payload: any, callback) {
const fiber = getInstance(inst);
const lane = requestUpdateLane(fiber);
const update = createUpdate(lane);
update.payload = payload;
// ... if --> update.callback = callback;
const root = enqueueUpdate(fiber, update, lane);
// ... root 가 없을 시 실행
},
코드를 읽고, 현재 알아보려는 내용과 상관이 깊지 않는 내용들은 주석으로 처리했다.
이제,
const fiber = getInstance(inst)const lane = requestUpdateLane(fiber)const update = createUpdate(lane)const root = enqueueUpdate(fiber, update, lane)
이러한 처음보는 변수와 메서드를 해석 해 보자.
만약에, fiber, lane, update 타입이 궁금하다면,
Fiber: Fiber 타입 선언 위치Lane: Lane 타입 선언 위치 - 그냥 number 이다.Update<State>: Update 상수 선언 위치 - Binary 형태로 수가 선언되어 있다.
0b0000000000000000000000000000100==4
1. getInstance(inst) : Fiber
getInstance 메서드 이름은 원본 이름이 아니다.
ReactFiberClassComponent.js 코드의 상단에서
메서드를 가져오면서, get 메서드를 getInstance 로 정정했다.
이 메서드를 가져온 곳은,
ReactInstanceMap.js 라는, 전혀 다른 디렉토리에 존재하는 파일이었다.
이 파일의 내부 :
/**
* `ReactInstanceMap` maintains a mapping from a public facing stateful
* instance (key) and the internal representation (value). This allows public
* methods to accept the user facing instance as an argument and map them back
* to internal methods.
*
* Note that this module is currently shared and assumed to be stateless.
* If this becomes an actual Map, that will break.
*/
export function get(key) {
return key._reactInternals;
}
export function set(key, value) {
key._reactInternals = value;
}
내부는 매우 간단하게 구현되어 있다.
const fiber = getInstance(inst) 로 사용되고 있는데,
즉, key 는 React.Component 인 것이다.
그런데, 기본 리액트 컴포넌트 코드에서 지금에 이르기까지,
_reactInternals 를 본 적이 없다.
즉, 리액트의 컴포넌트가 장착, 혹은 생성되는 과정에서,
set(key, value) 를 사용하여, React.Component 에
_reactInternals = value 를 실행시켜 value 를 넣어주는 것이다.
결국, classComponentUpdater 변수에서 const fiber = getInstance(inst)
로 사용되었기 때문에,
set 과정에서
key: React.Componentvalue: Fiber
로 설정 된 것이다.
이 파일은 react/packages/shared/ReactInstanceMap.js 인데,
내부의 메서드가 "특정 목적만을 위해" 만들어졌다고 보기는 어렵다.
즉, 인스턴스 내부 _reactInternals 변수에 다양한 형태의 정보를 넣을 수 있다.
React.Component 의 경우, Fiber 가 들어간다고 예측 할 수 있다.
2. requestUpdateLane(fiber) : Lane
requestUpdateLane(fiber) 메서드 파일의 위치
export function requestUpdateLane(fiber: Fiber): Lane {
// Special cases
const mode = fiber.mode;
if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
return (SyncLane: Lane);
} else if (
(executionContext & RenderContext) !== NoContext &&
workInProgressRootRenderLanes !== NoLanes
) {
// This is a render phase update. These are not officially supported. The
// old behavior is to give this the same "thread" (lanes) as
// whatever is currently rendering. So if you call `setState` on a component
// that happens later in the same render, it will flush. Ideally, we want to
// remove the special case and treat them as if they came from an
// interleaved event. Regardless, this pattern is not officially supported.
// This behavior is only a fallback. The flag only exists until we can roll
// out the setState warning, since existing code might accidentally rely on
// the current behavior.
return pickArbitraryLane(workInProgressRootRenderLanes);
}
const transition = requestCurrentTransition();
if (transition !== null)
// ... if(__DEV__)
return requestTransitionLane(transition);
}
return eventPriorityToLane(resolveUpdatePriority());
}
위 코드의 return pickArbitraryLane(...); 위의 주석을 살펴보면,
공식적으로 지원되지는 않는 코드라고 명시해 놓았다. 따라서,
requestCurrentTransition() 의 결과에 따라,
- NULL 이다 =>
requestTransitionLane(transition) - NULL 이 아니다. =>
eventPriorityToLane(resolveUpdatePriority())
중 하나가 반환된다.
requestCurrentTransition() 코드 : 코드 위치
import ReactSharedInternals from 'shared/ReactSharedInternals';
// ...
export function requestCurrentTransition(): Transition | null {
return ReactSharedInternals.T;
}
즉, 리액트 프로젝트가 자체가 공유하고 있는 ReactSharedInternals
값을 반환한다는 것을 추측할 수 있다.
ReactSharedInternals 객체 코드 : 코드 위치
import * as React from 'react';
const ReactSharedInternals =
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
export default ReactSharedInternals;
Transition 타입 위치 : 코드 위치
export type Transition = {
types: null | TransitionTypes, // enableViewTransition
gesture: null | GestureProvider, // enableGestureTransition
name: null | string, // enableTransitionTracing only
startTime: number, // enableTransitionTracing only
_updatedFibers: Set<Fiber>, // DEV-only
...
};
여기에서 굉장히 뇌정지가 왔다.
최대한 코드를 파헤치며, 이 코드가 무엇을 의미하는지 이해하려고 항상 노력하는데,
ReactSharedInternals 가,
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
이 값이라는 것을 어떻게 해도 이해 할 수가 없었다.
위에서 가져온 import * as React from "react"; 코드는
나의 생각만 더 혼란스럽게 만들었다..
- 현재
Transition을 요청하는 것은 알겠는데, 왜 요청하는지 모른다. Transition이 어디서 사용하는지 모른다.- 위의
requestUpdateLane(fiber) : Lane메서드의 분기를 제대로 이해하지 않았다.
따라서, 이 갈래에 대한 부분은 GPT 에게 물어보기로 결정했다. (현재 GPT-o3)
그래서 이 분기에 대한 답은 이러했다.
만약에, React17 버전 이하를 사용하고 있다면, SyncLane 을 반환한다.
그렇지 않고, React18 버전을 이용한다면,
- 대부분 자동으로 ConcurrentMode 가 적용된다. -
createRoot-->render
그리고, requestCurrentTransition 은, 현재 상태 변화가 useTransition 으로 생성된
트랜지션 내부에 상태 변화가 첨부되었는가를 물어보는 것이다.
당연히 useTransition 이 아니고, 단순히 setState 만 사용중이므로,
transition 은 null 이다.
따라서, React 18 이상을 사용하는 대부분의 프로젝트의 경우,
현재 eventPriorityToLane(resolveUpdatePriority()) 를 사용한다.
resolveUpdatePriority() 메서드는, ./ReactFiberConfig.js 파일로부터 가져왔다.
ReactFiberConfig.js :
/* eslint-disable react-internal/prod-error-codes */
// We expect that our Rollup, Jest, and Flow configurations
// always shim this module with the corresponding host config
// (either provided by a renderer, or a generic shim for npm).
//
// We should never resolve to this file, but it exists to make
// sure that if we *do* accidentally break the configuration,
// the failure isn't silent.
throw new Error('This module must be shimmed by a specific renderer.');
하하하하하하하하하!!!!!!!
분명히 가져온 경로가 맞는데... 이 모듈을 가져오면 에러가 일어나도록 만들어져 있다.
그래서 위의 주석을 약간 해석 해 보자면..
Jest 와 같은 테스팅 환경이나 플로우 설정 환경에서,
이 모듈을 가져오고 있는 환경이 특정 렌더러에 의해 다른 레인을 가져오고 있다는 것이다.
그리고, 호스트의 상황에 따라 현재 파일이 아닌, 다른 코드로 "주입"(shim) 되어야 한다는 것이다.
즉, 이 에러가 난다면, 렌더러 주입 설정이 잘못된 것으로 "의도적으로" 에러를 일으키는 것이라는 거다.
따라서, Github 의 기능을 통해, 같은 이름을 가진 메서드를 찾게 되었다!
ReactFiberConfig.custom.js 코드 위치
여기에 resolveUpdatePriority() 에 대한 정보를 곧바로 내보내고 있었다.
그런데, 이를 보니까, 파일 이름에 custom.js 가 붙여져 있었는데,
이는 React 를 렌더링하려는 여러 상황 :
- 브라우저
- 네이티브
- 테스팅
- 등등..
개발자가 다른 호스팅 상황을 구현하기 위해 사용하려는 커스텀 파일이다.
즉, 이 파일은 내가 찾는 파일이 아니다.
내가 찾는 파일은,
ReactFiberConfig.dom.js 코드 위치 이다.
export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
즉, resolveUpdatePriority() 함수는 위의 경로에서 올 것이다.
위의 경로와 파일 이름 dom 으로 알수 있듯, 브라우저 환경에서 구축되는
react-reconciler 환경변수와 함수들이 주입된다고 볼 수 있다.
1. react-dom-bindings/src/client/ReactFiberConfigDOM.js
이 파일에는 현재 디렉토리 계층에 존재하는 다양한 형태의 함수를 가져와서 다시 내보내고 있다.
파일 자체가 6005 줄이기 때문에, 필요한 코드만 보여주자면 :
/**
import ....
*/
export {
setCurrentUpdatePriority,
getCurrentUpdatePriority,
// 여기에서 내보내는 중.
resolveUpdatePriority,
} from './ReactDOMUpdatePriority';
/**
export ....
*/
따라서, ReactDomUpdatePriority.js 파일을 보자 :
ReactDomUpdatePriority.js 코드 위치
참고로, 이 파일은 버릴 것이 거의 없는 파일이라고 생각한다.
import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities';
import {getEventPriority} from '../events/ReactDOMEventListener';
import {
NoEventPriority,
DefaultEventPriority,
} from 'react-reconciler/src/ReactEventPriorities';
import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals';
export function setCurrentUpdatePriority(
newPriority: EventPriority,
// Closure will consistently not inline this function when it has arity 1
// however when it has arity 2 even if the second arg is omitted at every
// callsite it seems to inline it even when the internal length of the function
// is much longer. I hope this is consistent enough to rely on across builds
IntentionallyUnusedArgument?: empty,
): void {
ReactDOMSharedInternals.p /* currentUpdatePriority */ = newPriority;
}
export function getCurrentUpdatePriority(): EventPriority {
return ReactDOMSharedInternals.p; /* currentUpdatePriority */
}
export function resolveUpdatePriority(): EventPriority {
const updatePriority = ReactDOMSharedInternals.p; /* currentUpdatePriority */
if (updatePriority !== NoEventPriority) {
return updatePriority;
}
const currentEvent = window.event;
if (currentEvent === undefined) {
return DefaultEventPriority;
}
return getEventPriority(currentEvent.type);
}
export function runWithPriority<T>(priority: EventPriority, fn: () => T): T {
const previousPriority = getCurrentUpdatePriority();
try {
setCurrentUpdatePriority(priority);
return fn();
} finally {
setCurrentUpdatePriority(previousPriority);
}
}
왜 이 파일이 중요하다고 생각했을까?
이 글을 보고있으시다면, 우리는 resolveUpdatePriority() 함수를 찾기 위해
잠깐의 여정을 떠났다.
그런데, 이 함수는 특이한 점이 있다.
바로, 인자가 없다 는 점이다.
그렇다면 도대체, 현재 컴포넌트가 업데이트 될 "Lane" 을 구하기 위해,
현재 상태 변경을 요청한 컴포넌트가 들어간 상황도 아니고, 변환된 fiber 변수도 들어가지 않는다.
왜 그럴까?
여기서 요청하는 ReactDOMSharedInternals.p 데이터는,
리액트 시스템이 전역적으로 공유하며 넣어주기 때문이다.
2 번째 메서드였던
requestUpdateLane(fiber) : Lane설명이 길어지고 있지만,
필요한 내용이라고 판단해서 지속한다.
먼저,
setCurrentUpdatePriority(newPriority : EventPriority),getCurrentUpdatePriority()
이 2 개는 ReactDomSharedInternals.p 데이터를 설정하고, 가져오는 메서드라는 것만 알면 된다.
그래서, "도대체 어떤 코드가 ReactDOMSharedInternals.p 를 설정해 주는지 알아보기 위해,
깃허브의 자동 검색 기능을 이용하여 setCurrentUpdatePriority 코드 사용하는 위치를 추적했다.
한 30개가 넘는 참조 코드 위치가 존재했는데, 살펴 본 결과,
src/events/ReactDOMEventListner.js 가 거의 정확할 것이라고 판단했다.
버튼 이벤트 --> DOM 이벤트 (dispatchDiscreteEvent)
ReactDOMEventListener.js : function dispatchDiscreteEvent : 코드 위치
function dispatchDiscreteEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
container: EventTarget,
nativeEvent: AnyNativeEvent,
) {
const prevTransition = ReactSharedInternals.T;
ReactSharedInternals.T = null;
const previousPriority = getCurrentUpdatePriority();
try {
setCurrentUpdatePriority(DiscreteEventPriority);
dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
} finally {
setCurrentUpdatePriority(previousPriority);
ReactSharedInternals.T = prevTransition;
}
}
// function dispatchContinuousEvent ( 동일 ) {
// setCurrentUpdatePriority(ContinuousEventPriority); 만 다름
// }
//
//
// export function dispatchEvent( .... ) : void { .... }
자... 드디어 ReactSharedInternals.p 를 설정 해 주는 메서드를 찾았다.
이 코드 위치에 들어가서 맨 밑으로 내리면,
어떤 DOM 이벤트가 Discrete 에 해당하는지,
Continuous 에 해당하는지 알 수 있다.
만약에, 모든 경우에 해당하지 않는 DOM 이벤트 이름을 전해주면,
기본적으로 DefaultEventPriority 를 반환 해 준다.
결과적으로 보았을 때,
setCurrentUpdatePriority 를 사용하고 있는 이 파일의 코드는,
dispatchDiscreteEvent 함수와, dispatchContinuousEvent 함수 2 개였다.
그런데, 여기서 문제가 생긴다. 이 코드를 참조하는 다른 파일이 없다는 것이다.
아니 그렇다면, setCurrentUpdatePriority 는 도대체 누가 전역적으로 설정해 주는 건가?
그렇다면, reconciler 패키지 함수에 있던 setCurrentUpdatePriority 를 사용하는가?
물론, 위의 reconciler 를 사용하는 경우도 있겠지만,
function dispatchDiscreteEvent 코드 "바로 위" 를 보면, 이런 함수가 있다.
createEventListenerWrapperWithPriority(...) :
export function createEventListenerWrapperWithPriority(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
): Function {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent;
break;
}
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer,
);
}
위의 함수는 외부에서 dispatchDiscreteEvent 를 즉각 불러와서 사용하게 만드는 것이 아니고,
전달된 이벤트의 이름에 따라, EX - key down, mouse move, ... 등등
리액트 Listener 에 다른 이벤트를 "주입" 하고 있었던 것이다.
나의 경우, 버튼을 눌르는 예제를 들었기 때문에,
case DiscreteEventPriority: 에 해당한다.
그리고, 마지막으로 listenerWrapper.bind 는,
dispatchDiscreteEvent(...) 와 동일한 함수를 의미하게 된다.
참고로, .bind(null, ...) 은, this 역할을 해 줄 인자를 없앤 것이다.
-그래서 createEventListenerWrapperWithPriority 는 무슨 역할을 하나?-
이 메서드는 React 가 사용할 "Event Listener" 를 만드는데,
단순한 리스너 --> DefaultEvent 가 아니라,
앞으로 전담하게 될 이벤트들 - EX : 마우스, 키보드, 등등 DOM 이벤트들을
"미리 결합시켜 놓는" 역할을 수행 해 준다. - bind 로 묶음
잠깐만, 정리하고 넘어가겠습니다.
createEventListenerWrapperWithPriority(EventTarget, DOMEventName, EventSystemFlags)- 내부에서 리액트 이벤트 리스너를 "우선순위" 와 함께 bind 해 주고, 반환한다.
dispatchDiscreteEvent(DOMEventName, EventSystemFlags, EventTarget, ..)- DOM 이벤트 중, 버튼 클릭, 복사, 키 누름, 등등과 같은 단발성 이벤트에 대해서 동작한다.
- 여기에서
setCurrentUpdatePriority(...)를 통해 리액트의 전역 값을 담고 있는, ReactDOMSharedInternals.p를 설정한다.p: priority,T: 현재 트랜지션 상태라면 null, undefined 가 아님
setCurrentUpdatePriority-->resolveUpdatePriority() : EventPriority- test 환경을 제외한 "모든" 경우, 어떠한 함수나 객체이던
ReactDOMInternals.p를
설정하기 위해setCurrentUpdatePriority를 사용해야 한다. resolveUpdatePriority()메서드는, 일반적인 경우에는
ReactDOMSharedInternals.p--> 우선순위를 반환하는 것과 동일하고,- 만약, 리액트에서 특정되지 않은 DOM 이벤트라면,
DefaultEventPriority를 반환한다.
- test 환경을 제외한 "모든" 경우, 어떠한 함수나 객체이던
requestUpdateLane(fiber : Fiber) : Lane- 함수 내부의
eventPriorityToLane(resolveUpdatePriority())fiber은 리액트 컴포넌트를, 현재 보고 있는 업데이트 우선순위와 변경 논리에 추가하기 위한 일종의 변형 정보이다. 즉,fiber는 리액트 컴포넌트 스스로를 담고 있다.eventPriorityToLane코드 위치 : 위치- 위 파일에서
eventPriorityToLane은, 버튼 클릭과 같은 이벤트에
SyncLane을 반환하도록 "명시" 되어 있다. - 이 말은, 최대한 빠르게 즉시 처리한다는 의미이다.
enqueueSetState(instance, payload:변경할 상태, callback)함수 내부의
const lane = requestUpdateLane(fiber)- 이 메서드에서는
fiber,lane,update정보를 가지고, 동시성 업데이트 정보를 만들어서, - 스케쥴러에 등록한다.
- 단, 아직 이 메서드의 동작 과정은 전부 파악하지 않은 상태이다.
- 이 메서드에서는
Component.prototype.setState함수 내부의
this.updater.enqueueSetState(this, partialState, callback, 'setState')- 위의 "모든 과정" 을 트리거하는 함수이다.
- 우리가 단순하게 사용했던
state에 대한 관리는, 위와 같은 방식 그 이상으로 관리된다.
계층적으로 추적했으며, 내부의 함수 의미에 대해서도 알아보았다.
조금만 더.... 조금만 가면 끝이 나오지 않을까....?
정확하게 말하자면, 어떻게 setState 가 실행되었을 때,
이 상태 변환 이벤트에 대해서, 미리 "우선순위 (priority)" 가 설정되었는지 알지 못한다.
아마, createEventListenerWrapperWithPriority 메서드를 사용하는 "또 다른" 메서드가,
현재 내가 시작한 Component 를 등록하는 과정에서,
this.stateorthis.setState- 상태(state) 를 사용하기 위해 등록한 컴포넌트
- 특정 과정에서 인식한 이벤트 이름
이를 인수로 삼고 있다고 예상된다.
좀 더 나아가 보자.
createEventListenerWrapperWithPriority 함수를 사용하는 장소는 다행히 단, 한 장소였다.
바로, DOMPluginEventSystem.js 파일의 addTrappedEventListener 함수이다.
function addTrappedEventListener(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
isCapturePhaseListener: boolean,
isDeferredListenerForLegacyFBSupport?: boolean,
) {
let listener = createEventListenerWrapperWithPriority(
targetContainer,
domEventName,
eventSystemFlags,
);
// If passive option is not supported, then the event will be
// active and not passive.
let isPassiveListener: void | boolean = undefined;
if (passiveBrowserEventsSupported) {
// Browsers introduced an intervention, making these events
// passive by default on document. React doesn't bind them
// to document anymore, but changing this now would undo
// the performance wins from the change. So we emulate
// the existing behavior manually on the roots now.
// https://github.com/facebook/react/issues/19651
if (
domEventName === 'touchstart' ||
domEventName === 'touchmove' ||
domEventName === 'wheel'
) {
isPassiveListener = true;
}
}
targetContainer =
enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport
? (targetContainer: any).ownerDocument
: targetContainer;
let unsubscribeListener;
// When legacyFBSupport is enabled, it's for when we
// want to add a one time event listener to a container.
// This should only be used with enableLegacyFBSupport
// due to requirement to provide compatibility with
// internal FB www event tooling. This works by removing
// the event listener as soon as it is invoked. We could
// also attempt to use the {once: true} param on
// addEventListener, but that requires support and some
// browsers do not support this today, and given this is
// to support legacy code patterns, it's likely they'll
// need support for such browsers.
if (enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport) {
const originalListener = listener;
// $FlowFixMe[missing-this-annot]
listener = function (...p) {
removeEventListener(
targetContainer,
domEventName,
unsubscribeListener,
isCapturePhaseListener,
);
return originalListener.apply(this, p);
};
}
// TODO: There are too many combinations here. Consolidate them.
if (isCapturePhaseListener) {
if (isPassiveListener !== undefined) {
unsubscribeListener = addEventCaptureListenerWithPassiveFlag(
targetContainer,
domEventName,
listener,
isPassiveListener,
);
} else {
unsubscribeListener = addEventCaptureListener(
targetContainer,
domEventName,
listener,
);
}
} else {
if (isPassiveListener !== undefined) {
unsubscribeListener = addEventBubbleListenerWithPassiveFlag(
targetContainer,
domEventName,
listener,
isPassiveListener,
);
} else {
unsubscribeListener = addEventBubbleListener(
targetContainer,
domEventName,
listener,
);
}
}
}
알고 넘어가야 할 이 메서드 내부의 정보 :
EventTarget은 리액트 내부에서 정한 타입이 아니고, 브라우저 DOM 타입 그 자체를 의미한다.- 드디어.... 드디어! 리액트와 기본 DOM 이 연결되는 과정을 곧 볼 수 있다
eventSysteFlags는 숫자이며, 이따가0이 들어가는 것을 볼 수 있다.Legacy에 해당하지 않으므로, 관련된 변수는undefined이거나,false이다.listener은, 이전에 다룬createEventListenerWrapperWithPriority(...)결과물이다.- 결과물은 JS 기본 타입인
Function이며, DOM 에 부착 될 리스너 함수가 될 예정이다. DOMEventName은 기본 문자열로, html 개발 시 사용하는click과 같은 문자열이다.
- 결과물은 JS 기본 타입인
onClickCapture속성을 등록하지 않으므로,isCapturePhaseListener은false이다.isPassiveListener은 지속적으로 발생하는 이벤트 => 스크롤, 마우스 휠 감지 리스너를 의미한다.
즉, 위의 정보들을 조합하면, 결국 addTrappedEventListener 함수는,
addEventBubbleListener(targetContainer : EventTarget, domEventName : string, listener : Function)
의 형식으로 실행되고, 반환값은 unsubscribeListener 에 할당된다.
그런데, 이 변수는 지역 변수이지만, 사용되지 않으므로 신경쓰지 않아도 될 듯 하다.
결국, "클릭" 속성을 "버튼" 에 할당하고, 이를 this.setState 로 연결했을 때,
현재까지 추적된 메서드는 addEventBubbleListener(...) 이다.
react-dom-bindings/src/events/EventListener.js : 코드 위치
export function addEventBubbleListener(
target: EventTarget,
eventType: string,
listener: Function,
): Function {
target.addEventListener(eventType, listener, false);
return listener;
}
감격의 순간이다.
이 메서드와 내부는 전부 React 의 얽혀있는 타입이 아니라, 순수 JS, DOM 관련 타입이다.
즉,
target: DOM 객체로, 빌트인 메서드로addEventListener를 등록할 수 있다.
이는, 인자 3 개를 받는데, 1 -- 이벤트 문자열, 2 -- 이벤트 함수, 3 -- 콜백함수
이렇게 되어 있다.
즉, 우리는 addTrappedEventListener 메서드가 받는 인자
domEventName: 이벤트 이름 - 문자열targetContainer: 진짜 브라우저 DOM
그리고 createEventListenerWrapperWithPriority(...) 를 통해 생성된
리스너 함수를 "실제로" DOM 객체에 등록하게 된 것이다.
그 다음은? (이제 곧 우리가 리액트에서 보던 문법이 나옴.)
이제, addTrappedEventListener 를 호출하는 또 다른 클래스 혹은 함수를 찾아 보자.
addTrappedEventListener 를 사용하는 또 다른 함수들은 전부 같은 파일 안에 존재한다.
export function listenToNonDelegatedEvent(이벤트 이름, Element - 기본 DOM 타입)... listenToNativeEvent(이벤트 이름, 캡쳐여부 - false 예정, EventTarget)... listenToNativeEventForNonManagedEventTarget(이벤트 이름, 캡쳐여부, EventTarget)
일반 DOM 이벤트인데, 부착 대상이window와 같은 최상위 요소로,DOM요소가 아닌 경우.
결과적으로, listenToNativeEvent 가 실행된다. -> "버튼 클릭" 이벤트라서 그렇다.
delegate 라는 단어는 "위임", "대리" 하다 라는 의미인데,
버튼 클릭은 Root 에 "위임" 혹은 "대리" 시켜야 할 이벤트는 아니다.
따라서, listenToNativeEvent 함수가 실행된다.
export function listenToNativeEvent(
domEventName: DOMEventName,
isCapturePhaseListener: boolean,
target: EventTarget,
): void {
if (__DEV__) {
if (nonDelegatedEvents.has(domEventName) && !isCapturePhaseListener) {
console.error(
'Did not expect a listenToNativeEvent() call for "%s" in the bubble phase. ' +
'This is a bug in React. Please file an issue.',
domEventName,
);
}
}
// listenToNonDelegatedEvent 와는 다른 시스템 플래그이다.
let eventSystemFlags = 0;
if (isCapturePhaseListener) {
eventSystemFlags |= IS_CAPTURE_PHASE;
}
addTrappedEventListener(
target,
domEventName,
eventSystemFlags,
isCapturePhaseListener,
);
}
- 이 함수는
(이벤트 이름 - 문자열, 리액트가 관리하는 캡쳐링 이벤트 x - false, EventTarget)
을 인자로 받는다. - 만약에, 인자의 이벤트 이름이 "위임" 될 수 없는 이벤트이고, 리액트 루트에 캡쳐링되지 않았다면,
에러를 던진다. - 마지막으로
addTrappedEventListener(...)를 실행한다.
그리고, 동일한 파일에서, "단 하나" 의 메서드에서, listenToNativeEvent(...) 를 실행한다.
listenToAllSupportedEvents(rootContainerElement : EventTarget) : 코드 위치
const listeningMarker = '_reactListening' + Math.random().toString(36).slice(2);
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
if (!(rootContainerElement: any)[listeningMarker]) {
(rootContainerElement: any)[listeningMarker] = true;
allNativeEvents.forEach(domEventName => {
// We handle selectionchange separately because it
// doesn't bubble and needs to be on the document.
if (domEventName !== 'selectionchange') {
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement);
}
listenToNativeEvent(domEventName, true, rootContainerElement);
}
});
const ownerDocument =
(rootContainerElement: any).nodeType === DOCUMENT_NODE
? rootContainerElement
: (rootContainerElement: any).ownerDocument;
if (ownerDocument !== null) {
// The selectionchange event also needs deduplication
// but it is attached to the document.
if (!(ownerDocument: any)[listeningMarker]) {
(ownerDocument: any)[listeningMarker] = true;
listenToNativeEvent('selectionchange', false, ownerDocument);
}
}
}
}
먼저, listeningMarker 에 집중 해 보자. 결과적으로는 _reactListeneing19zisb7tn...
이러한 문자열을 의미하게 된다.
그런데, 이 메서드 내부에서는 인자로 받는 rootContainerElement : EventTarget 에,
만약 _reactListening19zisb7tn.... 와 같이 정확한 "Key" 가 이미 설정되거나, 존재한다면,
"아무것도" 실행되지 않는다.
if(!rootContainerElement[listeningMarker]) 조건문 때문이다. - 조금 축약함.
이는 listenToAllSupportedEvents 가 단 한번만 실행된다는 강력한 반증이다.
그리고, allNativeEvents 는 뭘까?
일단, 위에서 NonDelegated 와 Native 를 나누어 메서드를 실행했기 때문에,
단발성 이벤트만 allNativeEvents 에 들어갔다고 생각했다.
그런데, allNativeEvents 코드 위치 를 보고, "거의 대부분의 DOM 이벤트" 가 해당된다는 것을 알았다.
allNativeEvents 는 순회하며, 루트 컨테이너와 함께, 이벤트 이름으로 넘어간다.
이 때, 버블링, 즉, 단발성 이벤트의 경우
listenToNativeEvent(이벤트 이름, false, 루트 컨테이너) --> 버블링
로 넘어가며,
지속성 이벤트의 경우, listenToNativeEvent(이벤트 이름, true, 루트 컨테이너) --> 캡쳐링
으로 넘어간다.
그 이후는 현재 루트 노드가 selectionchange 이벤트를 수용하기 위해 수행하는 로직이다.
이제, listenToAllSupportedEvents 를 사용하는 함수 혹은 클래스를 찾아보자.
이 메서드를 사용하는 함수는 총 2 개가 있다.
createRoot(Element | Document | DocumentFragment, CreateRootOptions)hydrateRoot(Document | Element, ReactNodeList, HydrateRootOptions)
하하하하하하하하하하!!!!
드디어 리액트 개발 시 루트 엘리먼트를 지정하는 메서드인 createRoot 까지 도달했다!!!!
createRoot : 코드 위치
export function createRoot(
container: Element | Document | DocumentFragment,
options?: CreateRootOptions,
): RootType {
if (!isValidContainer(container)) {
throw new Error('Target container is not a DOM element.');
}
// __DEV__ 상황 일 때만 발동 (지금은 별 상관없는 메서드)
warnIfReactDOMContainerInDEV(container);
const concurrentUpdatesByDefaultOverride = false;
let isStrictMode = false;
let identifierPrefix = '';
let onUncaughtError = defaultOnUncaughtError;
let onCaughtError = defaultOnCaughtError;
let onRecoverableError = defaultOnRecoverableError;
let onDefaultTransitionIndicator = defaultOnDefaultTransitionIndicator;
let transitionCallbacks = null;
if (options !== null && options !== undefined) {
// 인자로 넘어온 options 가 존재한다면,
// 바로 위의 let 변수들은 options 에 담겨온 값으로 할당된다.
}
/**
* RootTag = 0 | 1;
* LegacyRoot = 0;
* ConcurrentRoot = 1;
*/
const root = createContainer(
container,
ConcurrentRoot, --> 1
null,
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
onUncaughtError,
onCaughtError,
onRecoverableError,
onDefaultTransitionIndicator,
transitionCallbacks,
);
markContainerAsRoot(root.current, container);
const rootContainerElement: Document | Element | DocumentFragment
= !disableCommentsAsDOMContainers && container.nodeType === COMMENT_NODE
? (container.parentNode: any)
: container;
listenToAllSupportedEvents(rootContainerElement);
// $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
return new ReactDOMRoot(root);
}
createRoot 는 어디에 사용될까?
<!DOCTYPE html>
<html>
<head> ... </head>
<body>
<div id="root"></div>
</body>
</html>
그리고, 이 파일과 연계된 JS 파일에는,
// jsx 파일이라고 가정한다.
const rootDOM = document.getElementById("root");
const root = ReactDOM.createRoot(rootDOM);
root.render(
...
)
이러한 형식으로, 리액트는 실제 루트가 될 DOM 하나를 가져와서,
이를 리액트 값(객체) 형식의 루트로 변형한다.
그 후, 생성된 리액트 루트를 렌더 함수를 통해 재귀적 렌더링을 시작한다.
자, 이제 우선순위가 어떻게 결정되는지 정리하자.
우리는 아직 2번 메서드 resolveUpdatePriority() 의 비밀을 풀기 위해 먼 길을 달려왔다.
인수가 존재하지 않던 이 메서드가 어떻게 우선순위를 반환했는지 이해하면,
3 번 메서드로 넘어갈 수 있다. (createUpdate(lane) : Update<State>)
3 번 메서드로 넘어가기 전, 그래프 정리
flowchart TB
subgraph createRoot
createRoot-role1("사용자는 이 메서드를 통해 리액트 루트 컨테이너를 생성할 수 있다.")
createRoot-role2("여기서 생성된 리액트 컨테이너-루트 를 인자로 listenToAllSupportedEvents 를 실행한다.")
end
subgraph listenToAllSupportedEvents
listenToAllSupportedEvents-role1("생성된 루트 컨테이너에 '대부분의 이벤트' 리스너를 달아준다.")
listenToAllSupportedEvents-role2("루프 과정 내부에서, listenToNativeEvent 메서드를 실행하며, 루트 컨테이너를 인자로 넣어준다.")
end
subgraph listenToNativeEvent
listenToNativeEvent-role1("단발성 이벤트 -EX 클릭- 와 같은 리스너, 혹은 스크롤과 같은 연속성 이벤트이나, 캡쳐링이 true 인 이벤트를 감지하며, 아니라면 오류를 검출한다.")
listenToNativeEvent-role2("이 때, 시스템 플래그를 0 으로 설정해 두며, addTrappedEventListener 메서드를 '실행' 한다.")
end
subgraph addTrappedEventListener
addTrappedEventListener-role1("다양한 갈래의 이벤트 등록 함수들을 케이스에 따라 실행 하는 역할을 해준다.")
addTrappedEventListener-role2("addEvent... 이름을 가진 다양한 함수를 인수에 따라 실행시켜주는 역할을 해 준다.")
addTrappedEventListener-role3("addEvent... 이름을 가진 함수들은, 실제 targetContainer 인 React 루트에 이벤트 리스너를 JS 형태로 등록한다.")
addTrappedEventListener-role4("실제로 리스너를 등록 해 주기 위해, createEventListenerWrapperWithPriority(...) 메서드를 실행하여 리스너를 반환받는다.")
end
subgraph createEventListenerWrapperWithPriority
createEventListenerWrapperWithPriority-role1("인수로 내려온 domEventName 문자열에 따라, getEventPriority(domEventName) 실행으로 어떤 우선순위인지 확인한다.")
createEventListenerWrapperWithPriority-role2("이 이벤트 우선순위는 동적으로 결정되지 않으며, 단발성 이벤트, 연속성 이벤트에 따라 다른 이벤트 함수가 '그대로' 바인딩된다.")
createEventListenerWrapperWithPriority-role3("이 과정에서, 클릭 이벤트에 대해 dispatchDiscreteEvent 함수가 이 메서드에 그대로 바인딩된다.")
end
subgraph dispatchDiscreteEvent
dispatchDiscreteEvent-role1("이 함수는 단발성 이벤트를 위해 실행되는 함수이다.")
dispatchDiscreteEvent-role2("전역적으로 공유되는 ReactSharedInternals 변수 중 트랜지션과 우선순위를 잠깐 저장하고, 트랜지션은 null 로 만든다.")
dispatchDiscreteEvent-role3("전역적 공유 변수 Reac.. 의 우선순위를 DiscreteEventPriority 로 변경한다.")
dispatchDiscreteEvent-role4("dispatchEvent 메서드를 실행한다.")
dispatchDiscreteEvent-role5("이벤트 처리 이후, 다시 전역 객체 ReactSharedInternals 의 트랜지션과 우선순위를 그대로 다시 할당 해 준다.")
end
subgraph React-Component
subgraph values
this-props
this-context
this-updater
this-props ~~~ this-context ~~~ this-updater
end
subgraph prototypes
isReactComponent
setState
forceUpdate
isReactComponent ~~~ setState ~~~ forceUpdate
end
end
subgraph Component-setState
Component-setState-role1("변경할 상태가 함수나 객체인지 확인한다.");
Component-setState-role2("컴포넌트 자신과 변경할 상태, 콜백 정보를 enqueueSetState 에 넘긴다.")
end
subgraph enqueueSetState
enqueueSetState-role1("클래스 컴포넌트에서 this.setState 를 실행했을 때, 이 메서드가 실행된다.")
enqueueSetState-role2("Fiber, Lane, Update<State> 정보를 활용하여 업데이트를 실행한다.")
end
subgraph ReactSharedInternals
ReactInternals-role1("코드 실행 시 다양한 전역 컨텍스트를 관리함")
ReactInternals-method1("setCurrentUpdatePriority(우선순위)")
ReactInternals-method2("getCurrentUpdatePriority() : 전역 우선순위")
end
subgraph requestUpdateLane
requestUpdateLane-role1("setState 를 실행한 인스턴스를 Fiber 클래스로 변형한 인자를 받는다.")
requestUpdateLane-role2("현재 상태가 트랜지션에 속한 상태인지, 아닌지에 따라 반환하는 lane 이 달라진다.")
requestUpdateLane-role3("Lane == EventPriority 타입 동일이기 때문에, 클릭의 경우 SyncLane 을 반환한다.")
requestUpdateLane-role4("이 메서드에서 resolveUpdatePriority() 메서드를 실행한다.")
end
subgraph eventPriorityToLane
eventPriorityToLane-role1("resolveUpdatePriority 메서드로 우선순위를 받아온다.")
eventPriorityToLane-role2("우선순위는 Lane 타입과 매칭되어 트랜지션 상황이 아닌 이상, 각각의 이벤트에 대해 매칭된 Lane, 클릭의 경우 SyncLane 을 반환한다.")
end
subgraph resolveUpdatePriority
resolveUpdatePriority-role1("전역 객체 ReactDOMSharedInternals 의 우선순위 p 를 빼낸다.")
resolveUpdatePriority-role2("추출한 우선순위는 Lane 타입과 일치하며, Discrete 이벤트의 경우 SyncLane 과 동일하다.")
end
createRoot --> listenToAllSupportedEvents
listenToAllSupportedEvents --> listenToNativeEvent
listenToNativeEvent --> addTrappedEventListener
addTrappedEventListener --> createEventListenerWrapperWithPriority
createEventListenerWrapperWithPriority --> dispatchDiscreteEvent
dispatchDiscreteEvent <--> ReactSharedInternals
React-Component --> Component-setState
Component-setState --> enqueueSetState
enqueueSetState --> requestUpdateLane
requestUpdateLane --> eventPriorityToLane
eventPriorityToLane --> resolveUpdatePriority
resolveUpdatePriority --> ReactSharedInternals
위의 과정에서, 아직 dispatchEvent 라는 중요한 메서드를 파악하지 못했다.
사실 그 내용도 작성하고 싶지만, 요약해서 말하는 것이 맞다고 판단했다.
이후에
dispatchEvent를 추적 및 조사했는데,
이 메서드 내부에서executionContext라는 ReactFiberWorkLoop.js 파일 전역 변수를BatchedContext = 0b001로 변경한다. (나중에 중요함)
위의 그래프는, 이러한 의미를 담는다.
컴포넌트가 setState 라는 메서드를 실행하게 되었을 때,
결과적으로 업데이트를 실행할 레인을 전역적으로 구하기 위해 resolveUpdatePriority 를 실행한다.
그런데, 이 메서드에는 "인자" 가 없다. 어떻게 이 변경점에 대해 우선순위를 판별하는 것인가?
따라서, resolveUpdatePriority 메서드는 ReactSharedInternals.p 와 완벽하게 연관관계가 존재한다.
따라서, 이 값을 관리하는 setCurrentUpdatePriority 메서드를 사용하는 또 다른 함수를
"역으로" 추적하는 것이다.
이 때, 나는 "버튼 클릭" 이라는 가상의 시나리오를 만들어서 추적했다.
결과적으로, createRoot 라는 리액트 로직 최상단의 메서드까지 도달했다.
그러나, 아직 풀리지 않은 의문점들은 여전히 많다.
즉,
"버튼 클릭" 시, dispatchDiscreteEvent 를 실행하는 주체가 ReactDOM 의 Root 라는 것.
물론, 연속적 이벤트의 경우, Root 가 관리하기 보다는, 해당 이벤트를 관리하는 컴포넌트가 할당된다.
역추적 중 알게 된 사실
루트 돔이 생성되는 순간, "대부분의" 이벤트들은 미리 루트 돔에 등록된다.
우리가 일반적으로 사용하는 DOM 에 리스너를 장착하는 방식과 조금 달리,
위임 가능한 이벤트, 불가능한 이벤트로 나뉘어 등록된다. (delegated, nonDelegated)
루트 돔이 "대부분의" 이벤트 리스너를 등록하는 과정에서, 각각의 이벤트 리스너는 고유의 우선순위 상수를 부여받는다.
그리고 이러한 우선순위는 해당 이벤트가 발생했을 때 "정해진" Priority 를 전역으로 설정한다.
즉, 컴포넌트에서 setState 가 실행된다면, 이 Priority 를 읽어 Lane 을 얻게 된다.
그러나, 특정 로딩, 즉, 트랜지션과 함께 묶이는 상황, Transition 과 묶인 setState 라면,
그 우선순위는 바뀐다.
정확한 것은 리액트 공식문서의
useTransition을 검색해 보길 바랍니다.
3. createUpdate(lane) : Update
드디어 길고 길었던 "우선순위 설정" 에 대한 의문을 풀고, 넘어왔다.
우리가 createUpdate(lane) 의 로직을 보려는 것은,
아직 enqueueSetState 에 대한 로직 파악이 끝나지 않았기 때문이다.
classComponentUpdater.enqueueSetState == Component.(this.updater) : 코드 위치
enqueueSetState(inst: any, payload: any, callback) {
const fiber = getInstance(inst); // 완료 - 컴포넌트의 fiber 정보 객체로 추출
const lane = requestUpdateLane(fiber); // 완료 - 추출된 fiber 로 우선순위 레인 추출
const update = createUpdate(lane); // 이제 이 로직을 살펴 볼 차례
update.payload = payload;
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback);
}
update.callback = callback;
}
const root = enqueueUpdate(fiber, update, lane);
if (root !== null) {
startUpdateTimerByLane(lane, 'this.setState()');
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitions(root, fiber, lane);
}
if (enableSchedulingProfiler) {
markStateUpdateScheduled(fiber, lane);
}
},
createUpdate(lane : Lane) : Update<mixed> : 코드 위치
// Update<State> 타입
export type Update<State> = {
lane: Lane,
tag: 0 | 1 | 2 | 3,
payload: any,
callback: (() => mixed) | null,
next: Update<State> | null,
};
export const UpdateState = 0;
export const ReplaceState = 1;
export const ForceUpdate = 2;
export const CaptureUpdate = 3;
export function createUpdate(lane: Lane): Update<mixed> {
const update: Update<mixed> = {
lane,
tag: UpdateState,
payload: null,
callback: null,
next: null,
};
return update;
}
이는 업데이트 상태를 나타낼 특정 객체를 만들어내는 일종의 new .... 와 비슷한 과정이다.
업데이트는 어떤 Lane 을 타야 하는지, 어떤 태그를 가지는지,
어떻게 바꿔야 하는지(payload), 이 업데이트가 끝나면서 어떤 콜백 함수가 실행되어야 하는지,
그리고 next 다음 업데이트는 무엇인지에 대한 정보를 담게 된다.
이 초기화 된 Update<State> 를 받고 나서,
update.payload = payload == this.setState 내부에 있던 객체 혹은 함수
update.callback = callback == 개발자가 원하면 콜백 함수를 넣을 수 있음
4. enqueueUpdate(fiber, update, lane) : FiberRoot | null
enqueueSetState(inst: any, payload: any, callback) {
const fiber = getInstance(inst); // 완료 - 컴포넌트의 fiber 정보 객체로 추출
const lane = requestUpdateLane(fiber); // 완료 - 추출된 fiber 로 우선순위 레인 추출
// 완료 - 우선순위 레인으로 Update<State> 초기화 객체 가져옴. tag : 0 인 상태.
const update = createUpdate(lane);
update.payload = payload; // 업데이트 객체에 우리가 삽입한 변경 객체 혹은 함수를 주입
if (callback !== undefined && callback !== null) {
// ...
update.callback = callback; // this.setState 에 인자로 준 콜백 함수 주입
}
const root = enqueueUpdate(fiber, update, lane); // 이 코드를 파헤칠 시간
if (root !== null) {
startUpdateTimerByLane(lane, 'this.setState()');
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitions(root, fiber, lane);
}
if (enableSchedulingProfiler) {
markStateUpdateScheduled(fiber, lane);
}
},
여태까지 조사 한 것들과, enqueueUpdate(fiber, update, lane) 을 보자면,
this.setState 를 실행시킨 컴포넌트의 Fiber 형태,
그리고 초기화 된 Update<State> 객체,
현재 이벤트에 대한 우선순위를 기반으로 설정된 Lane 이라는 바이너리 값까지 들어간다.
enqueue 는 "꼬리" 를 의미한다. 아마 이 메서드는 새로운 업데이트를 큐에 넣는다는 의미가 아닐까?
라는 추측하며 코드를 분석 해 본다.
enqueueUpdate(fiber, update, lane) : FiberRoot | null : 코드위치
export function enqueueUpdate<State>(
fiber: Fiber,
update: Update<State>,
lane: Lane,
): FiberRoot | null {
const updateQueue = fiber.updateQueue;
if (updateQueue === null) {
// 주석 해석 : 오로지 파이버가 언마운트 되었을 때만 null 로 반환한다.
return null;
}
const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
if (__DEV__) {
// 개발 상황 시
}
if (isUnsafeClassRenderPhaseUpdate(fiber)) {
/**
* UNSAFE_ 접두어가 붙은 메서드를 사용했을 때 이 분기로 들어온다.
* 그러나, 개발할 때 이런 접두어를 쓰지 않았다면 신경쓰지 않아도 된다.
*/
return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
} else {
// 결국 이 분기가 선택됨.
return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane);
}
}
위의 메서드는 enqueueConcurrentClassUpdate 메서드를 실행하기 위해,
특별한 인자 sharedQueue 를 기존 인스턴스의 fiber 로부터 updateQueue 를 추출하고 있다.
나는 이 글의 초창기 수준에서 살펴보았던 Fiber 타입을 다시 열어보았다 : Fiber 타입 선언
여기서 Fiber 타입의 updateQueue 를 살펴보려고 하는데, 전혀 발견되지 않았다.
즉, 특정 코드에서 updateQueue 를 타입에 맞지 않게 주입해 줬다는 결론에 도달했다.
이번에는 왠일로, `updateQueue 를 주입해 준 코드를 쉽게 찾을 수 있었다.
enqueueUpdate 코드가 작성된 동일한 파일에서, 176 번째 줄
function initializeUpdateQueue<State>(fiber : Fiber) : void 에서 찾았다.
export function initializeUpdateQueue<State>(fiber: Fiber): void {
const queue: UpdateQueue<State> = {
baseState: fiber.memoizedState,
firstBaseUpdate: null,
lastBaseUpdate: null,
shared: {
pending: null,
lanes: NoLanes,
hiddenCallbacks: null,
},
callbacks: null,
};
fiber.updateQueue = queue;
}
"업데이트 큐를 초기화" 하는 과정에서,
fiber.updateQueue 의 값에 어떠한 값이 초기화 되어 들어가는지 보았다.
그리고, 기존 코드에서는 SharedQueue<State> 타입으로 fiber.updateQueue.shared
이 값을 추출하고 있었다.
Update<State> && SharedQueue<State> && UpdateQueue<State> : 코드 위치
export type Update<State> = {
lane: Lane,
tag: 0 | 1 | 2 | 3,
payload: any,
callback: (() => mixed) | null,
next: Update<State> | null,
};
export type SharedQueue<State> = {
pending: Update<State> | null,
lanes: Lanes,
hiddenCallbacks: Array<() => mixed> | null,
};
export type UpdateQueue<State> = {
baseState: State,
firstBaseUpdate: Update<State> | null,
lastBaseUpdate: Update<State> | null,
shared: SharedQueue<State>,
callbacks: Array<() => mixed> | null,
};
나는 각 객체의 타입이 어떤 형식을 갖추는지 정확히 알겠으나,
어찌하여 업데이트 Queue와, 공유 Queue 가 따로 만들어져 있는지 알 수는 없었다. (곧 알게 되겠지만.)
하나 확실한 것은, 우리가 this.setState(State) 로 실행한 이후,
새로운 State 요청에 대해 업데이트를 수행하기 위해, 기본적인 유닛은 Update<State> 라는 것이다.
참고로 mixed 는, 어떠한 형태던 반환될 수 있는 일종의 표식이라고 이해 할 수 있다.
자 이제, enqueueUpdate(fiber, update, lane) : FiberRoot | null 메서드에 내부의
return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane) 을
이제 알아볼 차례다.
enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane) : 코드 위치
// 타입의 이름이 바뀐 것을 알 수 있음
import type {
SharedQueue as ClassQueue,
Update as ClassUpdate,
} from './ReactFiberClassUpdateQueue';
// ConcurrentUpdate 와 ConcurrentQueue 타입에 대한 추가 정보
export type ConcurrentUpdate = {
next: ConcurrentUpdate,
lane: Lane,
};
type ConcurrentQueue = {
pending: ConcurrentUpdate | null,
};
/**
...
*/
export function enqueueConcurrentClassUpdate<State>(
fiber: Fiber,
queue: ClassQueue<State>, // SharedQueue<State> 에서 변형됨 - 바로 위를 참조
update: ClassUpdate<State>, // Update<State> 에서 변형됨 - 바로 위로 참조
lane: Lane,
): FiberRoot | null {
const concurrentQueue: ConcurrentQueue = (queue: any);
const concurrentUpdate: ConcurrentUpdate = (update: any);
enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);
return getRootForUpdatedFiber(fiber);
}
위를 보면, 오히려 Update<State>, SharedQueue<State> 가
ConcurrentQueue, ConcurrentUpdate 보다 프로퍼티가 적은 것을 볼 수 있다.
왜 그럴까?
queue : any, update : any 인 것을 볼 수 있다.
즉, 이 부분에서 추측 할 수 있는 것은, 실제로는 프로퍼티가 줄어들지는 않았지만,
TypeScript 에게 좁은 스코프를 주기 위함이며, 또한 가독성을 위해 타입을 선언했다는 것을 알 수 있었다.
그렇지 않으면, 굳이 : any 를 선언 할 필요가 없었기 때문이다.
자, 이제 enqueueUpdate 메서드와, getRootForUpdatedFiber 메서드를 조사 할 시간이다.
enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane) : 코드 위치
function enqueueUpdate(
fiber: Fiber,
queue: ConcurrentQueue | null,
update: ConcurrentUpdate | null,
lane: Lane,
) {
// Don't update the `childLanes` on the return path yet. If we already in
// the middle of rendering, wait until after it has completed.
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
// The fiber's `lane` field is used in some places to check if any work is
// scheduled, to perform an eager bailout, so we need to update it immediately.
// TODO: We should probably move this to the "shared" queue instead.
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
}
enqueueUpdate 는 바로 직전의 enqueueConcurrentClassUpdate 메서드와
동일한 인자를 받고 있다.
enqueueConcurrentClassUpdate 는, 인자의 queue 와 update 타입 스코프를 줄여주는
역할만 수행했다. (물론 객체 안에 미리 든 프로퍼티는 사라지지 않는다.)
이 메서드는 어떠한 값도 반환하지 않는다. 하지만, 주목해야 할 점이 2 개가 있다.
- 이 파일의 전역 배열
concurrentQueues에 인자로 주어진 "모든" 정보를 차례대로 넣는다. fiber.lanes = mergeLane(fiber.lanes, lane);코드를 통해,
fiber.lanes가 갱신이 된다는 것이다.
concurrentQueues 배열은 어떻게 정의되어 있나? : 배열 선언 위치
// If a render is in progress, and we receive an update from a concurrent event,
// we wait until the current render is over (either finished or interrupted)
// before adding it to the fiber/hook queue. Push to this array so we can
// access the queue, fiber, update, et al later.
const concurrentQueues: Array<any> = [];
let concurrentQueuesIndex = 0;
let concurrentlyUpdatedLanes: Lanes = NoLanes;
일단, 이 배열 객체와 인덱스는 외부로 export 하지 않는다.
즉, 이 파일에서만 전역적으로 접근하는 값이라는 것이다.
-주석은 뭐라고 하고 있을까?-
만약에, 렌더가 진행중인데, 동시성 이벤트로부터 업데이트 객체를 수신한다면,
현재 렌더가 끝날 때 까지 기다린 후에, (종료되거나, 인터럽 되거나.)
fiber/hook 큐에 추가한다.
솔직히 말해서, 배열 안에 "동일한 형태의" 요소들은 나열하는 것이 아니라,
각기 다른 형태의 객체, 혹은 원시값을 차례대로 넣는것이 과연 효율적인가? 에 대해서 의문이 들기도 한다.
어떤 의미가 있을 지 모르니, 다시 코드로 돌아와서 로직을 살펴보자.
export function getConcurrentlyUpdatedLanes(): Lanes {
return concurrentlyUpdatedLanes;
}
function enqueueUpdate(
fiber: Fiber,
queue: ConcurrentQueue | null,
update: ConcurrentUpdate | null,
lane: Lane,
) {
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
// The fiber's `lane` field is used in some places to check if any work is
// scheduled, to perform an eager bailout, so we need to update it immediately.
// TODO: We should probably move this to the "shared" queue instead.
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
}
코드를 다시 본다면, ReactFiberConcurrentUpdate.js 코드 내부의 전역 배열인,
concurrentQueues 에, 차례대로 인자를 넣고 있는 것을 볼 수 있다.
만약에, 현재 concurrentQueuesIndex 가 0 이라면,
- state 이벤트를 일으킨 인스턴스의 fiber 객체 -
Fiber타입 - 이 fiber 객체의
updateQueue.shared속성 -SharedQueue<State>타입 - state 상태 변화 요구에 대한 정보를 담고 있는
Update<State>타입 - 이 이벤트를 처리할 우선순위 레인 - 클릭의 경우
SyncLane(바이너리) == 2
이렇게 각기 다른 4 개의 객체 혹은 원시값을 파일의 전역 배열에 담고 있다.
그런데, 위 코드를 다시 보자. 이 메서드는 "전역 배열을 사용하지 않는다."
따라서, 우리는 앞으로 수행할 로직 중, 특정 메서드가 이를 사용하거나, 다시 초기화시킨다는 것으로
추정할 수 있다.
실제로, finishQueueingConcurrentUpdates() 라는 메서드 :
가 이 전역 배열을 초기화 해 주긴 한다. (배열 내부를 순회하며 전부 null 로 만듬)
그 다음으로,
concurrentlyUpdatedLanes = mergeLanes(currentlyUpdatedLanes, lane)
메서드를 살펴보자.
concurrentlyUpdatedLane 전역 변수 (Lanes 타입) 은, 외부로부터 노출되는 변수이다.
mergeLanes(a : Lanes | Lane, b : Lanes | Lane) : Lanes : 코드 위치
export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
return a | b;
}
Lanes 와, Lane 은 기본적으로 number 로 표기되어 있지만,
0b00000000000000000.... 로 표시되는 바이너리 숫자이다.
그런데, 이 메서드에서는 이진 연산 표기법이 나왔다.
&: 각 자리수 비교시 동일하게 1 이 나왔을 때만 그 자리에 1이 오고, 나머지는 0 처리|: 각 자리수 비교시 한 쪽이라도 1 이 나왔을 때, 해당 자리는 1 이 온다.
그렇다면,
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
이 코드는 무엇을 의미할까?
즉, 전역 변수인 concurrentlyUpdatedLanes 는 NoLanes 로, 0 을 의미한다.
그런데, 우리가 전달해 준 lane 은 SyncLane 으로, 2 에 해당하는 바이너리이다.
그렇다면, 이 둘을 | 연산하면, concurrentlyUpdatedLanes 는, 2 가 된다.
따라서 이 파일의 전역 변수 concurrentlyUpdatedLanes 는 2 로 참조가 된다.
그리고, fiber.lanes = mergeLanes(fiber.lanes, lane); 에도 동일한 연산이 수행된다.
Fiber 타입의 기본적인 lanes 속성 또한 어디서 초기화 되었을 수도 있기 때문에,
ReactFiberClassComponent.js 파일의 constructClassInstance 함수를 살펴보았으나,
Fiber 객체 중 lanes 나 lane 을 조정하는 코드는 없었다.
즉, 초기화 된 NoLanes 라고 가정한다. == 0
즉, fiber.lanes = mergeLanes(0, 2); 가 되며,
결론적으로 fiber.lanes = 2 가 된다.
fiber 의 alternate 변수의 역할에 대해 이해하지 못해서 GPT o3 에게 물어보았는데,
Work In Progress <--> current
이 두 개의 트리는 Fiber 로 이루어져 있는데,
이 과정에서 각 노드를 의미하는 fiber 가 얕은 복사를 하며 같은 계층을 구성하고,
서로 다른 트리의 동일한 노드를 alternate 로 가지는 것이라고 한다.
즉, alternate.lanes = mergeLanes(alternate.lanes, lane) 을 통해 동일한 연산을 수행한다.
다시 돌아와서, enqueueConcurrentClassUpdate<State> 메서드를 보자.
export function enqueueConcurrentClassUpdate<State>(
fiber: Fiber,
queue: ClassQueue<State>,
update: ClassUpdate<State>,
lane: Lane,
): FiberRoot | null {
const concurrentQueue: ConcurrentQueue = (queue: any); // SharedQueue<State>
const concurrentUpdate: ConcurrentUpdate = (update: any); // Update<State>
enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane); // 완료
return getRootForUpdatedFiber(fiber);
}
우리는 enqueueUpdate 메서드를 통해 어떤 작업을 수행했냐면,
fiber,concurrentQueue,concurrentUpdate,lane을
전역 배열concurrentQueues에 차례대로 넣었다.- 전역 변수
concurrentlyUpdatedLanes바이너리를lane으로 병합시켰다. fiber,fiber.alternate를 주어진 인자lane으로 병합시켰다.
자, 이제 getRootForUpdatedFiber(fiber) 를 살펴보자.
getRootForUpdatedFiber(sourceFiber : Fiber) : FiberRoot | null : 코드위치
function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null {
// 순환참조 오류 때문에 업데이트가 업데이트를 불러오고, 이것이 서로 연결되었다면 에러를 던짐.
throwIfInfiniteUpdateLoopDetected();
// 개발 상황 시에만 고려
detectUpdateOnUnmountedFiber(sourceFiber, sourceFiber);
let node = sourceFiber;
let parent = node.return;
while (parent !== null) {
detectUpdateOnUnmountedFiber(sourceFiber, node);
node = parent;
parent = node.return;
}
return node.tag === HostRoot ? (node.stateNode: FiberRoot) : null;
}
function detectUpdateOnUnmountedFiber(sourceFiber: Fiber, parent: Fiber) {
if (__DEV__) {
const alternate = parent.alternate;
if (
alternate === null &&
(parent.flags & (Placement | Hydrating)) !== NoFlags
) {
warnAboutUpdateOnNotYetMountedFiberInDEV(sourceFiber);
}
}
}
detectUpdateOnUnmountedFiber 는, 우리가 아직 개발 상황으로 리액트를 조작할 때 실행되는
로직이다.
이 메서드는 현재 fiber 가 마운트 중(아직 렌더링 안됨), 언마운트 중(렌더링에서 빼는중)
이 상황일 때, 에러를 던진다.
getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null 메서드를 분석해보자.
먼저, 우리는 이 메서드를 setState 를 일으킨 인스턴스의 fiber 를 인자로 받아 실행한다.
그리고,
let node = sourceFiber;
let parent = node.return;
while (parent !== null) {
detectUpdateOnUnmountedFiber(sourceFiber, node);
node = parent;
parent = node.return;
}
return node.tag === HostRoot ? (node.stateNode : FiberRoot) : null;
이 코드 조각은 정확히 이렇게 의미한다.
Fiber.return은 현재fiber의 부모fiber를 의미한다.while문의 결과로,parent는null이 되며,node는 최고 부모인root가 된다.node는 루트이기 때문에, 이 루트fiber노드를 반환한다.
다시 돌아와서,
enqueueUpdate(fiber, update, lane) : FiberRoot | null 로 돌아오자.
export function enqueueUpdate<State>(
fiber: Fiber,
update: Update<State>,
lane: Lane,
): FiberRoot | null {
const updateQueue = fiber.updateQueue;
if (updateQueue === null) {
// 주석 해석 : 오로지 파이버가 언마운트 되었을 때만 null 로 반환한다.
return null;
}
const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
if (__DEV__) {
// 개발 상황 시
}
if (isUnsafeClassRenderPhaseUpdate(fiber)) {
/**
* UNSAFE_ 접두어가 붙은 메서드를 사용했을 때 이 분기로 들어온다.
* 그러나, 개발할 때 이런 접두어를 쓰지 않았다면 신경쓰지 않아도 된다.
*/
return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
} else {
// 결국 이 분기가 선택됨.
return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane);
}
}
이제, enqueueConcurrentClassUpdate 메서드가 FiberRoot 를 반환하는 과정을 이해했다.
다시 돌아와서, 우리는 이러한 논리를 이해했다.
const classComponentUpdater = {
// Component.prototype.setState 실행 시 이 메서드 실행
enqueueSetState(inst: any, payload: any, callback) {
// 타겟 인스턴스에 등록된 "Fiber" 객체를 추출.
const fiber = getInstance(inst);
// 이벤트가 일어남과 동시에 전역으로 설정되어 있던 고정된 레인을 반환받음(트랜지션 상황 제외)
const lane = requestUpdateLane(fiber);
// 우리가 업데이트 할 내용을 담을 Update<State> 타입 객체를 초기화 - lane => tag 정보와 함께.
const update = createUpdate(lane);
// 업데이트 객체에 우리가 설정한 함수 혹은 객체를 주입
update.payload = payload;
// 만약에 setState 메서드에 콜백 함수도 넣었다면, 이것도 업데이트 객체에 주입
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback);
}
update.callback = callback;
}
// ReactFiberConcurrentUpdates.js 파일 내부의
// 파일 전역 데이터 concurrentQueues 배열, 배열의 인덱스, --> 배열에 차례로 주입
// concurrentlyUpdatedLanes : Lanes 데이터를 조정한다. 즉, "클릭" 시 SyncLane
// 결국 while 문을 통해 현재 fiber 인스턴스에 대한 root Fiber 를 반환한다.
const root = enqueueUpdate(fiber, update, lane);
// 이제 RootFiber, 현재 인스턴스의 Fiber, 정해진 전역 우선순위 lane 을 통해 논리 수행
// 이제 여기를 조사.
if (root !== null) {
startUpdateTimerByLane(lane, 'this.setState()');
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitions(root, fiber, lane);
}
if (enableSchedulingProfiler) {
markStateUpdateScheduled(fiber, lane);
}
},
이제,
startUpdateTimerByLane(lane, 'this.setState()');scheduleUpdateOnFiber(root, fiber, lane);entangleTransitions(root, fiber, lane);
세 가지 로직을 이해해야 한다.
추출된 fiber, lane, update, root 를 통해 스케쥴러에 등록하기.
자, 이제 위에서 언급한 3 가지 내용을 분석하면
Component.prototype.setState 실행으로 일어난 로직을 대부분 파악했다고 말할 수 있겠다.
그러나, "Scheduler", "Fiber", "FiberRoot", "lane"
이러한 정보들의 연관관계는 명확하게 파악되지 않았다.
물론, 검색하면 추상적으로 이해할 수 있겠지만, 나의 코드 가독력 및 분석력 향상을 위해 이대로 진행한다.
1. startUpdateTimerByLane(lane: Lane, method: string) : void
function startUpdateTimerByLane(lane : Lane, method : string) : void :
export function startUpdateTimerByLane(lane: Lane, method: string): void {
if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
return;
}
if (isSyncLane(lane) || isBlockingLane(lane)) {
if (blockingUpdateTime < 0) {
// window.performance.now() 와 동일하다.
// 이 기능은 기존의 Date.now() 밀리세컨드보다 더 정밀하게 double 타입으로 할당한다.
blockingUpdateTime = now();
// 기본 객체 console 에 "createTask" 메서드를 선언 해 놓음
// createTask(label : string) : ConsoleTask
blockingUpdateTask = createTask(method);
if (isAlreadyRendering()) {
blockingSpawnedUpdate = true;
}
const newEventTime = resolveEventTimeStamp();
const newEventType = resolveEventType();
if (
newEventTime !== blockingEventTime ||
newEventType !== blockingEventType
) {
blockingEventIsRepeat = false;
} else if (newEventType !== null) {
// If this is a second update in the same event, we treat it as a spawned update.
// This might be a microtask spawned from useEffect, multiple flushSync or
// a setState in a microtask spawned after the first setState. Regardless it's bad.
blockingSpawnedUpdate = true;
}
blockingEventTime = newEventTime;
blockingEventType = newEventType;
}
} else if (isTransitionLane(lane)) {
// 트랜지션 레인이 아니라, SyncLane 이기에, 분기에 들어가지 않는다.
}
}
위의 로직을 이해하기 위해서, 당연지사, 각 메서드의 의미를 알아야 했다.
먼저 이 메서드 내부의 전역 변수들은,
blockingUpdateTime:double = -1.1blockingUpdateTask:ConsoleTask | null = nullblockingEventTime:double = -1.1blockingEventType:string | null = nullblockingEventIsRepeat:boolean = falseblockingSpawnedUpdate:boolean = false
이며, 이 메서드 내부에서 인자 없이, 다른 파일의 값을 읽어오는 메서드는,
isAlreadyRendering():./ReactFiberWorkLoop파일로부터.resolveEventTimeStamp():./ReactFiberConfigresolveEventType():./ReactFiberConfig
이다.
blockingUpdateTime = now(); 이 코드 부분은 주석으로 이미 충분한 설명을 작성했다.
더욱 정밀한 밀리세컨드 이하 마이크로세컨드를 위해 window.performance.now() 를 수행한다.
(글이 너무 길어지는 것을 예방.)
blockingUpdateTask = createTask(method) 에 대해서 알아보자.
해당 파일의 위쪽에 올라가면 이러한 코드 조각이 존재한다. : 코드 위치
const createTask =
// eslint-disable-next-line react-internal/no-production-logging
__DEV__ && console.createTask
? // eslint-disable-next-line react-internal/no-production-logging
console.createTask
: (name: string) => null;
즉, 현재 "개발 모드" 인지 "프로덕션 모드" 인지에 따라 갈린다.
우리는 여태까지 "개발 모드" 를 염두에 두고 조사를 수행하진 않았다.
즉, 여기서 createTask 는 (name : string) => null 이라고 생각하면 끝이다.
그러나, 나의 이목을 끈 것이 있었으니, 바로 console.createTask 였다.
기본적으로, 브라우저나 node.js 의 console 기본 객체는 이러한 메서드를 가지지 않는다.
그래서 도대체 무엇이지 하니, console 재선언 위치 에서 확인이 가능했다.
즉, 프로그램 스코프의 console 객체 기능을 "재선언" 한 것과 동일하다.
createTask 메서드의 결과로, ConsoleTask 객체를 반환하는데,
declare interface ConsoleTask {
run<T>(f: () => T): T;
}즉, 이 객체에서 createTask("this.setState()") 실행 및 반환 이후,
해당 값에서 run 을 수행하여 원하는 T 형식의 값을 반환받는 메서드였다.
결과적으로 blockingUpdateTask 라는 전역 객체에 ConsoleTask 를 할당하게 되었다.
그 다음으로는 isAlreadyRendering() 메서드였다. : 코드 위치
export function isAlreadyRendering(): boolean {
// Used by the renderer to print a warning if certain APIs are called from
// the wrong context, and for profiling warnings.
return (executionContext & (RenderContext | CommitContext)) !== NoContext;
}
이 코드는 ReactFiberWorkLoop.js 파일의 전역 변수 executionContext 를 읽는데,
type ExecutionContext = number;
export const NoContext = /* */ 0b000;
const BatchedContext = /* */ 0b001;
export const RenderContext = /* */ 0b010;
export const CommitContext = /* */ 0b100;
let executionContext: ExecutionContext = NoContext;
위의 타입 정보를 보면 쉽게 이해 할 수 있다.
이 메서드에는 2 가지의 이진 계산 기호가 들어간다. & 와, |.
즉, RenderContext, CommitContext 의 바이너리가 병합되며, 0b110 이 된다.
executionContext & 0b110 이라는 계산식으로 줄여지는데,
만약 executionContext 가, 0b000 or 0b001 이라면, 반환 결과는 false 가 되며,
현재 실행 컨텍스트가 렌더, 혹은 커밋이라면, 즉시 true 로 반환한다.
그런데, 이 글을 작성하는 때는, 단순히 버튼 클릭 후 카운트 텍스트를 변경하는 예제를 사용했다.
따라서, 이 메서드의 결과는 false 이므로, blockingSpawnedUpdate 는 그대로 false 이다.
resolveEventTimeStamp(), resolveEventType() 을 가져오는 ./ReactFiberConfig 경로는,
throw new Error('This module must be shimmed by a specific renderer.');
이러한 에러를 내보내고 있다.
이 파일을 아마 글의 초중반기에서 겪었던 기억이 있다.
이 때는, 다른 상위 디렉토리의 react-dom-bindings/src/client/ReactFiberConfigDOM 으로 찾아가야 한다. (브라우저 개발이므로)
.../client/ReactFiberConfigDOM.js : 코드 위치
let schedulerEvent: void | Event = undefined;
export function trackSchedulerEvent(): void {
schedulerEvent = window.event;
}
export function resolveEventType(): null | string {
const event = window.event;
return event && event !== schedulerEvent ? event.type : null;
}
export function resolveEventTimeStamp(): number {
const event = window.event;
return event && event !== schedulerEvent ? event.timeStamp : -1.1;
}
기본적으로 window.event 는 웹사이트의 코드가 "현재 처리 중인" 이벤트를 의미한다.
event.type: 현재 처리중인 이벤트의 타입 (문자열)event.timeStamp: "시간 원점" 으로부터 이벤트가 생성되기까지 "경과한 시간"을 밀리초 단위로 반환
여기서 말하는 "시간 원점" 이란, 웹사이트 스크립트의 문서 로딩을 시작했던 시점을 의미한다.
즉, 일반적으로 반환하는 1970년 1월 1일 부터의 타임스탬프는 아니란 의미이다.
그런데, schedulerEvent 는 전역 변수로, Event 타입 값을 지정받았다.
코드에서 보다시피, 우리는 아직 알지 못하지만, 또 다른 로직에서 trackSchedulerEvent()
메서드를 실행하여, 현재 window.event 를 캡쳐하여 schedulerEvent 를 저장 해 두는 것이다.
그렇다면, resolveEventType(), resolveEventTimeStamp() 가 의미하는 바는 동일하다.
- 현재 윈도우에서 이벤트가 일어난 것이 맞으며,
- 이 이벤트가, 전역 변수로 이미 등록된
schedulerEvent와 동일하지 않다면,
윈도우 이벤트 정보를 반환한다. - 만약에 이 이벤트가 거꾸로
schedulerEvent와 동일하다면,
윈도우 이벤트 정보를 반환하지 않고,ReactProfilerTimer.js의 전역 변수 기본 설정과 동일한null,-1.1을 반환한다.
이제 다시, startUpdateTimerByLane(lane: Lane, method: string): void 로 돌아오자.
export function startUpdateTimerByLane(lane: Lane, method: string): void {
if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
return;
}
if (isSyncLane(lane) || isBlockingLane(lane)) {
if (blockingUpdateTime < 0) {
// window.performance.now() 와 동일하다.
// 이 기능은 기존의 Date.now() 밀리세컨드보다 더 정밀하게 double 타입으로 할당한다.
blockingUpdateTime = now();
// 기본 객체 console 에 "createTask" 메서드를 선언 해 놓음
// createTask(label : string) : ConsoleTask
blockingUpdateTask = createTask(method);
// 렌더링 상황에서 상태를 변경하는 것이 아니므로, 이 분기에 들어가지 않는다.
if (isAlreadyRendering()) {
blockingSpawnedUpdate = true;
}
// 이벤트 타임스탬프 밀리세컨드
const newEventTime = resolveEventTimeStamp();
// 이벤트 이름
const newEventType = resolveEventType();
/**
이 분기가 의미하는 것은, resolveEventTimeStamp(), resolveEventType()
메서드 실행 과정에서, 이미 누군가가 ReactFiberConfigDOM.js 파일에서
윈도우에서 일어난 이벤트를 "저장" 해 두었다는 것이다.
따라서, blockingEventTime = -1.1; blockingEventType = null;
이렇게 기본값인 상황에서, 위의 2 메서드가 유의미한 결과를 얻어왔다면,
blockingEventIsRepeat = false; 가 설정된다.
*/
if (
newEventTime !== blockingEventTime ||
newEventType !== blockingEventType
) {
blockingEventIsRepeat = false;
} else if (newEventType !== null) {
// If this is a second update in the same event, we treat it as a spawned update.
// This might be a microtask spawned from useEffect, multiple flushSync or
// a setState in a microtask spawned after the first setState. Regardless it's bad.
/**
이 분기는 실제 DOM 이벤트로 일어난 setState 를 처리하는 것이 아니라,
해당 이벤트가 일어나면서, 연결된 state 변경 상황에서 이 분기로 들어온다.
주석 해설 :
만약에 현재 논리 진행이 같은 이벤트 내부에서의 2 번째 업데이트라면,
spawned 업데이트라고 칭한다.
이 분기는 useEffect 로부터 생성(spawned)된 microtask 이거나,
다중 flushSync 상황이라고 예상할 수 있다.
혹은, 첫 번째 setState 이후에 생성된 microtask 의 setState 를 의미한다.
바로 위의 상황이라면 이건 나쁜 상황임에도 불구하고 수행한다.
*/
blockingSpawnedUpdate = true;
}
// 파일 전역 데이터인 이벤트 시간과 타입을 resolve 한 정보로 변경한다.
blockingEventTime = newEventTime;
blockingEventType = newEventType;
}
} else if (isTransitionLane(lane)) {
// 트랜지션 레인이 아니라, SyncLane 이기에, 분기에 들어가지 않는다.
}
}
자, 이제 우리는 classComponentUpdater 변수의 enqueueSetState 메서드 내부에서
startUpdateTimerByLane 메서드가 어떤 역할을 하는지 이해하게 되었다.
ReactProfilerTimer.js파일의 전역 변수들을 설정한다.- 성능 측정에 필요한 변수들을 설정 해 준 것이다.
- 개발자가 디벨롭 상황이 아니라, 실 상황에서도 컴포넌트 이벤트에 대한 성능 측정 및 정보 추출을 위해 만들어 놓은 파일이라고 해석했다.
- 그 이유는,
enableProfilerTimer,enableComponentPerformanceTrack두 전역 값이 설정되어 있지 않으면, 애초에 메서드가 바로 반환하기 때문이다.
2. scheduleUpdateOnFiber(root, fiber, lane)
우리의 enqueueSetState 코드를 다시 보자.
const classComponentUpdater = {
// Component.prototype.setState 실행 시 이 메서드 실행
enqueueSetState(inst: any, payload: any, callback) {
// 타겟 인스턴스에 등록된 "Fiber" 객체를 추출.
const fiber = getInstance(inst);
// 이벤트가 일어남과 동시에 전역으로 설정되어 있던 고정된 레인을 반환받음(트랜지션 상황 제외)
const lane = requestUpdateLane(fiber);
// 우리가 업데이트 할 내용을 담을 Update<State> 타입 객체를 초기화 - lane => tag 정보와 함께.
const update = createUpdate(lane);
// 업데이트 객체에 우리가 설정한 함수 혹은 객체를 주입
update.payload = payload;
// 만약에 setState 메서드에 콜백 함수도 넣었다면, 이것도 업데이트 객체에 주입
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback);
}
update.callback = callback;
}
// ReactFiberConcurrentUpdates.js 파일 내부의
// 파일 전역 데이터 concurrentQueues 배열, 배열의 인덱스, --> 배열에 차례로 주입
// concurrentlyUpdatedLanes : Lanes 데이터를 조정한다. 즉, "클릭" 시 SyncLane
// 결국 while 문을 통해 현재 fiber 인스턴스에 대한 root Fiber 를 반환한다.
const root = enqueueUpdate(fiber, update, lane);
// 이제 RootFiber, 현재 인스턴스의 Fiber, 정해진 전역 우선순위 lane 을 통해 논리 수행
// 이제 여기를 조사.
if (root !== null) {
// 개발자가 프로파일링 도구를 켜 놨다면, 관련된 파일 전역 변수와 값을 세팅한다.
startUpdateTimerByLane(lane, 'this.setState()');
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitions(root, fiber, lane);
}
if (enableSchedulingProfiler) {
markStateUpdateScheduled(fiber, lane);
}
},
// ...
}
개인적으로 정말 긴 길을 걸었다고 생각한다.
오픈소스를 보는 방법에 눈을 떠 주게 해 줬다고 해야하나?
그러나, 나는 scheduleUpdateOnFiber 코드 원본을 보고, 깨닫았다.
"마지막이라고 생각할 때가 시작이구나.."
이제 본격적으로 FiberRoot, Fiber, Lane 에 세 가지 정해진 값을 통해
스케쥴링 업데이트를 수행 할 시간이다.
코드가 생각보다 길으므로, 적당히 주석으로 줄이겠다.
이미 리액트 속에서 먼 길을 걸었지만, 마음을 더 굳건히 먹고 시작한다.
scheduleUpdateOnFiber(root: FiberRoot, fiber: Fiber, lane: Lane) : 코드위치
export function scheduleUpdateOnFiber(
root: FiberRoot,
fiber: Fiber,
lane: Lane,
) {
if (__DEV__) {
// 개발 상황
}
if (__DEV__) {
// 개발 상황
}
// 주석 해석 : work 루프가 현재 suspended(대기중) 이며, 로딩을 끝내기 위해 데이터를 기다리는 상황인지 확인
// 버튼을 클릭 한 후, WAS 에서 데이터를 기다리기 위해 suspend 를 사용하지 않고,
// "버튼 클릭" 상황만을 가정했으므로, 이 분기는 들어가지 않는다.
if (
// Suspended render phase
(root === workInProgressRoot &&
(workInProgressSuspendedReason === SuspendedOnData ||
workInProgressSuspendedReason === SuspendedOnAction)) ||
// Suspended commit phase
root.cancelPendingCommit !== null
) {
// ...
}
// Mark that the root has a pending update.
// 현재 RootFiber 가 pending(보류) 중인 업데이트를 가지고 있다는 것을 "표시" 한다.
markRootUpdated(root, lane);
// setState 실행 과정에서 `executionContext` 값을 BatchedContext, 0b001 로 설정했다.
// 결국 괄호 내부가 NoContext 이므로, 이 분기는 false 이며, 들어가지 않는다.
// 이 상황은 리액트가 현재 렌더링 상황 중이라는 것을 의미한다.
if (
(executionContext & RenderContext) !== NoContext &&
root === workInProgressRoot
) {
// This update was dispatched during the render phase. This is a mistake
// if the update originates from user space (with the exception of local
// hook updates, which are handled differently and don't reach this
// function), but there are some internal React features that use this as
// an implementation detail, like selective hydration.
warnAboutRenderPhaseUpdatesInDEV(fiber);
// Track lanes that were updated during the render phase
workInProgressRootRenderPhaseUpdatedLanes = mergeLanes(
workInProgressRootRenderPhaseUpdatedLanes,
lane,
);
} else {
// This is a normal update, scheduled from outside the render phase. For
// example, during an input event.
//
// enableUpdaterTracking 상수는 "shared/ReactFeatureFlags.js" 파일에 존재한다.
// enableUpdaterTracking = __PROFILE__; 로 되어 있는데,
// 이는 글로벌 변수로서, 렌더링 상황(브라우저, 앱 등) 에 따라 값과 형태가 달라진다.
// 개발 상황 시, fiber 인스턴스 맵을 펼치기 위해 사용되는 분기이다.
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
addFiberToLanesMap(root, fiber, lane);
}
}
// 개발 상황 시.
warnIfUpdatesNotWrappedWithActDEV(fiber);
// 기본적으로 트랜지션 상황을 가정하지도 않았지만,
// 개발 상황 시에만 사용한다.
if (enableTransitionTracing) {
const transition = ReactSharedInternals.T;
if (transition !== null && transition.name != null) {
if (transition.startTime === -1) {
transition.startTime = now();
}
addTransitionToLanesMap(root, transition, lane);
}
}
// workInProgressRoot 를 파헤쳤고, fiberRoot 와 WIP 의 Root 또한 같다고 생각했다.
// 그러나, "현재 렌더 중" 상태를 보여주는 것이 바로 "workInProgressRoot" 였으며,
// 대부분의 상황에서 workInProgressRoot 는 null 이라는 것을 AI 가 알려줬다.
// 즉, 이 분기는 해당되지 않는다.
if (root === workInProgressRoot) {
// Received an update to a tree that's in the middle of rendering. Mark
// that there was an interleaved update work on this root.
// 즉, 렌더링 중간에도 트리에 대한 업데이트는 받는다.
//
if ((executionContext & RenderContext) === NoContext) {
workInProgressRootInterleavedUpdatedLanes = mergeLanes(
workInProgressRootInterleavedUpdatedLanes,
lane,
);
}
if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
// The root already suspended with a delay, which means this render
// definitely won't finish. Since we have a new update, let's mark it as
// suspended now, right before marking the incoming update. This has the
// effect of interrupting the current render and switching to the update.
// TODO: Make sure this doesn't override pings that happen while we've
// already started rendering.
const didAttemptEntireTree = false;
markRootSuspended(
root,
workInProgressRootRenderLanes,
workInProgressDeferredLane,
didAttemptEntireTree,
);
}
}
// 말 그대로 루트 fiber 가 스케줄에 등록되도록 만드는 기능을 수행한다.
// 보통은 루트가 1개가 등록되는데, 여러 루트를 등록할 경우,
// 스케쥴러가 여러 루트들을 전부 관리하기 위해 이러한 메서드 기능을 넣어놨다.
ensureRootIsScheduled(root);
// 이 분기에는 해당하지 않음.
if (
lane === SyncLane &&
executionContext === NoContext &&
!disableLegacyMode &&
(fiber.mode & ConcurrentMode) === NoMode
) {
if (__DEV__ && ReactSharedInternals.isBatchingLegacy) {
// Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
} else {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncWorkOnLegacyRootsOnly();
}
}
}
}
자... 이제, enqueueSetState 메서드 내부의 scheuleUpdateOnFiber 함수의 의미를
알아볼 시간이다. 우선, "프로덕션", "버튼 클릭", "트랜지션 X" 상황에 맞게 필요한 내부 값들을 펼쳐보자.
markRootUpdated(root, lane);ensureRootIsScheduled(root);
function markRootUpdated(root: FiberRoot, updateLane: Lane) : 코드 위치
export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
root.pendingLanes |= updateLane;
// 트랜지션 관련 - 이 분기는 들어가지 않는다.
if (enableDefaultTransitionIndicator) {
// 주석 해석 : 이 레인이 로딩 인디케이터를 보여줘야 할 필요가 있다는 것을 표시한다.
root.indicatorLanes |= updateLane & TransitionLanes;
}
// 주석 해석 :
// 만약에 어떠한 대기 트랜지션이 있다면, 이 새 업데이트가 블록 해제될 가능성이 있다.
// 대기 레인을 지움으로서, 우리는 이 이벤트 렌더링을 다시 시도할 수 있다.
//
// TODO: We really only need to unsuspend only lanes that are in the
// `subtreeLanes` of the updated fiber, or the update lanes of the return
// path. This would exclude suspended updates in an unrelated sibling tree,
// since there's no way for this update to unblock it.
//
// 만약에 지금 들어온 업데이트가 idle 레인이라면, 이를 수행하지 않는다.
// 왜냐면 idle 업데이트는 "모든" 정규 업데이트가 끝날 때 까지 절대 처리되지 않기 때문이다.
// 트랜지션을 해제할 수 있는 것은 없다.
if (updateLane !== IdleLane) {
root.suspendedLanes = NoLanes;
root.pingedLanes = NoLanes;
root.warmLanes = NoLanes;
}
}
먼저, 이 함수는 전달된 RootFiber 객체의 pendingLanes 속성에
updateLane == SyncLane 을 주입한다.
그리고, updateLane 는 SyncLane 이므로,
전달된 인자인 root : FiberRoot 에,
FiberRoot.suspendedLanes = NoLanesFiberRoot.pingedLanes = NoLanesFiberRoot.warmLanes = NoLanes
이렇게 3 개의 속성이 NoLanes 로 초기화? 된다.
파일 : react-reconciler/src/ReactFiberRootScheduler.js
ensureRootIsScheduled(root : FiberRoot) : void : 코드 위치
// 주석 해설 :
// 보류된 작업을 가지고 있는 모든 root 들에 대한 Linked List 를 의미한다.
// 보통은 하나의 root 를 가지지만, 우리는 다중 root 앱들도 지원한다.
// 따라서 이는 추가 복잡성을 일으킨다.
// 그러나, 이 모듈은 단일 root 의 경우에 대해 최적화 되어 있다.
export let firstScheduledRoot: FiberRoot | null = null;
let lastScheduledRoot: FiberRoot | null = null;
// 주석 해설 : microtask 가 중복으로 스케쥴 되는 것을 예방하는 데 사용한다.
let didScheduleMicrotask: boolean = false;
// 간단 해설 : 개발 상황 시 사용
let didScheduleMicrotask_act: boolean = false;
// 주석 해설 : 수행 할 동기적 작업이 없을 때, flushSync 를 빠르게 종료하기 위해 사용한다.
let mightHavePendingSyncWork: boolean = false;
let isFlushingWork: boolean = false;
let currentEventTransitionLane: Lane = NoLane;
export function ensureRootIsScheduled(root: FiberRoot): void {
// 주석 해석 :
// 이 함수는 루트가 업데이트를 받을 때 마다 호출된다.
// 이 함수는 2 가지를 실행한다.
//
// 1) RootFiber 가 root schedule 내부에 있다는 것을 보증한다.
// 2) 위로 인해 root schedule 를 처리할 pending microtask 가 있다는 것을 보증한다.
//
// 대부분의 실제 스케쥴링 로직은,
// "scheduleTaskForRootDuringMicrotask" 가 실행 될 때 까지 발생하지 않는다.
// 주석 해설 : 스케쥴에 root(FiberRoot) 를 추가한다.
if (root === lastScheduledRoot || root.next !== null) {
// 주석 해설 : 빠른 경로. 이 root(FiberRoot) 는 이미 스케쥴되었다.
} else {
if (lastScheduledRoot === null) {
// 대부분의 경우 우리는 루트를 하나만 만들기 때문에 이 부분에 해당한다.
firstScheduledRoot = lastScheduledRoot = root;
} else {
lastScheduledRoot.next = root;
lastScheduledRoot = root;
}
}
// 주석 해설 :
// 어떤 시간이던 root 가 업데이트를 수신하면, 우리가 스케쥴을 처리하는 다음 때 까지 true 로 설정한다.
// 만약 false 라면, 스케쥴을 확인하지 않고, 빠르게 flushSync 를 종료할 수 있다.
mightHavePendingSyncWork = true;
// 바로 아래에 코드가 존재함.
ensureScheduleIsScheduled();
// if(개발 상황 && 레거시 상황)
}
export function ensureScheduleIsScheduled(): void {
// 주석 해설 : 현재 이벤트의 종료에서, 각각의 루트들을 들러서
// 알맞은 우선순위로 작업이 스케쥴 되어 있는지 보증한다.
// 개발 상황 시를 가정하기에, 이 분기는 들어가지 않는다.
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
// We're inside an `act` scope.
if (!didScheduleMicrotask_act) {
didScheduleMicrotask_act = true;
scheduleImmediateRootScheduleTask();
}
} else { // 이 분기에 들어간다.
if (!didScheduleMicrotask) {
// 현재 파일 전역 값인 didScheduleMicrotask : boolean 값을 조정하고,
didScheduleMicrotask = true;
// scheduleImmediateRootScheduleTask() 를 실행한다.
scheduleImmediateRootScheduleTask();
}
}
}
현재 상황에서의 이 코드와 해당 파일의 의미는,
- 루트 1개가 보통이나, 관대하게 멀티 루트도 지원한다. 무조건 하나의 scheduledRoot 는 존재한다.
- 현재 이벤트를 스케쥴 할 루트에서 작업을 수행하고 있는지 확인할 수 있는 변수들을 지원한다.
- 즉, 이 메서드가 호출된다는 것은, 인자로 넣은 FiberRoot 의 작업이 무조건 있다는 것을 의미한다.
- 결국 다른 파일의 메서드인
scheduleImmediateRootScheduleTask()를 실행한다.
같은 파일 : ReactFiberRootScheduler.js 의 메서드 참조
function scheduleImmediateRootScheduleTask() {
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
// 개발 상황
}
// 간략정보 : ReactFiberConfigDOM 설정을 사용하므로,
// supportsMicrotasks 는 "true" 로 설정된다.
// queueMicrotask 라는 기능을 지원하는가 아닌가로 분류된다.
// 따라서, 이 분기를 타게 된다.
if (supportsMicrotasks) {
// 간략 정보 :
// scheduleMicrotask 메서드는 렌더러에 의해 결정되는 일종의 주입된 함수을 지칭한다.
// 이는 기본 기능인 Promise 를 이용해서, scheduleMicrotask 내부의 함수를
// 브라우저에서 알아서 비동기적으로 처리하도록 만든다.
//
scheduleMicrotask(() => {
// 정보 : ReactFiberWorkLoop.js 파일의 executionContext 전역 변수를 반환한다.
// 이 전역 값은 이전 로직으로 인해 BatchedContext 로 변경되어 있다.
// 따라서, 밑의 분기는 if(NoContext !== NoContext) 가 되므로, 성립하지 않는다.
const executionContext = getExecutionContext();
// 이 분기를 타지 않음. - 이 분기는 렌더링 중이거나, 적용 중 일때 탄다.
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
// ...
}
processRootScheduleInMicrotask();
});
} else { // 이 분기는 브라우저에서 실행되지 않는다. - 렌더러에 의해 달라짐.(앱, 브라우저 등등..)
// If microtasks are not supported, use Scheduler.
Scheduler_scheduleCallback(
ImmediateSchedulerPriority,
processRootScheduleInImmediateTask,
);
}
}
supportsMicroTasks 변수는 전역 변수이나, ReactFiberConfigDOM.js 의 파일 전역 변수이다.
분명히 supportsMicroTask 는 ReactFiberConfig.js 에서 가져왔으나,
해당 파일은 에러를 내보내는 2 줄의 코드만 담겨 있다.
그리고 주석으로, 렌더러에 의해, 이 경로를 거치는 메서드와 변수는 모두 "렌더러" 에 의해 결정(주입)
된다고 적혀 있다. 우리는 "브라우저" 라는 렌더러에 의해 결정된다.
즉, 우리는 DOM 을 사용하는 설정(Config) 를 사용하므로, ReactFiberConfigDOM.js 파일에서
가져온 것이다. 이런 것이 바로 Convention 을 잘 익혀야 하는 이유가 아닐까 생각이 된다.
동일한 의미에서, scheduleMicrotask(콜백함수) 또한, 렌더러에 의해 결정되는 다른 파열 메서드이다.
이상하게도 이는 깃허브의 자동 검색에서는 원하는 결과가 나오지 않았다.
이 메서드 또한 ReactFiberConfig.js 파일에서 불러오기에, ReactFiberConfigDOM.js 파일에서 찾아보았다.
// -------------------
// Microtasks
// -------------------
export const supportsMicrotasks = true;
export const scheduleMicrotask: any =
typeof queueMicrotask === 'function'
? queueMicrotask
: typeof localPromise !== 'undefined'
? callback =>
localPromise.resolve(null).then(callback).catch(handleErrorInNextTick)
: scheduleTimeout; // TODO: Determine the best fallback here.
직접적으로 MicroTasks 라는 친절한 주석과 함께 전역 파일 변수들이 선언되어 있다.
이게 무엇을 의미할까?
신형 브라우저와 Node, Bun 대부분의 환경은 결국 queueMicrotask === 'function' 이므로,
queueMicrotask 가 반환된다. 즉, 콜백으로 등록한 함수가 결국 그대로 콜백으로 실행된다.
그런데 뭔가 이상하다
실행하면 그냥 실행하면 될 것이지, "왜? 굳이 콜백으로 실행하나?"
나는 Microtask 라는 개념을 "리액트" 가 또 만들어서 사용하는 줄 알았다.
그런데, 내가 몰랐던 JavaScript 의 내부 기능이라는 것을 처음 알았다.
Event Loop : microtasks and macrotasks 공식 문서
queueMicrotask 는, 현재 콜백 함수들이 "비워질때까지" 기다리고, 비워지는 순간 실행된다.
즉, 내가 "버튼 클릭" 이라는 행위로 인해 발생하는 "메인 콜백 함수들" 이 모두 실행되어
상태 변경과 같은 메인 로직이 끝날 때 까지 기다린 후, diffing 과 repaint 와 같은
실제 렌더링 과정을 실행하겠다는 이야기이다.
그렇다면, 우리는 scheduleMicrotask 로 콜백 함수를 등록하는 것이,
queueMicrotask 내장 기능을 이용하여,
우리가 만든 하나의 이벤트가 일으킨 상태 변화들이 마무리 될 때 까지 기다린 후,
이를 한번에 처리한다는 의미가 되는 것이다.
동일한 ReactFiberRootScheduler.js 파일의 processRootScheduleInMicrotask()
function processRootScheduleInMicrotask() {
// 주석 해석 : 이 함수는 언제나 microtask 내부에서 호출된다.
// 이건 절대로 동기적(일반 함수) 적으로 호출되면 안된다.
//
// 개인 해석 : 스케쥴러가 줄줄이 등록 될 때는 true 로 이어져서 등록되지만,
// 메인 콜스택이 끝나면서 queueTask 가 실행될 때, didScheduleMicrotask = false;
// 가 발동된다.
didScheduleMicrotask = false;
if (__DEV__) {
didScheduleMicrotask_act = false;
}
// 주석 해석 : 우리는 모든 루트들과(특수 케이스) 스케줄링들을 돌아가면서 재계산 할 것이다.
mightHavePendingSyncWork = false;
let syncTransitionLanes = NoLanes;
// 만약 현재 트랜지션 상황이라면, - 이 분기에 들어가지 않는다.
if (currentEventTransitionLane !== NoLane) {
// ...
}
// 지금 시각을 가져온다.
const currentTime = now();
let prev = null;
let root = firstScheduledRoot; // 대부분의 경우, first..., lastScheduleRoot 가 동일
while (root !== null) { // 멀티 루트일 경우 상정 - 보통은 한 번만 iterate 한다.
// 현재 root 의 다음 스케줄 된 root 를 미리 추출 해 놓는다. -
// 단일 루트라서 null 이다.
const next = root.next;
// 현재 루트와 시각을 같이 전달하여 업데이트 할 레인을 계산한다.
const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);
if (nextLanes === NoLane) {
// 주석 해석 : 이 루트는 더 이상 대기&보류(pending) 된 작업이 없다.
// 스케쥴로부터 이 루트를 제거한다.
// 이러한 미묘한? 재 진입 버그를 막기 위해, 이 microtask를 수행하는 유일한 장소이다.
// - 언제든 루트들을 스케쥴에 추가할 수 있지만, 오로지 이곳에서만 루트를 삭제 할 수 있는 곳이다.
// 주석 해석 : 현재 root 에 대한 객체들을 null 로 만들어서 스케줄러로부터 제거한다.
root.next = null;
if (prev === null) {
// 주석 해석 : 이 리스트의 새로운 헤드가 된다.
firstScheduledRoot = next;
} else {
prev.next = next;
}
if (next === null) {
// 주석 해석 : 이 스케줄 리스트의 새로운 꼬리가 된다.
lastScheduledRoot = prev;
}
} else {
// 주석 해석 : 이 루트는 여전히 작업할 것이 있다. 따라서 리스트에 남겨놓는다.
prev = root;
// Transition 상황일 시 발동. 그 외 위의 메서드의 결과가 SyncLane 으로 작업이 있을 때,
// 등등과 같은 상황이 매칭 될 시 발동.
if (
syncTransitionLanes !== NoLanes ||
includesSyncLane(nextLanes) ||
(enableGestureTransition && isGestureRender(nextLanes))
) {
mightHavePendingSyncWork = true;
}
}
// 이미 next 는 처음 루프문 돌 때, 단일 루트이기 때문에 null 이다.
root = next; // root = null; 과 동일.
}
// 밑의 2 개의 분기 모두 트랜지션 상황에서 고려하는 분기이다. - 2 개 다 실행 x
// 자세한 설명은 위의 코드 위치에서 확인하길 바랍니다.
if (!hasPendingCommitEffects()) {
flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
}
if (currentEventTransitionLane !== NoLane) {
// Reset Event Transition Lane so that we allocate a new one next time.
currentEventTransitionLane = NoLane;
startDefaultTransitionIndicatorIfNeeded();
}
}
여기서 제일 중요한 것은,
const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime); 이다.
- 우리는 여러 개의 루트 돔을 만들지 않았다. -> 이 루프는 1 번만 돈다.
- 트랜지션을 적용하지 않으며, 개발 상황을 상정하지 않는다. -> 프로덕션 && Transition 관련 x
function scheduleTaskForRootDuringMicrotask(root: FiberRoot, currentTime: number) : Lane
코드 위치 target="_blank" href="https://github.com/facebook/react/blob/65c4decb565b4eb1423518e76dbda7bc40a01c04/packages/react-reconciler/src/ReactFiberRootScheduler.js#L383"
이번 코드는 고려해야 할 것이 "정말정말" 많다..
마음을 다잡고 또다시 분석한다.
function scheduleTaskForRootDuringMicrotask(
root: FiberRoot,
currentTime: number,
): Lane {
// 주석 해석 : 이 함수는 언제나 microtask 내부에서 호출된다.
// 혹은, 렌더링 작업 마지막 부분에서 메인 스레드에 양보하기 직전에 호출된다.
// 이 함수는 절대로 동기적으로 호출되면 안된다.
//
// 또한 이 함수는 절대로 리액트의 작업을 동기적으로 수행하지 않는다.
// 이 함수는 오로지 나중에 수행할 작업한 스케줄해야 한다.
// 다른 작업으로 인해 레인이 부족한지 확인한다.
// 이들을 expired 로 표시하여 다음 작업을 진행할 수 있게 만든다.
markStarvedLanesAsExpired(root, currentTime);
// 다음에 작업할 레인을 결정하고, 이 작업의 우선순위도 결정한다.
const rootWithPendingPassiveEffects = getRootWithPendingPassiveEffects();
const pendingPassiveEffectsLanes = getPendingPassiveEffectsLanes();
const workInProgressRoot = getWorkInProgressRoot();
const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();
const rootHasPendingCommit =
root.cancelPendingCommit !== null || root.timeoutHandle !== noTimeout;
const nextLanes =
enableYieldingBeforePassive && root === rootWithPendingPassiveEffects
? // This will schedule the callback at the priority of the lane but we used to
// always schedule it at NormalPriority. Discrete will flush it sync anyway.
// So the only difference is Idle and it doesn't seem necessarily right for that
// to get upgraded beyond something important just because we're past commit.
pendingPassiveEffectsLanes
: getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
rootHasPendingCommit,
);
const existingCallbackNode = root.callbackNode;
if (
// Check if there's nothing to work on
nextLanes === NoLanes ||
// If this root is currently suspended and waiting for data to resolve, don't
// schedule a task to render it. We'll either wait for a ping, or wait to
// receive an update.
//
// Suspended render phase
(root === workInProgressRoot && isWorkLoopSuspendedOnData()) ||
// Suspended commit phase
root.cancelPendingCommit !== null
) {
// Fast path: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackNode = null;
root.callbackPriority = NoLane;
return NoLane;
}
// Schedule a new callback in the host environment.
if (
includesSyncLane(nextLanes) &&
// If we're prerendering, then we should use the concurrent work loop
// even if the lanes are synchronous, so that prerendering never blocks
// the main thread.
!checkIfRootIsPrerendering(root, nextLanes)
) {
// Synchronous work is always flushed at the end of the microtask, so we
// don't need to schedule an additional task.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackPriority = SyncLane;
root.callbackNode = null;
return SyncLane;
} else {
// We use the highest priority lane to represent the priority of the callback.
const existingCallbackPriority = root.callbackPriority;
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (
newCallbackPriority === existingCallbackPriority &&
// Special case related to `act`. If the currently scheduled task is a
// Scheduler task, rather than an `act` task, cancel it and re-schedule
// on the `act` queue.
!(
__DEV__ &&
ReactSharedInternals.actQueue !== null &&
existingCallbackNode !== fakeActCallbackNode
)
) {
// The priority hasn't changed. We can reuse the existing task.
return newCallbackPriority;
} else {
// Cancel the existing callback. We'll schedule a new one below.
cancelCallback(existingCallbackNode);
}
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
// Scheduler does have an "ImmediatePriority", but now that we use
// microtasks for sync work we no longer use that. Any sync work that
// reaches this path is meant to be time sliced.
case DiscreteEventPriority:
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root),
);
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
return newCallbackPriority;
}
}
여기서 일단 종료하는 것이 옳다.
렌더링을 위한 최종 커밋 과정까지 리뷰하기 위해 미리 코드를 추적하여 commitRoot
메서드까지 도달했다. 그리고 나서 이 과정을 여기서 종료하기로 마음먹었다.
현재 에디터에 3500줄이 좀 넘게 작성했는데, 마지막까지 가지 못해 아쉽다는 생각이 남았다.
현재까지 파악된 setState 의 데이터 흐름 그래프
flowchart TB
subgraph Component.setState
Com-setState1("개발자가 지시한 사항이 객체인지 함수인지 파악 -> 둘 다 아니면 오류")
Com-setState2("컴포넌트 자신과, 지시된 사항, callback 함수를 enqueueSetState 에게 넘김")
end
subgraph updater.enqueueSetState
subgraph Fiber
fiber("전달된 컴포넌트는 하나의 fiber 와 연결되어 있으므로, 이를 추출한다.")
end
subgraph Lane
lane-1("현재 일어난 DOM 이벤트와 우선순위 레인을 이미 지정해 놓음 - 트랜지션 상황 가정 x")
lane-2("즉, setState 와 DOM 이벤트는 떨어져 있으며, 타이밍을 맞게 설정해 놓음")
end
subgraph Update
update-1("전달된 지시사항과 콜백 함수를 넣기 위한 기본적인 초기화 인스턴스를 반환")
update-2("반환 후, payload, callback 으로 속성을 주입")
end
subgraph FiberRoot
fiberRoot-1("인스턴스의 fiber 객체로부터 최종 부모를 찾는데, 이는 FiberRoot 이다.")
fiberRoot-2("일반적인 경우 리액트 프로젝트 자체의 루트는 1 개이며, 멀티 루트도 지원한다.")
fiberRoot-3("루트는 상태를 관리하는 핵심적인 정보이다.")
end
end
subgraph updater.scheduleUpdateOnFiber
schedule-1("루트 fiber, 컴포넌트 fiber, 우선순위 lane 을 인자로 받는다.")
schedule-2("리액트에서 발생할 수 있는 모든 상황에 대해 특정 논리로 대응한다.")
schedule-3("수십개의 파일에 존재하는 파일 전역 변수들을 가져와서 현재 상황을 파악한다.")
schedule-4("이 메서드 실행 과정에서 queueMicrotask 를 쌓아놓는다.")
schedule-5("즉, 렌더링 및 커밋 할 수행 목록들을 스케줄에 넣어놓는다")
schedule-6("결과물을 계산하여 결과적으로 commitRoot 를 실행하여 렌더링한다.")
schedule-7("diff 와 commit 과정에서 executionContext 는 RenderContext 나 CommitContext 로 잠시 변경한다.")
schedule-8("이전의 과정들은 executionContext 에 따라 블로킹 될지, 언블로킹인지 결정된다.")
end
Component.setState --> updater.enqueueSetState
updater.enqueueSetState --> updater.scheduleUpdateOnFiber
왜 그만두나?
나는 컴포넌트 내장 메서드의 setState 를 실행시켰을 때, 상태 변화와 렌더링 과정을 지켜보고자 했다.
조금은 긴 여정이 될 줄 알았던 코드의 구조는 끝이 보이지 않을 정도로 복잡했다.
그렇다고 스파게티 코드란 것은 아니고, 하나의 거대하고 복잡한 예술품을 분석하는 느낌이었다.
그러나, 나는 setState 수준에서 머물지 않고, "거의 모든" 연관 코드를 분석했다.
이 과정에서 Virtual DOM + Fiber 아키텍쳐가 적용되었다는 것을 알 수 있었다.
그러나, 나는 enqueueSetState 메서드의 scheduleUpdateOnFiber 메서드 내부를
조사하면서 한계를 느꼈다.
그 이유는, 바로 이 아키텍쳐의 작동 방식 자체를 모르기 때문이다.
나는 사실 React 에서 useContext 를 어떻게 사용했었는지도 까먹었다.
그런 사람이, 내부의 fiber, lane, WIP Tree, EventPriority 를 다룬 것이다.
또 다른 이유로는, "setState" 를 통해 알아가는 로직 이 아니라,
constructClassComponent 변수를 먼저 분석하여,
루트 컴포넌트와 일반 컴포넌트의 설정 속성을 이해하고,
각 컴포넌트 fiber 와 fiberRoot 가 가지는 속성들을 분석하여 알아야 했다.
그렇지 않고 역으로 하나의 이벤트에서 아키텍쳐를 분석하니,
이제는 처음 보는 수십개의 전역 파일 import 변수들이 정말로 내가 생각하는 값이 맞는지 알 수 없었다.
이러한 이유들로 인해 이 주제에 대한 React 아키텍쳐 분석 글은 마친다.
나도 이러한 이유들이, 반대로 바라보아, 내가 도망친 이유라고도 할 수 있겠다.
그러나, 나는 결국 다시 돌아와서 공부하게 될 것이다.
나는 모르는 것이 있으면 반드시 알기로 약속했고, 개발자가 아닌 개발 엔지니어가 되기로 약속했다.
리액트를 잘 사용하지 못하면서 오픈소스를 경험한 느낌은?
리액트를 "잘" 사용하지는 못하지만, 기본적인 응용 방식은 안다.
내가 확신하건대, 리액트 "사용법" 을 아무리 잘 아는 사람이라도,
오픈소스를 까서 이해하기는 정말정말 어려운 도전이 될 것이라고 자신한다.
그러나, 이 도전으로 당신은 뛰어난 코드 분석 능력을 갖추게 될 것이라고 자신있게 말할 수 있다.
그리고 또 말할 수 있는 것은, 리액트는 JS 최신 기능을 접목하여 최적화 시킨,
하나의 논리 예술품이라고 말할 수 있다. 이정도의 복잡성을 갖췄는데, 스파게티 코드가 없다.
정말 미친 수준의 라이브러리 아닌가?
그리고 깃허브의 코드 linking 기술은 프로젝트의 구조를 파악하는데 정말로 많은 도움이 되었다.
이 기능이 없었다면, 이 정도까지 분석하지 못하고, 절반 정도만 가능했을지도 모른다.
아직 마무리하지 못한 기능을 설명하자면..
먼저 이 글을 작성하면서 파악했던 내용을 추상적으로 펼쳐보자면,
1. Fiber
각 컴포넌트가 가지고 있는 고유의 속성 인스턴스이다.
컴포넌트는 생성과 함께 fiber 라는 속성과 1 : 1 매칭한다.
그리고, 리액트 아키텍쳐의 실행에 필수적인 내용들을 포함한다.
또한, "렌더링" 과정에서 Work In Progress 라는 WIP 트리가 생성되는데,
이 fiber 인스턴스 하나 당, 동일한 속성을 가지는 WIP fiber 가 생겨난다.
이 WIP fiber 는, 기존의 fiber 객체의 alternate 속성으로 할당되며,
WIP fiber 또한, 기존의 fiber 객체를 alternate 속성으로 할당하며, 체인을 형성한다.
또한, 내부에는 파이버 공유 큐가 존재하는데, 이에 대해서 자세히 파악하지 못했다.
2. Lane
우선순위를 가지고 있는 "특정 큐" 라고 생각하는 것이 맞다고 생각한다.
이 레인들은 특정 이벤트에 대응하는 여러 Lane 들을 가진다.
예시로, SyncLane, DefaultLane, TransitionLane, 등등 많음
리액트는 createRoot 와 함께, 현재 브라우저에 존재하는 대부분의 이벤트에 대해
특정 이벤트 리스너를 "wrapper" 로 감싼다.
이 래퍼는 dispatchEvent 를 실행하며, 각 위치의 파일 전역 변수들을 세팅한다.
즉, 현재 일어난 이벤트에 대해 "위임 가능" 혹은 "불가능" 으로 나누어
컴포넌트가 이벤트를 대응할지, 아니면 컴포넌트 루트의 FiberRoot 가 담당할지 나뉜다.
특히, dispatchEvent 는 batchedUpdate 메서드를 통해 executionContext 라는
매우 중요한 현재 컨텍스트 정보를 업데이트 한다.
이 부분을 제대로 다루지 못하고 넘어가는 것이 아쉽다.
3. Update
우리가 컴포넌트에서 만약 this.setState(() => ({...})) 를 실행한다면,
설정할 값이 어떻게 적용되는지를 알려주게 된 타입이다.
enqueueSetState 에서는 초기화 된 Update<State> 를 통하여, (lane 에 의한 적용은 있음)
적용할 업데이트 정보를 enqueueUpdate 메서드에 넣는다.
이 때, 해당 메서드의 파일이 관리하는 전역 배열이 존재하는데,
여기에 fiber, queue, update, lane 을 차례로 넣는다.
(queue 는 컴포넌트 fiber 내부의 공유 큐를 의미한다. 이것도 무언인지 파악하지 못했다.)
이 과정에서, fiber 내부 속성 레인이,
리액트가 정적으로 고정 해 놓은 우선순위 레인으로 결합되며 정보가 바뀐다.
중요한 건, Update<State> 타입에, 우리가 설정한 함수 혹은 객체와, 콜백 함수가 들어간다는 것이다.
이제 돌아와서, 아직 진행 중이던 scheduleUpdateOnFiber 를 말하자면,
이 때가 바로 위에서
fiber = 리액트 인스턴스 필요 정보lane = 이 이벤트 처리 우선순위update = 우리가 설정한 처리 방식
이를 적용하게 되는 부분이다.
scheduleUpdateOnFiber 메서드를 따라 렌더링 정보까지 변경되는 최종 위치는,
바로 commitRoot 메서드이다.
여태까지 봐 온 소수의 여러 파일들의 전역 변수나 메서드를 사용할 줄 알았지만,
내가 전혀 예상하지 못한 파일들의 전역 변수와 메서드들로 가득 찼었다.
이 메서드는 "스케줄러" 역할을 하는 파일들과 관련이 당연히 깊은데,
이 스케줄러는 멀티 루트 시스템을 지원한다. 그러나, 주석으로 멀티 루트는 복잡하다고 언급이 되어 있다.
이 메서드는 "현재 렌더링 중인지", "적용 중인지", 에 따른 변화가 존재하며,
microTask 라는 매우 중요한 기능을 소개시켜 주었다.
즉, 현재 실행중인 이벤트에 대한 처리가 끝난다면,
엔진 자체적으로 queueTask 로 등록된 함수들을 일괄 처리하는 것이다.
그러니까, 현재 상태 변화로 인해 계산중인 상황이 끝날 때 까지(메인 함수 큐가 빌 때 까지)
기다리다가, queueTask 에 쌓인 업무들을 한꺼번에 flush(쏟아내듯이 처리) 한다.
이에 대한 내용은 반드시 검색해 보길 바란다. (최신 기능이긴 한데, 개념이 중요하다.)
이 글을 마무리 하면서 느낀 점
공부에도 순서가 있다는 것을 "처참하게" 느낀 경험이었다.
당연히 돌아와서 이 Fiber Architecture 에 대해서 파악하기 위해 돌아올 테지만,
아직은 때가 아니라는 판단이 든다.
이유는, 나는 아직 useContext 이나 커스텀 훅, 등 리액트를 제대로 사용해 보지 않은 사람이기 떄문이다.
물론, 이에 대한 클론 코딩은 프로그래머스 부트캠프에서 진행했으나, 이에 대해 "이해했다" 라고는
절대로 말할 수 없다.
"리액트를 사용할 줄 알아요" 와, "리액트를 이해했어요" 는 매우 다른 개념이라고 생각한다.
나는 후자인 "이해한다" 라는 접근법으로 공부했지만, 그 순서가 틀린 경우이다.
만약에 이 글을 마지막까지 읽은 분이 계시다면, 끝까지 추적하지 못해 죄송하다는 말씀을 드립니다.
그러나, 제 특성상 언젠간 이 아키텍쳐를 완벽히 이해하기 위해서 돌아올 것이라고 약속을 드립니다.
참조 사이트
React Github 코드 패키지 (ReactBaseClasses.js)
https://github.com/facebook/react/blob/main/packages/react/src/ReactBaseClasses.js
React Github 코드 패키지 (ReactJSXElement.js)
https://github.com/facebook/react/blob/main/packages/react/src/jsx/ReactJSXElement.js
DEV 사이트 블로그 글 (Turning 'class ... extends React.Component' into a coding lesson)
React 레거시 문서 (제목 : React.Component)
https://ko.legacy.reactjs.org/docs/react-component.html#render
React Github 코드 패키지 (ReactInstanceMap.js)
https://github.com/facebook/react/blob/main/packages/shared/ReactInstanceMap.js
수십개의 외 참조 사이트들
깃허브의 facebook/react/../src/react-reconciler/... 들입니다.
'Web-Server > React' 카테고리의 다른 글
| create-react-app 은 레거시화 되었다. - 빠르게 변화하는 웹 진영 템플릿에 대한 생각 (0) | 2025.08.20 |
|---|---|
| index.html 에서 시작하는 점진적인 리액트 프로젝트 적용 방법에 대해서 (2) | 2025.07.15 |
| HTML 파일에서 React 사용하는 법 - (부제 : 클래스, 함수 컴포넌트 적용) (5) | 2025.06.16 |
| React, 기반 뿌리부터 살펴보자 - (부제 : 다양한 방법론의 조합) (4) | 2025.06.10 |