React Hooks의 동작원리

순수함수로 작성된 함수형 컴포넌트는 어떻게 내부 상태를 관리하는지 궁금했었는데, 얼마전 클로저를 이용하여 관리한다는 것을 알게되었다.
더 확실하게 알고자 Hooks의 동작원리에 대해 알아보았다.

클로저

클로저는 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 떄에도 이 스코프에 접근할 수 있게 하는 기능을 뜻한다.

이는 렉시컬 스코핑과 관련이 있다.

아래의 코드는 예시 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function useState(initialValue) {
var _val = initialValue;
function state() {
return _val;
}
function setState(newVal) {
_val = newVal;
}
return [state, setState];
}
var [foo, setFoo] = useState(0);
console.log(foo()); // 0
setFoo(1);
console.log(foo()); // 1

useState 내부에서 _val이라는 지역변수를 만들고 state 함수와 setState함수로 지역변수를 노출하지 않고 변경한다.

state는 getter 함수로 구현해야 하지만, foo와 setFoo를 사용하여 내부 변수에 접근하고 조작할 수 있다는 것이다.

이 둘은 useState의 스코프에 대한 접근권한을 가지고 있고, 이를 클로저라고 한다.

함수형 컴포넌트에서 사용하기

아래의 코드는 위에서 만든 useState 복제본을 함수형 컴포넌트에 적용한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = useState(0);
return {
click: () => setCount(count() + 1),
render: () => console.log("render:", { count: count() }),
};
}
const C = Counter();
C.render(); // render: { count: 0 }
C.click();
C.render(); // render: { count: 1 }

DOM으로 렌더링하는 대신 상태를 console.log로 출력하도록 하였다.

오래된 클로저

오래된 클로저는 클로저로 인해 갇혀있던 값이 업데이트 되지 않고 남아있는 현상이다.

원시값의 경우 참조가 없기 때문에 발생한다.

실제 React API와 동일하게 만들려면 state가 함수가 아닌 변수여야 한다. 단순히 _val을 함수로 감싸지 않고 노출하면 버그가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
function useState(initialValue) {
var _val = initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
}
var [foo, setFoo] = useState(0);
console.log(foo); // 0
setFoo(1); //
console.log(foo); // 0

_val은 원시값이기 때문에 참조가 없다.

때문에 useState의 호출에서 _val을 참조하고 다시는 변경되지 않는다.
즉 foo는 initialValue값만을 가지고 setFoo는 lexical scope의 _var를 수정한다.

때문에 setFoo를 사용하여 _var를 수정하여도 foo의 주소는 바뀌지 않는다.

모듈 안에서의 클로저

이는 우리의 클로저를 또 다른 클로저의 내부로 이동시켜서 해결할 수 있다. 즉 다른 실행컨텍스트에 _val을 묶어둠으로 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const MyReact = (function () {
let _val;
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();

모듈패턴을 사용하여 구성된 코드로, React와 마찬가지로 컴포넌트의 상태를 추적한다. 또한 매번 항상 올바른 클로저를 통해 내부의 _val값을 할당할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = MyReact.useState(0);
return {
click: () => setCount(count + 1),
render: () => console.log("render:", { count }),
};
}
let App;
App = MyReact.render(Counter); // render: { count: 0 }
App.click();
App = MyReact.render(Counter); // render: { count: 1 }

useEffect 복제하기

useEffect도 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const MyReact = (function () {
let _val, _deps;
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const hasChangedDeps = _deps
? !depArray.every((el, i) => el === _deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
_deps = depArray;
}
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();

function Counter() {
const [count, setCount] = MyReact.useState(0);
MyReact.useEffect(() => {
console.log("effect", count);
}, [count]);
return {
click: () => setCount(count + 1),
noop: () => setCount(count),
render: () => console.log("render", { count }),
};
}
let App;
App = MyReact.render(Counter);
// 이펙트 0
// render {count: 0}
App.click();
App = MyReact.render(Counter);
// 이펙트 1
// render {count: 1}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1}
App.click();
App = MyReact.render(Counter);
// 이펙트 2
// render {count: 2}

의존성 배열이 변경될 때 useEffect가 다시 실행되므로, 이를 추적하는 별도의 변수 _deps를 추가하였다.

마법이 아니라 배열일 뿐

지금까지 만든 복제본은 잘못 구현된 싱글톤 패턴이다.
즉 각각 하나 이상이 존재하면 버그가 발생한다.

여러개의 상태를 버그 없이 관리하기 위해선 배열형태로 관리할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const MyReact = (function () {
let hooks = [],
currentHook = 0;
return {
render(Component) {
const Comp = Component();
Comp.render();
currentHook = 0;
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const deps = hooks[currentHook]; // type: array | undefined
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
hooks[currentHook] = depArray;
}
currentHook++;
},
useState(initialValue) {
hooks[currentHook] = hooks[currentHook] || initialValue; // type: any
const setStateHookIndex = currentHook;
const setState = (newState) => (hooks[setStateHookIndex] = newState);
return [hooks[currentHook++], setState];
},
};
})();

setStateHookIndex는 setState에서 currentHook 변수가 덮어 씌워지는 것을 방지하는데에 사용된다.

이를 제거할 경우 오래된 클로저 문제를 일으켜 setState를 다시 호출했을 때에 동작하지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Counter() {
const [count, setCount] = MyReact.useState(0);
const [text, setText] = MyReact.useState("foo"); // 두번 째 상태 Hook!
MyReact.useEffect(() => {
console.log("effect", count, text);
}, [count, text]);
return {
click: () => setCount(count + 1),
type: (txt) => setText(txt),
noop: () => setCount(count),
render: () => console.log("render", { count, text }),
};
}

let App;
App = MyReact.render(Counter);
// 이펙트 0 foo
// render {count: 0, text: 'foo'}
App.click();
App = MyReact.render(Counter);
// 이펙트 1 foo
// render {count: 1, text: 'foo'}
App.type("bar");
App = MyReact.render(Counter);
// 이펙트 1 bar
// render {count: 1, text: 'bar'}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1, text: 'bar'}
App.click();
App = MyReact.render(Counter);
// 이펙트 2 bar
// render {count: 2, text: 'bar'}

위와 같이 기본적인 개념은 Hooks의 배열과 Hook이 호출될 때 증가하고 컴포넌트가 렌더링될 때 초기화되는 인덱스를 갖는 것이다.

Hook 규칙 도출하기

이러한 동작원리가 최상위에서만 Hook을 호출해야하는 이유이다.

Ref

Learning JavaScript Design Patterns
[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까?

댓글