前言
一般而言一个组件库都会设计一套相对来说合乎公众审美或产品需要的主题,然而主题定制需要永远都存在,所以组件库个别都会容许使用者自定义主题,我司的 vue 组件库 hui 的定制主题简略来说是通过批改预约义的 scss 变量的值来做到的,新体系下还做到了动静换肤,因为皮肤实质上是一种动态资源(CSS 文件和字体文件),所以只须要约定一种形式来每次动静申请加载不同的文件就能够了,为了不便这一需要,还配套开发了一个 Vessel 脚手架的插件,只须要以配置文件的形式列出你须要批改的变量和值,一个命令就能够帮你生成对应的皮肤。
然而目前的换肤还存在几个问题,一是不直观,无奈不便实时的看到批改后的组件成果,二是倡议批改的变量比拟少,这很大起因也是因为问题一,因为不直观所以自觉批改后的成果可能达不到预期。
针对这几个问题,所以实现一个在线主题编辑器是一个有意义的事件,目前最风行的组件库之一的 Element 就反对主题在线编辑,地址:https://element.eleme.cn/#/zh-CN/theme,本我的项目是在参考了 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. 色彩预览组件优化
因为色彩预览组件是须要显示以后色彩和色彩值的,那么就会有一个问题,字体色彩不能写死,否则如果字体写死红色,那么如果这个变量的色彩值又批改成红色,那么将一片红色,啥也看不见,所以须要动静判断是用彩色还是红色,有趣味具体理解判断算法可浏览:https://segmentfault.com/a/11…。
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 不相熟的话可先浏览一下文档:https://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 文件的内容,详情请看:https://sass-lang.com/documentation/js-api#importer
然而理论应用过程中,不知为何 sass 包的这个配置项是有效的,所以只能应用 node-sass,这两个包的 api 根本是一样的,然而 node-sass 装置起来比拟麻烦,尤其是 windows 上,装置办法大抵有两种:
1.
npm install -g node-gyp
npm install --global --production windows-build-tools
npm install node-sass --save-dev
2.
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,可参考:https://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 的解析合并形式,此外还有多语言、多版本的问题须要思考。