앱 테마 구현 : Vue.js와 스타일드 컴포넌트로 실시간 테마 구현하기


원문 : https://medium.com/maestral-solutions/coloring-your-app-implementing-live-theming-with-vue-js-and-styled-components-29e428900394

image

Vue.js 프로젝트를 진행하면서 구현하기 조금 까다로웠던 기능이 있다. 완벽한 Vue 문서(Docs)만으로 바로 답을 찾을 수 없었던 부분이 있었는데, 고객 요구 사항으로 사용자 프로필에 대한 테마를 직접 선택할 수 있도록 하는 기능이었다.

개발 초기에는 요구 사항이 그리 복잡하지 않았다. 몇 가지 색상을 제공하고 사용자가 선택한 색상이 현재 페이지에 잘 반영되기만 하면 되므로 따로 사용자 프로필 테마 색상을 따로 저장할 필요가 없었다. 팀에서는 이 기능의 이름을 "라이브 테마(Live Theming)"라고 지었다.

나는 이 기능을 구현하기 위한 좋은 해결책을 찾는 과정에서 스타일드 컴포넌트(styled-components)를 찾았다. 이것은 컴포넌트 기반의 웹앱을 만들 때 내가 가장 좋아하는 모듈 중 하나이기도 하다.

알고 보니 스타일드 컴포넌트는 Vue와 Vuex 기반의 솔루션에서 꼭 필요한 부분이었다.

image

이 글에서는 스타일드 컴포넌트를 실용적인 예와 함께 설명한다. 재미있게 읽고 Vue에 스타일드 컴포넌트를 적용할 때 도움이 되길 바란다.

바로 시작해보자.

라이브 테마 구현의 궁극적인 목표는 사용자가 색을 선택할 수 있는 컬러 피커 컴포넌트를 갖는 것이다. 사용자가 컬러피커에서 선택한 색상이 테마에 영향을 받도록 설계된 컴포넌트들에 바로 반영되는 것이었다.

이 프로젝트를 진행할 때 중요한 점은 화면에 실행시키는 것이 아니라, 프로젝트의 라이프 사이클 전반에 걸쳐 사용하고 유지 보수하기 쉬운 방식으로 구현하는 것이다.

실제 구현하기에 앞서, 다음과 같은 목표를 세웠다.

  • 테마 컴포넌트는 반복 스타일 또는 중복 코드를 지정하는 것에 대해 추가적으로 걱정하지 않고, 코드의 어느 곳에서도 쉽게 배치하고 사용할 수 있어야 한다.
  • 평상시처럼 html 태그로 사용할 수 있기를 원하지만, 이미 테마 컴포넌트 안에 통합되어 있어야 한다.
  • 예를 들어 호버 시 적용되는 스타일처럼, 더 복잡한 스타일을 해당 컴포넌트에 정의할 수 있어야 한다.
  • Bulma 또는 Bootstrap과 같은 CSS 프레임워크를 컴포넌트 스타일 상단에서 사용할 때 문제가 되지 않아야 한다. 앱에서 테마를 입히고 나머지 부분은 CSS 프레임워크를 사용하여 스타일을 적용할 수 있기 때문이다. 만약 애플리케이션에서 사용하는 버튼이 Bulma 스타일의 버튼이면서, 동시에 사용자 테마를 적용할 수 있는 버튼을 사용할 수도 있다.

이를 실현하기 위해 다음과 같이 6단계에 걸쳐 진행해보자

1. 프로젝트 생성

가장 먼저 해야 할 일은 새로운 Vue 프로젝트를 만드는 것이다. 데모에서는 프로토타입을 작성하기에 적합한 vue-loader와 웹팩을 포함하는 간단한 템플릿을 사용했다. 사용법(README)에 나와있는 명령어들을 따라서 하면 손쉽게 Vue 프로젝트를 시작할 수 있다.

2. Vuex 스토어 설정

Vuex는 현재 선택된 테마 색상 정보를 가지고 있다. 이 색상 정보가 전역 스코프에 존재하기 때문에, 애플리케이션의 모든 컴포넌트에서 사용할 수 있다.

아래의 명령어를 사용하여 Vuex를 설치한다.

npm install vuex --save

