vue元件庫的線上主題編輯器的實現思路
一般而言一個元件庫都會設計一套相對來說符合大眾審美或產品需求的主題,但是主題定製需求永遠都存在,所以元件庫一般都會允許使用者自定義主題,我司的vue元件庫hui的定製主題簡單來說是通過修改預定義的scss變數的值來做到的,新體系下還做到了動態換膚,因為面板本質上是一種靜態資源(CSS檔案和字型檔案),所以只需要約定一種方式來每次動態請求載入不同的檔案就可以了,為了方便這一需求,還配套開發了一個Vessel腳手架的外掛,只需要以配置檔案的方式列出你需要修改的變數和值,一個命令就可以幫你生成對應的面板。
但是目前的換膚還存在幾個問題, 一是不直觀,無法方便實時的看到修改後的元件效果,二是建議修改的變數比較少,這很大原因也是因為問題一,因為不直觀所以盲目修改後的效果可能達不到預期。
針對這幾個問題,所以實現一個線上主題編輯器是一個有意義的事情,目前最流行的元件庫之一的Element就支援主題線上編輯,地址:element.eleme.cn/#/zh-CN/the… ,本專案是在參考了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"// 列表,用來分組進行顯示 }] }
此列表是後端返回的,選擇器的選項是遍歷該列表取出所有的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.12),0 2px 4px 0 hlsa(0,0 2px 4px 0 #sdf,0 2px 0 hlsa(0,0 2px hlsa(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.先把初始的主題資料拷貝一份扔進歷史陣列themeHistoryLis
t裡,請求到變數資料後扔進variableHistoryList
數組裡
2.每次修改後把修改後的變數資料和主題資料都複製一份扔進去,同時指標historyIndex
加1
3.根據前進還是回退來設定historyIndex
的值,同時取出對應位置的主題和變數資料替換當前的資料,然後請求編譯
需要注意的是在重置和返回主題列表頁面時要復位themeHistoryList、variableHistoryList、historyIndex
3.顏色預覽元件優化
因為顏色預覽元件是需要顯示當前顏色和顏色值的,那麼就會有一個問題,字型顏色不能寫死,否則如果字型寫死白色,那麼如果這個變數的顏色值又修改成白色,那麼將一片白色,啥也看不見,所以需要動態判斷是用黑色還是白色,有興趣詳細瞭解判斷演算法可閱讀:
function const getContrastYIQ = (hexcolor) => { hexcolor = colorToHEX(hexcolor).substring(1) let r = parseInt(hexcolor.substr(0,2),16) let g = parseInt(hexcolor.substr(2,16) let b = parseInt(hexcolor.substr(4,16) let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000 return (yiq >= 128) ? 'black' : 'white' }
colorToHEX是一個將各種型別的顏色值都轉為十六進位制顏色的函式。
4.一些小細節
logo、導航、返回按鈕、返回頂部等小控制元件隨當前編輯中的主題色進行變色。
到這裡前端部分就結束了,讓我們喝口水繼續。
後端部分
後端用的是nodejs及eggjs框架,對eggjs不熟悉的話可先閱讀一下文件: 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檔案的內容,詳情請看:sass-lang.com/documentati…
但是實際使用過程中,不知為何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,可參考: 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的解析合併方式,此外還有多語言、多版本的問題需要考慮。
到此這篇關於vue元件庫的線上主題編輯器的實現思路的文章就介紹到這了,更多相關vue線上主題編輯器內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!