문득 [프레임워크 없는 프론트엔드 개발]이라는 책을 읽으면서 가상 돔에 대해서 정리하고자 한다.
: 브라우저가 서버로부터 HTML을 전달받으면, 렌더링 엔진이 HTML을 파싱(구문 분석)하고, DOM의 Node로 이루어진 트리를 만든다.
: CSS파일과 각 엘리먼트의 inline 스타일을 파싱하고 CSSOM(CSS Object Model)을 생성한다.
: 생성된 Render Tree의 각 Node들이 스크린상의 좌표와 크기 등이 결정된다.
: 구성된 레이아웃을 화면에 그리는 과정이 진행되고 트리의 각 노드들을 거쳐가면서 화면이 스크린에 나타난다. DOM을 임의적으로 조작하면 이 과정을 반복하게 된다.
🔥 DOM의 문제
- DOM의 변화로 위의 과정이 반복될수록 브라우저의 전체적인 성능이 떨어지고 속도가 느려지게 된다.
- SPA로 만들어진 페이지의 DOM 객체는 매우 많고 복잡하여 비효율적인 프로세스가 만들어진다.
Virtual DOM은 real dom으로부터 virtual dom을 만든 메모리 상에 존재하는 하나의 객체이고, 리액트는 특정 state에 변화가 생겼다는 알림을 받으면 Real DOM이 아닌 Virtual DOM을 렌더링 시킨다.
여기서 핵심은 브라우저를 렌더링 시키는 비용보다 객체를 새로 만드는 비용이 더 저렴하기 때문에 Virtual DOM을 사용하는 것이다.
1.real dom으로부터 virtual dom을 만든다(virtual dom은 메모리 상에 존재하는 하나의 객체다)
2.변화가 생기면 새로운 버전의 virtual dom을 만든다.
3.old 버전의 virtual dom과 new 버전의 virtual dom을 비교한다.(diff algorithm)
4.비교 과정을 통해서 발견한 차이점을 real dom에 적용한다.
리액트는 render() 함수를 통해 리액트 element들의 트리를 만든다.
리액트 state 값이 바뀌면서 re-rendering이 발생하면 render() 함수는 새로운 리액트 element tree를 생성해서 반환해줘야 하는데 하나의 트리가 N개의 element를 가지고 있을 때, 새로운 element tree로 변환하는데 적지않은 O(n³)시간이 소요된다.
그래서 리액트의 가상 돔은 이전 가상 돔과 현재의 가상돔을 비교해서 바뀐 부분만 변경한다. 가장 효과적으로 갱신하는 방법으로 Reconciliation이 있다.
짧게 말해서 "실제 DOM이랑 비교해서 변경 사항이 있는 부분만 다시 그려준다."
렌더 함수를 살펴보기 전에 확인하기.
const render = () => {
window.requestAnimationFrame(() => {
const main = document.querySelector('.todoapp');
const newMain = registry.renderRoot(main, state)
applyDiff(document.body, main, newMain)
}
}
if(realNode && !virtualNode) {
realNode.remove()
if(!realNode && virtualNode) {
parentNode.appendChild(virtualNode)
}
if(isNodeChanged(virtualNode, realNode)) {
realNode.replaceWith(virtualNode)
}
const realChildren = Array.from(realNode.children)
const virtualChildren = Array.from(virtualNode.children)
const max = Math.max(
realChildren.length,
virtualChildren.length
)
for (let i = 0, i < max; i++){
applyDiff(
realNode,
realChildren[i],
virtualChildren[i]
)
}
const applyDiff = (
parentNode,
realNode,
virtualNode
) => {
// 새 노드가 정의되지 않은 경우 실제 노드를 삭제.
if (realNode && !virtualNode) {
realNode.remove()
return ;
}
// 실제 노드가 정의되지 않았지만 가상 노드가 존재하는 경우 부모 노드에 추가.
if (!realNode && virtualNode) {
parentNode.appendChild(virtualNode)
return ;
}
// 두 노드(실제 노드와 가상 노드)가 모두 정의된 경우 두 노드 간에 차이가 있는 확인
if(isNodeChanged(virtualNode, realNode)) {
realNode.replaceWith(virtualNode)
return ;
}
// 모든 하위 노드에 대해 동일한 diff 알고리즘 적용
const realChildren = Array.from(realNode.children)
const virtualChildren = Array.from(virtualNode.children)
const max = Math.max(
realChildren.length,
virtualChildren.length
)
for (let i = 0, i < max; i++) {
applyDiff(
realNode,
realChildren[i],
virtualChildren[i]
)
}
}
const isNodeChanged = (virtualNode, realNode) => {
// 타입이 다른지 확인
if(virtualNode.type !== realNode.type) {
return true;
}
const n1Attributes = virtualNode.attributes;
const n2Attributes = realNode.attributes;
// 속성 수가 다르다
if(n1Attributes.length !== n2Attributes.length) {
return true;
}
// 하나 이상의 속성이 변경되었다.
const differentAttribute = Array.from(n1Attribute).find(attribute => {
const { name } = attribute;
const attribute1 = virtualNode.getAttribute(name);
const attribute2 = realNode.getAttribute(name);
return attribute1 !== attribute2;
}
if (differentAttribute) {
return true;
}
// 노드에는 자식이 없으며, textContent가 다르다.
if (virtualNode.children.length === 0
&& realNode.children.length === 0
&& virtualNode.textContent !== realNode.textContent) {
return true;
}
return false
}
: reconciliation의 한계로 인해 react v16.0에서 소개된 리액트의 새로운 코어 알고리즘
virtual tree 상에서 변경 사항을 찾아내기 위해서 diff 알고리즘이 진행될 텐데, 이 때 두 객체를 비교하기 위해선 재귀적으로 진행할 수 밖에 없다.
재귀 알고리즘은 call stack과 연관이 있고, 가장 상단에 있는 함수가 호출되면 해당 함수는 call stack 가장 아래에 쌓일 것.
비동기 작업들은 event loop가 call stack이 비어있는 여부를 확인한 후에야 콜백함수들을 call stack에 올려 놓고 실행한다.
즉각적으로 user event에 대응할 수도 없을 뿐더러, 프레임 드롭이라는 문제를 일어날 수 있다.
: react fiber가 해결하고자 하는 것은 이런 순회 작업을 멈출 수도 있고, 재개할 수도 있고, 필요에 따라서는 그냥 내다버릴 수도 있게 만드는 것.
리액트 팀은 자바스크립트 엔진의 call stack 대신 virtual stack을 구현(실제 stack이 아니라, 메모리 상에 존재하는 가상의 stack)
Vitual Stack은 단일 연결 리스트를 활용해 구현되었다.
render 함수의 인자로 넘어온 element 객체는 fiber node로 변환되고, 그 node 들은 모두 연결된다.
class Node {
constructor(instance) {
this.instance = instance
this.child = null // 자식 노드
this.sibling = null // 형제 노드
this.return = null // 부모 노드
}
}
각 fiber 노드들은 3가지 필드를 가진다.
return
The return fiber is the fiber to which the program should return after processing the current one. It is conceptually the same as the return address of a stack frame. It can also be thought of as the parent fiber.
If a fiber has multiple child fibers, each child fiber's return fiber is the parent. So in our example in the previous section, the return fiber of Child1 and Child2 is Parent.
그리고 인자로 받아 온 노드들을 모두 단일 연결 리스트로 연결 시켜주는 함수.
function link(parent, elements) {
if (elements == null) elements = []
parent.child = elements.reduceRight((prev, cur) => {
const node = new Node(cur)
node.return = parent
node.sibling = prev
return node
}, null)
return parent.child
}
const children = [{ name: "b1" }, { name: "b2" }]
const parent = new Node({ name: "a1" })
const child = link(parent, children)
child.instance.name === "b1" //true
child.sibling.instance === children[1] // true
현재 노드와 자식 노드들의 연결을 도와주는 helper 함수
function doWork(node) {
console.log(node.instance.name)
const children = node.instance.render()
return link(node, children)
}
연결된 함수들을 탐색하는 walk 함수(기본적으로 깊이 우선 탐색으로 이루어짐).
function walk(o) {
let root = o
let current = o
while (true) {
let child = doWork(current)
//자식이 있으면 현재 active node로 지정한다.
if (child) {
current = child
continue
}
//가장 상위 노드까지 올라간 상황이라면 그냥 함수를 끝낸다.
if (current === root) {
return
}
//형제 노드를 찾을 때까지 while문을 돌린다. 이 함수에서는 자식에서 부모로 올라가면서 형제가 있는지를 찾아주는 역할을 하고 있다.
while (!current.sibling) {
//top 노드에 도달했으면 그냥 끝낸다.
if (!current.return || current.return === root) {
return
}
//부모노드를 현재 노드에 넣어준다.
current = current.return
}
current = current.sibling // while문을 빠져나왔다는 것은 sibling을 찾았다는 것이다. 찾은 sibling을 현재 current node에 넣어준다.
}
}
재귀는 한번 시작하면 끝까지 실행해야 하지만 이제는 중간에 멈춰도 작업 기록이 남아있기에 멈출 수 있다.
브라우저는 idle period가 되면 requestIdleCallback이 실행된다.
var handle = window.requestIdleCallback(callback[, options])
: 브라우저의 idle 상태에 호출될 함수를 대기열에 넣고, 일반적으로 first-in-first-out(FIFO) 순서로 호출.
이 함수의 콜백함수가 받게 될 파라미터에는 deadline이라는 객체가 있다. 이 객체는 2 가지 속성을 가지고 있다.
render가 순회하는 흐름.
빨간 네모 박스가 effect node.
그 effet node들 끼리 list가 형성되는 것.
const reconcileChildren = (currentFiber, newChildren) => {
let newChildIndex = 0;
let prevSibling // previous child fiver
// while문에서 element를 fiber로 생성
while(newChildIndex < newChildren.length){
let newChild = newChildren[newChildIndex]
let tag
// fiber type을 정의하기 위해 if문에서 tag에 적절한 값들을 할당.
if(newChild.type === ELEMENT_TEXT){
tag = TAG_TEXT // type 이 ELEMENT_TEXT라는 것은 text라는 것을 의미.
}else if(typeof newChild.type === 'string'){
tag = TAG_HOST // string === native DOM이라는 의미.
}
let newFiber = {
tag,
type : newChild.type,
props : newChild.props,
stateNode : null,
return : currentFiber,
effectTag : INSERT,
nextEffect : null
}
if(newFiber){
if(newChildIndex === 0){
currentFiber.child = newFiber // 첫번째 child라는 것을 의미.
}else{
prevSibling.sibling = newFiber // 첫번째 자식의 형제를 두번째 자식을 가리키게 한다.
}
prevSibling = newFiber
}
newChildIndex++
}
}
각각의 fiber는 2가지 속성을 가지고 있다.
// nextEffect: 두 자식 fiber 사이를 연결.
const compleUnitOfWork = (currentFiber) => {
let returnFiber = currentFiber.return // 부모 피버
if(returnFiber){
// returnFiber.firstEffect가 없으면
// returnFiber.firstEffect는 currentFiber.firstEffect가 된다.
if(!returnFiber.firstEffect){
returnFiber.firstEffect = currentFiber.firstEffect
}
if(currentFiber.lastEffect){
if(returnFiber.lastEffect){
// currentFiber와 returnFiber가 lastEffect가 있으면
// currentFiber.firstEffect를 returnFiber.lastEffect와 연결한다.
returnFiber.lastEffect.nextEffect = currentFiber.firstEffect
}
// currentFiber.lastEffect가 있으면서 returnFiber.lastEffect가 없으면
// currentFiber.lastEffect를 returnFiber.lastEffect에 삽입.
returnFiber.lastEffect = currentFiber.lastEffect
}
const effectTag = currentFiber.effectTag
// effectTag가 있는가?
if(effectTag){
// returnFiber.lastEffect가 있는가?
if(returnFiber.lastEffect){
// returnFiber.lastEffect와 currentFiber를 연결.
returnFiber.lastEffect.nextEffect = currentFiber
}else{
// returnFiber.lastEffect가 없으면 currentFiber 삽입
returnFiber.firstEffect = currentFiber
}
returnFiber.lastEffect = currentFiber
}
}
}
const performUnitOfWork = (currentFiber) => {
beginWork(currentFiber)
//child node가 있으면 child node 먼저 순회
if(currentFiber.child){
return currentFiber.child
}
//child node가 없다면, effect 수집.
// 자신 -> 형제 -> 부모순으로
while(currentFiber){
completeUnitOfWork(currentFiber)
if(currentFiber.sibling){
return currentFiber.sibling
}
// 만약 형제 node가 없으면 부모 node로 이동.
currentFiber = currentFiber.return
}
}
commit phase에서는 중간에 작업을 멈출 수 없다. 이 단계에는 이전 단계에서 모았던 effect list 를 한 번에 dom에 적용하는데 멈추는 일 없이 한번에 적용한다.
const commitWork = currentFiber => {
if (!currentFiber) return
let returnFiber = currentFiber.return
let returnDOM = returnFiber.stateNode // 부모 요소
if (currentFiber.effectTag === INSERT) {
returnDOM.appendChild(currentFiber.stateNode)
} else if (currentFiber.effectTag === DELETE) {
returnDOM.removeChild(currentFiber.stateNode)
} else if (currentFiber.effectTag === UPDATE) {
if (currentFiber.type === ELEMENT_TEXT) {
if (currentFiber.alternate.props.text !== currentFiber.props.text) {
currentFiber.stateNode.textContent = currentFiber.props.text
}
}
}
currentFiber.effectTag = null
}
const commitRoot = () => {
let currentFiber = workInProgressRoot.firstEffect
while (currentFiber) {
commitWork(currentFiber)
currentFiber = currentFiber.nextEffect
}
// Assign the current root fiber that is successfully rendered to currentRoot
currentRoot = workInProgressRoot
workInProgressRoot = null
}
const workloop = (deadline) => {
let shouldYield = false // 작업 유무
while (nextUnitOfWork && !shouldYield) { // render phase
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1 // performUnitOfWork작업을 한 후에 1ms도 남지 않았으면, 브라우저에게 다시 통제권을 넘길 것이다.
}
if (!nextUnitOfWork && workInProgressRoot) {
console.log('The end of the render stage ')
commitRoot() // commit phase.
}
// Request the browser to reschedule another task
requestIdleCallback(workloop, { timeout: 1000 })
}
가상 돔을 공부하려고 했는데 가상 스택.. React Fiber까지 알게 되어서 좋지만 React Fiber는 사실 크게 이해 되지 않지만.. 가상 돔이 왜 필요해졋는지 그리고 가상 돔에 어떠한 문제가 있어서 React Fiber가 생겼는지 알 수 있는 공부였다. ChatGPT한테 물어보니 쉽게 설명을 해줘서 자주 이용하면 좋을 것 같기도 했다.