저는 CSS-in-JS
를 좋아합니다.
이유는 JS
안에서 일관성 있고 유동적으로 CSS를 조작할 수 있기 때문인데요.
그런 측면에서 Vue3
은 개발 경험이 매우 좋았습니다.
v-bind
, global selector
그리고 v-deep
을 통해 좀 더 융통성 있게 스타일 시트의 값을 조작할 수 있었기 때문이었습니다.
이는 마치 Vue
에서도 CSS-in-JS
의 느낌을 줄 수 있다는 점에서 만족스러웠어요.
그렇지만 이 역시 개인적으로 한계가 있었어요. 다음 2가지였습니다.
- 아쉬움 1. 결국 JS에서 CSS로 내려준다는 건, JS에서 일일이 변수를 할당해줘야 하는 거 아냐?
- 아쉬움 2. 그런데 사실 엄밀히 말하자면,
Vue
에서 스타일시트를 정의하는 건 scss를 쓰고 있는데 변수를 JS에서 관리한다는 꼴이 좀 웃기지 않아?
따라서 저는 좀 엽기적인(?) 생각을 떠올리게 됐습니다.
바로, scss에서 변수를 export하고, 이를 마치 globalStyle
처럼 관리한다는 것이었죠.
css
는 마치 모듈처럼 export
할 수도 있다는 사실, 알고 있으셨나요?
모듈화된 css
는 :export
문법을 통해 객체의 형태로 JS에 export
가 가능해집니다.
다음과 같이 말이죠.
그렇다면 우리의 전제는 모듈화된 CSS를 어떻게 만들어내느냐가 되겠군요!
일단 제가 직관적으로 알고 있는, 모듈화하는 방법은 크게 2가지 입니다.
css-loader
을 직접 설정한다.css-loader
에는 options
가 있고, 이 options
안에는 modules
라는 옵션 프로퍼티가 존재합니다.
이를 true
로 바꿔주면, 모듈화시킬 수 있는데요.
Vue-loader
를 보면 다음과 같이 서술되어 있군요.
// webpack.config.js -> module.rules
{
test: /\.scss$/,
use: [
'vue-style-loader',
{
loader: 'css-loader',
options: { modules: true }
},
'sass-loader'
]
}
이렇게, 옵션을 활성화시켜주면 sass-loader
에서 전처리된 로더는 다시 css-loader
을 거치게 되고, css-loader
에서 module로 만들어주어 export 문법을 가능하게 합니다.
우리, Vue에서 style을 작성할 때 scoped
를 많이 쓰지 않나요?
이 scoped
를 쓰는 이유는, 최대한 다른 컴포넌트와의 스타일 정의 중복을 최대한 피하기 위함인데요.
그 원리를 생각해봐야 해요.
왜 scoped가 있을까요?
이유는,Vue-loader
은 기본적으로 모듈화로 설정되어 있지 않기 때문입니다.
그렇기 때문에 다음과 같은 문제점이 생겨버려요.
- Vue-loader은
vue-style-loader
을 기반으로 하나로 묶어서 스타일을 관리한다.- 변경한
css-loader
은 하나하나를 다 모듈화로 분리시켜버린다.- 결과적으로 컴포넌트가 정의된 스타일을 찾아야 하는데, 모듈화로 인해 찾질 못한다.
이에 대한 해결방법은, scoped
가 아닌 style
에 module
이라는 힌트를 주는 겁니다.
<style module>
.red {
color: red;
}
.bold {
font-weight: bold;
}
</style>
그런데, 이대로만 하면 결과가 나오질 않아요. 하나 더 고려해야 하는데요.
모듈화된 CSS를 적용하기 위해서는 클래스 역시 v-directive
를 통해 설정해줘야 합니다.
<template>
<div>
<p :class="{ [$style.red]: isRed }">
Am I red?
</p>
<p :class="[$style.red, $style.bold]">
Red and bold
</p>
</div>
</template>
물론, 모듈화를 한다는 건 스타일 전체를 쪼갠다는 의미이고,
그렇다면 필요한 CSS만 불러다 쓰므로 번들의 크기를 줄여주는 효과가 있을 것입니다.
하지만, 소규모의 프로젝트라면 이러한 모듈화는 꽤나 번거로운 작업들이 많아보였어요.
저는 vue-style-loader
의 작업으로도 충분했기에, 따라서 다른 방법을 찾았어요.
module.scss
형태로 만들고 분기별로 로더 옵션을 설정한다.결국, 로더 옵션만 분기로 처리해서 적용하면 그만이지 않을까요?
따라서, 글로벌 변수로 export할 scss
파일들만 위처럼 모듈화시켜주고, configuration
을 해주시면 돼요.
저의 경우 다음과 같이 설정을 해주었답니다.
const path = require('path');
const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: true,
configureWebpack: {
// ...
loader: {
module: {
rules: [
{
test: /\.module.scss$/,
use: [
'vue-style-loader',
{ loader: 'css-loader', options: { modules: true } },
'sass-loader',
],
},
{
test: /\.scss$/,
exclude: /\.module.scss$/,
use: ['vue-style-loader', 'css-loader', 'sass-loader'],
},
],
},
},
},
});
이런식으로 하면, 결과적으로 로더에는 모듈화된 scss
는 모듈 처리를,
아닌 경우에는 모듈 처리를 하지 않고 스타일로더에 스타일시트 값들을 정의해주겠죠?
따라서 제가 원하는 방법이라 생각해서 후자를 택했습니다.
저는 이후에, 이 친구들을 어떻게 재사용하면 좋을까를 고민했어요.
고민 끝에 내린 결론은, 유틸함수로 이러한 값들을 모두 캐싱하는 방법을 택했어요.
사실 이 방법은 좋은 방법은 아닐 수 있습니다.
객관적으로 보면 불필요하게 CSS의 값들을 컴포지트 패턴으로 객체화시켜서, 번들러의 트리쉐이킹을 방해할 수 있기 때문입니다.
하지만 개인적으로 시험차 만든 것이니, 이렇게 설계할 수도 있겠구나!라고 생각해주세요. 👐🏻
따라서 다음과 같이 제 입맛에 맞게 설정해주었어요.
import scssVars from '@css/vars.module.scss';
interface GlobalCSSInterface {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
[key: string]: any;
}
const globalCSS: GlobalCSSInterface = {};
Object.entries(scssVars).forEach(([key, value]) => {
const dashIndex = key.indexOf('-');
if (dashIndex < 0) {
globalCSS[key] = value;
} else {
const prefix = key.slice(0, dashIndex);
const cssVar = key.slice(dashIndex + 1);
globalCSS[prefix] = globalCSS[prefix] ?? {};
globalCSS[prefix][cssVar] = value;
}
});
export default globalCSS;
코드를 좀만 설명을 드리자면, 저는 -
가 있으면 객체의 값을 체이닝이 아닌 인덱스를 통해 접근해야 하므로 매우 불편하다고 생각했기 때문에 카멜케이스로 모든 케밥케이스 키를 변환시켜주었어요.
결과적으로, 원하는 값을 입력하고 내뱉은 결과 다음처럼 원하는 기댓값을 얻을 수 있었어요.
어때요. 꽤나 직관적이고 편하지 않나요?
저는 이제 더이상 Vue
의 SFC
에서 일관성 있는 CSS 관리에 고민할 필요가 없어졌어요.
그저 이 globalCSS
를 리턴하고 v-bind
를 통해 관리하면 되기 때문이죠!
예컨대, 다음과 같은 방식으로 공통 CSS를 일관성 있게 관리할 수 있습니다.
<template>
<ul class="tabs">
<template v-for="(tab, idx) in tabs" :key="tab.id">
<li
class="tabs__tab"
@click="() => onClick(tab, idx)"
:class="{ 'tabs__tab--active': tabActiveIndex === idx }"
>
{{ tab.label }}
</li>
</template>
<div class="tabs__highlight"></div>
</ul>
</template>
<script lang="ts">
import globalCSS from '@/utils/globalCSS';
import { defineComponent, PropType, ref } from 'vue';
import { TabInterface } from './types';
const DEFAULT_TAB_WIDTH = '100%';
const DEFAULT_TAB_HEIGHT = '3rem';
export default defineComponent({
name: 'DefaultTabs',
props: {
tabs: {
type: Array as PropType<TabInterface[]>,
default: () => [],
},
activeIndex: {
type: Number,
default: 0,
},
tabWidth: {
type: String,
default: DEFAULT_TAB_WIDTH,
},
tabHeight: {
type: String,
default: DEFAULT_TAB_HEIGHT,
},
activeBackgroundColor: {
type: String,
default: globalCSS.color.default,
},
activeTextColor: {
type: String,
default: globalCSS.color.white,
},
borderWidth: {
type: String,
default: '1px',
},
borderColor: {
type: String,
default: globalCSS.color.sub,
},
},
setup(props, { emit }) {
const activeItem = ref<TabInterface>(props.tabs[0]);
const tabActiveIndex = ref(props.activeIndex);
const onClick = (item: TabInterface, idx) => {
activeItem.value = item;
tabActiveIndex.value = idx;
emit('update:tab', item);
};
return {
activeItem,
onClick,
props,
globalCSS,
tabActiveIndex,
};
},
});
</script>
<style lang="scss" scoped>
$common-border: v-bind('props.borderWidth') solid v-bind('props.borderColor');
$activeIndex: v-bind('tabActiveIndex');
.tabs {
cursor: pointer;
border-radius: v-bind('globalCSS.borderRadius.soft');
overflow: hidden;
display: flex;
position: relative;
z-index: 1;
height: v-bind('props.tabHeight');
border: $common-border;
.tabs__tab {
display: flex;
justify-content: center;
align-items: center;
width: v-bind('props.tabWidth');
&:not(:first-of-type) {
border-left: $common-border;
}
&.tabs__tab--active {
color: v-bind('props.activeTextColor');
}
}
.tabs__highlight {
position: absolute;
z-index: -1;
width: calc(100% / v-bind('props.tabs.length'));
height: v-bind('props.tabHeight');
background-color: v-bind('props.activeBackgroundColor');
transform: translateX(calc(-100% + 100% * v-bind('activeItem.id')));
transition: all 0.3s;
}
}
</style>
더이상 JS에서 값을 따로, CSS에서 additionalData
한 CSS Variable 따로 작성할 필요가 없어요. 그저 하나의 객체에 나올 값들만 생각하면 됩니다. 이는 컴포넌트 설계에 있어 인지회로를 최소화시켜주기 때문에 효율적이라 생각해요.
단지, 단일 원천이 될 scss
에서 export를 해주고, 이를 store
처럼 관리할 객체 하나만 핸들링하면 모든 값을 일관성 있게 사용할 수 있어요.
단점이라면 이쁘진 않을 수 있습니다.
v-bind
라는 게 생소할 수도 있으며, 뭔가 scss
같지 않은 분위기를 자아내기도 하죠.
하지만, CSS-in-JS
의 느낌을 살짝 낼 수도 있어서, 저는 나름 만족스러운 개발 경험을 가졌습니다.
아직 테스트 중이고, 좋은 방법이 아닐 수 있습니다.
물론 v-global
이라는 대안도 있고, 더 다양한 방법은 존재합니다.
하지만, 최근에 설계한 것 중 꽤나 재밌었던 고민이었어요. 스타일 시트라는 원천에서 값들이 파생하는 것이 옳다고 판단했고, 따라서 위와 같이 설계를 해봤어요.
앞으로도 이런 반복되는 패턴에 대한 고민과 공부를 끊임없이 해보려 합니다.
그럼, 누군가에겐 좋은 영감이 되었길 바라며. 이상 🌈
글로벌한 css variable이 필요한 거라면 https://quasar.dev/style/sass-scss-variables#variables-list 퀘이사 프레임워크같은게 도움이 될지도 모르겠네요