前言
一般而言一個(gè)組件庫(kù)都會(huì)設(shè)計(jì)一套相對(duì)來(lái)說(shuō)符合大眾審美或產(chǎn)品需求的主題遣钳,但是主題定制需求永遠(yuǎn)都存在彤避,所以組件庫(kù)一般都會(huì)允許使用者自定義主題傅物,我司的vue組件庫(kù)hui的定制主題簡(jiǎn)單來(lái)說(shuō)是通過(guò)修改預(yù)定義的scss變量的值來(lái)做到的,新體系下還做到了動(dòng)態(tài)換膚琉预,因?yàn)槠つw本質(zhì)上是一種靜態(tài)資源(CSS文件和字體文件)董饰,所以只需要約定一種方式來(lái)每次動(dòng)態(tài)請(qǐng)求加載不同的文件就可以了,為了方便這一需求模孩,還配套開(kāi)發(fā)了一個(gè)Vessel腳手架的插件尖阔,只需要以配置文件的方式列出你需要修改的變量和值,一個(gè)命令就可以幫你生成對(duì)應(yīng)的皮膚榨咐。
但是目前的換膚還存在幾個(gè)問(wèn)題介却, 一是不直觀,無(wú)法方便實(shí)時(shí)的看到修改后的組件效果块茁,二是建議修改的變量比較少齿坷,這很大原因也是因?yàn)閱?wèn)題一,因?yàn)椴恢庇^所以盲目修改后的效果可能達(dá)不到預(yù)期数焊。
針對(duì)這幾個(gè)問(wèn)題永淌,所以實(shí)現(xiàn)一個(gè)在線主題編輯器是一個(gè)有意義的事情,目前最流行的組件庫(kù)之一的Element就支持主題在線編輯佩耳,地址:https://element.eleme.cn/#/zh-CN/theme遂蛀,本項(xiàng)目是在參考了Element的設(shè)計(jì)思想和界面效果后開(kāi)發(fā)完成的,本文將開(kāi)發(fā)思路分享出來(lái)干厚,如果有一些不合理地方或有一些更好的實(shí)現(xiàn)方式李滴,歡迎指出來(lái)一起討論螃宙。
實(shí)現(xiàn)思路
主題在線編輯的核心其實(shí)就是以一種可視化的方式來(lái)修改主題對(duì)應(yīng)scss變量的值。
項(xiàng)目總體分為前端和后端兩個(gè)部分所坯,前端主要負(fù)責(zé)管理主題列表谆扎、編輯主題和預(yù)覽主題,后端主要負(fù)責(zé)返回變量列表和編譯主題芹助。
后端返回主題可修改的變量信息堂湖,前端生成對(duì)應(yīng)的控件,用戶可進(jìn)行修改状土,修改后立即將修改的變量和修改后的值發(fā)送給后端无蜂,后端進(jìn)行合并編譯,生成css返回給前端声诸,前端動(dòng)態(tài)替換style標(biāo)簽的內(nèi)容達(dá)到實(shí)時(shí)預(yù)覽的效果酱讶。
主題列表頁(yè)面
主題列表頁(yè)面的主要功能是顯示官方主題列表和顯示自定義主題列表退盯。
官方主題可進(jìn)行的操作有預(yù)覽和復(fù)制彼乌,不能修改,修改的話會(huì)自動(dòng)生成新主題渊迁。自定義主題可以編輯和下載慰照,及進(jìn)行修改名稱、復(fù)制琉朽、刪除操作毒租。
官方主題列表后端返回,數(shù)據(jù)結(jié)構(gòu)如下:
{
name: '官方主題-1', // 主題名稱
by: 'by hui', // 來(lái)源
description: '默認(rèn)主題', // 描述
theme: {
// 主題改動(dòng)點(diǎn)列表
common: {
'$--color-brand': '#e72528'
}
}
}
自定義主題保存在localstorage里箱叁,數(shù)據(jù)結(jié)構(gòu)如下:
{
name: name, // 主題名稱
update: Date.now(), // 最后一次修改時(shí)間
theme: { // 主題改動(dòng)點(diǎn)列表
common: {
//...
}
}
}
復(fù)制主題即把要復(fù)制的主題的theme.common數(shù)據(jù)復(fù)制到新主題上即可墅垮。
需要注意的就是新建主題時(shí)要判斷主題名稱是否重復(fù),因?yàn)閿?shù)據(jù)結(jié)構(gòu)里并沒(méi)有類似id的字段耕漱。另外還有一個(gè)小問(wèn)題是當(dāng)預(yù)覽官方主題時(shí)修改的話會(huì)自動(dòng)生成新主題算色,所以還需要自動(dòng)生成可用的主題名,實(shí)現(xiàn)如下:
const USER_THEME_NAME_PREFIX = '自定義主題-';
function getNextUserThemeName() {
let index = 1
// 獲取已經(jīng)存在的自定義主題列表
let list = getUserThemesFromStore()
let name = USER_THEME_NAME_PREFIX + index
let exist = () => {
return list.some((item) => {
return item.name === name
})
}
// 循環(huán)檢測(cè)主題名稱是否重復(fù)
while (exist()) {
index++
name = USER_THEME_NAME_PREFIX + index
}
return name
}
界面效果如下:
因?yàn)樯婕暗綆讉€(gè)頁(yè)面及不同組件間的互相通信螟够,所以vuex是必須要使用的灾梦,vuex的state要存儲(chǔ)的內(nèi)容如下:
const state = {
// 官方主題列表
officialThemeList: [],
// 自定義主題列表
themeList: [],
// 當(dāng)前編輯中的主題id
editingTheme: null,
// 當(dāng)前編輯的變量類型
editingActionType: 'Color',
// 可編輯的變量列表數(shù)據(jù)
variableList: [],
// 操作歷史數(shù)據(jù)
historyIndex: 0,
themeHistoryList: [],
variableHistoryList: []
}
editingTheme是代表當(dāng)前正在編輯的名字,主題編輯時(shí)依靠這個(gè)值來(lái)修改對(duì)應(yīng)主題的數(shù)據(jù)妓笙,這個(gè)值也會(huì)在localstorage里存一份若河。
editingActionType是代表當(dāng)前正在編輯中的變量所屬組件類型,主要作用是在切換要修改的組件類型后預(yù)覽列表滾動(dòng)到對(duì)應(yīng)的組件位置及用來(lái)渲染對(duì)應(yīng)主題變量對(duì)應(yīng)的編輯控件寞宫,如下:
頁(yè)面在vue實(shí)例化前先獲取官方主題萧福、自定義主題、最后一次編輯的主題名稱辈赋,設(shè)置到vuex的store里鲫忍。
編輯預(yù)覽頁(yè)面
編輯預(yù)覽頁(yè)面主要分兩部分毛俏,左側(cè)是組件列表,右側(cè)是編輯區(qū)域饲窿,界面效果如下:
組件預(yù)覽區(qū)域
組件預(yù)覽區(qū)域很簡(jiǎn)單煌寇,無(wú)腦羅列出所有組件庫(kù)里的組件,就像這樣:
<div class="list">
<Color></Color>
<Button></Button>
<Radio></Radio>
<Checkbox></Checkbox>
<Inputer></Inputer>
<Autocomplete></Autocomplete>
<InputNumber></InputNumber>
//...
</div>
同時(shí)需要監(jiān)聽(tīng)一下editingActionType值的變化來(lái)滾動(dòng)到對(duì)應(yīng)組件的位置:
<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>
編輯區(qū)域
編輯區(qū)域主要分為三部分逾雄,工具欄阀溶、選擇欄、控件區(qū)鸦泳。這部分是本項(xiàng)目的核心也是最復(fù)雜的一部分银锻。
先看一下變量列表的數(shù)據(jù)結(jié)構(gòu):
{
"name": "Color",// 組件類型/類別
"config": [{// 配置列表
"type": "color",// 變量類型,根據(jù)此字段渲染對(duì)應(yīng)類型的控件
"key": "$--color-brand",// sass變量名
"value": "#e72528",// sass變量對(duì)應(yīng)的值做鹰,可以是具體的值击纬,也可以是sass變量名
"category": "Brand Color"http:// 列表,用來(lái)分組進(jìn)行顯示
}]
}
此列表是后端返回的钾麸,選擇器的選項(xiàng)是遍歷該列表取出所有的name字段的值而組成的更振。
因?yàn)橛行┳兞康闹凳且蕾嚵硪粋€(gè)變量的,所依賴的變量也有可能還依賴另一個(gè)變量饭尝,所以需要對(duì)數(shù)據(jù)進(jìn)行處理肯腕,替換成變量最終的值,實(shí)現(xiàn)方式就是循環(huán)遍歷數(shù)據(jù)钥平,這就要求所有被依賴的變量也存在于這個(gè)列表中实撒,否則就找不到了,只能顯示變量名涉瘾,所以這個(gè)實(shí)現(xiàn)方式其實(shí)是有待商榷的知态,因?yàn)橛行┍灰蕾嚨淖兞克赡懿⒉恍枰虿荒芸删庉嫞卷?xiàng)目目前版本是存在此問(wèn)題的立叛。
此外還需要和當(dāng)前編輯中的主題變量的值進(jìn)行合并负敏,處理如下:
// Editor組件
async getVariable() {
try {
// 獲取變量列表,res.data就是變量列表囚巴,數(shù)據(jù)結(jié)構(gòu)上面已經(jīng)提到了
let res = await api.getVariable()
// 和當(dāng)前主題變量進(jìn)行合并
let curTheme = store.getUserThemeByNameFromStore(this.$store.state.editingTheme) || {}
let list = []
// 合并
list = this.merge(res.data, curTheme.theme)
// 變量進(jìn)行替換處理原在,因?yàn)槟壳按嬖谠撉闆r的只有顏色類型的變量,所以為了執(zhí)行效率加上該過(guò)濾條件
list = store.replaceVariable(list, ['color'])
// 排序
list = this.sortVariable(list)
this.variableList = list
// 存儲(chǔ)到vuex
this.$store.commit('updateVariableList', this.variableList)
} catch (error) {
console.log(error)
}
}
merge方法就是遍歷合并對(duì)應(yīng)變量key的值彤叉,主要看replaceVariable方法:
function replaceVariable(data, types) {
// 遍歷整體變量列表
for(let i = 0; i < data.length; i++) {
let arr = data[i].config
// 遍歷某個(gè)類別下的變量列表
for(let j = 0; j < arr.length; j++) {
// 如果不在替換類型范圍內(nèi)的和值不是變量的話就跳過(guò)
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方法通過(guò)遞歸進(jìn)行查找:
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)
}
}
}
}
}
接下來(lái)是具體的控件顯示邏輯,根據(jù)當(dāng)前編輯中的類型對(duì)應(yīng)的配置數(shù)據(jù)進(jìn)行渲染秽浇,模板如下:
// 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是對(duì)應(yīng)變量類型里的config數(shù)據(jù)浮庐,是個(gè)計(jì)算屬性:
{
computed: {
data() {
// 找出當(dāng)前編輯中的變量類別
let _data = this.$store.state.variableList.find(item => {
return item.name === this.$store.state.editingActionType
})
if (!_data) {
return []
}
let config = _data.config
// 進(jìn)行分組
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是具體的控件顯示組件,某個(gè)變量具體是用輸入框還是下拉列表都在這個(gè)組件內(nèi)進(jìn)行判斷,核心是使用component動(dòng)態(tài)組件:
// 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() {
// 根據(jù)變量類型來(lái)顯示對(duì)應(yīng)的控件
return componentMap[this.data.type]
}
}
}
</script>
一共有顏色選擇組件审残、輸入框組件梭域、選擇器組件、陰影編輯組件搅轿,具體實(shí)現(xiàn)很簡(jiǎn)單就不細(xì)說(shuō)了病涨,大概就是顯示初始傳入的變量,然后修改后觸發(fā)修改事件change璧坟,經(jīng)Control組件傳遞到Editor組件既穆,在Editor組件上進(jìn)行變量修改及發(fā)送編譯請(qǐng)求,不過(guò)其中陰影組件的實(shí)現(xiàn)折磨了我半天雀鹃,主要是如何解析陰影數(shù)據(jù)幻工,這里用的是很暴力的一種解析方法,如果有更好的解析方式的話可以留言進(jìn)行分享:
// 解析css陰影數(shù)據(jù)
// 因?yàn)閞gb顏色值內(nèi)也存在逗號(hào)黎茎,所以就不能簡(jiǎn)單的用逗號(hào)進(jìn)行切割解析
function parse() {
if (!this.value) {
return false
}
// 解析成復(fù)合值數(shù)組
// 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)"
// 根據(jù)右括號(hào)來(lái)進(jìn)行分割成數(shù)組
let arr = this.value.split(/\)\s*,\s*/gim)
arr = arr.map(item => {
// 補(bǔ)上右括號(hào)
if (item.includes('(') && !item.includes(')')) {
return item + ')'
} else {// 非rgb顏色值的直接返回
return item
}
})
let farr = []
arr.forEach(item => {
let quene = []
let hasBrackets = false
// 逐個(gè)字符進(jìn)行遍歷
for (let i = 0; i < item.length; i++) {
// 遇到非顏色值內(nèi)的逗號(hào)直接拼接目前隊(duì)列里的字符添加到數(shù)組
if (item[i] === ',' && !hasBrackets) {
farr.push(quene.join('').trim())
quene = []
} else if (item[i] === '(') {//遇到顏色值的左括號(hào)修改標(biāo)志位
hasBrackets = true
quene.push(item[i])
} else if (item[i] === ')') {//遇到右括號(hào)重置標(biāo)志位
hasBrackets = false
quene.push(item[i])
} else {// 其他字符直接添加到隊(duì)列里
quene.push(item[i])
}
}
// 添加隊(duì)列剩余的數(shù)據(jù)
farr.push(quene.join('').trim())
})
// 解析出單個(gè)屬性
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組件囊颅,編輯控件觸發(fā)了修改事件后需要更新變量列表里面對(duì)應(yīng)的值及對(duì)應(yīng)主題列表里面的值,同時(shí)要發(fā)送編譯請(qǐng)求:
// data是變量里config數(shù)組里的一項(xiàng)傅瞻,value就是修改后的值
function valueChange(data, value) {
// 更新當(dāng)前變量對(duì)應(yīng)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
}
})
// 因?yàn)槭侵С诸伾敌薷臑槟承┳兞康奶叽砸匦逻M(jìn)行變量替換處理
cloneData = store.replaceVariable(cloneData, ['color'])
this.$store.commit('updateVariableList', cloneData)
// 更新當(dāng)前主題
let curTheme = store.getUserThemeByNameFromStore(this.$store.state.editingTheme, true)
if (!curTheme) {// 當(dāng)前是官方主題則創(chuàng)建新主題
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
})
}
// 請(qǐng)求編譯
this.updateVariable()
}
接下來(lái)是發(fā)送編譯請(qǐng)求:
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)
}
}
參數(shù)為當(dāng)前主題修改的變量數(shù)據(jù),后端編譯完后返回css字符串俭正,需要?jiǎng)討B(tài)插入到head標(biāo)簽里:
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)
}
}
這樣就達(dá)到了修改變量后實(shí)時(shí)預(yù)覽的效果奸鬓,下載主題也是類似,把當(dāng)前編輯的主題的數(shù)據(jù)發(fā)送給后端編譯完后生成壓縮包進(jìn)行下載掸读。
下載:因?yàn)橐l(fā)送主題變量進(jìn)行編譯下載,所以不能使用get方法宏多,但使用post方法進(jìn)行下載比較麻煩儿惫,所以為了簡(jiǎn)單起見(jiàn),下載操作實(shí)際是在瀏覽器端做的伸但。
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()
})
}
至此肾请,主流程已經(jīng)跑通,接下來(lái)是一些提升體驗(yàn)的功能更胖。
1.重置功能:重置理應(yīng)是重置到某個(gè)主題復(fù)制來(lái)源的那個(gè)主題的铛铁,但是其實(shí)必要性也不是特別大,所以就簡(jiǎn)單做却妨,直接把當(dāng)前主題的配置變量清空饵逐,即theme.common={},同時(shí)需要重新請(qǐng)求變量數(shù)據(jù)及請(qǐng)求編譯彪标。
2.前進(jìn)回退功能:前進(jìn)回退功能說(shuō)白了就是把每一步操作的數(shù)據(jù)都克隆一份并存到一個(gè)數(shù)組里倍权,然后設(shè)置一個(gè)指針,比如index捞烟,指向當(dāng)前所在的位置薄声,前進(jìn)就是index++当船,后退就是index--,然后取出對(duì)應(yīng)數(shù)組里的數(shù)據(jù)替換當(dāng)前的數(shù)據(jù)默辨。對(duì)于本項(xiàng)目德频,需要存兩個(gè)東西,一個(gè)是主題數(shù)據(jù)缩幸,一個(gè)是變量數(shù)據(jù)抱婉。可以通過(guò)對(duì)象形式存到一個(gè)數(shù)組里桌粉,也可以向本項(xiàng)目一樣搞兩個(gè)數(shù)組蒸绩。
具體實(shí)現(xiàn):
1.先把初始的主題數(shù)據(jù)拷貝一份扔進(jìn)歷史數(shù)組themeHistoryList里,請(qǐng)求到變量數(shù)據(jù)后扔進(jìn)variableHistoryList數(shù)組里
2.每次修改后把修改后的變量數(shù)據(jù)和主題數(shù)據(jù)都復(fù)制一份扔進(jìn)去铃肯,同時(shí)指針historyIndex加1
3.根據(jù)前進(jìn)還是回退來(lái)設(shè)置historyIndex的值患亿,同時(shí)取出對(duì)應(yīng)位置的主題和變量數(shù)據(jù)替換當(dāng)前的數(shù)據(jù),然后請(qǐng)求編譯
需要注意的是在重置和返回主題列表頁(yè)面時(shí)要復(fù)位themeHistoryList押逼、variableHistoryList步藕、historyIndex
3.顏色預(yù)覽組件優(yōu)化
因?yàn)轭伾A(yù)覽組件是需要顯示當(dāng)前顏色和顏色值的,那么就會(huì)有一個(gè)問(wèn)題挑格,字體顏色不能寫(xiě)死咙冗,否則如果字體寫(xiě)死白色,那么如果這個(gè)變量的顏色值又修改成白色漂彤,那么將一片白色雾消,啥也看不見(jiàn),所以需要?jiǎng)討B(tài)判斷是用黑色還是白色挫望,有興趣詳細(xì)了解判斷算法可閱讀: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是一個(gè)將各種類型的顏色值都轉(zhuǎn)為十六進(jìn)制顏色的函數(shù)。
4.一些小細(xì)節(jié)
logo媳板、導(dǎo)航桑腮、返回按鈕、返回頂部等小控件隨當(dāng)前編輯中的主題色進(jìn)行變色蛉幸。
到這里前端部分就結(jié)束了破讨,讓我們喝口水繼續(xù)。
后端部分
后端用的是nodejs及eggjs框架奕纫,對(duì)eggjs不熟悉的話可先閱讀一下文檔:https://eggjs.org/zh-cn/提陶,后端部分比較簡(jiǎn)單,先看路由:
module.exports = app => {
const { router, controller } = app
// 獲取官方主題列表
router.get(`${BASE_URL}/getOfficialThemes`, controller.index.getOfficialThemes)
// 返回變量數(shù)據(jù)
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)
}
目前官方主題列表和變量數(shù)據(jù)都是一個(gè)寫(xiě)死的json文件若锁。所以核心只有兩部分搁骑,編譯scss和下載,先看編譯。
編譯scss
主題在線編輯能實(shí)現(xiàn)靠的就是scss的變量功能仲器,編譯scss可用使用sass包或者node-sass包煤率,前端傳過(guò)來(lái)的參數(shù)其實(shí)就一個(gè)json類型的對(duì)象,key是變量乏冀,value是值蝶糯,但是這兩個(gè)包都不支持傳入額外的變量數(shù)據(jù)和本地的scss文件進(jìn)行合并編譯,但是提供了一個(gè)配置項(xiàng):importer辆沦,可以傳入函數(shù)數(shù)組昼捍,它會(huì)在編譯過(guò)程中遇到 @use
or @import
語(yǔ)法時(shí)執(zhí)行這個(gè)函數(shù),入?yún)閡rl肢扯,可以返回一個(gè)對(duì)象:
{
contents: `
h1 {
font-size: 40px;
}
`
}
contents的內(nèi)容即會(huì)替代原本要引入的對(duì)應(yīng)scss文件的內(nèi)容妒茬,詳情請(qǐng)看:https://sass-lang.com/documentation/js-api#importer
但是實(shí)際使用過(guò)程中,不知為何sass包的這個(gè)配置項(xiàng)是無(wú)效的蔚晨,所以只能使用node-sass乍钻,這兩個(gè)包的api基本是一樣的,但是node-sass安裝起來(lái)比較麻煩铭腕,尤其是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
因?yàn)橹黝}的變量定義一般都在統(tǒng)一的一個(gè)或幾個(gè)文件內(nèi),像hui累舷,是定義在var-common.scss和var.scss兩個(gè)文件內(nèi)浩考,所以可以讀取這兩個(gè)文件的內(nèi)容然后將其中對(duì)應(yīng)變量的值替換為前端傳過(guò)來(lái)的變量,替換完成后通過(guò)importer函數(shù)返回進(jìn)行編譯被盈,具體替換方式也有多種析孽,我同事的方法是自己寫(xiě)了個(gè)scss解析器,解析成對(duì)象害捕,然后遍歷對(duì)象解析替換绿淋,而我,比較草率尝盼,直接用正則匹配解析修改,實(shí)現(xiàn)如下:
function(data) {
// 前端傳遞過(guò)來(lái)的數(shù)據(jù)
let updates = data.common
// 兩個(gè)文件的路徑
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')
// 讀取兩個(gè)文件的內(nèi)容
let commonScssContent = fs.readFileSync(commonScssPath, {encoding: 'utf8'})
let varScssContent = fs.readFileSync(varScssPath, {encoding: 'utf8'})
// 遍歷要修改的變量數(shù)據(jù)
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`)
})
// 修改路徑為絕對(duì)路徑佑菩,否則會(huì)報(bào)錯(cuò)
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()
}
下載主題
下載的主題包里有兩個(gè)數(shù)據(jù)盾沫,一個(gè)是配置源文件,另一個(gè)就是編譯后的主題包殿漠,包括css文件和字體文件赴精。創(chuàng)建壓縮包使用的是jszip,可參考:https://github.com/Stuk/jszip绞幌。
主題包的目錄結(jié)構(gòu)如下:
-theme
--fonts
--index.css
-config.json
實(shí)現(xiàn)如下:
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
}
至此蕾哟,前端和后端的核心實(shí)現(xiàn)都已介紹完畢。
總結(jié)
本項(xiàng)目目前只是一個(gè)粗糙的實(shí)現(xiàn),旨在提供一個(gè)實(shí)現(xiàn)思路谭确,還有很多細(xì)節(jié)需要優(yōu)化帘营,比如之前提到的變量依賴問(wèn)題,還有scss的解析合并方式逐哈,此外還有多語(yǔ)言芬迄、多版本的問(wèn)題需要考慮。