원래 밸로그에서 글을 쓰다가 티스토리에서 처음 글을 써보네요. 오늘은 리액트에서의 추상화에 대해서 공부를 해보겠습니다.
추상화란 ?
위키백과에 나온 내용을 보면
추상화(abstraction)는 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것을 말한다.
라고 하네요.
리액트의 컴포넌트와 훅에서는 어떻게 할 수 있을까요 ?
리액트에서의 추상화
리액트에서 추상화를 할때 이런점들이 고려되어야 합니다.
- 한가지의 역활 (단일 책임)
- 재사용성 (도메인에 종속되면 안됩니다.)
- 변경에 쉽게 대응
- 변경에는 닫혀있고 추가에는 열림 (개방 폐쇠)
변경에 쉽게 대응인데 변경에는 닫혀있다는 말은 모순인데요 ? 🤔
변경에 쉽게 대응에서의 변경과 개방폐쇠에서의 변경은 다른 의미입니다.
변경에 쉽게 대응에서의 변경 : 디자인, 기획, 새로운 기능 등에 대해서 쉽게 대응이 가능, 유연함
개방폐쇠에서의 변경 : 특정 기능 개발을 위해 모듈 자체를 수정하는 행위
실재로 잘 추상화를 하면 재사용성, 변경에 쉽게 대응의 장점이 있을까요 ?
한번 직접 만들어보면서 눈으로 확인해봐야겠습니다.
컴포넌트의 추상화
컴포넌트를 직접 만들고 수정하면서 위에서 언급했던것들을 확인해보겠습니다.
오늘의 집 상품
오늘의 집의 이 부분을 개발을 한다고 해볼게요.
한번 바로 만들어볼까요 ?
일단 그냥 만들어보기
우선 그냥 만들어봤습니다 (css는 거의 안넣겠습니다.)
import { IconStar } from "./IconStar";
interface ProductProps {
imageUrl: string;
brand: string;
name: string;
price: number;
reviewCount: number;
reviewRating: number;
badges: string[];
}
export const Product = ({
badges,
brand,
imageUrl,
name,
price,
reviewCount,
reviewRating,
}: ProductProps) => {
return (
<article style={{ display: "flex", flexDirection: "column" }}>
<img alt={`${name} 이미지`} src={imageUrl} />
<div>{brand}</div>
<div>{name}</div>
<div>{price.toLocaleString("kr")}</div>
<div>
<IconStar /> {reviewRating} 리뷰 {reviewCount}
</div>
{badges.map((badge) => (
<div key={badge}>{badge}</div>
))}
</article>
);
};
일단은 잘돌아가는거 같습니다. 이제 이 Product 컴포넌트를 다른곳에서 사용해볼까요 ?
상품 상세 페이지에 적용시켜보기
이제 상품 상세 페이지를 만들어 보려고 합니다.
ui를 확인해볼까요 ?
어라…? 지금 만든 Product 컴포넌트의 구조로는 불가능해보입니다.
props을 몇개 추가해서 대응을 해보겠습니다.
css를 입히는것은 번거로워서 안했습니다… 🤫
import { ReactNode } from "react";
import { IconStar } from "./IconStar";
interface Product {
imageUrl: string;
brand: string;
name: string;
price: number;
reviewCount: number;
reviewRating: number;
badges: string[];
}
interface ProductProps {
data: Product;
isRowSort?: boolean;
rightBottomNode: ReactNode;
}
export const Product = ({ data, isRowSort, rightBottomNode }: ProductProps) => {
const { badges, brand, imageUrl, name, price, reviewCount, reviewRating } =
data;
return isRowSort ? (
<article style={{ display: "flex", flexDirection: "row" }}>
<img alt={`${name} 이미지`} src={imageUrl} />
<div>
<div>{brand}</div>
<div>{name}</div>
<div>{price.toLocaleString("kr")}</div>
<div>
{Array.from({ length: reviewRating }, () => (
<IconStar />
))}
{reviewCount}개의 리뷰
</div>
{rightBottomNode}
</div>
</article>
) : (
<article style={{ display: "flex", flexDirection: "column" }}>
<img alt={`${name} 이미지`} src={imageUrl} />
<div>{brand}</div>
<div>{name}</div>
<div>{price.toLocaleString("kr")}</div>
<div>
<IconStar /> {reviewRating} 리뷰 {reviewCount}
</div>
{badges.map((badge) => (
<div key={badge}>{badge}</div>
))}
</article>
);
};
props 2개가 더 추가됐는데 망한거 같죠 ?
지금 당장은 이렇게 사용하긴 하지만
- 디자인 수정
- 기획 변경
- 다른 페이지에서는 이렇게~ 해주세요
등등 다양한 요구사항이 들어오면 너무 복잡해지고 결국 나중에는 대응이 불가능한 코드가 될거에요
이 망한 Product 컴포넌트를 한번 개선해보겠습니다
컴파운드 컴포넌트
Compound, 합성이라는 뜻인데요. 이름을 보면 컴포넌트를 합성해서 사용한다라는 것을 유추할 수 있습니다.
컴파운드 컴포넌트를 만들때나 리액트의 Context Api를 사용할때 유용한 모듈 하나 먼저 소개해드리고 만들어볼게요 !
import {
createContext as createReactContext,
useContext as useReactContext,
} from "react";
class ContextError extends Error {
constructor(contextName: string, consumerName: string) {
super(`${contextName}안에 ${consumerName}를 사용해야합니다.`);
}
}
export function createContext<T>(contextName: string) {
const context = createReactContext<T | null>(null);
const useContext = (consumerName: string) => {
const value = useReactContext(context);
if (value === null) {
throw new ContextError(contextName, consumerName);
}
return value;
};
return [context.Provider, useContext] as const;
}
얼마전에 알게된 방법인데요.
이렇게 하면 Context api관련 에러메세지로 디버깅도 편해지고 context 선언과 해당 context를 사용하는 훅이 한번에 선언되어서 매우 편리하더라고요.
다시 본론으로 넘어가서 Product 컴포넌트를 수정해보겠습니다.
import { ComponentProps, ReactNode } from 'react';
import { createContext } from './createContext';
interface ProductType {
badges: string[];
brand: string;
imageUrl: string;
name: string;
price: number;
reviewCount: number;
reviewRating: number;
}
const [ProductProvider, useProduct] = createContext<ProductType>('Product Context');
interface ProductProps {
children: ReactNode;
product: ProductType;
}
function ProductRoot({ children, product }: ProductProps) {
return <ProductProvider value={product}>{children}</ProductProvider>;
}
type ProductImageProps = ComponentProps<'img'>;
function ProductImage(props: ProductImageProps) {
const { imageUrl, name } = useProduct('ProductImage');
return <img alt={name} src={imageUrl} {...props} />;
}
type ProductBrandProps = ComponentProps<'div'>;
function ProductBrand(props: ProductBrandProps) {
const { brand } = useProduct('ProductBrand');
return <div {...props}>{brand}</div>;
}
type ProductNameProps = ComponentProps<'div'>;
function ProductName(props: ProductNameProps) {
const { name } = useProduct('ProductName');
return <div {...props}>{name}</div>;
}
type ProductPriceProps = ComponentProps<'div'>;
function ProductPrice(props: ProductPriceProps) {
const { price } = useProduct('ProductPrice');
return <div {...props}>{price.toLocaleString('kr')}</div>;
}
type ProductReviewCountProps = ComponentProps<'div'>;
function ProductReviewCount(props: ProductReviewCountProps) {
const { reviewCount } = useProduct('ProductReview');
return <div {...props}>{reviewCount}</div>;
}
type ProductReviewRatingProps = ComponentProps<'div'>;
function ProductReviewRating(props: ProductReviewRatingProps) {
const { reviewRating } = useProduct('ProductReviewRating');
return <div {...props}>{reviewRating}</div>;
}
interface ProductBadgesProps extends ComponentProps<'div'> {
render: (badges: string[]) => ReactNode;
}
function ProductBadges({ render, ...rest }: ProductBadgesProps) {
const { badges } = useProduct('ProductBadges');
return <div {...rest}>{render(badges)}</div>;
}
export const Product = Object.assign(ProductRoot, {
Badges: ProductBadges,
Brand: ProductBrand,
Image: ProductImage,
Name: ProductName,
Price: ProductPrice,
ReviewCount: ProductReviewCount,
ReviewRating: ProductReviewRating,
});
하나씩 분석을 해볼까요 ?
우선 위에서 만든 createContext
를 사용해서 전체적으로 값을 공유합니다.
const [ProductProvider, useProduct] = createContext<ProductType>('Product Context');
전체적으로
function ProductName(props: ProductNameProps) {
const { name } = useProduct('ProductName');
return <div {...props}>{name}</div>;
}
이런 느낌으로 구성이 변했습니다. 이렇게 기본적으로 컴포넌트가 꼭 해야하는 기본적인 것들만 명시되어 있고(name을 보여준다.) 외부에서 다른것들을 주입해주는(css등 다른 속성들) 방식입니다.
ProductBadges를 보면 다른친구들과 좀 달라보입니다.
function ProductBadges({ render, ...rest }: ProductBadgesProps) {
const { badges } = useProduct('ProductBadges');
return <div {...rest}>{render(badges)}</div>;
}
badges
는 다른애들과 달리 배열입니다. 그래서 각각 배열의 item에 대해서 어떻게 보여줄지 Render Props 패턴으로 작성해봤습니다.
그리고 마지막으로
export const Product = Object.assign(ProductRoot, {
Badges: ProductBadges,
Brand: ProductBrand,
Image: ProductImage,
Name: ProductName,
Price: ProductPrice,
ReviewCount: ProductReviewCount,
ReviewRating: ProductReviewRating,
});
사용하기 편하게 Product라는 object에 넣었습니다.
이제 이것들을 사용해볼까요 ?
맨처음 만들어봤던 Product 컴포넌트부터 만들어보겠습니다 !
import { Product, ProductType } from './Product';
export function ColumnProduct({ product }: { product: ProductType }) {
return (
<Product product={product}>
<article>
<Product.Image />
<Product.Name />
<Product.Price />
<div>
<IconStar /> <Product.ReviewRating /> 리뷰 <Product.ReviewCount />
</div>
<Product.Badges
render={(badges) => badges.map((badge) => <div key={badge}>{badge}</div>)}
/>
</article>
</Product>
);
}
전이랑 다르게 Product
컴포넌트에 전체적인 상품 데이터를 넣고 안에서 다른 하위 컴포넌트를 조립
하는 방식으로 수정이 되었습니다. 그리고 css, event등 다른 동작들을 주입 받을 수 있게되었습니다.
상품 상세 페이지에서의 Product
컴포넌트도 한번 수정해볼까요 ?
interface RowProductListProps {
product: ProductType;
rightBottomNode: ReactNode;
}
export function RowProductList({ product, rightBottomNode }: RowProductListProps) {
return (
<Product product={product}>
<article>
<Product.Image />
<div>
<Product.Brand />
<Product.Name />
<Product.Price />
<div>
{Array.from({ length: product.reviewRating }, () => (
<IconStar />
))}
<Product.ReviewCount />
게의 리뷰
</div>
<Product.Badges
render={(badges) => badges.map((badge) => <div key={badge}>{badge}</div>)}
/>
</div>
{rightBottomNode}
</article>
</Product>
);
}
이런식으로 수정을 해봤습니다.
ColumnProduct
, RowProductList
로 나누면서 기존에 props으로 복잡하게 관리하는 코드가 사라졌어요. 그리고 앞으로는 비슷한 상품 관련 컴포넌트를 만들때 Product 컴포넌트의 변경 없이 필요한 것들을 주입하고 조립하면서 확장할 수 있는 구조로 수정되었습니다.
이제는 Modal, Toggle, Accordion 등등 많은 서비스에서 사용되는 컴포넌트들에 대한 추상화에 대해서 알아보겠습니다.
Radix Ui
Radix Ui
는 유명한 Headless Ui 라이브러리입니다.
Headless Ui란 ?
스타일이 입혀지지 않고 그 Ui의 필수적인 기능만을 제공하는 Ui를 말합니다.
한번 사용법을 확인해볼까요 ?
import * as Dialog from '@radix-ui/react-dialog';
export default () => (
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
Dialog 컴포넌트에 필요한 여러 기능들을 따로 컴포넌트로 분리해서 제공하고 이것들을 조립하면서 사용합니다. 또한 기본적인 Dialog에서 필요한 상태 (open 여부)를 내부에서 관리하며 Trigger, Close로 변경합니다.
내부 구현을 잠깐 살펴볼까요 ?
전체 코드는 여기서 확인하실 수 있어요.
const [DialogProvider, useDialogContext] = createDialogContext<DialogContextValue>(DIALOG_NAME);
interface DialogProps {
children?: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?(open: boolean): void;
modal?: boolean;
}
const Dialog: React.FC<DialogProps> = (props: ScopedProps<DialogProps>) => {
const {
__scopeDialog,
children,
open: openProp,
defaultOpen,
onOpenChange,
modal = true,
} = props;
const triggerRef = React.useRef<HTMLButtonElement>(null);
const contentRef = React.useRef<DialogContentElement>(null);
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return (
<DialogProvider
scope={__scopeDialog}
triggerRef={triggerRef}
contentRef={contentRef}
contentId={useId()}
titleId={useId()}
descriptionId={useId()}
open={open}
onOpenChange={setOpen}
onOpenToggle={React.useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])}
modal={modal}
>
{children}
</DialogProvider>
);
};
createDialogContext
으로 데이터를 공유할 Provider과 useContext를 만들고 Root에서 하위 컴포넌트로 내려주는 방식이네요.
- 기본적인 기능들을 나눠서 제공
- 내부의 상태로 관리
- css등 외부에서 주입 받아서 사용
- 조립하여 사용
이러한 특징들이 있어서 재사용성이 뛰어난거 같습니다.
리액트에서 컴포넌트의 추상화 특히 컴파운드 컴포넌트에 대해서 글을 작성해봤습니다. 잘못된 내용이 있으면 편하게 말씀해주세요 ! 감사합니다 !
참고자료들
https://fe-developers.kakaoent.com/2022/221020-component-abstraction/
https://www.youtube.com/watch?v=fR8tsJ2r7Eg&t=1000s
'frontend' 카테고리의 다른 글
No.1 Css 라이브러리 "Panda Css" (0) | 2024.05.06 |
---|---|
🚀 "우당탕탕 도서관" 프론트엔드 개발자 글쓰기 커뮤니티 2기 모집 안내 🚀 (0) | 2024.05.04 |
번들러 없이 개발하다 머리깨진썰 푼다. (부제: 번들러에 대해서 공부해보자) (1) | 2024.03.26 |
폼 미쳤다... React Hook Form (0) | 2024.03.25 |
React Suspense 알아보기 (1) | 2024.03.25 |