如何實(shí)現(xiàn)一個(gè)vue組件庫(kù)的在線主題編輯器

前言

一般而言一個(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)題氮昧,字體顏色不能寫死框杜,否則如果字體寫死白色浦楣,那么如果這個(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è)寫死的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)行編譯矩距,具體替換方式也有多種拗盒,我同事的方法是自己寫了個(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)題需要考慮。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末鹃觉,一起剝皮案震驚了整個(gè)濱河市专酗,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌盗扇,老刑警劉巖祷肯,帶你破解...
    沈念sama閱讀 219,539評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異疗隶,居然都是意外死亡佑笋,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評(píng)論 3 396
  • 文/潘曉璐 我一進(jìn)店門斑鼻,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)蒋纬,“玉大人,你說(shuō)我怎么就攤上這事〉唢保” “怎么了法牲?”我有些...
    開(kāi)封第一講書人閱讀 165,871評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)琼掠。 經(jīng)常有香客問(wèn)我拒垃,道長(zhǎng),這世上最難降的妖魔是什么瓷蛙? 我笑而不...
    開(kāi)封第一講書人閱讀 58,963評(píng)論 1 295
  • 正文 為了忘掉前任悼瓮,我火速辦了婚禮,結(jié)果婚禮上艰猬,老公的妹妹穿的比我還像新娘横堡。我一直安慰自己,他們只是感情好冠桃,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,984評(píng)論 6 393
  • 文/花漫 我一把揭開(kāi)白布命贴。 她就那樣靜靜地躺著,像睡著了一般食听。 火紅的嫁衣襯著肌膚如雪胸蛛。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書人閱讀 51,763評(píng)論 1 307
  • 那天樱报,我揣著相機(jī)與錄音葬项,去河邊找鬼。 笑死迹蛤,一個(gè)胖子當(dāng)著我的面吹牛民珍,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播盗飒,決...
    沈念sama閱讀 40,468評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼嚷量,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了逆趣?” 一聲冷哼從身側(cè)響起津肛,我...
    開(kāi)封第一講書人閱讀 39,357評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎汗贫,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體秸脱,經(jīng)...
    沈念sama閱讀 45,850評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡落包,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,002評(píng)論 3 338
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了摊唇。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片咐蝇。...
    茶點(diǎn)故事閱讀 40,144評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖巷查,靈堂內(nèi)的尸體忽然破棺而出有序,到底是詐尸還是另有隱情抹腿,我是刑警寧澤,帶...
    沈念sama閱讀 35,823評(píng)論 5 346
  • 正文 年R本政府宣布旭寿,位于F島的核電站警绩,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏盅称。R本人自食惡果不足惜肩祥,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,483評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望缩膝。 院中可真熱鬧混狠,春花似錦、人聲如沸疾层。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 32,026評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)痛黎。三九已至予弧,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間舅逸,已是汗流浹背桌肴。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 33,150評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留琉历,地道東北人坠七。 一個(gè)月前我還...
    沈念sama閱讀 48,415評(píng)論 3 373
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像旗笔,于是被迫代替她去往敵國(guó)和親彪置。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,092評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容