--- 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強大的模組化功能,多數時候你仍會看到混用,這很需要看每個需求的建構複雜度,如果你的專案不需要考慮「即時更換主題」這檔事,那麼依照目前你的團隊所熟悉的習慣應該都是夠用的。 但如果已經是個中大型專案的規模,要考慮的就已經不是「快不快」的問題,還需要可維護性和取得視覺設計、使用者體驗、功能性的平衡,畢竟在企劃、開發、設計等不同角度的工作者眼裡會有不同的解讀。 ---