如何实现一个vue组件库的在线主题编辑器

2,105 阅读4分钟

前言

一般而言一个组件库都会设计一套相对来说符合大众审美或产品需求的主题,但是主题定制需求永远都存在,所以组件库一般都会允许使用者自定义主题,我司的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, 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)"
    // 根据右括号来进行分割成数组
    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.先把初始的主题数据拷贝一份扔进历史数组themeHistoryList里,请求到变量数据后扔进variableHistoryList数组里

2.每次修改后把修改后的变量数据和主题数据都复制一份扔进去,同时指针historyIndex加1

3.根据前进还是回退来设置historyIndex的值,同时取出对应位置的主题和变量数据替换当前的数据,然后请求编译

需要注意的是在重置和返回主题列表页面时要复位themeHistoryList、variableHistoryList、historyIndex

3.颜色预览组件优化

因为颜色预览组件是需要显示当前颜色和颜色值的,那么就会有一个问题,字体颜色不能写死,否则如果字体写死白色,那么如果这个变量的颜色值又修改成白色,那么将一片白色,啥也看不见,所以需要动态判断是用黑色还是白色,有兴趣详细了解判断算法可阅读:segmentfault.com/a/119000001…

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是一个将各种类型的颜色值都转为十六进制颜色的函数。

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的解析合并方式,此外还有多语言、多版本的问题需要考虑。