모든 스토어(store) 모듈을 유지하기 위해 스토어를 폴더로 분리해서 사용하는 것을 추천한다. 지금처럼 간단한 예제인 경우 전역 스토어(global store) 하나만 사용하겠지만, 전체 프로젝트에서는 스토어를 모듈로 배치하여 사용할 것이다. 여기서는 빠르게 작성하기 위해 하나하나 모듈로 배치하지 않고, store 폴더 내 index.js에 바로 작성하겠다. src 폴더 내 store 폴더를 추가하고 index.js 파일을 생성한다. 상태(state)는 기본 값으로 Vue의 색상이 할당된 테마 색 정보를 들고 있고, 컬러 피커에서 사용자의 입력에 따라 해당 색상 값을 설정해주는 뮤테이션(mutation)를 설정해준다.

마지막으로, 게터(getter)를 사용하여 해당 색상 값을 모든 테마를 사용하는 컴포넌트에 전달해주도록 한다. 아래 코드는 src/store/index.js 파일의 내용이다.

/* src/store/index.js */
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const state = {
  themeColor: '#41B883'
};

const mutations = {
  setThemeColor(state, color) {
    state.themeColor = color;
  }
};

const getters = {
  themeColor: state => state.themeColor,
};

export default new Vuex.Store({
  state,
  mutations,
  getters,
});

App 컴포넌트(App.vue)를 사용할 때 위에서 만든 스토어 정보를 추가해주자. src/main.js 파일 시작 부분에 import store form './store';를 추가하면 선언된 스토어를 가져올 수 있다. Vue 인스턴스를 생성할 때 스토어 정보를 추가해 주도록 한다.

/* src/main.js */
import Vue from 'vue'
import App from './App.vue'
import store from './store';

new Vue({
  render: h => h(App),
  store,
}).$mount('#app')

3. 컬러 피커(ColorPicker) 컴포넌트 작성

이제, 색상 값을 설정하여 스토어와 상호작용할 컬러피커를 만들어보자. 데모에서는 실제 프로젝트에 적용할 예쁜 디자인을 제공해주는 오픈소스 컬러피커를 사용했다. 하지만 자유롭게 <input type="color">를 사용해도 무방하다.

먼저 src 폴더 내 컴포넌트를 위한 폴더를 생성하고 ThemePicker.vue라는 컬러 피커 컴포넌트 Vue 파일을 생성한다. 그리고 위에서 언급한 오픈소스 컬러피커를 설치한다.

npm install vue-color --save

ThemePicker.vue파일의 스크립트 태그 내에 import { Chrome } from 'vue-color';를 추가하고, 크롬 컬러 피커를 사용하기 위해 components속성에 크롬 컬러 피커 컴포넌트를 선언한다.

import { Chrome } from 'vue-color';

export default {
 components: {
  'chrome-color-picker': Chrome,
 },
 ...
};

만약 여기서 사용한 Vue 컬러피커 컴포넌트에 대해 좀 더 자세한 사항을 알고 싶거나, 다른 디자인을 사용하기 원한다면 사이트에서 사용법(README)을 참조하길 바란다.

컬러 피커 컴포넌트를 스토어와 연동해보자. 이 연동 코드에서는 vuex의 mapGettersmapMutation을 import하고 현재 선택된 색상 정보(themeColor)를 가져와서 사용한다. 사용자가 색상을 선택하면 스토어에 선택한 색상 정보를 업데이트할 것이다.

import { Chrome } from 'vue-color';
import { mapGetters, mapMutations } from 'vuex';

export default {
  components: {
    'chrome-color-picker': Chrome,
  },
  computed: {
    ...mapGetters([
      'themeColor'
    ]),
  },
  methods: {
    ...mapMutations([
      'setThemeColor'
    ]),
  },
};

템플릿에 있는 chrome-color-picker 컴포넌트에 스토어와 연동하여 ThemePicker 컴포넌트를 완성하면 아래 코드와 같다.

/* src/components/ThemePicker.vue */
<template>
  <chrome-color-picker :value ="themeColor" @input="setThemeColor($event.hex)" :disableAlpha="true"/>
</template>

<script>
import { Chrome } from 'vue-color';
import { mapGetters, mapMutations } from 'vuex';
export default {
  components: {
    'chrome-color-picker': Chrome,
  },
  computed: {
    ...mapGetters([
      'themeColor'
    ]),
  },
  methods: {
    ...mapMutations([
      'setThemeColor'
    ]),
  },
};
</script>

