많은 일이 있었다. 열심히 만들었던 메인페이지를 싹 다 갈아엎고 다시 만들기도 했고 (디자인이 너무 맘에 안들었음) 유명한 사이트들을 많이 보면서 참고도 하고 영감(?)을 얻어보려고 노력했음
우선 내가 만들 웹사이트는 웹 애플리케이션이다.
단순히 정적이고 블로그 글만을 보여주는 곳이 아닌 글도 작성하고 더 나아가 상호작용 가능한 웹 애플리케이션을 만드는 것이 목표이기 때문이다. 한두 달짜리 포트폴리오만을 위한(물론 포폴목적도 있음) 내가 개인 공부를 위한 다양한 애플리케이션들을 만들어보기 위함이다.(채팅, 대쉬보드, 이메일, 페어프로그래밍 등등 기획만 김칫국임)
내가 필요한 기능들
써놓고 보니 별거아닌거 같은데 0에서 시작하려니 디자인 하기가 너무 어려웠고 효율적인 퍼블리싱을 많이 고민했다. 근데 진짜 많이 힘들었음.
이렇다.
CSS가 진짜 생각보다 골치 아픔. 처음엔 가상 선택자로 after속성을 활용했으나 이벤트를 붙이기가 어려워서 margin-left : auto를 줘서 해결했다. CSS는 진짜 정답이 없고 활용을 어떻게 하는지가 중요한데 많은 경험이 필요한거 같다.
그니까 사이드바가 축소되면서 아이콘들이 왼쪽으로, 오른쪽으로 밀리면 엄청 버벅대는것처럼 보임. 그래서 왼쪽에 고정된 여백값(margin,padding)을 주고 오른쪽만 줄어들면서 넘치는 컨텐츠들은 안보이게 해야 한다. 그과정에서 어차피 사이드바는 고정된 가로 값을 가지고 있기 때문에 pixel단위로 수정할 수 있었음.
축소된 사이드바의 상태는 overflow:hidden과 max-width값을 주면 된다.
이걸 내가 썼던 이유가 뭐나면 우선 공식문서에 소개된 내용을 보자면 제네릭한 아이콘 컴포넌트를 쉽게 만들어서 name속성으로 여러 아이콘들을 재사용가능하게 사용할 수 있다.(컬러,사이즈 등과 같은 다양한 속성도 조절 가능)
import dynamic from 'next/dynamic'
import { LucideProps } from 'lucide-react';
import dynamicIconImports from 'lucide-react/dynamicIconImports';
interface IconProps extends LucideProps {
name: keyof typeof dynamicIconImports;
}
const Icon = ({ name, ...props }: IconProps) => {
const LucideIcon = dynamic(dynamicIconImports[name])
return <LucideIcon {...props} />;
};
export default Icon;
NEXT.js의 Dynamic import를 통해서 성능적으로도 최적화를 시도할 수 있다. 근데 다만 상호작용이 없는 정적인 컴포넌트(서버 컴포넌트)에서는 유용할 수 있다. 근데 상호작용이 필요한 사이드바, 네비게이션 바에서는 리렌더링이 발생하고 그러면 깜빡임이 생기기 때문에 매우 이상함.!
그래서 구글링을 해봤다. 나랑 같은 문제가 있는 사람은 없는지, 넥스트 다이나믹 임포트가 문제인건지. 또 공식문서도 다시 잘 봐봄
1.아이콘 컴포넌트를 다시 보자
import dynamic from 'next/dynamic';
import { memo } from 'react';
import { LucideProps } from 'lucide-react';
import dynamicIconImports from 'lucide-react/dynamicIconImports';
interface IconProps extends LucideProps {
name: keyof typeof dynamicIconImports;
}
const Icon = memo(({ name, ...props }: IconProps) => {
const LucideIcon = dynamic(dynamicIconImports[name]);
return <LucideIcon {...props} />;
});
export default Icon;
npm run dev를 해서 dev서버를 키면 hmr이라고 개발자가 바꾼 코드가 막 실시간으로 반영이 되어야 하는데 memo를 쓰니까 import해오는 모듈을 찾아서 다시 새로 가져와야 함. 그러니까 0.2초 걸리던게 11초, 12초,22초 이렇게 걸린다. 얼마나 답답함. console.log 하나 찍는데도 11초 걸리니까 진짜 개발을 할 수가 없음.
export default function Task() {
return (
<svg
className="w-[20px] h-[20px] text-slate-700 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M9 8h10M9 12h10M9 16h10M4.99 8H5m-.02 4h.01m0 4H5"
/>
</svg>
);
}
이렇게 여러 아이콘 컴포넌트들을 만들어주고 아이콘 컴포넌트들을 매핑해준다.
import PairProgramming from './assets/PairProgramming';
import Payment from './assets/Payment';
import Task from './assets/Task';
import Chat from './assets/Chat';
import Posts from './assets/Posts';
import Dashboard from './assets/Dashboard';
import Email from './assets/Email';
import User from './assets/User';
export const iconMap = {
home: Posts,
dashboard: Dashboard,
chat: Chat,
email: Email,
user: User,
task: Task,
pairProgramming: PairProgramming,
payment: Payment,
};
export type IconType = keyof typeof iconMap;
import { IconType, iconMap } from './IconMap';
export type Props = {
name: IconType;
size?: number;
};
const Icon = ({ name }: Props) => {
const IconSVGComponent = iconMap[name];
return <IconSVGComponent />;
};
export default Icon;
<SidebarItem name="home" title="Posts" />
<Icon name={name} size={20} />
이전과 동일하게 name만 프롭스로 주면 된다.
이랬더니 다시 0.2초로 돌아옴 속이 시원해짐 ㅋㅋ
사이드바는 이렇게 마무리 지었다.
이미지에 보이는 것처럼, 또 이전에 내가 설명했던 것을 참고하면 사이드바의 상태는 세가지이다. 확장, 축소, 안보임인데 이에 따른 오른쪽 메인컨텐츠 영역이 바뀐다.
아니 나 flex 잘 씀. 근데 이건 잘 몰랐음
flex CSS 속성은 하나의 플렉스 아이템이 자신의 컨테이너가 차지하는 공간에 맞추기 위해 크기를 키우거나 줄이는 방법을 설정하는 속성입니다. flex는 flex-grow, flex-shrink, flex-basis의 단축 속성입니다.
결론적으로 부모 컨테이너에서 남는 공간을 어떻게 차지할 것인지, 아니면 내가 지정한 DOM요소가 얼마나 공간을 차지하고 남는 공간을 다른애한테 줘버릴 것인지 등등 이런걸 효과적으로 제어할 수 있음. 여튼 잘 몰랐던 flex를 활용해서 문제를 해결할 수 있었음.
그니까 무슨 말이냐면 내가 위에도 언급했지만 사이드바 상태에 따른 메인 컨텐츠 레이아웃 영역의 가로가 매우 가변적임. 닫았다, 열었다, 없어졌다 할 때마다 계속 바뀌는데?? 브라우저 뷰포트로 미디어 쿼리를 잡으면 대응이 안됨. 사이드바 상태가 바껴서 메인컨텐츠 레이아웃 영역이 바뀌더라도 뷰포트 크기가 바뀌는건 아니기 때문.
intersection observer와 같은 관찰자 api라고 보면 되는데, 이걸 알게되어서 활용했다.
import { RefObject, useLayoutEffect, useReducer } from 'react';
interface Options<T extends HTMLElement = HTMLElement> {
ref: RefObject<T>;
}
interface ResponsiveClassName {
sm: string;
md: string;
lg: string;
xl: string;
'2xl': string;
}
interface Action {
type: keyof typeof responsiveClassNames;
payload: (typeof responsiveClassNames)[keyof typeof responsiveClassNames];
}
interface State {
className: string;
}
const responsiveClassNames: ResponsiveClassName = {
'2xl': 'grid-cols-5 gap-6',
//1536
xl: 'grid-cols-4 gap-[23px]',
// 1280
lg: 'grid-cols-3 gap-4',
//1024
md: 'grid-cols-2 gap-6',
//786
sm: 'grid-cols-1 gap-6',
//640
};
function getContainerWidth_returnClassName(width: number): {
type: keyof ResponsiveClassName;
payload: (typeof responsiveClassNames)[keyof typeof responsiveClassNames];
} {
if (width >= 1620) {
return { type: '2xl', payload: responsiveClassNames['2xl'] };
} else if (width >= 1240) {
return { type: 'xl', payload: responsiveClassNames['xl'] };
} else if (width >= 1024) {
return { type: 'lg', payload: responsiveClassNames['lg'] };
} else if (width >= 730) {
return { type: 'md', payload: responsiveClassNames['md'] };
} else {
return { type: 'sm', payload: responsiveClassNames['sm'] };
}
}
const reducer: React.Reducer<State, Action> = (state, action) => {
if (!action) {
throw new Error('디스패치 함수에 액션이 정의되지 않았습니다??');
}
const { className } = state;
const { type, payload } = action;
switch (type) {
case '2xl':
return { className: payload };
case 'xl':
return { className: payload };
case 'lg':
return { className: payload };
case 'md':
return { className: payload };
case 'sm':
return { className: payload };
default:
return { className };
}
};
export function useResizeObserver({ ref }: Options) {
const [state, dispatch] = useReducer(reducer, { className: '' });
useLayoutEffect(() => {
if (!ref.current) return;
if (typeof window === 'undefined' || !('ResizeObserver' in window)) return;
const observer = new ResizeObserver(([entry]) => {
const { type, payload } = getContainerWidth_returnClassName(
entry.borderBoxSize[0].inlineSize,
);
if (state.className !== payload) {
dispatch({ payload, type });
}
});
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [state.className]);
return state;
}
컨텐츠 영역의 레이아웃 크기가 각각의 케이스별로 있다. sm,md,lg,xl,2xl 등등 그렇기 때문에 useReducer가 useState보다 상태를 관리하는데 있어서 더 효율적이고 가독성도 좋아짐.
CSS, 즉 스타일링을 주입하기 때문에 useEffect같은 경우에는 브라우저 렌더링 동작 순서를 생각해보았을 때 페인트 과정이 끝나고 나서 JS를 실행한다. 그렇지만 useLayoutEffect는 그보다 좀 더 빠른 단계에서 페인트를 멈춰놓고 JS를 실행하는데 이 경우엔 내가 스타일링을 직접적으로 제어하기 때문에 useLayoutEffect가 맞다고 생각했음.
ResizeObserverEntry.borderBoxSize 읽기 전용
콜백이 실행될 때 관찰된 요소의 새 테두리 상자 크기를 포함하는 객체 배열입니다.
ResizeObserverEntry.contentBoxSize 읽기 전용
콜백이 실행될 때 관찰된 요소의 새 콘텐츠 상자 크기를 포함하는 객체 배열입니다.
ResizeObserverEntry.devicePixelContentBoxSize 읽기 전용
콜백이 실행될 때 관찰된 요소의 장치 픽셀 단위로 새 콘텐츠 상자 크기를 포함하는 객체 배열입니다.
ResizeObserverEntry.contentRect 읽기 전용
DOMRectReadOnly콜백이 실행될 때 관찰된 요소의 새 크기를 포함하는 객체입니다 . 이는 이제 이전 버전과의 호환성을 위해서만 사양에 유지되는 레거시 속성입니다.
ResizeObserverEntry.target 읽기 전용
관찰 중인 Element또는 에 대한 참조입니다 .SVGElement
즉, margin영역을 포함하는지, padding까지만 볼 건지, border-box를 기준으로 볼 것인지 등등 정할 수 있다.
function getContainerWidth_returnClassName(width: number): {
type: keyof ResponsiveClassName;
payload: (typeof responsiveClassNames)[keyof typeof responsiveClassNames];
} {
if (width >= 1620) {
return { type: '2xl', payload: responsiveClassNames['2xl'] };
} else if (width >= 1240) {
return { type: 'xl', payload: responsiveClassNames['xl'] };
} else if (width >= 1024) {
return { type: 'lg', payload: responsiveClassNames['lg'] };
} else if (width >= 730) {
return { type: 'md', payload: responsiveClassNames['md'] };
} else {
return { type: 'sm', payload: responsiveClassNames['sm'] };
}
}
const { type, payload } = getContainerWidth_returnClassName(
entry.borderBoxSize[0].inlineSize,
);
if (state.className !== payload) {
dispatch({ payload, type });
}
해주면 된다.
export default function PostFeed() {
const ref = useRef(null);
const { className } = useResizeObserver({ ref });
return (
<div className={clsx(`grid py-5 px-6 ${className}`)} ref={ref}>
{MOCK_DATA.map((post, idx) => (
<PostCard
key={idx}
title={post.title}
subTitle={post.subTitle}
userName={post.userName}
date={post.date}
/>
))}
</div>
);
}
이렇게 사용해주면 된다.
리액트에서는 전역 상태와 지역 상태 두 가지로 구분할 수 있다.
코드의 복잡성, 효율성, 간단한 상태 제어 등등 다 떠나서 전역 상태 관리 라이브러리는 리액트의 지역 상태관리를 다 대체할 수 있다. useState,useReducer를 써도 되지만 전역 상태 관리로 스토어에서도 가져다 쓸 수 있다. 이건 확실하다. Context API는 애매하지만 이것도 어찌되었든 다 대체 가능함.
꼭 전역 상태 관리가 필요한 것만 전역 상태 관리를 하고 지역 상태 관리를 할 수 있는 것을 하는 이유는 뭘까??에 대해서 고민했던 적이 있다. 그 당시에는 뭐 가독성? 명시적인 코드? 배보다 배꼽이 더 큰거? 이런거 아닐까? 왜냐면 전역 상태관리도 렌더링 최적화를 해주는 라이브러리가 많기 때문에 전역 상태 관리가 지역 상태 관리를 다 대체할 수 있지만 굳이 안쓰는건 이런 이유때문이다고 생각했음
쉽다. 전역으로 상태를 관리하게 되고 props로 사이드바에서 아이템, 카테고리, 툴팁 컴포넌트에 내려줄 필요 없이 하위 컴포넌트에서 상태를 읽어오기만 하면 된다.
이건 진짜 경험하기 힘든 좋은 케이스였다고 생각함
전역으로 관리하는 상태는 어쨌든 하나의 상태다.
음.. 설명이 매우 어려울 거 같은데 이미지와 코드를 첨부하면서 설명해보겠습니다.
export default function SidebarItem({ name, title }: SidebarItemProps) {
const expand = useSidebarStore((state) => state.expand);
const [showToolTip, setShowTooltip] = useState<boolean>(false);
const handleTooltip = () => {
setShowTooltip((show) => !show);
};
{!expand ? <HoverTooltip title={title} show={showToolTip} /> : null}
}
export default function HoverTooltip({ title, show }: Props) {
const expand = useSidebarStore((state) => state.expand);
return (
<div
className={clsx(
`${
!expand && show ? 'flex' : 'hidden'
} items-center justify-center bg-slate-200 rounded-md absolute top-[50%] left-[100%] -translate-y-[50%] z-[9999] p-[6px] tracking-wide dark:bg-postcard/90`,
)}
>
<p className="text-xs font-light dark:text-primary">{title}</p>
</div>
);
}
이렇게 상태를 관리하게 되는데 코드상에서는 show가 지역 상태로 관리하지만 원래는 zustand로 관리했었다. 그럼 뭐가 다르고 뭐가 문제여서 내가 useState로 바꿨을까??
리액트 딥 다이브 스터디하면서 공부했던 내용이 갑자기 스쳐지나감.아래는 내가 공식문서 보면서 정리했던 내용인데
좋은 싸움 이었다.!
이사준비? 아직 멀었다. 언제 다 끝낼지 모르겠지만 열심히 해보자 3편 포스팅도 꼭 하겠다는 결심을 하면서.... 안녕히 계세요.!
호준님이 하고픈 말이 이거였었군요!! 가볍게 읽으려다가 정독 했습니다 & 많이 배우고 갑니다유 ㅎㅎㅎ 그나저나 포스트 진짜 잘쓰시네여!! 근데 이걸 이제 말로만 잘 옮기시면.... XD