728x90

 

 

Tailwindcss를 더 효과적으로 활용하기

 

 

❓상황

tailwindcss를 사용하여 신규 프로젝트를 개발을 하던 도중 동료분께서 내가 작성한 코드를 보고 패키지들을 추천해줬다.

그래서 해당 패키지들을 알게되었고, 이를 프로젝트에 적용시키려고 한다.

 

기(起)

나는 가독성을 높이기 위해 코드를 줄이는 것을 좋아한다. 그래서 단축평가를 모든 코드에 녹아내려고 노력을 한다.

 

tailwindcss에서 제공해주는 스타일을 적용해주기 위해 클래스를 적어야한다.

조건부로 클래스를 적을 때는 단축평가를 사용할 수 없는데 그 이유는, Boolean 값이 클래스에 적혀져 나오기 때문이다.

 

예를들어 isOpen 이라는 변수의 Boolean 값이 true라면 'opened-modal' 이라는 클래스가 적용된다.

<div className={isOpen && 'opened-modal'}>
	...
</div>

 

 

하지만, isOpen의 Boolean 값이 false라면, <div> 태그에는 'false'라는 클래스가 적용된다.

<div className="false">
	...
</div>

 

false 라는 이름의 클래스를 정의하지 않는다면 큰 문제는 되지 않지만, 태그에 불필요한 클래스가 있다는 것과 개발자도구를 활용하여 디버깅할때 문제가 될 것이라고 생각이 들었다.

 

 

승(承)

그래서 단축평가 대신 삼항연산자를 이용하여 불필요한 Boolean 값이 클래스에 적혀 나오지 않도록 했다.

<div className={isOpen ? 'opened-modal' : ''}>
	...
</div>

 

 

문제는 해결이 되었지만, 단축평가보단 삼항연산자로 인해 코드가 길어짐에 따라 한눈에 파악하기 어려워졌다.

이를 해결하기 위해 변수에 삼항연산자 결과값을 저장하고, 변수명을 활용하여 가독성을 높이고자했다.

 

const openedModalStyle = isOpen ? 'opened-modal' : '';


<div className={openedModalStyle}>
	...
</div>

 

 

기본적으로 적용되는 클래스와 조건부로 적용되는 클래스가 필요했기 때문에 백틱을 활용하여 템플릿 문자열을 사용했다.

const openedModalStyle = `modal-container ${isOpen ? 'opened-modal' : ''}`;


<div className={openedModalStyle}>
	...
</div>

 

여기서 삼항연산자에서 isOpen이 false여서 빈 문자열을 반환할 경우 불필요한 빈칸이 그대로 클래스에 추가되었기 때문에 보기에 좋지않았다.

 

const openedModalStyle = `modal-container `;


<div className="modal-container ">
	...
</div>

 

 

전(轉)

이것을 해결하기 위해 나는 joinedClassName이라는 유틸함수를 만들었고, 인수로 배열을 받아 배열의 원소값이 falsy한 값을 반환할 경우 제외하고 join을 하여 하나의 문자열을 반환하고, 각 원소들 사이에는 한칸의 빈칸만 나오도록 했다.

const joinedClassName = (stringArr: string[]) => {
	return stringArr.filter(str => str).join(' ')
}

const openedModalStyle = joinedClassName(['modal-container', isOpen ? 'opened-modal' : '']);


<div className={openedModalStyle}>
	...
</div>

 

 

내가 원하는 방향으로 코드가 작성되었지만, 하나 아쉬웠던 점은 삼항연산자가 늘어나면 늘어날 수록 코드가 길어지는 것은 당연했고,

각 태그마다 변수를 정의했기 때문에 가독성이 좋아졌는지 의문이 들기 시작했다.

const joinedClassName = (stringArr: string[]) => {
	return stringArr.filter(str => str).join(' ')
}

const openedModalStyle = joinedClassName(['modal-container', isOpen ? 'opened-modal' : '']);
const openedModalContentsStyle = joinedClassName(['modal-contents', ...]);


<div className={openedModalStyle}>
	<div className={openedModalContentsStyle}></div>
</div>

 

결정적으로 기본적으로 적용해야할 클래스가 있고, 조건부에 따라 클래스를 변경해야할 때 삼항연산자의 반대값으로 넣어야했기 때문에 스타일을 추가하기 위해 기존에 해당 스타일이 적용되었는지 확인해야하는 불편함이 생기기 시작했다.

 

예를 들어 기본적으로 적용되는 padding과 margin 값이 있는 경우

조건부에 따라 padding과 margin을 변경할 때 충돌로 인해 조건부의 padding과 margin이 적용되지 않는 현상이 발생했다.

const joinedClassName = (stringArr: string[]) => {
	return stringArr.filter(str => str).join(' ')
}

const openedModalStyle = joinedClassName(['p-[10px] m-[10px]', isOpen ? 'p-[20px] m-[20px]' : '']);


/*
	아래와 같이 openedModalStyle는 p-10px m-10px p-20px m-20px이 모두 클래스에 작성된다.
	그래서 조건부의 padding과 margin이 적용이 되지 않는 현상이 발생헀다.
*/
<div className="p-10px m-10px p-20px m-20px">
	...
</div>

/*
	그래서 아래와 같이 삼항연산자에 기존에 적용된 padding과 margin값을 추가해줘야했다.
*/

const openedModalStyle = joinedClassName([isOpen ? 'p-[20px] m-[20px]' : 'p-[10px] m-[10px]']);

 

 

이렇게 되면 모든 상황에 적용되야하는 클래스인지 아닌지를 파악하기 어려웠기 때문에 디자인이 변경될때 개발 리소스가 많이 소모될 것이 우려되었다.

 

 

