[React] Function Components와 hooks

Juno·2021년 9월 14일
2
post-thumbnail

🙌 들어가기

안녕하세요:) 이번 포스팅은 지난 [React] Reconciliation - 재조정에 이어서 공부하는 AUSG 스터디를 위해 작성된 글입니다. 부족한 점 있으시면 언제든 피드백 주시면 감사하겠습니다 🙏

💪 function components 지원 추가하기

function App(props) {
	return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
      
const container = document.getElementById("root")
Didact.render(element, container)

먼저, h1태그를 반환하는 간단한 함수형 컴포넌트의 예시를 보겠습니다.

function App(props) {
	return Didact.createElement("h1", null, "Hi ", props.name);
}
const element = Didact.createElement(App, {
	name: "foo",
});

바벨을 통해서 JSX 코드가 다음과 같이 JS 코드로 바뀌게 되었습니다. 여기서 element는 태그의 타입이 h1과 같은 일반적인 타입이 아닌 App이라는 타입을 가진 엘리멘트를 만들었습니다.

하지만, 이때 보시다시피 App은 함수를 의미하고, 이 함수를 실행했을 때 비로소 h1태그를 가진 엘리멘트를 반환하게 됩니다. 기존의 엘리먼트와 달리 children을 props로 전달받는 것이 아닌, 함수를 호출하여 children을 반환합니다.

💡 함수형 컴포넌트는 다음과 같은 두 가지 차이점을 가집니다.
함수형 컴포넌트에서 만들어진 fiber는 DOM 노드가 없습니다.
children을 props를 통해 가져오는 대신 함수를 실행하여 얻습니다.

따라서 함수형 컴포넌트에 대해서는 다르게 처리를 해 주어야 합니다.

function performUnitOfWork(fiber) {
	const isFunctionComponent = fiber.type instanceof Function
    if (isFunctionComponent) {
    	updateFunctionCompnent(fiber)
    } else {
      updateHostComponent(fiber)
    }
  // codes...
 function updateFucntionComponet(fiber) {
 	// TODO
 }
 function updateHostComponent(fiber) {
 	if(!fiber.dom) {
    	fiber.dom = createDom(fiber)
    }
   reconcileChildren(fiber, fiber.props.children)
 }
}

updateFunctionComponent에서는 children을 반환하는 함수를 실행합니다. App 함수를 실행했을 때 h1 엘리먼트를 가진 React 엘리먼트가 반환되는 것과 같습니다. 그 이후 재조정은 같은 방식으로 수행되기 때문에 함수형 컴포넌트에 대한 예외처리만 해주면 이전과 같이 동작합니다.

function updateFunctionComponent(fiber) {
	const children = [fiber.type(fiber.props)]
    reconcileChildren(fiber, children)
}

그 다음은 commitWork 함수의 변경이 필요합니다.

function commitWork(fiber) {
	if(!fiber) {
    	return
    }
  
  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
  	domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom
  
  if(fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
  	domParent.appendChild(fiber.dom)
  }
}

위에서 보았듯, 함수형 컴포넌트에서 만들어진 fiber는 DOM 노드를 가지고 있지 않기 때문에 DOM노드를 가진 parent를 찾을 때 까지 트리의 상단으로 올라가 DOM 노드를 추가하는 로직이 필요합니다.

function commitWork(fiber) {
  // codes...
	else if (fiber.effectTag === "DELETION") {
		commitDeletion(fiber, domParent)
	}
}

function commitDeletion(fiber, domParent) {
	if(fiber.dom) {
    	domParent.removeChild(fiber.dom)
    } else {
      commitDeletion(fiber.child, domParent)
    }
}

삭제하는 로직도 마찬가지로 함수형 컴포넌트일때와 아닐때를 구분하여 처리해 줍니다. fiber가 DOM 노드를 가지지 않을 경우에(함수형 컴포넌트) DOM 노드를 가진 자식을 찾을 때 까지 탐색을 수행 한 이후에 원래의 로직을 실행합니다.

⚓️ 훅(Hooks)

const Didact = {
	createElement,
  	render,
  	useState, // TODO
}

function Counter() {
	const [state, setState] = Didact.useState(1)
    return (
    	<h1 onClick={() => setState(c => c+1)}>
        	Count: {state}
        </h1>
    )
}
const element = <Counter />

hook을 설명할 때 가장 먼저 언급되는 counter 예제에서 볼 수 있는 useState를 구현해 보려고 합니다. 여기서 count를 클릭할 때 마다 state가 1씩 증가합니다. 여기서 우리는 Didact.useState를 사용해서 카운터의 값을 얻거나 갱신하게 됩니다.

useState 훅을 직접 구현하기 위해선 몇가지 전역 변수들의 초기화가 필요합니다. 이를 함수형 컴포넌트일 때 실행되는 updateFunctionComponent 에서 설정합니다.

let wipFiber = null
let hookIndex = null

function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
}

