---
title: '如何在 Vue3.0 使用 Scss 建立容易更換 Skin 的架構'
disqus: hackmd
---
# 如何在 Vue3.0 使用 Scss 建立容易更換 Skin 的架構
在開發需求上,也許你聽過「換皮」這兩個字。但你知道怎麼設計一個容易維護的多主題架構嗎?這裡分享一個我自己團隊常用的方式給大家參考看看。
---
綱要
[TOC]
## 源碼位置
:::info
本篇係基於這篇的Scss Library的內容做架構設計:
https://hackmd.io/@FortesHuang/SJ9DhgTGn
實際上Style配置和打包設定請以當下的需要為主
:::
## 全域設定
使用Global Scss來管理每個頁面和組件的樣式,需要按照以下結構配置
### 建議檔案結構
```json
src
│
├── style
│ ├── main.scss (入口檔案,放在main.js做引入)
│ ├── base.scss
│ ├── config (mixins、flexbox、typography、remAdaptor等)
│ └── themes (放置各主題)
│ │
│ ├── theme_1
│ │ ├── components (各組件scss檔案放在這,命名最好和Vue組件一樣)
│ │ ├── mixins (過長的樣式可以拆到這裡做mixin)
│ │ ├── plugin (Vant、Element+、Vuetify等UI框架自定義的樣式檔)
│ │ └── theme.scss // 由於這個檔案有 @use '@/style/base.scss' as base;
│ │ // 故從這層起調用config和theme.scss的變數或方法皆要前綴加上base.anything
│ │ // ex: @include base.flexBox(row, null, null);
│ │ // ex: base.$text-size-xl;
│ │
│ └── theme_2 (同theme_1)
│
│
├── store
├── request
├── plugins
│ └── loadTheme.js/.ts
├── directive
├── assets
├── components
├── views
├── main.js/.ts
└── (... anything 你專案內需要的東西)
```
由於 @style/themes/theme.scss 這隻檔案會指定 base.scss 位置來命名一個叫做base的樣式組件引入,也就是 ***==@use '@style/base.scss' as base==*** 這段。
而main.scss、base.scss中,==**是不會引入 themes 裡面任何檔案來使用的。**==
通常在 Vite Config 也會設定 alias 來連結指定的自定義 path
所以你也可以放心的將 style/themes 拆到其他路徑下,再透過 CI/CD 來分開部署
如果你的專案是將PC版和Mobile版本放在同一包專案以不同 vite config 打包、再用package.json定義dev、build、preview各自指向的環境,那建議更應該將themes搬移到外面來,像這樣:
```json
Project
├── common
│ ├── request
│ ├── i18n
│ ├── store
│ ├── composable
│ └── themes
│ ├── theme_1
│ │ │
│ │ ├── components
│ │ ├── mixins
│ │ ├── plugin
│ │ └── theme.scss
│ │
│ └── theme_2 (結構和上面一樣)
├── pc
└── mobile
```
**@style/main.scss**
```sass=
//main.scss,你會放在main.js或者nuxt.config、vite.config的入口檔案
/* import base Libs */
@import 'base.scss';
```
**@style/base.scss**
```sass=
// base.scss
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* import config Libs */
@import './config/easing.scss';
@import './config/mixins';
@import './config/flexbox.scss';
@import './config/grid.scss';
@import './config/typography.scss';
@import './config/remAdaptor.scss';
```
**@style/themes/theme-1/theme.scss**
```sass=
// @style/theme-1/theme.scss
//** 這層開始調用@style/base裡引入的mixins或變數,一概需要前綴
//** ex: @include base.flexBox(row, center, null);
@use '@style/base.scss' as base;
/*==== Theme Colors ===*/
// 請參照前一篇的Library關於theme的色票定義
/* import components,也就是你的頁面上那些需要STYLE的組件 */
@import './components/Layout.scss';
@import './components/Navigator.scss';
@import './components/MainMenu.scss';
@import './components/Footer.scss';
```
### 動態載入主題(Javascript 版本)
**創建loadTheme.js**
路徑:`src/plugins/loadTheme.js`
```javascript=
// loadTheme.js
/** DON'T DO IT **/
// import newYearTheme from '@/style/themes/new-year/theme.scss'
// 這裡千萬不要將Scss直接import,渲染後會連同沒用到的Styles被植入造成不當覆蓋
export async function loadTheme(theme) {
// 使用Object物件映射可以更好的處理動態載入Scss來源
const themeMap = {
newYearTheme: () => import('@/style/themes/new-year/theme.scss'),
xmasTheme: () => import('@/style/themes/xmas/theme.scss'),
};
if (theme in themeMap) {
const selectedThemeModule = await themeMap[theme]();
applyThemeStyles(selectedThemeModule.default);
} else {
console.error(`Invalid theme: ${theme}`);
}
}
/**
* 根據API回傳的結果來套用指定主題
* 並且在DOM上產生一組 <style id="theme-styles">
**/
function applyThemeStyles() {
const styleElement = document.getElementById("theme-styles");
if (styleElement) {
styleElement.textContent = styles;
} else {
const newStyleElement = document.createElement("style");
newStyleElement.id = "theme-styles";
newStyleElement.textContent = styles;
document.head.appendChild(newStyleElement);
}
}
```
**設定main.js**
```javascript=
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from '@/router'
import i18n from '@/i18n'
// 全域路徑請按照自己的Vite Alias設定撰寫
import '@/style/main.scss'
// 動態載入指定主題scss
import { loadTheme } from './plugins/loadTheme'
const app = createApp(App)
const pinia = createPinia()
// 定義 fetchThemeName 函數
async function fetchThemeName() {
// 模擬請求API行為,放個假的JSON在public
const response = await fetch("/theme.json");
const data = await response.json();
return data.theme;
}
(async () => {
// 從後端 API 獲取主題名稱
const themeName = await fetchThemeName();
// 根據後端返回的主題名稱動態切換主題
await loadTheme(themeName);
app
.use(pinia)
.use(i18n)
.use(router)
.mount('#app')
})()
```
### 動態載入主題(Typescript 版本)
**創建loadTheme.ts**
路徑:`src/plugins/loadTheme.ts`
```javascript=
// loadTheme.ts
// 關於這裡的型別,theme一定是string,並且讓loadTheme指定為一個Promise
export async function loadTheme(theme: string): Promise<void> {
// 使用Object物件映射可以更好的處理動態載入Scss來源
const themeMap: { [key: string]: () => Promise<any> } = {
newYearTheme: () => import('@/style/themes/new-year/theme.scss'),
xmasTheme: () => import('@/style/themes/xmas/theme.scss'),
};
if (theme in themeMap) {
const selectedThemeModule = await themeMap[theme]();
applyThemeStyles(selectedThemeModule.default);
} else {
console.error(`Invalid theme: ${theme}`);
}
}
/**
* 根據API回傳的結果來套用指定主題
* 並且在DOM上產生一組 <style id="theme-styles">
**/
function applyThemeStyles(styles: string): void {
// 這裡要特別指定styleElement為一個HTMLStyleElement
// 初始狀態下為Null,所以需要一個 or 運算符來預防TSLint噴出Error
const styleElement = document.getElementById("theme-styles") as HTMLStyleElement | null;
if (styleElement) {
styleElement.textContent = styles;
} else {
const newStyleElement = document.createElement("style");
newStyleElement.id = "theme-styles";
newStyleElement.textContent = styles;
document.head.appendChild(newStyleElement);
}
}
```
**設定main.ts**
```typescript=
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from '@/router'
import i18n from '@/i18n'
import { loadTheme } from './plugins/loadTheme'
import './style/main.scss'
const app = createApp(App)
const pinia = createPinia()
async function fetchThemeName(): Promise<string> {
// 模擬請求API行為,放個假的JSON在public
// 請按照專案的實際API設定進行請求
const response = await fetch("/theme.json");
const data = await response.json();
return data.theme;
}
(async () => {
// 從後端 API 獲取主題名稱
const themeName = await fetchThemeName();
// 根據後端返回的主題名稱動態切換主題
await loadTheme(themeName);
app
.use(pinia)
.use(i18n)
.use(router)
.mount('#app')
})()
```
## Scss寫作風格
我們都知道CSS本身是易學難精的東西,大多時候都會因為時間有限、團隊成員知識領域不一定相等,協作上必須選擇妥協,只能透過硬擠出的時間來Review和重構。但實際上寫作風格的養成其實可以減少很多不必要的麻煩。
假設你的團隊有 ==**QA使用Selenium做E2E自動化測試**== 。
前端寫TailwindCSS的習慣並非在global class name上 **`@apply`**,而是直接在Template上大量的疊加 `flex align-center justify-between w-full ...`
這情況下QA只有XPATH可以定位的到DOM元素,沒辦法判別CLASS_NAME或NAME、ID,也不能使用CSS_SELECTOR尋找v-for產生的元素集合。
那麼一旦畫面有更動UI的需求出現,測試腳本就會因為XPATH定位被更新了,勢必得重寫元素定位。
除此之外,前端維護程式碼若是註解沒好好寫,也很難理解這個區塊的<template>程式碼是幹什麼用的。
為了自己團隊好之外,也想想日常需要緊密配合的其他部門同僚,假如有E2E自動化需要,那建議還是在撰寫TailwindCSS、Scss時都盡量以 ==**可辨識的BEM命名**== 為主,以及加上 ==**name="something"**== 的 attribute,你好,我好,大家就都會好。
### 一、善用 Scss Map 以及 map-get 來管理樣式
假如有個navigator我們希望長這樣的結構:
```htmlembedded=
<template>
<nav class="site-nav">
<ul class="site-nav-list">
<li class="site-nav-list__item" v-for="item in list">{{ item }}</li>
</ul>
</nav>
</template>
```
建議的寫作方式:
```sass=
// DO IT
/** 遍歷抓取map物件中的樣式並渲染出來 **/
@mixin getMapStyles($map) {
@each $key, $value in $map {
#{$key}: $value;
}
}
%full-fixed-top {
position: fixed;
width: 100%;
top: 0;
left: 0;
}
$nav-config: (
main: (
position: fixed,
),
list: (
main: (
width: 800px,
gap: 10px,
),
item: (
width: 100%;
),
),
);
$nav-selector--main: '.site-nav';
$nav-style-main: map-get($nav-config, main);
$nav-style-list: map-get(map-get($nav-config, list), main);
$nav-style-list-item: map-get(map-get($nav-config, list), item);
#{$nav-selector--main} {
// mixin from: style/config/flexbox.scss
@include base.flexBox(row, center, space-between);
@include getMapStyles($nav-style-main);
@extend %full-fixed-top;
&-list {
@include base.flexBox(row, center, center);
@include getMapStyles($nav-style-list);
&__item {
@include base.flexBox(row, center, center);
@include getMapStyles($nav-style-list-item);
}
}
}
```
可以的話,盡量避免的寫作方式:
```sass=
// DON'T ALWAYS DO THAT, IF YOU CAN
.site-nav {
@include flexBox(row, center, space-between);
position: fixed;
width: 100%;
top: 0;
left: 0;
&-list {
@include base.flexBox(row, center, center);
width: 800px;
gap: 10px;
&__item {
@include base.flexBox(row, center, center);
width: 100%
}
}
}
```
乍看下你可能會認為第二種寫法相對較短,但這個例子卻很常出現在落落長的 global scss 或者某個 component 下的 scoped style。
如果能在前面100行就把組件換好樣式,何必滾動數十、甚至數百行去找樣式對象呢?
:::info
你或許會有疑慮,這樣寫久了幾乎都是透過 @each 產生CSS,不會使打包後的檔案過大嗎?
:::
實際上還真的不會,如下圖所示,除了Element+ (已經按需引入)這個肥仔以外,專案中指定的主題多達30個Component,打包起來也才這麼點,移除一些for迴圈產生的預設變數甚至還可以降到120KB以下。
![](https://hackmd.io/_uploads/rkhAktYTh.png)
---
### 二、避免使用@for迴圈產生數字過大的class name
主要是避免使打包容量過大,這種做法很容易產生過多根本用不到的規則:
```sass=
/** Don't do it!! no matter what happens!! **/
@for $i from 1 through 100 {
w-#{$i} {
width: #{$i}%;
}
}
```
### 三、將例外交給TailwindCSS
除了另外定義class name來做特定狀態,比如`is-disabled` `in-xxx-mode`的命名用來判別套用那些CSS規則之外,有時候你不會想要為了很瑣碎的事情去修改class中的margin/padding 值,那乾脆就直接交給Tailwind處理。
比如已經做好一個用來放置卡片排版的元素`card-container`,底下所有`card`元素都有10px左右的gap間隔。
```htmlmixed=
<template>
<div class="card-container">
<div class="card" v-for="item in cardList" :key="item.id">{{ item.label }}</div>
</div>
</template>
```
```sass=
.card-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
gap: 10px;
.card {
display: block;
width: 120px;
height: 200px;
}
}
```
但現在出現需要做例外的情形,你不得不將這個gap改設為8px來符合Figma設計稿、或者預料外的情形,那建議不要直接修改來源的CSS,疊個gap-[8px]就行了。
```htmlmixed=
<div class="card-container gap-[8px]">...</div>
```
## 總結
雖然選擇正確的工具和開發框架對於確保專案的成功至關重要,每個工具都有其獨特的優點,重要的是,視覺設計細節的完整呈現,對比功能快速迭代,還是值得思考如何去取得平衡點。
技術領域總是在不斷變化和進化。並非用了Tailwind你就不再需要SCSS強大的模組化功能,多數時候你仍會看到混用,這很需要看每個需求的建構複雜度,如果你的專案不需要考慮「即時更換主題」這檔事,那麼依照目前你的團隊所熟悉的習慣應該都是夠用的。
但如果已經是個中大型專案的規模,要考慮的就已經不是「快不快」的問題,還需要可維護性和取得視覺設計、使用者體驗、功能性的平衡,畢竟在企劃、開發、設計等不同角度的工作者眼裡會有不同的解讀。
---