반응형 웹 구현을 시작하고 보니 버튼 관련 코드 리팩토링이 시급해서 다른 섹션의 반응형 레이아웃을 구현하기 전에 리팩토링부터 바로 착수하기로 했다.
반응형 화면을 구성하는 과정에서 비슷하지만 다른 버튼들을 여러 곳에서 사용하게 되다 보니 버튼 관련 코드를 리팩토링해야 할 때가 왔다.
그리고 현재 아이콘들을 버튼으로 사용하는 경우도 많은데, 아이콘 관련 코드도 같이 손을 봐야겠다.
3가지 문제점이 있다.
Header/HeaderButton
과 SlideMenu/MenuButton
에 흩어져 있다.ButtonGroup
에 Header/HeaderButton
4개를 나열해놨다.어차피 섹션 버튼들은 함께 사용되기 때문에, 섹션 버튼을 일반화하고 섹션의 string 배열에 map을 사용해 SectionButtons
이라는 배열로 만들었다.
// components/Buttons/SectionButtons.tsx
import Link from 'next/link';
interface Props {
wrapperStyle?: string;
buttonStyle?: string;
onClick?: (...args: any) => void;
}
const sections = ['main', 'about', 'projects', 'contact'];
const SectionButtons = ({ wrapperStyle, buttonStyle, onClick }: Props) => {
return (
<>
{sections.map((section) => (
<div key={section} className={wrapperStyle}>
<Link href={`#${section}`} scroll={false} onClick={onClick}>
<button className={'capitalize ' + buttonStyle}>{section}</button>
</Link>
</div>
))}
</>
);
};
export default SectionButtons;
이제 기존 코드를 변경된 SectionButtons
로 대체한다.
// components/Header/Header.tsx
/* BEFORE */
<HeaderButton name="main" />
<HeaderButton name="about" />
<HeaderButton name="projects" />
<HeaderButton name="contact" />
/* AFTER */
<SectionButtons wrapperStyle="inline-block" buttonStyle="px-4" />
// components/SlideMenu/SlideMenu.tsx
/* BEFORE */
<MenuButton name="main" />
<MenuButton name="about" />
<MenuButton name="projects" />
<MenuButton name="contact" />
/* AFTER */
<SectionButtons wrapperStyle="block" buttonStyle="py-16 text-2xl" onClick={toggleOpen} />
2가지 문제점이 있다.
아이콘 또한 크기와 스타일을 props로 받게끔 변경하고, 기능을 분리해 버튼으로 옮겨주는 작업을 할 것이다. 아이콘 버튼 또한 아이콘 이름의 배열을 순회하며 렌더링하기 때문에, 코드 중복을 줄이기 위해 각 아이콘에 맞는 함수를 짝지어주는 custom hook을 만들 것이다.
아이콘 버튼은 섹션 버튼과 다르게 버튼 리스트가 일관되지 않고, 개별적으로 존재하는 버튼들도 있기 때문에 한 데 묶어서 IconButtons
로 만들지는 않을 것이다.
// hooks/useButtonActions.ts
import { IconNames } from '@/components/Icons/types';
import useLanguage from './useLanguage';
import { useStoreDarkMode, useStoreSlideMenu } from './useStore';
type ButtonActions = {
[key in IconNames]?: (...args: any) => any;
};
const useButtonActions = (): ButtonActions => {
const { toggleDarkMode } = useStoreDarkMode();
const { toggleOpen } = useStoreSlideMenu();
const { currentLanguage, changeLocale, languageList } = useLanguage();
function openLink(url: string) {
window.open(url);
}
function changeToNextLanguage() {
const nextIndex = languageList.indexOf(currentLanguage) + 1;
changeLocale(languageList[nextIndex % languageList.length]);
}
const buttonActions: ButtonActions = {
GitHubIcon: () => openLink('https://github.com'),
HamburgerIcon: toggleOpen,
LanguageIcon: changeToNextLanguage,
LightDarkIcon: toggleDarkMode,
RightArrowIcon: toggleOpen,
VelogIcon: () => openLink('https://velog.io'),
};
return buttonActions;
};
export default useButtonActions;
이에 맞춰 IconButton
컴포넌트를 수정해준다.
// component/Buttons/IconButton.tsx
import { Icons } from '@/components/Icons';
import { IconNames, IconProps } from '@/components/Icons/types';
import useButtonActions from '@/hooks/useButtonActions';
import { ComponentType } from 'react';
interface Props extends IconProps {
name: IconNames;
buttonStyle?: string;
}
const IconButton = ({ name, buttonStyle, size, style }: Props) => {
const buttonActions = useButtonActions();
const Icon: ComponentType<IconProps> = Icons[name];
return (
<button className={buttonStyle} onClick={buttonActions[name]}>
<Icon size={size} style={style} />
</button>
);
};
export default IconButton;
아이콘은 여러 곳에서 같은 스타일을 사용하고 있으므로 스타일을 별도의 파일로 빼준다.
// constants/styles.ts
export const iconStyle = 'transition hover:scale-125 dark:fill-slate-300 dark:hover:fill-slate-100';
마지막으로 각 IconButton
을 사용하는 코드를 다음과 같이 변경해준다.
// components/Header/Header.tsx
/* ... */
<div className="sm:hidden">
<IconButton name="HamburgerIcon" size={36} style={iconStyle} />
</div>
/* ... */
이번 리팩토링을 통해 코드의 중복을 줄였고 컴포넌트들의 확장성과 유지보수성을 높였다.
각 아이콘에 자유자재로 함수를 붙였다 뗐다 할 수 있게 되어서 특히 맘에 든다.
최근 디자인 패턴을 계속 공부하고 있었는데, 다른 onClick
함수들을 가진 버튼이 아이콘을 감싼다는 점에서 데코레이터 패턴과 유사한 느낌의 구현인 것 같다는 생각도 든다.
다음으로는 반응형 웹 레이아웃 작업을 계속 이어나갈 것이다.