3주차 과제도 <프레임워크 없이 SPA 만들기>로, 주제는 이벤트 관리와 렌더링 성능 최적화다.
목표는
이다.
shallowEquals
는 얕은 비교를 하는 함수로 객체의 첫 번째 깊이의 값만 비교한다.
이번 과제는 지난 과제들과 다르게 TypeScript로 작성됐다.
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
!is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
위는 실제로 리액트(https://github.com/facebook/react/blob/main/packages/shared/shallowEqual.js)에 구현된 shallowEqual
함수다.
is
와 hasOwnProperty
는 Object.is
와 Object.hasOwnProperty
와 동일한 로직이다.
Object.is
와 Object.hasOwnProperty
메서드가 ES6에서 제공하는 기능이기 때문에 이를 구현한 폴리필을 사용했다.
아무튼 리액트와 같은 방식으로 과제에 작성하면 타입 오류가 발생한다.
'string' 형식의 식을 '{}' 인덱스 형식에 사용할 수 없으므로 요소에 암시적으로 'any' 형식이 있습니다.
'{}' 형식에서 'string' 형식의 매개 변수가 포함된 인덱스 시그니처를 찾을 수 없습니다.ts(7053)
원인은 TypeScript는 객체의 인덱스 접근에 대해 매우 엄격하여 제네릭 타입 T
가 인덱스 시그니처를 가지고 있다는 보장이 없어서 나는 오류다.
즉 빈 객체일 수도 있는데 string
을 키로 사용하는 것을 안전하지 않다고 판단한다.
해결 방법으로는 타입 단언을 할 수도 있다.
앞서 거친 조건문으로 타입 단언을 선언해도 괜찮다고 생각하지만, TypeScript를 잘 다룰 줄 모르는 입장에서 타입 단언은 왠지 거부감이 든다.
고민을 하던 중, 팀원 분께서 Reflect
를 알려주셨다.
Reflect
는 자바스크립트 내장 객체이다.
객체를 조작하는 여러 메서드들(get
, set
, has
등)을 제공한다.
기존 점 표기법이나 대괄호 표기법과 비슷하지만 함수 형태로 사용할 수 있다.
Reflect.get
은 객체의 프로퍼티에 접근하는 방법으로 아래와 같은 특징이 있다.
undefined
반환.Reflect
를 검색해보면 Proxy
와 함께 소개된다.
Proxy
에 대해서 잘 모르기 때문에 모두 학습하고 넘어가는 건 시간이 지연될 것 같아서 위 내용만 간단하게 알고 넘어갔다.
export function shallowEquals<T>(objA: T, objB: T): boolean {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== "object" ||
typeof objB !== "object" ||
objA === null ||
objB === null
) {
return false;
}
const objAKeys = Object.keys(objA);
const objBKeys = Object.keys(objB);
if (objAKeys.length !== objBKeys.length) {
return false;
}
for (const key of objAKeys) {
const valueA = Reflect.get(objA, key);
const valueB = Reflect.get(objB, key);
if (!Object.is(valueA, valueB)) {
return false;
}
}
return true;
}
Reflect
가 적용된 최종 코드다.
Object.is
를 사용하기 전에 Reflect.has
메서드를 사용했었는데, 생각해보니 Reflect.get
에서 해당 프로퍼티가 없을 경우 undefined
를 반환해주기 때문에 제거했다.
export function deepEquals<T>(objA: T, objB: T): boolean {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== "object" ||
typeof objB !== "object" ||
objA === null ||
objB === null
) {
return false;
}
const objAKeys = Object.keys(objA);
const objBKeys = Object.keys(objB);
if (objAKeys.length !== objBKeys.length) {
return false;
}
for (const key of objAKeys) {
const valueA = Reflect.get(objA, key);
const valueB = Reflect.get(objB, key);
if (!deepEquals(valueA, valueB)) {
return false;
}
}
return true;
}
deepEquals
도 shallowEquals
와 유사하다.
차이점이라면 objA
와 objB
의 타입이 "object"
일 경우 해당 프로퍼티의 값들을 다시 deepEquals
함수의 인자로 넣어 가장 마지막 deps
까지 비교한다.
shallowEquals
와 deepEquals
함수는 동일한 로직이 동작한다.
다른 점은 마지막 조건문에서 Object.is
를 썼냐, deepEquals
를 썼냐는 점이다.
Object.is
와 deepEquals
는 두 개의 값을 받아 boolean
을 반환하는 함수다.
코드의 다른 점에서 유사한 구조를 띄고 있으니, 공통 함수로 묶을 수 있다.
export function baseEquals<T>(
objA: T,
objB: T,
compareValue: (valueA: unknown, valueB: unknown) => boolean,
): boolean {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== "object" ||
typeof objB !== "object" ||
objA === null ||
objB === null
) {
return false;
}
const objAKeys = Object.keys(objA);
const objBKeys = Object.keys(objB);
if (objAKeys.length !== objBKeys.length) {
return false;
}
for (const key of objAKeys) {
const valueA = Reflect.get(objA, key);
const valueB = Reflect.get(objB, key);
if (!compareValue(valueA, valueB)) {
return false;
}
}
return true;
}
공통된 로직을 하나의 함수로 묶으면 위와 같다.
compareValue
함수를 인수로 받는다.
shallowEquals
, deepEquals
에서 각각 Object.is
와 deepEquals
를 넘겨주면 된다.
과제에서 구현해야 할 훅들은 useRef
, useMemo
, useCallback
이다.
이 훅들의 공통점은 리렌더링 최적화와 관련있는 훅들이다.
그렇다면 리액트에서는 언제 리렌더링이 발생하는지를 먼저 정리해보자.
setState
가 실행되는 경우.dispatch
가 실행되는 경우.key props
가 변경되는 경우.props
가 변경되는 경우.useRef
와 useState
는 상태를 저장하고 관리할 수 있다.
차이점은 useRef
는 객체 형태로 current
프로퍼티에 값이 저장되고 값이 변해도 리렌더링을 발생시키지 않는다.
리렌더링이 발생해도 current
에 저장된 상태를 유지하기 때문에 리렌더링과 관련없이 상태를 보관해야 할 때 유용하게 사용할 수 있다.
useState
를 이용해서 useRef
를 구현하라는 과제의 요구사항이 있다.
인자로 받은 값을 current
프로퍼티의 값으로 담아서 첫 번째 반환값인 state
에 내보내주면 된다.
import { useState } from "react";
export function useRef<T>(initialValue: T): { current: T } {
// React의 useState를 이용해서 만들어보세요.
const [ref] = useState({ current: initialValue });
return ref;
}
두 번째 반환값인 setState
는 리렌더링을 발생시키므로, ref
의 값을 바꾸고 싶을 때는 ref.current = newValue
로 바꿔줄 수 있다.
내가 헷갈렸던 부분이 '상태를 변경시킬 때 무언가를 해줘야 하는 거 아닌가?'였다.
즉 상태를 직접 바꾸는 게 아니라 전용 함수를 통해서 바꿔줘야 하는 것이 아닐까라는 생각이 들었다.
useState
에서 state
를 직접 접근해서 바꾸지 않고 setState
를 사용하는 것처럼.
하지만 useState
를 돌이켜보면 state
의 값을 직접 접근해서 바꿀 수 있다.
다만 setState
를 사용하지 않기 때문에 리렌더링이 일어나지 않아 상태 변화를 감지하지 못할 뿐이다.
이 점을 놓치고 생각해서 setState
와 같은 함수로 값을 변경해야 하는 거 아닌가라는 모호한 생각이 자꾸들었었다.
useMemo
는 첫 번째 인자로 함수를 받고, 두 번째 인자로 의존성 배열을 받는다.
의존성 배열의 값이 변경될 때만 함수를 실행한다.
언제 의존성 배열의 값을 비교하게 될까?
useMemo
뿐만 아니라 의존성 배열을 받는 훅들(useEffect
, useCallback
등) 모두 동일하게 컴포넌트가 리렌더링되면서 실행된다.
컴포넌트도 하나의 함수고 리렌더링이란 함수를 다시 실행하는 것을 의미한다.
컴포넌트라는 함수가 실행되면서 내부의 함수(훅)들을 다시 실행하고 이 과정에서 의존성 배열로 받은 값들이 변했는지 확인한다.
useMemo
와 useCallback
의 차이점은 useMemo
는 함수의 결과값을 저장하고, useCallback
은 함수 자체를 저장한다.
과제에서는 useRef
를 통해서 useMemo
를 구현하도록 요구했다.
useMemo
는 실행할 함수와 배교할 의존성 배열 두 개의 인자를 받아서 상태로 저장해야 한다.
export function useMemo<T>(
factory: () => T,
_deps: DependencyList,
_equals = shallowEquals,
): T {
const ref = useRef<{ deps: DependencyList; value: T } | null>(null);
if (ref.current === null) {
ref.current = { deps: _deps, value: factory() };
}
if (!_equals(ref.current.deps, _deps)) {
ref.current = { deps: _deps, value: factory() };
}
return ref.current.value;
}
초기값으로 null
을 넣어주고 null
일 경우 ref.current
에 { deps: _deps, value: factory() }
를 넣어준다.
왜 한 번에
useRef<{ deps: DependencyList; value: T }>({ deps: _deps, value: factory() });
와 같이 넣지 않을까?
왜냐하면 useRef
가 실행될 때마다 factory
함수가 실행되기 때문이다.
불필요하게 함수가 계속 실행되는 것을 막고자, 훅의 최초 호출 시에 null
을 넣어주고 ref.current
가 null
일 경우 함수의 실행 결과를 저장한다.
useRef
도 계속 실행되니까null
이 들어가고 그러면 어차피 첫 번째 조건문에서 다시 값을 넣어주면서factory
함수가 실행되는 거 아닐까?
useRef
는 useState
로 구현되어 있다.
useState
에 이미 저장된 상태가 있다면 useState(initialValue)
를 다시 실행해도 initialValue
를 무시하고 이미 저장된 값을 반환한다.
따라서 useRef
가 실행되도 current
프로퍼티 값이 null
로 덮어씌워지지 않고 이전에 저장한 값을 불러옴으로써 첫 번재 조건문은 최초 훅 호출 시에만 통과하게 된다.
앞서 본 것처럼 useMemo
와 useCallback
의 차이점은 함수의 실행 결과를 저장하냐, 함수를 저장하냐의 차이다.
export function useCallback<T extends Function>(
factory: T,
_deps: DependencyList,
) {
return useMemo(() => factory, _deps);
}
이 차이점만 알면 useCallback
의 구현은 간단해진다.
함수의 실행 결과가 아닌 함수 자체를 저장해야 하기 때문에 함수를 래핑해서 넘겨주면 된다.
useMemo
와useCallback
의 차이점은 알겠는데, 어느 상황에서 어떻게 쓰는 게 좋을까?
useMemo
는 함수의 결과를 저장하므로, 비용이 큰 계산의 결과를 저장할 수 있다.
매번 비용이 큰 계산을 실행한다면 말 그대로 비용이 많이 들기 때문에, 결과에 영향을 줄 수 있는 걸 의존성 배열에 담아서 그 값이 바뀌지 않는 한 다시 계산하지 않고 계산된 결과를 반환하게 한다.
useCallback
은 함수 자체를 저장한다.
컴포넌트가 리렌더링된다는 것은 컴포넌트 함수를 실행하는 것이고, 컴포넌트 내부의 함수를 재생성하게 된다.
useCallback
은 리렌더링 시에 의존성 배열의 값이 변경되지 않는 한 함수를 재생성하지 않는다.
React.memo
와 사용하면 부모 컴포넌트가 리렌더링 되더라도 자식 컴포넌트는 리렌더링 되지 않게 할 수도 있다.
React.memo
와 같이 사용하지 않아도 여러 예시를 보긴 했는데, 사실 내가 써본 게 아니라서 잘 와닿지는 않았다.
과제를 진행하면서 상황에 맞춰서 사용해보자.
React.memo
는 인자로 받은 컴포넌트를 렌더링하고 결과를 저장한 뒤, 리렌더링 시 컴포넌트의 props
에 변화가 없다면 저장된 값을 재사용한다.
따라서 부모 컴포넌트로 부터 함수를 받고 있는데, 함수를 useCallback
으로, 자식 컴포넌트를 React.memo
한다면 부모 컴포넌트가 리렌더링 되어도 함수가 바뀌지 않으면 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있다.
import { shallowEquals } from "../equalities";
import React, { ComponentType } from "react";
import { useRef } from "../hooks";
export function memo<P extends object>(
Component: ComponentType<P>,
_equals = shallowEquals,
) {
const MemoizedComponent = (props: P) => {
const memoizedProps = useRef<P | null>(null);
if (memoizedProps.current === null) {
memoizedProps.current = props;
return React.createElement(Component, props);
}
if (!_equals(memoizedProps.current, props)) {
memoizedProps.current = props;
return React.createElement(Component, props);
}
};
return MemoizedComponent;
}
useRef
를 사용하여 구현한 코드다.
useMemo
와 유사하여 이를 사용하려 했으나,
useMemo
의 의존성 배열에 props
를 넣으려면 배열로 감싸야 함.prop
의 변경 여부와 상관없이 항상 리렌더링 발생.위와 같은 이유로 useRef
를 사용하여 props
를 저장하고 비교하는 방식으로 구현했다.
ContextAPI 사용법에 대해 상세하게 설명해주는 글(https://velog.io/@velopert/react-context-tutorial)
팀원 분께서 ContextAPI에 대해 자세히 알려주는 아티클을 소개해줬는데 주소는 위에 있다.
값과 업데이트 함수를 두 개의 Context로 분리하면 업데이트 함수 사용 시 값만 사용하고 있는 컴포넌트는 리렌더링되지 않는다.
기존에는 값과 업데이트 함수를 분리하지 않고 하나의 Context에서 사용했는데, 위 글을 보고 분리했다.
값과 없데이트 함수를 분리하면 값을 사용하지 않고, 업데이트 함수만 사용하는 컴포넌트는 값이 변경되도 리렌더링되지 않는다.
import React, { createContext, useContext, useState } from "react";
import { INotification } from "../type/type";
import { useCallback, useMemo } from "../@lib";
interface NotificationStateContextType {
notifications: INotification[];
}
interface NotificationActionContextType {
addNotification: (message: string, type: INotification["type"]) => void;
removeNotification: (id: number) => void;
}
const NotificationStateContext = createContext<
NotificationStateContextType | undefined
>(undefined);
const NotificationActionContext = createContext<
NotificationActionContextType | undefined
>(undefined);
export const useNotificationStateContext = () => {
const state = useContext(NotificationStateContext);
if (state === undefined) {
throw new Error(
"useNotificationStateContext must be used within an NotificationContext",
);
}
return state;
};
export const useNotificationActionContext = () => {
const actions = useContext(NotificationActionContext);
if (actions === undefined) {
throw new Error(
"useNotificationActionContext must be used within an NotificationContext",
);
}
return actions;
};
export const NotificationContextProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [notifications, setNotifications] = useState<INotification[]>([]);
const addNotification = useCallback(
(message: string, type: INotification["type"]) => {
const newNotification: INotification = {
id: Date.now(),
message,
type,
};
setNotifications((prev) => [...prev, newNotification]);
},
[],
);
const removeNotification = useCallback((id: number) => {
setNotifications((prev) =>
prev.filter((notification) => notification.id !== id),
);
}, []);
const notificationStateContextValue: NotificationStateContextType = useMemo(
() => ({
notifications,
}),
[notifications],
);
const notificationActionContextValue: NotificationActionContextType = useMemo(
() => ({
addNotification,
removeNotification,
}),
[addNotification, removeNotification],
);
return (
<NotificationActionContext.Provider value={notificationActionContextValue}>
<NotificationStateContext.Provider value={notificationStateContextValue}>
{children}
</NotificationStateContext.Provider>
</NotificationActionContext.Provider>
);
};
내가 작성한 Context 가운데 하나다.
값과 업데이트 함수를 별도의 Context로 만들어 하나의 Provider로 내보냈다.
업데이트 함수를 useCallback
으로 감싸고 최초 렌더링 시에만 함수가 생성되도록 의존성 배열은 비워줬다.
추가로 props
에 넘길 때, 불필요한 리렌더링을 방지하기 위해서 useMemo
로 props
에 넘겨줄 값들을 감싸줬다.
'notificationActionContextValue
는 의존성 배열에 addNotification
과 removeNotification
를 넣어줬는데, 두 함수 모두 최초 렌더링 시에만 만들어지는데, 굳이 넣어줄 필요가 있을까?'라는 고민이 있었지만 lint 에러가 났고, 웬만하면 지켜주는게 좋다고 하여 추가해줬다.
이번 과제에서 상품을 검색하는 Input
이 있고, onChange
시에 setFilter
로 filter
값이 변경되어 컴포넌트가 리렌더링되는 부분이 있었다.
키보드를 누를 때마다 리렌더링이 발생하여, 이를 줄이고자 useDebounce
훅을 만들었다.
아쉽게 과제 테스트 코드 조건으로는 만족하지 못해, 사용하지 않았지만... 이런 점도 고려했다~~
import { useEffect, useState } from "react";
import { useCallback } from "../@lib";
export const useDebounce = ({
setValue,
ms,
}: {
setValue: (value: string) => void;
ms: number;
}) => {
const [timeoutId, setTimeoutId] = useState<number | null>(null);
const debounceChange = useCallback(
(value: string) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
const newTimeout = setTimeout(() => {
setValue(value);
}, ms);
setTimeoutId(newTimeout);
},
[timeoutId, setValue, ms],
);
useEffect(() => {
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [timeoutId]);
return debounceChange;
};
개인적으로 3주동안 이번 과제가 제일 어려웠다.
다른 분들은 이번주 난이도가 제일 낮다고 했지만, 대부분 프론트엔드 개발자로 재직하고 계신 분들이어서 그런 것 같다.
나는 아직 프론트엔드 경험이 없어서 그런지 타입스크립트와 리액트에 익숙지 않다는 걸 체감했다.
그래도 이번 기회에 리액트에 대해서 자세히 짚고 넘어갈 수 있었다.
배운 내용을 정리하면 아래와 같다.
props
, state
업데이트 시props
로 넘겨주는 함수를 메모이제이션 하고 자식 컴포넌트는 React.memo
로 메모이제이션 한다면 부모 컴포넌트가 리렌더링되더라도 리렌더링을 방지할 수 있다.props
를 메모이제이션하여 props
가 변경되지 않는 다면 리렌더링하지 않는다.props
가 아닌 다른 방법.Provider
의 value
를 useMemo
를 통해 메모이제이션 하지 않으면 불필요한 리렌더링이 발생할 수 있다.과제를 마치고 나서 궁금증이 더 생긴 건, 컴포넌트를 어떤 기준으로 분리하는지였다.
마침 다음 챕터인 클린 코드부터 다룰 주제라고 해서 기대가 된다.
이런 흐름으로 가도록 항해에서 커리큘럼을 짠 건가?!
이번주는 연말 약속이 있어, 과제를 바로 시작하지 못하고 월요일부터 시작했다.
늦게 시작하기도 했고, 리액트에 익숙지 않아, 하나씩 짚고 넘어가자는 생각에 천천히 과제를 진행했다.
기존에는 빠르게 과제 완수 후 리팩토링으로 했었는데, 이 방법도 나쁘지 않은 것 같다.
할 수 있다면, 퐁당퐁당해보고 나한테 더 맞는 방법을 찾아야겠다.
토요일 약속, 일요일 숙취로 이틀을 보내고, 1월 1일에도 반나절은 잠만 잤다.
문득 무서워졌다.
24년도 뭔가 한 거 없이 지나간 것 같은데, 똑같이 반복될까봐.
슬랙에 연간 계획과 관련하여 코치님께서 올려주신 영상이 있어서 보고 연간 계획을 세웠다.
이직을 하고 나서도 나태해지면 안 되겠지만, 이직하기 전에는 더더욱 그러면 안 되는데.
아침에 할 일 들을 적어놓는 습관을 들이고 있다.
파워 P라서 계획 세우는 걸 평생하지 않았지만, 요즘 부쩍 필요하다고 느낀다.
막연히 목표를 생각하고 행동을 하기보다, 목표를 생각하고 할 일을 쪼개서 하루마다 뭘 해야할지 어느정도 잡아놓고 실행하는 것이 필요하다.
저번주 코치님께서 해주신 말씀이 기억난다.
생각을 하는데 에너지를 쓰지말고 행동을 하는데 써야한다.