wipFiber(작업중인 fiber)를 설정해 주고 해당 fiber에 hooks 배열을 추가함으로서 동일한 컴포넌트에서 useState 훅을 여러번 사용할 수 있도록 해줍니다. 또한,hookIndex를 통해 현재 hook의 index를 트래킹해줍니다.

function useState(initial) {
	const oldHook = 
          wipFiber.alternate &&
          wipFiber.alternate.hooks &&
          wipFiber.alternate.hooks[hookIndex]
    
    const hook = {
      state: oldHook ? oldHook.state : initial,
    }
    wipFiber.hooks.push(hook)
  	hookIndex++
  	return [hook.state]
}

그 다음은 useState 함수입니다. 함수형 컴포넌트에서 useState를 호출했을 때, 기존에 추가하고 있는 oldHook이 있는지 체크합니다. (이때 oldHook이란, wipFiber에 이미 추가되어 있는(alternate) hook을 의미합니다.)

oldHook이 존재한다면, oldHookstate를 새로 추가할 hook에 추가하고, 그렇지 않을 경우엔 파라미터로 받은 initial 값을 새로운 훅의 state로 사용합니다. 그 이후 wipFiber에 새로운 훅을 추가하고 hookIndex를 하나 증가시킨 이후에 새로운 훅의 state를 반환해 줍니다.

function useState(initial) {
	//	codes...
    const hook = {
      state: oldHook ? oldHook.state : initial,
      queue: [],
    }
    
    const setState = action => {
      hook.queue.push(action)
      wipRoot = {
      	dom: currentRoot.dom,
        props: currentRoot.props,
        alternate: currentRoot,
      }
      nextUnitOfWork = wipRoot
      deletions = []
    }
    
    wipFiber.hooks.push(hook)
  	hookIndex++
  	return [hook.state, setState]
}

useState 훅은 state를 업데이트 시켜주는 setState 함수도 함께 반환해 주어야 합니다. setState 함수는 action을 파라미터로 받아옵니다. action은 이전 상태를 받아서 다음 상태로 업데이트 해주는 함수입니다.(위의 counter 예제로 설명하면 action은, state를 증가시키는 동작을 의미합니다.) 그리고 해당 action을 hook의 queue에 추가합니다. queue를 만들어서 action을 추가시키는 이유는, 여러 action을 queue에 추가해 놓은 뒤 다음 render 시점에 queue에 저장된 모든 action을 배치실행 될 수 있도록 하기 위해서 입니다.

그리고 setState 함수가 호출되면, 해당 함수 컴포넌트는 리렌더링 되어야 하기 때문에 render 함수에서와 비슷하게 새로운 wipRoot를(currentRoot의 요소들로 할당) nextUnitOfWork로 지정해 주어서 새로운 render phase를 시작하도록 합니다.(리렌더링 시키는 것이죠)

const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
  hook.state = action(hook.state)
})

위에서 설명했듯, action들을 한번에 실행시켜주는 코드를 추가합니다. 컴포넌트가 리렌더링 된 이후에 실행되므로 oldHook의 queue에 접근해서 저장되어있는 모든 action을 forEach 구문을 통해 이전의 state들을 업데이트된 state로 반환해 줌으로써 state가 변경됨을 확인하실 수 있습니다.

profile
사실은 내가 보려고 기록한 것 😆

1개의 댓글

comment-user-thumbnail
2022년 1월 22일

좋은 글 감사합니다

답글 달기