[항해플러스 프론트엔드 2기] 3주차 후기 - 리액트 파헤치기
들어가며
안녕하세요 초원입니다. 👩🏻💻
오늘도 멘토링 때 얻게 된 인사이트를 공유하며 시작해보려고 해요!
나는 이런 걸 잘 하는 사람이야. 고민을 통해 회사에 적용을 했고 이런 성과를 냈어.
너희에게도 이런 걸 제공하여 도움을 줄 수 있을 것 같아.
오 .. 이 마인드 정말 멋지지 않나요.
취업을 준비할 때는 자신도 없고 회사 가서 일 해보며 많이 배워야지하며
소극적인 생각만 가졌던 것 같아요.
멘토링을 통해 생각의 확장이 많이 생기는 것 같습니다.
일의 난이도가 쉽다면 스스로 올려보자
'7일 정도면 끝낼 수 있을 것 같아' -> '하루 만에 끝낼 수 있는 방법은 없을까?' : 생산성을 위한 고민을 하게 됨
이 문장도 마음에 콕 박혔어요.
주도적으로, 적극적으로 일하기- 아자 아자 홧팅 👍🏻
3주차 참여 후기
3주 내내 우수 과제에 선정되었어요.
잘 하시는 분들이 훨씬 많은데 과제 제출하고 시간 내서 열심히 작성한 pr 코멘트 덕분이지 않을까 .. 싶습니다!
잘 적어둔 코멘트들이 지금 블로그 글로 나오기도 하고, 여러모로 아주 유용합니다요
3주차 과제
본 게시글은 신입 개발자가 항해플러스 프론트엔드 2기 _리액트 파헤치기 과정을 거치며 고민한 내용을 담았습니다. 과제와 테스트 코드를 제공해주신 준일 코치님께 감사 인사 드립니다 🙇🏻♀️
총 3주 간의 리액트 과정에서 우리는 다음과 같은 목표를 가집니다.
1주차 - useState, useCallback, useMemo, PureComponent 등 리액트의 기본 개념을 확실하게 이해합니다. 또한 useRef를 직접 구현하면서 react의 lifecycle에 대해 이해해 봅니다.
2주차 - 값, 참조, 비교, 배열 메소드 등에 대해 학습합니다. 연산 결과를 캐시하고, 연산에 사용되는 요소가 변경될 때 다시 계산하는 함수를 직접 만들어봅니다.
3주차 - 1,2주차에 배운 내용들을 종합하여 리액트에서 쓰이는 최소한의 기본 개념들을 만들어보는 것을 목표로 합니다. 또한 기본 과제에 대해 최적화를 해봅니다.
가상돔을 직접 만들고 리액트에 대해 보다 깊이 이해하고자 직접 구현한 간단한 예제입니다.
JSX
export function jsx(type, props, ...children) {
return {type, props, children: children.flat()}
}
역할
jsx 함수는 매개변수로 받아온 인자들을 객체 형태로 반환합니다.
내용
- type: HTML 태그 이름을 표현합니다.
- props: HTML 태그가 가진 속성을 표현합니다.
- children: HTML 태그가 가진 자식 요소들을 표현합니다.
...children 형태의 나머지 인자는 배열로 묶여서 children에 할당이 됩니다.
이때 children이 중첩배열일 경우,
DOM요소를 렌더링 할 때 순회하고 처리하는 로직이 복잡해지기 때문에
flat 메서드를 사용해 평탄화된 배열을 반환하도록 합니다.
궁금증
flat 메서드를 적용해 평탄화된 배열이 되면,
자식 안의 자식 요소 등의 깊이 정보는 어떻게 알 수 있을까?
해결
function jsx(type, props, ...children) {
return { type, props, children: children.flat().map((child, index) => ({
...child,
originalIndex: index
})) };
}
간단히 index를 부여해 줄 수 있습니다.
각 요소가 원래 어디에 위치했는지에 대한 정보를 저장합니다.
createElement
export function createElement(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
node.children.forEach(child => {
$el.appendChild(createElement(child));
});
node.props && Object.keys(node.props).forEach(key => {
$el.setAttribute(key, node.props[key]);
});
return $el;
}
역할
createElement 함수는 주어진 노드를 기반으로 실제 DOM 요소를 생성합니다.
내용
- node: 이 함수는 jsx 함수에서 반환된 객체를 매개변수로 받습니다.
(node는 type, props, children을 포함합니다.)
- 문자 노드 처리: 만약 node가 문자열이면, TextNode를 생성하여 반환합니다.
- 엘리먼트 노드 생성: node.type에 해당하는 DOM 요소를 생성합니다.
- 자식 요소 추가: node.children 배열을 순회하며 각 자식 요소를 재귀적으로 생성하여 부모 요소에 추가합니다.
- 속성 설정: node.props 객체를 순회하며 각 속성을 생성된 DOM 요소에 설정합니다.
궁금증1
'string'이라고 써야 하나 String이라고 써야하나 .. (머쓱)
해결
'string'은 원시 문자열 타입을 의미하고,
String은 자바스크립트의 문자열 객체 타입입니다.
typeof 연산자는 원시 타입을 반환하기 때문에 'string'을 사용해야 합니다.
궁금증 2
innerHTML과 appendChild 중 뭘 써야 하지?
해결
- innerHTML은 요소의 HTML 콘텐츠를 설정하거나 가져오는 데 사용됩니다.
전체 내용을 덮어쓰게 되어 기존 자식 요소가 모두 제거될 수 있습니다.
- appendChild는 노드를 특정 부모 노드의 자식으로 추가하는 메서드로,
기존 내용을 유지하면서 새로운 요소를 추가할 수 있습니다.
- 이 함수에서는 새로운 요소를 순차적으로 추가하는 것이므로 appendChild가 적절합니다.
updateAttributes
function updateAttributes(target, newProps, oldProps) {
newProps = newProps || {};
oldProps = oldProps || {};
Object.keys(newProps).forEach(key => {
if (oldProps[key] === newProps[key]) {
return;
}
target.setAttribute(key, newProps[key]);
});
Object.keys(oldProps).forEach(key => {
if (newProps[key]) {
return;
}
target.removeAttribute(key);
});
}
역할
updateAttributes 함수는 기존의 DOM 요소 속성을 새로운 속성 값으로 업데이트합니다.
내용
- target: 업데이트할 대상 DOM 요소입니다.
- newProps: 새로운 속성 값들을 포함한 객체입니다.
- oldProps: 이전 속성 값들을 포함한 객체입니다.
newProps와 oldProps를 비교하여 변경된 속성을 target에 반영합니다.
new Props에 없는 속성은 target에서 제거합니다.
궁금증
undefined나 null 처리 어떻게 예쁘게 하지 ...?
해결
newProps = newProps || {};oldProps = oldProps || {};
newProps = newProps ?? {};
oldProps = oldProps ?? {};
console.log로 확인했을 때 값이 undefined인 경우를 발견하고 처리해주려고 했는데
이런 경우 다들 어떻게 처리하시나요? 🤷🏻♀️
추가
HTML 태그 속성에는 id, class 외에도 style이나 이벤트 리스너 등이 포함되는데,
해당 케이스도 처리해보자!
render
export function render(parent, newNode, oldNode, index = 0) {
if (!newNode && oldNode) {
parent.removeChild(parent.children[index]);
return;
}
if (newNode && !oldNode) {
parent.appendChild(createElement(newNode));
return;
}
if (typeof newNode === 'string' && typeof oldNode === 'string' && newNode !== oldNode) {
parent.replaceChild(createElement(newNode), parent.children[index]);
return;
}
if (newNode.type !== oldNode.type) {
parent.replaceChild(createElement(newNode), parent.children[index]);
return;
}
updateAttributes(parent.children[index], newNode.props, oldNode.props);
const newChildLength = newNode.children ? newNode.children.length : 0;
const oldChildLength = oldNode.children ? oldNode.children.length : 0;
const length = Math.max(newChildLength, oldChildLength);
for (let i = 0; i < length; i++) {
render(parent.children[index], newNode.children[i], oldNode.children[i], i);
}
}
역할
render 함수는 새로운 노드를 부모 요소에 렌더링 하거나, 기존 노드와 비교하여 업데이트 합니다.
내용
- parent: 새로운 노드를 추가하거나 기존 노드를 업데이트할 부모 요소입니다.
- newNode: 렌더링하거나 업데이트할 새로운 노드입니다.
- oldNode: 기존에 렌더링된 노드입니다.
- index: 현재 노드의 위치를 나타내는 인덱스입니다.
궁금증1
과제를 시작하면 가장 먼저 든 생각 → 'index가 props로 왜 들어왔지?'
순서가 필요한가? 위치? 뭐지?
해결
이 함수에서 index는 현재 노드의 위치를 나타내며, 이를 통해 parent의 자식 노드 중
특정 위치의 노드를 선택하거나 업데이트 할 수 있습니다.
이렇게 함으로써 정확한 위치에 있는 노드를 제거하거나 교체할 수 있습니다.
이렇게! 👇🏻
if (!newNode && oldNode) {
parent.removeChild(parent.children[index]);
return;
}
(oldNode를 지워버리는 것과 parent.children[index]를 정확히 지우는 것은 분명한 차이가 있음)
궁금증 2
자식 노드 개수가 차이날 때 render 함수에서 어떻게 처리하지 ...?
해결
일단 다 순회는 해야하니까 더 긴 길이의 자식을 찾고, 반복문을 돌립니다.
재귀 함수를 써보며 early return의 기특함을 알았음!
createHooks
import { deepEquals } from "../../../assignment-2/src/basic/basic";
export function createHooks(callback) {
let state = [];
let stateIndex = 0;
let memos = [];
let memoIndex = 0;
const useState = (initState) => {
const hookIndex = stateIndex;
const setState = (newState) => {
if (deepEquals(state[hookIndex], newState)) {
return;
}
state[hookIndex] = newState;
callback();
};
if (state[hookIndex] === undefined) {
state[hookIndex] = initState;
}
stateIndex++;
return [state[hookIndex], setState];
};
const useMemo = (fn, refs) => {
const hookIndex = memoIndex;
if (memos[hookIndex] === undefined) {
memos[hookIndex] = {value: fn(), refs};
}
if (!deepEquals(memos[hookIndex].refs, refs)) {
memos[hookIndex] = {value: fn(), refs};
}
memoIndex++;
return memos[hookIndex].value;
};
const resetContext = () => {
stateIndex = 0;
memoIndex = 0;
}
return { useState, useMemo, resetContext };
}
개요
createHooks 함수는 상태관리와 메모이제이션을 위한 커스텀 훅인 useState와 useMemo를 제공합니다.
렌더링 시 상태와 메모를 유지할 수 있도록 합니다.
이 함수는 callback을 인자로 받고, 상태와 메모가 업데이트될 때마다 callback이 다시 호출됩니다.
내용
- useState: 상태를 생성하고 업데이트하는 훅입니다.
- useMemo: 값을 캐싱하여 불필요한 계산을 피하는 훅입니다.
- resetContext: 상태 인덱스와 메모 인덱스를 초기화하여 훅의 컨텍스트를 리셋합니다.
역할
resetContext 함수는 상태 인덱스와 메모 인덱스를 초기화하여 훅의 컨텍스트를 리셋하는 함수입니다.
내용
stateIndex와 memoIndex를 0으로 초기화하여, 다음 렌더링 시 훅들이 올바르게 초기화되도록 합니다.
궁금증
멘토링 때 resetContext가 왜 필요한지 간단히 설명을 들었지만,
구현을 시작할 때 다시 이런 생각이 들었습니다.
'resetContext가 왜 필요하지 ......?'
해결
function render() {
const [count, setCount] = useState(0);
const [text, setText] = useState('initial');
console.log(`Count: ${count}, Text: ${text}`);
return { setCount, setText };
}
const { useState } = createHooks(render);
// 초기 렌더링const { setCount, setText } = render();
count와 text 상태를 관리하기 위해 useState가 두 번 사용되었습니다.
이제 각 setState 함수를 호출해봅시다.
// 상태 업데이트setCount(1);
setText('updated');
useState는 헷갈립니다.
잠만 ... count를 update로 바꿔줘야 했던가 ..? (억지)
아하! 나에겐 판단할 수 있는 인덱스가 있지
count는 0번이네. 0번 상태를 업데이트 해줘야겠다. 그다음은 1번.
문제는 resetContext를 사용하지 않을경우,
클로저 개념을 활용해 본인의 context를 유지하고 있던 함수가
각각의 인덱스를 1,2 로 만들어버리면
count를 바꾸려고 해도 (0번 이었지만 1번이 되어버렸음)
기존 1번 인덱스를 가지고 있던 text가 변경되겠죠?
따라서 각 렌더링 전에 인덱스를 초기화하면,
useState가 올바른 인덱스를 참조하여 상태를 정확하게 업데이트 할 수 있습니다. 예!
역할
useMemo 함수는 값을 캐싱하여 불필요한 계산을 피하는 함수입니다.
내용
- memoIndex를 통해 메모 배열에서 현재 훅의 인덱스를 추적합니다.
- 메모가 초기화되지 않은 경우, fn()을 호출하여 값을 계산하고 이를 저장합니다.
- 의존성 배열 refs가 변경되지 않은 경우, 기존 값을 반환합니다.
- 의존성 배열이 변경된 경우, fn()을 다시 호출하여 값을 업데이트합니다.
궁금증
테스트 코드를 보면 의존성 배열인 refs에 일반 변수가 들어가 있던데,
useMemo를 사용할 땐 거의 state를 넣어줬던 것 같습니다.
실제 코드를 작성할 때 의존성 배열에 일반 변수를 추가하는 경우가 있을까요?
해결
은 못했습니다 (허허)
역할
useState 함수는 상태 변수를 생성하고 업데이트 하는 함수입니다.
내용
- stateIndex를 통해 상태 배열에서 현재 훅의 인덱스를 추적합니다.
- 상태가 초기화되지 않은 경우, initState를 사용하여 초기화합니다.
- 상태를 업데이트하는 setState 함수를 반환합니다.
- 상태가 변경될 때, callback이 호출되어 상태를 다시 렌더링합니다.
궁금증 1
코치님은 왜 useState 구현할 때 클로저 개념이 필요하다고 했을까?
해결
처음에 아래와 같이 구현하고 test 1,2가 통과한다고 아주 좋아했습니다.
다음 테스트들을 고민하기 전까지는 ...
const useState = (initState) => {
let state = initState;
const setState = (newState) => {
if (deepEquals(state, newState)) {
return;
}
state = newState;
callback();
};
return [state, setState];
};
useState 내부에서 state를 관리하게 되면,
resetContext 함수가 호출되어 state가 초기화됨에 따라 리렌더링이 일어나고
state가 매번 새로 생성되어 유지할 수 없는 문제가 발생합니다.
수정한 코드에서는 useState 외부에 state 변수를 선언하였고,
useState가 여러 번 호출될 가능성을 고려해 stateIndex도 함께 관리를 했습니다.
궁금증 2
useState 함수에서 stateIndex를 바로 쓰지 않고 hookIndex라는 새로운 변수에 할당한 이유는 뭘까요~? (ㅋㅋ)
해결
const [a, setA] = useState(1); // stateIndex : 0
const [b, setB] = useState(3) // stateIndex: 1
위 코드에서 useState가 두 번 불렸습니다.
stateIndex의 초기값은 0이었기 때문에 상태 a와 b는 각각 0과 1의 인덱스를 가집니다.
(문제는 useState가 실행될 때 stateIndex++ 로 인해 stateIndex가 증가하여 2가 되었다는 사실 ...!)
state[stateIndex] = newState;
이 상황에서 setA(3)를 호출하면, stateIndex가 0인 녀석이 바뀌어야 하는데
stateIndex 2인 녀석의 상태가 바뀌게 되므로 예상대로 동작하지 않게 됩니다. (정답!)
궁금증 3
hookIndex와 같이 내부 변수로 할당하지 않고,
다른 방식으로 각 useState의 상태를 유지하도록 구현하는 방법은?
MyReact
import { createHooks } from "./hooks";
import { render as updateElement } from "./render";
function MyReact() {
let _root = null;
let _rootComponent = null;
const _render = () => {
resetHookContext();
const newNode = _rootComponent();
updateElement(_root, newNode, _root.children[0]);
};
function render($root, rootComponent) {
_root = $root;
_rootComponent = rootComponent;
_render();
}
const { useState, useMemo, resetContext: resetHookContext } = createHooks(_render);
return { render, useState, useMemo };
}
export default MyReact();
역할
MyReact 함수는 간단한 리액트 라이브러리를 구현하여, 상태 관리와 메모이제이션을 위한 훅(useState, useMemo)을 제공하고, 컴포넌트를 렌더링하는 기능을 제공합니다.
내용
- _root: 렌더링할 DOM 요소입니다.
- _rootComponent: 렌더링할 루트 컴포넌트입니다.
- _render: 현재 상태에 따라 루트 컴포넌트를 렌더링하는 함수입니다.
- render: 주어진 루트 DOM 요소와 루트 컴포넌트를 저장하고, 초기 렌더링을 수행하는 함수입니다.
- useState: 상태를 생성하고 업데이트하는 훅입니다.
- useMemo: 값을 캐싱하여 불필요한 계산을 피하는 훅입니다.
requestAnimationFrame
import { deepEquals } from "../../../assignment-2/src/basic/basic";
export function createHooks(callback) {
let state = [];
let stateIndex = 0;
let memos = [];
let memoIndex = 0;
let callbackRequestId = null;
const useState = (initState) => {
const hookIndex = stateIndex;
const setState = (newState) => {
if (deepEquals(state[hookIndex], newState)) {
return;
}
if (!callbackRequestId) {
callbackRequestId = requestAnimationFrame(() => {
callback();
callbackRequestId = null;
});
}
state[hookIndex] = newState;
};
if (state[hookIndex] === undefined) {
state[hookIndex] = initState;
}
stateIndex += 1;
return [state[hookIndex],setState];
};
const useMemo = (fn, refs) => {
const hookIndex = memoIndex;
if (memos[hookIndex] === undefined) {
memos[hookIndex] = {values: fn(), refs};
}
if (!deepEquals(memos[hookIndex].refs, refs)) {
memos[hookIndex] = {values: fn(), refs};
}
memoIndex += 1;
return memos[hookIndex].values;
};
const resetContext = () => {
stateIndex = 0;
memoIndex = 0;
}
return { useState, useMemo, resetContext };
}