<style>
</style>

스토어의 모든 입력값이 뮤테이션을 통하여 업데이트 되면, getters의 값에 바로 바인딩되기 때문에, 컬리 피커는 항상 현재 선택되어 있는 themeColor를 보여준다. 컬러 피커와 스토어의 저장된 색상 정보의 값이 항상 동기화를 보장한다.

이제 App.vue 컴포넌트에 ThemePicker 컴포넌트를 가져와서 components 목록에 추가하여 사용할 수 있다.

<template>
  <div id="app">
    <img src="./assets/logo.png">
    <h1>Live Theming with Styled-Components in Vue</h1>
    <hr>
    <theme-picker/>
  </div>
</template>

<script>
import ThemePicker from './components/ThemePicker';
export default {
  name: 'app',
  components: {
    ThemePicker,
  },
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  text-align: center;
  margin-top: 30px;
}
</style>

화면에는 다음과 같이 표시된다.

image

4. 테마 컴포넌트 만들기

이제 입력받는 색상 정보를 넘겨받아 테마가 적용되는 컴포넌트를 만들 차례이다. 첫 번째로 인라인 스타일로 스토어에 저장된 색상 정보를 단순히 넘겨주기만 하면 완벽하게 잘 동작할 것이라고 생각했지만, 아쉽게도 인라인 스타일을 활용하는 방식을 사용하기에는 문제가 조금 있었다.

1)유지 보수하기 어려우며, 2)테마 컴포넌트를 재사용하는 개발자가 구현 세부 사항을 일일히 확인하고 사용해야 하는 번거로움이 있으며, 3)호버 같은 복잡한 스타일 선택자는 사용할 수 없다.

CSS를 따로 분리하면, 관심사의 분리(SoC)가 되어서 나중에 유지 보수하기 좋아진다.

또한, 나중에 작업하게 될 개발자들이 구현 세부 사항을 숨기고 싶을 때나, 테마가 적용된 컴포넌트라는 것을 명시적으로 표현하고 싶은 경우에 좀 더 유연하게 구현할 수 있도록 해야한다. 향후 이 컴포넌트를 사용하는 개발자는 구현 세부 사항에는 신경쓰지 않아야 한다.

위에서 언급한 모든 문제점에 대한 완벽한 해결책은 스타일드 컴포넌트를 사용하는 것이다. CSS를 분리할 수 있고, 페이지가 이미 로드된 이후에 컴포넌트의 색상을 변경할 수 있도록 props를 전달해준다.

가장 먼저 Vue용 스타일드 컴포넌트를 설치해보자.

npm install vue-styled-components --save

components 폴더 내 styled-components 폴더를 만들고 스타일링된 컴포넌트 파일을 생성하여 일반적인 컴포넌트와 분리한다. 전체 애플리케이션 내에서 사용할 수 있는 모든 컴포넌트를 테마 컴포넌트로 추가해보자. 아래 코드는 테마가 적용되는 버튼 컴포넌트이다.

/* src/components/styled-components/ThemedButton.js */
import styled from 'vue-styled-components';

const themeProps = { color: String };

const ThemedButton = styled('button', themeProps)`
  background-color: ${props => props.color};
  color: white;
`;

export default ThemedButton;

테마 버튼에서 배경 색과 텍스트 색만 정의했지만, CSS에서 제공하는 모든 규칙을 컴포넌트의 스타일을 정의하는 문자열 리터럴에 그대로 사용할 수 있다.

이제 이 컴포넌트는 스토어에서 색상 정보가 변경되어 스토어가 업데이트되면 갱신된 색상 정보를 color 프로퍼티를 통해 넘겨받아 테마를 바로 반영할 수 있게 되었다.

5. 전역적으로 사용할 수 있는 컴포넌트 만들기

테마 버튼을 가져와서 다음과 같이 사용한다고 가정해보자.

<ThemedButton :color="$store.getters['themeColor']">Some Button</ThemedButton>

하지만 템플릿에서 위처럼 ThemedButton을 사용하면 테마 컴포넌트를 사용하고 싶을 때마다, 컴포넌트를 매번 import 하여, 매번 스토어에서 가리키는 color 프로퍼티를 추가해주어야 하는 반복적인 작업을 하게된다. 이러한 작업은 버튼처럼 자주 재사용하는 공통적인 컴포넌트를 사용할 때에 시간을 낭비하게 만들고, 지루한 작업의 연속이 된다.

