前言
一般而言一個組件庫都會設計一套相對來說符合大眾審美或產品需求的主題,但是主題定制需求永遠都存在,所以組件庫一般都會允許使用者自定義主題,我司的vue組件庫hui的定制主題簡單來說是通過修改預定義的scss變量的值來做到的,新體系下還做到了動態換膚,因為皮膚本質上是一種靜態資源(CSS文件和字體文件),所以只需要約定一種方式來每次動態請求加載不同的文件就可以了,為了方便這一需求,還配套開發了一個Vessel腳手架的插件,只需要以配置文件的方式列出你需要修改的變量和值,一個命令就可以幫你生成對應的皮膚。
但是目前的換膚還存在幾個問題, 一是不直觀,無法方便實時的看到修改后的組件效果,二是建議修改的變量比較少,這很大原因也是因為問題一,因為不直觀所以盲目修改后的效果可能達不到預期。
針對這幾個問題,所以實現一個在線主題編輯器是一個有意義的事情,目前最流行的組件庫之一的Element就支持主題在線編輯,地址:https://element.eleme.cn/#/zh-CN/theme,本項目是在參考了Element的設計思想和界面效果后開發完成的,本文將開發思路分享出來,如果有一些不合理地方或有一些更好的實現方式,歡迎指出來一起討論。
實現思路
主題在線編輯的核心其實就是以一種可視化的方式來修改主題對應scss變量的值。
項目總體分為前端和后端兩個部分,前端主要負責管理主題列表、編輯主題和預覽主題,后端主要負責返回變量列表和編譯主題。
后端返回主題可修改的變量信息,前端生成對應的控件,用戶可進行修改,修改后立即將修改的變量和修改后的值發送給后端,后端進行合并編譯,生成css返回給前端,前端動態替換style標簽的內容達到實時預覽的效果。
主題列表頁面
主題列表頁面的主要功能是顯示官方主題列表和顯示自定義主題列表。
官方主題可進行的操作有預覽和復制,不能修改,修改的話會自動生成新主題。自定義主題可以編輯和下載,及進行修改名稱、復制、刪除操作。
官方主題列表后端返回,數據結構如下:
{
name: '官方主題-1', // 主題名稱
by: 'by hui', // 來源
description: '默認主題', // 描述
theme: {
// 主題改動點列表
common: {
'$--color-brand': '#e72528'
}
}
}
自定義主題保存在localstorage里,數據結構如下:
{
name: name, // 主題名稱
update: Date.now(), // 最后一次修改時間
theme: { // 主題改動點列表
common: {
//...
}
}
}
復制主題即把要復制的主題的theme.common數據復制到新主題上即可。
需要注意的就是新建主題時要判斷主題名稱是否重復,因為數據結構里并沒有類似id的字段。另外還有一個小問題是當預覽官方主題時修改的話會自動生成新主題,所以還需要自動生成可用的主題名,實現如下:
const USER_THEME_NAME_PREFIX = '自定義主題-';
function getNextUserThemeName() {
let index = 1
// 獲取已經存在的自定義主題列表
let list = getUserThemesFromStore()
let name = USER_THEME_NAME_PREFIX + index
let exist = () => {
return list.some((item) => {
return item.name === name
})
}
// 循環檢測主題名稱是否重復
while (exist()) {
index++
name = USER_THEME_NAME_PREFIX + index
}
return name
}
界面效果如下:
因為涉及到幾個頁面及不同組件間的互相通信,所以vuex是必須要使用的,vuex的state要存儲的內容如下:
const state = {
// 官方主題列表
officialThemeList: [],
// 自定義主題列表
themeList: [],
// 當前編輯中的主題id
editingTheme: null,
// 當前編輯的變量類型
editingActionType: 'Color',
// 可編輯的變量列表數據
variableList: [],
// 操作歷史數據
historyIndex: 0,
themeHistoryList: [],
variableHistoryList: []
}
editingTheme是代表當前正在編輯的名字,主題編輯時依靠這個值來修改對應主題的數據,這個值也會在localstorage里存一份。
editingActionType是代表當前正在編輯中的變量所屬組件類型,主要作用是在切換要修改的組件類型后預覽列表滾動到對應的組件位置及用來渲染對應主題變量對應的編輯控件,如下:
頁面在vue實例化前先獲取官方主題、自定義主題、最后一次編輯的主題名稱,設置到vuex的store里。
編輯預覽頁面
編輯預覽頁面主要分兩部分,左側是組件列表,右側是編輯區域,界面效果如下:
組件預覽區域
組件預覽區域很簡單,無腦羅列出所有組件庫里的組件,就像這樣:
<div class="list">
<Color></Color>
<Button></Button>
<Radio></Radio>
<Checkbox></Checkbox>
<Inputer></Inputer>
<Autocomplete></Autocomplete>
<InputNumber></InputNumber>
//...
</div>
同時需要監聽一下editingActionType值的變化來滾動到對應組件的位置:
<script>
{
watch: {
'$store.state.editingActionType'(newVal) {
this.scrollTo(newVal)
}
},
methods:{
scrollTo(id) {
switch (id) {
case 'Input':
id = 'Inputer'
break;
default:
break;
}
let component = this.$children.find((item) =>{
return item.$options._componentTag === id
})
if (component) {
let el = component._vnode.elm
let top = el.getBoundingClientRect().top + document.documentElement.scrollTop
document.documentElement.scrollTop = top - 20
}
}
}
}
</script>
編輯區域
編輯區域主要分為三部分,工具欄、選擇欄、控件區。這部分是本項目的核心也是最復雜的一部分。
先看一下變量列表的數據結構:
{
"name": "Color",// 組件類型/類別
"config": [{// 配置列表
"type": "color",// 變量類型,根據此字段渲染對應類型的控件
"key": "$--color-brand",// sass變量名
"value": "#e72528",// sass變量對應的值,可以是具體的值,也可以是sass變量名
"category": "Brand Color"http:// 列表,用來分組進行顯示
}]
}
此列表是后端返回的,選擇器的選項是遍歷該列表取出所有的name字段的值而組成的。
因為有些變量的值是依賴另一個變量的,所依賴的變量也有可能還依賴另一個變量,所以需要對數據進行處理,替換成變量最終的值,實現方式就是循環遍歷數據,這就要求所有被依賴的變量也存在于這個列表中,否則就找不到了,只能顯示變量名,所以這個實現方式其實是有待商榷的,因為有些被依賴的變量它可能并不需要或不能可編輯,本項目目前版本是存在此問題的。
此外還需要和當前編輯中的主題變量的值進行合并,處理如下:
// Editor組件
async getVariable() {
try {
// 獲取變量列表,res.data就是變量列表,數據結構上面已經提到了
let res = await api.getVariable()
// 和當前主題變量進行合并
let curTheme = store.getUserThemeByNameFromStore(this.$store.state.editingTheme) || {}
let list = []
// 合并
list = this.merge(res.data, curTheme.theme)
// 變量進行替換處理,因為目前存在該情況的只有顏色類型的變量,所以為了執行效率加上該過濾條件
list = store.replaceVariable(list, ['color'])
// 排序
list = this.sortVariable(list)
this.variableList = list
// 存儲到vuex
this.$store.commit('updateVariableList', this.variableList)
} catch (error) {
console.log(error)
}
}
merge方法就是遍歷合并對應變量key的值,主要看replaceVariable方法:
function replaceVariable(data, types) {
// 遍歷整體變量列表
for(let i = 0; i < data.length; i++) {
let arr = data[i].config
// 遍歷某個類別下的變量列表
for(let j = 0; j < arr.length; j++) {
// 如果不在替換類型范圍內的和值不是變量的話就跳過
if (!types.includes(arr[j].type) || !checkVariable(arr[j].value)) {
continue
}
// 替換處理
arr[j].value = findVariableReplaceValue(data, arr[j].value) || arr[j].value
}
}
return data
}
findVariableReplaceValue方法通過遞歸進行查找:
function findVariableReplaceValue(data, value) {
for(let i = 0; i < data.length; i++) {
let arr = data[i].config
for(let j = 0; j < arr.length; j++) {
if (arr[j].key === value) {
// 如果不是變量的話就是最終的值,返回就好了
if (!checkVariable(arr[j].value)) {
return arr[j].value
} else {// 如果還是變量的話就遞歸查找
return findVariableReplaceValue(data, arr[j].value)
}
}
}
}
}
接下來是具體的控件顯示邏輯,根據當前編輯中的類型對應的配置數據進行渲染,模板如下:
// Editor組件
<template>
<div class="editorContainer">
<div class="editorBlock" v-for="items in data" :key="items.name">
<div class="editorBlockTitle">{{items.name}}</div>
<ul class="editorList">
<li class="editorItem" v-for="item in items.list" :key="item.key">
<div class="editorItemTitle">{{parseName(item.key)}}</div>
<Control :data="item" @change="valueChange"></Control>
</li>
</ul>
</div>
</div>
</template>
data是對應變量類型里的config數據,是個計算屬性:
{
computed: {
data() {
// 找出當前編輯中的變量類別
let _data = this.$store.state.variableList.find(item => {
return item.name === this.$store.state.editingActionType
})
if (!_data) {
return []
}
let config = _data.config
// 進行分組
let categorys = []
config.forEach(item => {
let category = categorys.find(c => {
return c.name === item.category
})
if (!category) {
categorys.push({
name: item.category,
list: [item]
})
return false
}
category.list.push(item)
})
return categorys
}
}
}
Control是具體的控件顯示組件,某個變量具體是用輸入框還是下拉列表都在這個組件內進行判斷,核心是使用component動態組件:
// Control組件
<template>
<div class="controlContainer">
<component :is="showComponent" :data="data" :value="data.value" @change="emitChange" :extraColorList="extraColors"></component>
</div>
</template>
<script>
// 控件類型映射
const componentMap = {
color: 'ColorPicker',
select: 'Selecter',
input: 'Inputer',
shadow: 'Shadow',
fontSize: 'Selecter',
fontWeight: 'Selecter',
fontLineHeight: 'Selecter',
borderRadius: 'Selecter',
height: 'Inputer',
padding: 'Inputer',
width: 'Inputer'
}
{
computed: {
showComponent() {
// 根據變量類型來顯示對應的控件
return componentMap[this.data.type]
}
}
}
</script>
一共有顏色選擇組件、輸入框組件、選擇器組件、陰影編輯組件,具體實現很簡單就不細說了,大概就是顯示初始傳入的變量,然后修改后觸發修改事件change,經Control組件傳遞到Editor組件,在Editor組件上進行變量修改及發送編譯請求,不過其中陰影組件的實現折磨了我半天,主要是如何解析陰影數據,這里用的是很暴力的一種解析方法,如果有更好的解析方式的話可以留言進行分享:
// 解析css陰影數據
// 因為rgb顏色值內也存在逗號,所以就不能簡單的用逗號進行切割解析
function parse() {
if (!this.value) {
return false
}
// 解析成復合值數組
// let value = "0 0 2px 0 #666,0 0 2px 0 #666, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 hlsa(0, 0, 0, 0.12),0 2px 4px 0 #sdf, 0 2px 4px 0 hlsa(0, 0, 0, 0.12), 0 2px 0 hlsa(0, 0, 0, 0.12), 0 2px hlsa(0, 0, 0, 0.12), 0 2px 4px 0 hlsa(0, 0, 0, 0.12)"
// 根據右括號來進行分割成數組
let arr = this.value.split(/\)\s*,\s*/gim)
arr = arr.map(item => {
// 補上右括號
if (item.includes('(') && !item.includes(')')) {
return item + ')'
} else {// 非rgb顏色值的直接返回
return item
}
})
let farr = []
arr.forEach(item => {
let quene = []
let hasBrackets = false
// 逐個字符進行遍歷
for (let i = 0; i < item.length; i++) {
// 遇到非顏色值內的逗號直接拼接目前隊列里的字符添加到數組
if (item[i] === ',' && !hasBrackets) {
farr.push(quene.join('').trim())
quene = []
} else if (item[i] === '(') {//遇到顏色值的左括號修改標志位
hasBrackets = true
quene.push(item[i])
} else if (item[i] === ')') {//遇到右括號重置標志位
hasBrackets = false
quene.push(item[i])
} else {// 其他字符直接添加到隊列里
quene.push(item[i])
}
}
// 添加隊列剩余的數據
farr.push(quene.join('').trim())
})
// 解析出單個屬性
let list = []
farr.forEach(item => {
let colorRegs = [/#[a-zA-Z0-9]{3,6}$/, /rgba?\([^()]+\)$/gim, /hlsa?\([^()]+\)$/gim, /\s+[a-zA-z]+$/]
let last = ''
let color = ''
for (let i = 0; i < colorRegs.length; i++) {
let reg = colorRegs[i]
let result = reg.exec(item)
if (result) {
color = result[0]
last = item.slice(0, result.index)
break
}
}
let props = last.split(/\s+/)
list.push({
xpx: parseInt(props[0]),
ypx: parseInt(props[1]),
spread: parseInt(props[2]) || 0,
blur: parseInt(props[3]) || 0,
color
})
})
this.list = list
}
回到Editor組件,編輯控件觸發了修改事件后需要更新變量列表里面對應的值及對應主題列表里面的值,同時要發送編譯請求:
// data是變量里config數組里的一項,value就是修改后的值
function valueChange(data, value) {
// 更新當前變量對應key的值
let cloneData = JSON.parse(JSON.stringify(this.$store.state.variableList))
let tarData = cloneData.find((item) => {
return item.name === this.$store.state.editingActionType
})
tarData.config.forEach((item) => {
if (item.key === data.key) {
item.value = value
}
})
// 因為是支持顏色值修改為某些變量的,所以要重新進行變量替換處理
cloneData = store.replaceVariable(cloneData, ['color'])
this.$store.commit('updateVariableList', cloneData)
// 更新當前主題
let curTheme = store.getUserThemeByNameFromStore(this.$store.state.editingTheme, true)
if (!curTheme) {// 當前是官方主題則創建新主題
let theme = store.createNewUserTheme('', {
[data.key]: value
})
this.$store.commit('updateEditingTheme', theme.name)
} else {// 修改的是自定義主題
curTheme.theme.common = {
...curTheme.theme.common,
[data.key]: value
}
store.updateUserTheme(curTheme.name, {
theme: curTheme.theme
})
}
// 請求編譯
this.updateVariable()
}
接下來是發送編譯請求:
async function updateVariable() {
let curTheme = store.getUserThemeByNameFromStore(this.$store.state.editingTheme, true, true)
try {
let res = await api.updateVariable(curTheme.theme)
this.replaceTheme(res.data)
} catch (error) {
console.log(error)
}
}
參數為當前主題修改的變量數據,后端編譯完后返回css字符串,需要動態插入到head標簽里:
function replaceTheme(data) {
let id = 'HUI_PREVIEW_THEME'
let el = document.querySelector('#' + id)
if (el) {
el.innerHTML = data
} else {
el = document.createElement('style')
el.innerHTML = data
el.id = id
document.head.appendChild(el)
}
}
這樣就達到了修改變量后實時預覽的效果,下載主題也是類似,把當前編輯的主題的數據發送給后端編譯完后生成壓縮包進行下載。
下載:因為要發送主題變量進行編譯下載,所以不能使用get方法,但使用post方法進行下載比較麻煩,所以為了簡單起見,下載操作實際是在瀏覽器端做的。
function downloadTheme(data) {
axios({
url: '/api/v1/download',
method: 'post',
responseType: 'blob', // important
data
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'theme.zip')
link.click()
})
}
至此,主流程已經跑通,接下來是一些提升體驗的功能。
1.重置功能:重置理應是重置到某個主題復制來源的那個主題的,但是其實必要性也不是特別大,所以就簡單做,直接把當前主題的配置變量清空,即theme.common={},同時需要重新請求變量數據及請求編譯。
2.前進回退功能:前進回退功能說白了就是把每一步操作的數據都克隆一份并存到一個數組里,然后設置一個指針,比如index,指向當前所在的位置,前進就是index++,后退就是index--,然后取出對應數組里的數據替換當前的數據。對于本項目,需要存兩個東西,一個是主題數據,一個是變量數據。可以通過對象形式存到一個數組里,也可以向本項目一樣搞兩個數組。
具體實現:
1.先把初始的主題數據拷貝一份扔進歷史數組themeHistoryList里,請求到變量數據后扔進variableHistoryList數組里
2.每次修改后把修改后的變量數據和主題數據都復制一份扔進去,同時指針historyIndex加1
3.根據前進還是回退來設置historyIndex的值,同時取出對應位置的主題和變量數據替換當前的數據,然后請求編譯
需要注意的是在重置和返回主題列表頁面時要復位themeHistoryList、variableHistoryList、historyIndex
3.顏色預覽組件優化
因為顏色預覽組件是需要顯示當前顏色和顏色值的,那么就會有一個問題,字體顏色不能寫死,否則如果字體寫死白色,那么如果這個變量的顏色值又修改成白色,那么將一片白色,啥也看不見,所以需要動態判斷是用黑色還是白色,有興趣詳細了解判斷算法可閱讀:https://segmentfault.com/a/1190000018907560。
function const getContrastYIQ = (hexcolor) => {
hexcolor = colorToHEX(hexcolor).substring(1)
let r = parseInt(hexcolor.substr(0, 2), 16)
let g = parseInt(hexcolor.substr(2, 2), 16)
let b = parseInt(hexcolor.substr(4, 2), 16)
let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000
return (yiq >= 128) ? 'black' : 'white'
}
colorToHEX是一個將各種類型的顏色值都轉為十六進制顏色的函數。
4.一些小細節
logo、導航、返回按鈕、返回頂部等小控件隨當前編輯中的主題色進行變色。
到這里前端部分就結束了,讓我們喝口水繼續。
后端部分
后端用的是nodejs及eggjs框架,對eggjs不熟悉的話可先閱讀一下文檔:https://eggjs.org/zh-cn/,后端部分比較簡單,先看路由:
module.exports = app => {
const { router, controller } = app
// 獲取官方主題列表
router.get(`${BASE_URL}/getOfficialThemes`, controller.index.getOfficialThemes)
// 返回變量數據
router.get(`${BASE_URL}/getVariable`, controller.index.getVariable)
// 編譯scss
router.post(`${BASE_URL}/updateVariable`, controller.index.updateVariable)
// 下載
router.post(`${BASE_URL}/download`, controller.index.download)
}
目前官方主題列表和變量數據都是一個寫死的json文件。所以核心只有兩部分,編譯scss和下載,先看編譯。
編譯scss
主題在線編輯能實現靠的就是scss的變量功能,編譯scss可用使用sass包或者node-sass包,前端傳過來的參數其實就一個json類型的對象,key是變量,value是值,但是這兩個包都不支持傳入額外的變量數據和本地的scss文件進行合并編譯,但是提供了一個配置項:importer,可以傳入函數數組,它會在編譯過程中遇到 @use
or @import
語法時執行這個函數,入參為url,可以返回一個對象:
{
contents: `
h1 {
font-size: 40px;
}
`
}
contents的內容即會替代原本要引入的對應scss文件的內容,詳情請看:https://sass-lang.com/documentation/js-api#importer
但是實際使用過程中,不知為何sass包的這個配置項是無效的,所以只能使用node-sass,這兩個包的api基本是一樣的,但是node-sass安裝起來比較麻煩,尤其是windows上,安裝方法大致有兩種:
npm install -g node-gyp
npm install --global --production windows-build-tools
npm install node-sass --save-dev
npm install -g cnpm --registry=https://registry.npm.taobao.org
cnpm install node-sass
因為主題的變量定義一般都在統一的一個或幾個文件內,像hui,是定義在var-common.scss和var.scss兩個文件內,所以可以讀取這兩個文件的內容然后將其中對應變量的值替換為前端傳過來的變量,替換完成后通過importer函數返回進行編譯,具體替換方式也有多種,我同事的方法是自己寫了個scss解析器,解析成對象,然后遍歷對象解析替換,而我,比較草率,直接用正則匹配解析修改,實現如下:
function(data) {
// 前端傳遞過來的數據
let updates = data.common
// 兩個文件的路徑
let commonScssPath = path.join(process.cwd(), 'node_modules/hui/packages/theme/common/var-common.scss')
let varScssPath = path.join(process.cwd(), 'node_modules/hui/packages/theme/common/var.scss')
// 讀取兩個文件的內容
let commonScssContent = fs.readFileSync(commonScssPath, {encoding: 'utf8'})
let varScssContent = fs.readFileSync(varScssPath, {encoding: 'utf8'})
// 遍歷要修改的變量數據
Object.keys(updates).forEach((key) => {
let _key = key
// 正則匹配及替換
key = key.replace('$', '\\$')
let reg = new RegExp('(' +key + '\\s*:\\s*)([^:]+)(;)', 'img')
commonScssContent = commonScssContent.replace(reg, `$1${updates[_key]}$3`)
varScssContent = varScssContent.replace(reg, `$1${updates[_key]}$3`)
})
// 修改路徑為絕對路徑,否則會報錯
let mixinsPath = path.resolve(process.cwd(), 'node_modules/hui/packages/theme/mixins/_color-helpers.scss')
mixinsPath = mixinsPath.split('\\').join('/')
commonScssContent = commonScssContent.replace(`@import '../mixins/_color-helpers'`, `@import '${mixinsPath}'`)
let huiScssPath = path.join(process.cwd(), 'node_modules/hui/packages/theme/index.scss')
// 編譯scss
let result = sass.renderSync({
file: huiScssPath,
importer: [
function (url) {
if (url.includes('var-common')) {
return {
contents: commonScssContent
}
}else if (url.includes('var')) {
return {
contents: varScssContent
}
} else {
return null
}
}
]
})
return result.css.toString()
}
下載主題
下載的主題包里有兩個數據,一個是配置源文件,另一個就是編譯后的主題包,包括css文件和字體文件。創建壓縮包使用的是jszip,可參考:https://github.com/Stuk/jszip。
主題包的目錄結構如下:
-theme
--fonts
--index.css
-config.json
實現如下:
async createThemeZip(data) {
let zip = new JSZip()
// 配置源文件
zip.file('config.json', JSON.stringify(data.common, null, 2))
// 編譯后的css主題包
let theme = zip.folder('theme')
let fontPath = 'node_modules/hui/packages/theme/fonts'
let fontsFolder = theme.folder('fonts')
// 遍歷添加字體文件
let loopAdd = (_path, folder) => {
fs.readdirSync(_path).forEach((file) => {
let curPath = path.join(_path, file)
if (fs.statSync(curPath).isDirectory()) {
let newFolder = folder.folder(file)
loopAdd(curPath, newFolder)
} else {
folder.file(file, fs.readFileSync(curPath))
}
})
}
loopAdd(fontPath, fontsFolder)
// 編譯后的css
let css = await huiComplier(data)
theme.file('index.css', css)
// 壓縮
let result = await zip.generateAsync({
type: 'nodebuffer'
})
// 保存到本地
// fs.writeFileSync('theme.zip', result, (err) => {
// if (err){
// this.ctx.logger.warn('壓縮失敗', err)
// }
// this.ctx.logger.info('壓縮完成')
// })
return result
}
至此,前端和后端的核心實現都已介紹完畢。
總結
本項目目前只是一個粗糙的實現,旨在提供一個實現思路,還有很多細節需要優化,比如之前提到的變量依賴問題,還有scss的解析合并方式,此外還有多語言、多版本的問題需要考慮。