Vue 3에서 SCSS Export를 통해 JS에서 재사용하기

young_pallete·2022년 11월 17일
1

Vue

목록 보기
3/3

🌈 시작하며

저는 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처럼 관리한다는 것이었죠.

🚦 본론

SCSS Export

css는 마치 모듈처럼 export할 수도 있다는 사실, 알고 있으셨나요?
모듈화된 css:export 문법을 통해 객체의 형태로 JS에 export가 가능해집니다.
다음과 같이 말이죠.

그렇다면 우리의 전제는 모듈화된 CSS를 어떻게 만들어내느냐가 되겠군요!

Vue에서 CSS를 모듈화하는 2가지 방법

일단 제가 직관적으로 알고 있는, 모듈화하는 방법은 크게 2가지 입니다.

1. 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-loader과 호환이 그렇게 썩 좋지는 않다.

우리, Vue에서 style을 작성할 때 scoped를 많이 쓰지 않나요?
scoped를 쓰는 이유는, 최대한 다른 컴포넌트와의 스타일 정의 중복을 최대한 피하기 위함인데요.

그 원리를 생각해봐야 해요.

왜 scoped가 있을까요?
이유는, Vue-loader은 기본적으로 모듈화로 설정되어 있지 않기 때문입니다.

그렇기 때문에 다음과 같은 문제점이 생겨버려요.

  1. Vue-loader은 vue-style-loader을 기반으로 하나로 묶어서 스타일을 관리한다.
  2. 변경한 css-loader은 하나하나를 다 모듈화로 분리시켜버린다.
  3. 결과적으로 컴포넌트가 정의된 스타일을 찾아야 하는데, 모듈화로 인해 찾질 못한다.

이에 대한 해결방법은, scoped가 아닌 stylemodule이라는 힌트를 주는 겁니다.

<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의 작업으로도 충분했기에, 따라서 다른 방법을 찾았어요.

2. 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는 모듈 처리를,
아닌 경우에는 모듈 처리를 하지 않고 스타일로더에 스타일시트 값들을 정의해주겠죠?
따라서 제가 원하는 방법이라 생각해서 후자를 택했습니다.

globalCSS를 함수화하여 내뱉기

저는 이후에, 이 친구들을 어떻게 재사용하면 좋을까를 고민했어요.
고민 끝에 내린 결론은, 유틸함수로 이러한 값들을 모두 캐싱하는 방법을 택했어요.

사실 이 방법은 좋은 방법은 아닐 수 있습니다.
객관적으로 보면 불필요하게 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;

코드를 좀만 설명을 드리자면, 저는 -가 있으면 객체의 값을 체이닝이 아닌 인덱스를 통해 접근해야 하므로 매우 불편하다고 생각했기 때문에 카멜케이스로 모든 케밥케이스 키를 변환시켜주었어요.

결과적으로, 원하는 값을 입력하고 내뱉은 결과 다음처럼 원하는 기댓값을 얻을 수 있었어요.

어때요. 꽤나 직관적이고 편하지 않나요?

기대 결과

저는 이제 더이상 VueSFC에서 일관성 있는 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이라는 대안도 있고, 더 다양한 방법은 존재합니다.

하지만, 최근에 설계한 것 중 꽤나 재밌었던 고민이었어요. 스타일 시트라는 원천에서 값들이 파생하는 것이 옳다고 판단했고, 따라서 위와 같이 설계를 해봤어요.

앞으로도 이런 반복되는 패턴에 대한 고민과 공부를 끊임없이 해보려 합니다.

그럼, 누군가에겐 좋은 영감이 되었길 바라며. 이상 🌈

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

2개의 댓글

comment-user-thumbnail
2022년 12월 10일

글로벌한 css variable이 필요한 거라면 https://quasar.dev/style/sass-scss-variables#variables-list 퀘이사 프레임워크같은게 도움이 될지도 모르겠네요

1개의 답글