如何實(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)題挑格,字體顏色不能寫(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)題需要考慮。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末昂秃,一起剝皮案震驚了整個(gè)濱河市禀梳,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌肠骆,老刑警劉巖算途,帶你破解...
    沈念sama閱讀 216,402評(píng)論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異蚀腿,居然都是意外死亡嘴瓤,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門(mén)唯咬,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)纱注,“玉大人,你說(shuō)我怎么就攤上這事胆胰∧” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 162,483評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵蜀涨,是天一觀的道長(zhǎng)瞎嬉。 經(jīng)常有香客問(wèn)我,道長(zhǎng)厚柳,這世上最難降的妖魔是什么氧枣? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,165評(píng)論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮别垮,結(jié)果婚禮上便监,老公的妹妹穿的比我還像新娘。我一直安慰自己碳想,他們只是感情好烧董,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評(píng)論 6 388
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著胧奔,像睡著了一般逊移。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上龙填,一...
    開(kāi)封第一講書(shū)人閱讀 51,146評(píng)論 1 297
  • 那天胳泉,我揣著相機(jī)與錄音拐叉,去河邊找鬼。 笑死扇商,一個(gè)胖子當(dāng)著我的面吹牛凤瘦,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播钳吟,決...
    沈念sama閱讀 40,032評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼廷粒,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了红且?” 一聲冷哼從身側(cè)響起坝茎,我...
    開(kāi)封第一講書(shū)人閱讀 38,896評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎暇番,沒(méi)想到半個(gè)月后嗤放,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,311評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡壁酬,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評(píng)論 2 332
  • 正文 我和宋清朗相戀三年次酌,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片舆乔。...
    茶點(diǎn)故事閱讀 39,696評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡岳服,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出希俩,到底是詐尸還是另有隱情吊宋,我是刑警寧澤,帶...
    沈念sama閱讀 35,413評(píng)論 5 343
  • 正文 年R本政府宣布璃搜,位于F島的核電站,受9級(jí)特大地震影響鳞上,放射性物質(zhì)發(fā)生泄漏这吻。R本人自食惡果不足惜篙议,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評(píng)論 3 325
  • 文/蒙蒙 一唾糯、第九天 我趴在偏房一處隱蔽的房頂上張望鬼贱。 院中可真熱鬧趾断,春花似錦吩愧、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至糖权,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間星澳,已是汗流浹背疚顷。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,815評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留腿堤,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,698評(píng)論 2 368
  • 正文 我出身青樓如暖,卻偏偏與公主長(zhǎng)得像笆檀,于是被迫代替她去往敵國(guó)和親盒至。 傳聞我的和親對(duì)象是個(gè)殘疾皇子酗洒,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評(píng)論 2 353

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