새로운 컴포넌트로 스타일 컴포넌트를 감싸는 방식으로 버튼 컴포넌트를 전체 애플리케이션 내에서 사용할 수 있는 전역 컴포넌트로 만들어주었다.

styled-components 폴더 내 index.js 파일을 추가해주고, 이 파일에서 모든 스타일 전역 컴포넌트를 등록해준다. main.js에서 index.js 파일을 import한 후 사용할 수 있다. (import './components/styled-components';)

Vue와 이전에 정의한 테마 버튼(ThemedButton.js)을 import하고, themed-btn이라는 새로운 컴포넌트를 전역으로 선언한다. 이제 버튼을 사용할 때마다 같은 코드를 매번 작성하는 대신, 간단히 themed-btn을 바로 사용할 수 있다.

// index.js
import Vue from 'vue';

import ThemedButton from './ThemedButton';

export const themedButton = Vue.component('themed-btn', {
  components: { ThemedButton },
  template: `<ThemedButton :color="$store.getters['themeColor']"><slot></slot></ThemedButton>`,
});

<themed-btn> Some Button </ themed-btn>을 타이핑하여 쉽게 테마 버튼을 추가할 수 있다.

6. 컴포넌트 사용

마지막으로 화면에서 레이아웃을 구성하기 위해 Bulma CSS 프레임워크를 사용할 것이다. 어떻게 전역으로 등록된 테마 버튼과 같은 스타일드 컴포넌트에 Bulma를 적용할 수 있는지 알아보자.

우선 npm install bulma --save를 실행하여 Bulma를 설치하고, App.vue에 bulma.css 파일을 import해준다.(import 'bulma/css/bulma.css';)

그런 다음, index.js파일의 ThemedButton템플릿에 간단히 class="button"(.button은 bulma에서 사용하는 Button관련 클래스)를 추가하면 된다. 아래 코드는 themed-btn 컴포넌트만 사용해도 class=button이 속성이 포함된 테마 버튼을 사용한 것과 같은 효과가 나타난다.

export const themedButton = Vue.component('themed-btn', {
  components: {ThemedButton},
  template: `<ThemedButton class="button" :color="$store.getters['themeColor']"><slot></slot></ThemedButton>`,
});

App.vue에 bulma 클래스를 추가하여 컴포넌트들의 레이아웃을 열(column)로 구성하고, ThemedTitleThemedFooter라는 두 가지 다른 스타일 컴포넌트를 만들었다.

# App.vue
<template>
  <div id="app">
    <div class="container">
      <img src="./assets/logo.png">
      <h1 class="title is-4">Live Theming with Styled-Components in Vue</h1>
      <hr>
      <div class="columns">
        <div class="column">
          <theme-picker class="column"/>
        </div>
        <div class="column is-three-quarters">
          <themed-title>Examples of Themed Components</themed-title>
          <themed-btn>Themed Button</themed-btn>
          <themed-btn>Another Themed Button</themed-btn>
        </div>
      </div>
    </div>
    <themed-footer>Made by Dalila Avdukic</themed-footer>
  </div>
</template>

<script>
import 'bulma/css/bulma.css';
import ThemePicker from './components/ThemePicker';

export default {
  name: 'app',
  components: {
    ThemePicker,
  },
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  text-align: center;
  margin-top: 30px;
}
</style>

전체 예제 코드는 이곳에서 확인할 수 있다.

프로젝트가 완성된 모습은 아래와 같다.

이제 컬러 피커를 색상을 선택하면 테마 컴포넌트에 색상이 바로 반영된다. 더 나아가 사용자는 프로필 테마 색상을 저장하기 전에(사용자 프로필 테마 설정값 저장 기능) 미리 적용된 테마를 볼 수 있는 프리뷰 기능을 지원할 수 있게 되었다.

이런 기능을 추가해 달라는 요청은 자주 있는 일이 아니지만, 개발하는 동안 매우 즐거웠다. Vue의 단순함과 스타일드 컴포넌트의 간결하지만 뛰어난 기능에 감사를 표한다.

이 글에 대한 질문이나 제안할 것이 있다면 언제든지 dalilaav@maestralsolutions.com으로 문의하길 바란다.