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

前言

一般而言一個組件庫都會設(shè)計(jì)一套相對來說符合大眾審美或產(chǎn)品需求的主題,但是主題定制需求永遠(yuǎn)都存在,所以組件庫一般都會允許使用者自定義主題,我司的vue組件庫hui的定制主題簡單來說是通過修改預(yù)定義的scss變量的值來做到的,新體系下還做到了動態(tài)換膚,因?yàn)槠つw本質(zhì)上是一種靜態(tài)資源(CSS文件和字體文件),所以只需要約定一種方式來每次動態(tài)請求加載不同的文件就可以了,為了方便這一需求,還配套開發(fā)了一個Vessel腳手架的插件,只需要以配置文件的方式列出你需要修改的變量和值,一個命令就可以幫你生成對應(yīng)的皮膚。

但是目前的換膚還存在幾個問題, 一是不直觀,無法方便實(shí)時的看到修改后的組件效果,二是建議修改的變量比較少,這很大原因也是因?yàn)閱栴}一,因?yàn)椴恢庇^所以盲目修改后的效果可能達(dá)不到預(yù)期。

針對這幾個問題,所以實(shí)現(xiàn)一個在線主題編輯器是一個有意義的事情,目前最流行的組件庫之一的Element就支持主題在線編輯,地址:https://element.eleme.cn/#/zh-CN/theme,本項(xiàng)目是在參考了Element的設(shè)計(jì)思想和界面效果后開發(fā)完成的,本文將開發(fā)思路分享出來,如果有一些不合理地方或有一些更好的實(shí)現(xiàn)方式,歡迎指出來一起討論。

實(shí)現(xiàn)思路

主題在線編輯的核心其實(shí)就是以一種可視化的方式來修改主題對應(yīng)scss變量的值。

項(xiàng)目總體分為前端和后端兩個部分,前端主要負(fù)責(zé)管理主題列表、編輯主題和預(yù)覽主題,后端主要負(fù)責(zé)返回變量列表和編譯主題。

后端返回主題可修改的變量信息,前端生成對應(yīng)的控件,用戶可進(jìn)行修改,修改后立即將修改的變量和修改后的值發(fā)送給后端,后端進(jìn)行合并編譯,生成css返回給前端,前端動態(tài)替換style標(biāo)簽的內(nèi)容達(dá)到實(shí)時預(yù)覽的效果。

主題列表頁面

主題列表頁面的主要功能是顯示官方主題列表和顯示自定義主題列表。

官方主題可進(jìn)行的操作有預(yù)覽和復(fù)制,不能修改,修改的話會自動生成新主題。自定義主題可以編輯和下載,及進(jìn)行修改名稱、復(fù)制、刪除操作。

官方主題列表后端返回,數(shù)據(jù)結(jié)構(gòu)如下:

{
    name: '官方主題-1', // 主題名稱
    by: 'by hui', // 來源
    description: '默認(rèn)主題', // 描述
    theme: {
        // 主題改動點(diǎn)列表
        common: {
            '$--color-brand': '#e72528'
        }
    }
}

自定義主題保存在localstorage里,數(shù)據(jù)結(jié)構(gòu)如下:

{
    name: name, // 主題名稱
    update: Date.now(), // 最后一次修改時間
    theme: { // 主題改動點(diǎn)列表
        common: {
            //...
        }
    }
}

復(fù)制主題即把要復(fù)制的主題的theme.common數(shù)據(jù)復(fù)制到新主題上即可。

需要注意的就是新建主題時要判斷主題名稱是否重復(fù),因?yàn)閿?shù)據(jù)結(jié)構(gòu)里并沒有類似id的字段。另外還有一個小問題是當(dāng)預(yù)覽官方主題時修改的話會自動生成新主題,所以還需要自動生成可用的主題名,實(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)檢測主題名稱是否重復(fù)
  while (exist()) {
    index++
    name = USER_THEME_NAME_PREFIX + index
  }
  return name
}

界面效果如下:

因?yàn)樯婕暗綆讉€頁面及不同組件間的互相通信,所以vuex是必須要使用的,vuex的state要存儲的內(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)前正在編輯的名字,主題編輯時依靠這個值來修改對應(yīng)主題的數(shù)據(jù),這個值也會在localstorage里存一份。

editingActionType是代表當(dāng)前正在編輯中的變量所屬組件類型,主要作用是在切換要修改的組件類型后預(yù)覽列表滾動到對應(yīng)的組件位置及用來渲染對應(yīng)主題變量對應(yīng)的編輯控件,如下:

頁面在vue實(shí)例化前先獲取官方主題、自定義主題、最后一次編輯的主題名稱,設(shè)置到vuex的store里。

編輯預(yù)覽頁面

編輯預(yù)覽頁面主要分兩部分,左側(cè)是組件列表,右側(cè)是編輯區(qū)域,界面效果如下:

組件預(yù)覽區(qū)域

組件預(yù)覽區(qū)域很簡單,無腦羅列出所有組件庫里的組件,就像這樣:

