Try   HackMD

如何在 Vue3.0 使用 Scss 建立容易更換 Skin 的架構

在開發需求上,也許你聽過「換皮」這兩個字。但你知道怎麼設計一個容易維護的多主題架構嗎?這裡分享一個我自己團隊常用的方式給大家參考看看。


綱要

源碼位置

本篇係基於這篇的Scss Library的內容做架構設計:
https://hackmd.io/@FortesHuang/SJ9DhgTGn
實際上Style配置和打包設定請以當下的需要為主

全域設定

使用Global Scss來管理每個頁面和組件的樣式,需要按照以下結構配置

建議檔案結構

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搬移到外面來,像這樣:

Project
    ├── common
    │     ├── request
    │     ├── i18n
    │     ├── store
    │     ├── composable
    │     └── themes
    │            ├── theme_1 
    │            │      │
    │            │      ├── components
    │            │      ├── mixins
    │            │      ├── plugin
    │            │      └── theme.scss
    │            │
    │            └── theme_2 (結構和上面一樣)
    ├── pc
    └── mobile

@style/main.scss

//main.scss,你會放在main.js或者nuxt.config、vite.config的入口檔案 /* import base Libs */ @import 'base.scss';

@style/base.scss

// 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

// @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

// 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

// 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

// 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

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我們希望長這樣的結構:

<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>

建議的寫作方式:

// 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); } } }

可以的話,盡量避免的寫作方式:

// 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行就把組件換好樣式,何必滾動數十、甚至數百行去找樣式對象呢?

你或許會有疑慮,這樣寫久了幾乎都是透過 @each 產生CSS,不會使打包後的檔案過大嗎?

實際上還真的不會,如下圖所示,除了Element+ (已經按需引入)這個肥仔以外,專案中指定的主題多達30個Component,打包起來也才這麼點,移除一些for迴圈產生的預設變數甚至還可以降到120KB以下。


二、避免使用@for迴圈產生數字過大的class name

主要是避免使打包容量過大,這種做法很容易產生過多根本用不到的規則:

/** 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間隔。

<template> <div class="card-container"> <div class="card" v-for="item in cardList" :key="item.id">{{ item.label }}</div> </div> </template>
.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]就行了。

<div class="card-container gap-[8px]">...</div>

總結

雖然選擇正確的工具和開發框架對於確保專案的成功至關重要,每個工具都有其獨特的優點,重要的是,視覺設計細節的完整呈現,對比功能快速迭代,還是值得思考如何去取得平衡點。

技術領域總是在不斷變化和進化。並非用了Tailwind你就不再需要SCSS強大的模組化功能,多數時候你仍會看到混用,這很需要看每個需求的建構複雜度,如果你的專案不需要考慮「即時更換主題」這檔事,那麼依照目前你的團隊所熟悉的習慣應該都是夠用的。

但如果已經是個中大型專案的規模,要考慮的就已經不是「快不快」的問題,還需要可維護性和取得視覺設計、使用者體驗、功能性的平衡,畢竟在企劃、開發、設計等不同角度的工作者眼裡會有不同的解讀。