결(結)

동료분께서 내 코드를 보시더니 알려줘야할 패키지가 있다면서 얘기해주셨고, 나는 해당 패키지를 긍정적으로 사용해야겠다는 생각을 하게되었다.

 

tailwind-merge

tailwind-merge 패키지에서 제공해주는 twMerge 함수의 경우 충돌이 발생하는 클래스를 가장 마지막에 적힌 클래스로 병합하여 문자열을 반환해준다.

 

예를 들어 아래와 같이 충돌되는 클래스가 있다면, 가장 마지막의 클래스로 병합해준다.

<div className={twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')}></div>

// → <div className='hover:bg-dark-red p-3 bg-[#B91C1C]'></div>

 

 

class-variance-authority

cva는 기본적으로 적용되어야할 클래스와 조건부로 적용되어야할 클래스를 각 인수로 받는다.

따라서 첫번째 인수로는 기본적으로 적용되는 클래스를 전달해주고, 두번째 인수로는 조건부로 적용되어야할 클래스를 전달해주면된다.

 

첫번째 인수로 전달해야할 클래스의 경우 문자열으로 전달해주거나 배열로 전달해주면 되는데,

나의 경우 배열로 전달하는 것이 원소별로 끊어서 읽기에 편하기때문에 배열을 더 선호한다.

const button = cva(["font-semibold", "border", "rounded"], 조건부로 적용되야할 클래스);

 

두번째 인수로는 조건부로 적용되어야할 클래스를 작성해주면 되는데,

이 부분은 cva 공식문서에서 알려주는 문법을 준수하면서 진행하면 된다.

 

변수명은 variants 객체의 키로 사용되고, 변수값은 변수명의 객체의 키로 사용된다. 그리고 변수값으로된 키의 value가 적용될 클래스를 작성해주면된다.

compoundVariants 에 적힌 키들이 변수명과 변수값이 일치할 경우 class에 적힌 값이 클래스로 적용된다.

defaultVariants는 변수값의 기본값을 세팅할 때 사용된다.

const button = cva(기본으로 적용되는 클래스, {
  variants: {
    intent: {
      primary: [
        "bg-blue-500",
        "text-white",
        "border-transparent",
        "hover:bg-blue-600",
      ],
      // **or**
      // primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
      secondary: [
        "bg-white",
        "text-gray-800",
        "border-gray-400",
        "hover:bg-gray-100",
      ],
    },
    size: {
      small: ["text-sm", "py-1", "px-2"],
      medium: ["text-base", "py-2", "px-4"],
    },
  },
  compoundVariants: [
    {
      intent: "primary",
      size: "medium",
      class: "uppercase",
      // **or** if you're a React.js user, `className` may feel more consistent:
      // className: "uppercase"
    },
  ],
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

 

cva는 함수를 반환하는데 해당 함수에 인수로 변수를 객체로 전달해주면 반환값으로 우리가 원하는 문자열 형태의 클래스들을 얻을 수 있다.

 

 

실제로 사용하기 위해서는 컴포넌트에 propsType을 정의하고 props에 기본값을 세팅했는데 cva에서 제공해주는 VariantProps를 사용하여 type을 정의할 수 있다.

 

const button = cva(기본으로 적용되는 클래스, {
  variants: {
    intent: {
      primary: [
        "bg-blue-500",
        "text-white",
        "border-transparent",
        "hover:bg-blue-600",
      ],
      // **or**
      // primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
      secondary: [
        "bg-white",
        "text-gray-800",
        "border-gray-400",
        "hover:bg-gray-100",
      ],
    },
    size: {
      small: ["text-sm", "py-1", "px-2"],
      medium: ["text-base", "py-2", "px-4"],
    },
  },
  compoundVariants: [
    {
      intent: "primary",
      size: "medium",
      class: "uppercase",
      // **or** if you're a React.js user, `className` may feel more consistent:
      // className: "uppercase"
    },
  ],
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

interface ButtonProps
	extends React.PropsWithChildren<React.ButtonHTMLAttributes<HTMLButtonElement>>
    , VariantProps<typeof button> {};
    
const Button = ({ className, intent, size, ...props}: ButtonProps) => {
	return <button className={button({ intent, size, className })} {...props} />
}

 

 

동료분은 clsx도 알려주셨는데, clsx와 굉장히 유사한 cx라는 함수를 제공해주기 때문에 따로 적지는 않으려고 한다.

왜냐하면 class-variance-authority도 clsx 패키지를 사용하기 때문에 아예 동일하다고 보면 된다.

 

cx의 경우 전달받은 인수들을 truly한 값만 필터링하여 하나의 문자열로 반환한다.

예를 들어 아래와 같이 복잡한 인수를 전달하더라도 truly한 원소들만 문자열로 반환해준다.

 

또한 cx는 단축평가를 할 수 있도록 도와주기 때문에 코드양을 줄일 수 있어 굉장히 좋았다.

cx(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]], isOpen && 'opened');
// 'foo bar baz hello there opened'

 

세가지 함수를 모두 사용하기에는 코드가 복잡해져 보일 아쉬움이 있어서, 유틸함수로 분리하고 싶은 생각이 들었다.

 

import { cva, cx } from "class-variance-authority";
import { twMerge } from "tailwind-merge";

const cn: typeof cva = (base, config) => {
  return (props) => {
    return twMerge(cx(cva(base, config)(props)));
  };
};

 

 

clsx와 twMerge를 합친 유틸함수는 블로그에서 많이 찾아볼 수 있지만, cx, twMerge 그리고 cva 함수를 유틸함수로 만든 블로그에서 찾아볼 수 없어서 내가 따로 정리해보았다.

복사했습니다!