<div class="list">
    <Color></Color>
    <Button></Button>
    <Radio></Radio>
    <Checkbox></Checkbox>
    <Inputer></Inputer>
    <Autocomplete></Autocomplete>
    <InputNumber></InputNumber>
    //...
</div>

同時需要監(jiān)聽一下editingActionType值的變化來滾動到對應(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ù)此字段渲染對應(yīng)類型的控件
        "key": "$--color-brand",// sass變量名
        "value": "#e72528",// sass變量對應(yīng)的值,可以是具體的值,也可以是sass變量名
        "category": "Brand Color"http:// 列表,用來分組進(jìn)行顯示
    }]
}

此列表是后端返回的,選擇器的選項(xiàng)是遍歷該列表取出所有的name字段的值而組成的。

因?yàn)橛行┳兞康闹凳且蕾嚵硪粋€變量的,所依賴的變量也有可能還依賴另一個變量,所以需要對數(shù)據(jù)進(jìn)行處理,替換成變量最終的值,實(shí)現(xiàn)方式就是循環(huán)遍歷數(shù)據(jù),這就要求所有被依賴的變量也存在于這個列表中,否則就找不到了,只能顯示變量名,所以這個實(shí)現(xiàn)方式其實(shí)是有待商榷的,因?yàn)橛行┍灰蕾嚨淖兞克赡懿⒉恍枰虿荒芸删庉?,本?xiàng)目目前版本是存在此問題的。

此外還需要和當(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í)行效率加上該過濾條件
        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方法就是遍歷合并對應(yīng)變量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++) {
        // 如果不在替換類型范圍內(nèi)的和值不是變量的話就跳過
      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方法通過遞歸進(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)
        }
      }
    }
  }
}

接下來是具體的控件顯示邏輯,根據(jù)當(dāng)前編輯中的類型對應(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是對應(yīng)變量類型里的config數(shù)據(jù),是個計(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是具體的控件顯示組件,某個變量具體是用輸入框還是下拉列表都在這個組件內(nèi)進(jìn)行判斷,核心是使用component動態(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ù)變量類型來顯示對應(yīng)的控件
            return componentMap[this.data.type]
        }
    }
}
</script>

一共有顏色選擇組件、輸入框組件、選擇器組件、陰影編輯組件,具體實(shí)現(xiàn)很簡單就不細(xì)說了,大概就是顯示初始傳入的變量,然后修改后觸發(fā)修改事件change,經(jīng)Control組件傳遞到Editor組件,在Editor組件上進(jìn)行變量修改及發(fā)送編譯請求,不過其中陰影組件的實(shí)現(xiàn)折磨了我半天,主要是如何解析陰影數(shù)據(jù),這里用的是很暴力的一種解析方法,如果有更好的解析方式的話可以留言進(jìn)行分享:

// 解析css陰影數(shù)據(jù)
// 因?yàn)閞gb顏色值內(nèi)也存在逗號,所以就不能簡單的用逗號進(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ù)右括號來進(jìn)行分割成數(shù)組
    let arr = this.value.split(/\)\s*,\s*/gim)
    arr = arr.map(item => {
        // 補(bǔ)上右括號
        if (item.includes('(') && !item.includes(')')) {
            return item + ')'
        } else {// 非rgb顏色值的直接返回
            return item
        }
    })
    let farr = []
    arr.forEach(item => {
        let quene = []
        let hasBrackets = false
        // 逐個字符進(jìn)行遍歷
        for (let i = 0; i < item.length; i++) {
            // 遇到非顏色值內(nèi)的逗號直接拼接目前隊(duì)列里的字符添加到數(shù)組
            if (item[i] === ',' && !hasBrackets) {
                farr.push(quene.join('').trim())
                quene = []
            } else if (item[i] === '(') {//遇到顏色值的左括號修改標(biāo)志位
                hasBrackets = true
                quene.push(item[i])
            } else if (item[i] === ')') {//遇到右括號重置標(biāo)志位
                hasBrackets = false
                quene.push(item[i])
            } else {// 其他字符直接添加到隊(duì)列里
                quene.push(item[i])
            }
        }
        // 添加隊(duì)列剩余的數(shù)據(jù)
        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組件,編輯控件觸發(fā)了修改事件后需要更新變量列表里面對應(yīng)的值及對應(yīng)主題列表里面的值,同時要發(fā)送編譯請求:

// data是變量里config數(shù)組里的一項(xiàng),value就是修改后的值
function valueChange(data, value) {
    // 更新當(dāng)前變量對應(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)槭侵С诸伾敌薷臑槟承┳兞康?,所以要重新進(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
        })
    }
    // 請求編譯
    this.updateVariable()
}

接下來是發(fā)送編譯請求:

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字符串,需要動態(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í)時預(yù)覽的效果,下載主題也是類似,把當(dāng)前編輯的主題的數(shù)據(jù)發(fā)送給后端編譯完后生成壓縮包進(jìn)行下載。

下載:因?yàn)橐l(fā)送主題變量進(jìn)行編譯下載,所以不能使用get方法,但使用post方法進(jì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)跑通,接下來是一些提升體驗(yàn)的功能。

1.重置功能:重置理應(yīng)是重置到某個主題復(fù)制來源的那個主題的,但是其實(shí)必要性也不是特別大,所以就簡單做,直接把當(dāng)前主題的配置變量清空,即theme.common={},同時需要重新請求變量數(shù)據(jù)及請求編譯。

2.前進(jìn)回退功能:前進(jìn)回退功能說白了就是把每一步操作的數(shù)據(jù)都克隆一份并存到一個數(shù)組里,然后設(shè)置一個指針,比如index,指向當(dāng)前所在的位置,前進(jìn)就是index++,后退就是index--,然后取出對應(yīng)數(shù)組里的數(shù)據(jù)替換當(dāng)前的數(shù)據(jù)。對于本項(xiàng)目,需要存兩個東西,一個是主題數(shù)據(jù),一個是變量數(shù)據(jù)??梢酝ㄟ^對象形式存到一個數(shù)組里,也可以向本項(xiàng)目一樣搞兩個數(shù)組。

具體實(shí)現(xiàn):

1.先把初始的主題數(shù)據(jù)拷貝一份扔進(jìn)歷史數(shù)組themeHistoryList里,請求到變量數(shù)據(jù)后扔進(jìn)variableHistoryList數(shù)組里

2.每次修改后把修改后的變量數(shù)據(jù)和主題數(shù)據(jù)都復(fù)制一份扔進(jìn)去,同時指針historyIndex加1

3.根據(jù)前進(jìn)還是回退來設(shè)置historyIndex的值,同時取出對應(yīng)位置的主題和變量數(shù)據(jù)替換當(dāng)前的數(shù)據(jù),然后請求編譯

需要注意的是在重置和返回主題列表頁面時要復(fù)位themeHistoryList、variableHistoryList、historyIndex

3.顏色預(yù)覽組件優(yōu)化

因?yàn)轭伾A(yù)覽組件是需要顯示當(dāng)前顏色和顏色值的,那么就會有一個問題,字體顏色不能寫死,否則如果字體寫死白色,那么如果這個變量的顏色值又修改成白色,那么將一片白色,啥也看不見,所以需要動態(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是一個將各種類型的顏色值都轉(zhuǎn)為十六進(jìn)制顏色的函數(shù)。

4.一些小細(xì)節(jié)

logo、導(dǎo)航、返回按鈕、返回頂部等小控件隨當(dāng)前編輯中的主題色進(jìn)行變色。

到這里前端部分就結(jié)束了,讓我們喝口水繼續(xù)。

后端部分

后端用的是nodejs及eggjs框架,對eggjs不熟悉的話可先閱讀一下文檔:https://eggjs.org/zh-cn/,后端部分比較簡單,先看路由:

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ù)都是一個寫死的json文件。所以核心只有兩部分,編譯scss和下載,先看編譯。

編譯scss

主題在線編輯能實(shí)現(xiàn)靠的就是scss的變量功能,編譯scss可用使用sass包或者node-sass包,前端傳過來的參數(shù)其實(shí)就一個json類型的對象,key是變量,value是值,但是這兩個包都不支持傳入額外的變量數(shù)據(jù)和本地的scss文件進(jìn)行合并編譯,但是提供了一個配置項(xiàng):importer,可以傳入函數(shù)數(shù)組,它會在編譯過程中遇到 @use or @import語法時執(zhí)行這個函數(shù),入?yún)閡rl,可以返回一個對象:

{
    contents: `
    h1 {
    font-size: 40px;
    }
    `
}

contents的內(nèi)容即會替代原本要引入的對應(yīng)scss文件的內(nèi)容,詳情請看:https://sass-lang.com/documentation/js-api#importer

但是實(shí)際使用過程中,不知為何sass包的這個配置項(xiàng)是無效的,所以只能使用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

因?yàn)橹黝}的變量定義一般都在統(tǒng)一的一個或幾個文件內(nèi),像hui,是定義在var-common.scss和var.scss兩個文件內(nèi),所以可以讀取這兩個文件的內(nèi)容然后將其中對應(yīng)變量的值替換為前端傳過來的變量,替換完成后通過importer函數(shù)返回進(jìn)行編譯,具體替換方式也有多種,我同事的方法是自己寫了個scss解析器,解析成對象,然后遍歷對象解析替換,而我,比較草率,直接用正則匹配解析修改,實(shí)現(xiàn)如下:

function(data) {
    // 前端傳遞過來的數(shù)據(jù)
    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')
    // 讀取兩個文件的內(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`)
    })
    // 修改路徑為絕對路徑,否則會報錯
    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()
}

下載主題

下載的主題包里有兩個數(shù)據(jù),一個是配置源文件,另一個就是編譯后的主題包,包括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)目目前只是一個粗糙的實(shí)現(xiàn),旨在提供一個實(shí)現(xiàn)思路,還有很多細(xì)節(jié)需要優(yōu)化,比如之前提到的變量依賴問題,還有scss的解析合并方式,此外還有多語言、多版本的問題需要考慮。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容