本地篇
需求说明
在开发小程序的时候,尤其是开发第三方小程序,我们作为开发者,只需要开发一套模板,客户的小程序对我们进行授权管理,我们需要将这套模板应用到对方的小程序上,然后由我们进行发版审核即可;
但是个别客户的小程序需要做 定制化配色方案
,也就是说,不同的小程序个体需要对页面的元素(比如:按钮,字体等)进行不同的配色设置,接下来我们来讨论一下怎么实现它。
方案和问题
一般来说,有两种解决方案可以解决小程序动态换肤的需求:
-
小程序内置几种主题样式,通过更换类名来实现动态改变小程序页面的元素色值;
-
后端接口返回色值字段,前端通过
内联
方式对页面元素进行色值设置。
当然了,每种方案都有一些问题,问题如下:
-
方案1较为死板,每次更改主题样式都需要发版小程序,如果主题样式变动不大,可以考虑这种;
-
方案2对于前端的改动很大,
内联
也就是通过style
的方式内嵌到wxml
代码中,代码的阅读性会变差,但是可以解决主题样式变动不用发版小程序的问题。
前期准备
本文采用的是 gulp
+ stylus
引入预编译语言来处理样式文件,大家需要全局安装一下 gulp
,然后安装两个 gulp
的插件
gulp-stylus
(stylus文件转化为css文件)gulp-rename
(css文件重命名为wxss文件)。
gulp
这里简单贴一下gulpfile文件的配置,比较简单,其实就是借助 gulp-stylus
插件将 .styl
结尾的文件转化为 .css
文件,然后引入 gulp-rename
插件对文件重命名为 .wxss
文件;
再创建一个任务对 .styl
监听修改,配置文件如下所示:
var gulp = require('gulp');
var stylus = require('gulp-stylus');
var rename = require('gulp-rename');
function stylusTask() {
return gulp.src('./styl/*.styl')
.pipe(stylus())
.pipe(rename(function(path) {
path.extname = '.wxss'
}))
.pipe(gulp.dest('./wxss'))
}
function autosTask() {
gulp.watch('./styl/*.styl', stylusTask)
}
exports.default = gulp.series(gulp.parallel(stylusTask, autosTask))
stylus
这里会分为两个文件,一个是主题样式变量定义文件,一个是页面皮肤样式文件,依次如下所示:
- 主题样式变量设置
// define.styl
// theme1
theme1-main = rgb(254, 71, 60)
theme1-sub = rgb(255, 184, 0)
// theme2
theme2-main = rgb(255, 158, 0)
theme2-sub = rgb(69, 69, 69)
// theme3
theme3-main = rgb(215, 183, 130)
theme3-sub = rgb(207, 197, 174)
- 页面皮肤样式
@import './define.styl'
// 拼接主色值
joinMainName(num)
theme + num + -main
// 拼接辅色值
joinSubName(num)
theme + num + -sub
// 遍历输出改变色值的元素类名
for num in (1..3)
.theme{num}
.font-vi
color joinMainName(num)
.main-btn
background joinMainName(num)
.sub-btn
background joinSubName(num)
输出:
.theme1 .font-vi {
color: #fe473c;
}
.theme1 .main-btn {
background: #fe473c;
}
.theme1 .sub-btn {
background: #ffb800;
}
.theme2 .font-vi {
color: #ff9e00;
}
.theme2 .main-btn {
background: #ff9e00;
}
.theme2 .sub-btn {
background: #454545;
}
.theme3 .font-vi {
color: #d7b782;
}
.theme3 .main-btn {
background: #d7b782;
}
.theme3 .sub-btn {
background: #cfc5ae;
}
代码我写上了注释,我还是简单说明一下上面的代码:我首先定义一个主题文件 define.styl
用来存储色值变量,然后会再定义一个皮肤文件 vi.styl
,这里其实就是不同 主题类名
下需要改变色值的元素的属性定义,元素的色值需要用到 define.styl
预先定义好的变量,是不是很简单,哈哈哈。
具体使用
但是在具体页面中需要怎么使用呢,接下来我们来讲解一下
- 页面的
wxss
文件导入编译后的vi.wxss
文件
@import '/wxss/vi.wxss';
- 页面的
wxml
文件需要编写需要改变色值的元素,并且引入变量theme
<view class="intro {{ theme }}">
<view class="font mb10">正常字体</view>
<view class="font font-vi mb10">vi色字体</view>
<view class="btn main-btn mb10">主色按钮</view>
<view class="btn sub-btn">辅色按钮</view>
</view>
- 页面
js
文件动态改变theme
变量值
data: {
theme: ''
},
handleChange(e) {
const { theme } = e.target.dataset
this.setData({ theme })
}
效果预览
接口篇
需求说明
但是产品经理觉得每次改主题配置文件,都要发版,觉得太麻烦了,于是发话了:我想在管理后台有一个界面,可以让运营自行设置颜色,然后小程序这边根据运营在后台设置的色值来实现动态换肤,你们来帮我实现一下。
方案和问题
首先我们知道小程序是不能动态引入 wxss
文件的,这时候的色值字段是需要从后端接口获取之后,然后通过 style
内联的方式动态写入到需要改变色值的页面元素的标签上;
工作量之大,可想而知,因此,我们需要思考下面几个问题,然后尽可能写出可维护性,可扩展性的代码来:
-
页面元素组件化 —— 像
按钮
标签
选项卡
价格字体
模态窗
等组件抽离出来,认真考虑需要换肤的页面元素,避免二次编写; -
避免内联样式直接编写,提高代码可阅读性 —— 内联编写样式会导致大量的
wxml
和wxss
代码耦合一起,可考虑采用wxs
编写模板字符串,动态引入,减少耦合; -
避免色值字段频繁赋值 —— 页面或者组件引入
behaviors
混入色值字段,减少色值赋值代码编写;
实现
接下来具体来详细详解一下我的思路和如何实现这一过程:
- model层: 接口会返回色值配置信息,我创建了一个
model
来存储这些信息,于是,我用单例的方式创建一个全局唯一的model
对象 ——ViModel
// viModel.js
/**
* 主题对象:是一个单例
* @param {*} mainColor 主色值
* @param {*} subColor 辅色值
*/
function ViModel(mainColor, subColor) {
if (typeof ViModel.instance == 'object') {
return ViModel.instance
}
this.mainColor = mainColor
this.subColor = subColor
ViModel.instance = this
return this
}
module.exports = {
save: function(mainColor = '', subColor = '') {
return new ViModel(mainColor, subColor)
},
get: function() {
return new ViModel()
}
}
- service层: 这是接口层,封装了读取主题样式的接口,比较简单,用
setTimeout
模拟了请求接口访问的延时,默认设置了500
ms,如果大家想要更清楚的观察 observer 监听器 的处理,可以将值调大若干倍
// service.js
const getSkinSettings = () => {
return new Promise((resolve, reject) => {
// 模拟后端接口访问,暂时用500ms作为延时处理请求
setTimeout(() => {
const resData = {
code: 200,
data: {
mainColor: '#ff9e00',
subColor: '#454545'
}
}
// 判断状态码是否为200
if (resData.code == 200) {
resolve(resData)
} else {
reject({ code: resData.code, message: '网络出错了' })
}
}, 500)
})
}
module.exports = {
getSkinSettings,
}
- view层: 视图层,这只是一个内联css属性转化字符串的过程,我美其名曰视图层,正如我开篇所说的,
内联
样式的编写会导致大量的wxml
和wxss
代码冗余在一起,如果换肤的元素涉及到的css
属性改动过多,再加上一堆的js
的逻辑代码,后期维护代码必定是灾难性的,根本无法下手,大家可以看下我优化后的处理方式:
// vi.wxs
/**
* css属性模板字符串构造
*
* color => color属性字符串赋值构造
* background => background属性字符串赋值构造
*/
var STYLE_TEMPLATE = {
color: function(val) {
return 'color: ' + val + '!important;'
},
background: function(val) {
return 'background: ' + val + '!important;'
}
}
module.exports = {
/**
* 模板字符串方法
*
* @param theme 主题样式对象
* @param key 需要构建内联css属性
* @param second 是否需要用到辅色
*/
s: function(theme, key, second = false) {
theme = theme || {}
if (typeof theme === 'object') {
var color = second ? theme.subColor : theme.mainColor
return STYLE_TEMPLATE[key](color)
}
}
}
注意:wxs文件的编写不能出现es6以后的语法,只能用es5及以下的语法进行编写
- mixin: 上面解决完
wxml
和wxss
代码混合的问题之后,接下来就是js
的冗余问题了;我们获取到接口的色值信息之后,还需要将其赋值到Page
或者Component
对象中去,也就是this.setData({....})
的方式, 才能使得页面重新render
,进行换肤;
微信小程序原生提供一种Behavior
的属性,使我们避免反复setData
操作,十分方便:
// viBehaviors.js
const observer = require('./observer');
const viModel = require('./viModel');
module.exports = Behavior({
data: {
vi: null
},
attached() {
// 1. 如果接口响应过长,创建监听,回调函数中读取结果进行换肤
observer.addNotice('kNoticeVi', function(res) {
this.setData({ vi: res })
}.bind(this))
// 2. 如果接口响应较快,modal有值,直接赋值,进行换肤
var modal = viModel.get()
if (modal.mainColor || modal.subColor) {
this.setData({ vi: modal })
}
},
detached() {
observer.removeNotice('kNoticeVi')
}
})
到这里为止,基本的功能性代码就已经完成了,接下来我们来看一下具体的使用方法吧
具体使用
- 小程序启动,我们就需要去请求色值配置接口,获取主题样式,如果是需要从后台返回前台的时候也要考虑主题变动,可以在
onShow
方法处理
// app.js
const { getSkinSettings } = require('./js/service');
const observer = require('./js/observer');
const viModel = require('./js/viModel');
App({
onLaunch: function () {
// 页面启动,请求接口
getSkinSettings().then(res => {
// 获取色值,保存到modal对象中
const { mainColor, subColor } = res.data
viModel.save(mainColor, subColor)
// 发送通知,变更色值
observer.postNotice('kNoticeVi', res.data)
}).catch(err => {
console.log(err)
})
}
})
- 混入主题样式字段
Page
页面混入
// interface.js
const viBehaviors = require('../../js/viBehaviors');
Page({
behaviors: [viBehaviors],
onLoad() {}
})
Component
组件混入
// wxButton.js
const viBehaviors = require('../../js/viBehaviors');
Component({
behaviors: [viBehaviors],
properties: {
// 按钮文本
btnText: {
type: String,
value: ''
},
// 是否为辅助按钮,更换辅色皮肤
secondary: {
type: Boolean,
value: false
}
}
})
- 内联样式动态换肤
Page
页面动态换肤
<view class="intro">
<view class="font mb10">正常字体</view>
<view class="font font-vi mb10" style="{{_.s(vi, 'color')}}">vi色字体</view>
<view class="btn main-btn mb10" style="{{_.s(vi, 'background')}}">主色按钮</view>
<view class="btn sub-btn" style="{{_.s(vi, 'background', true)}}">辅色按钮</view>
<!-- 按钮组件 -->
<wxButton class="mb10" btnText="组件按钮(主色)" />
<wxButton class="mb10" btnText="组件按钮(辅色)" secondary />
</view>
<!-- 引入模板函数 -->
<wxs module="_" src="../../wxs/vi.wxs"></wxs>
Component
组件动态换肤
<view class="btn" style="{{_.s(vi, 'background', secondary)}}">{{ btnText }}</view>
<!-- 模板函数 -->
<wxs module="_" src="../../wxs/vi.wxs" />
再来对比一下传统的内联方式处理换肤功能的实现:
<view style="color: {{ mainColor }}; background: {{ background }}">vi色字体</view>
如果后期再加入复杂的逻辑代码,开发人员后期再去阅读代码简直就是要抓狂的;
当然了,这篇文章的方案只是一定程度上简化了内联代码的编写,原理还是内联样式的注入;
我目前有一个想法,想通过某种手段在获取接口主题样式字段之后,借助 stylus
等预编译语言的变量机制,动态修改其变量,改变主题样式,方为上策;
效果预览
- 接口响应较快 ——
ViModel
取值换肤
- 接口响应过慢 ——
observer
监听器回调取值换肤
终极篇
回顾
早些日子,我写过两篇文章介绍过在微信小程序内,如何实现换肤功能,下面贴出链接,没看过的同学可以先看看
但是上面两种方案都有不足之处,所以我在文末也备注了会出 终极篇解决方案,拖延了一些时间,今天看到评论区有人cue我说什么时候出终极篇,于是,今天花了写时间整理了一下,希望可以帮助到大家。
方案
其实这篇文章提供的解决方案,更多是 接口篇
的优化版本。
解决思路就是:
将接口获取到的皮肤色值属性,动态设置到需要换肤的元素的某个属性上,本质上就是替换元素的css属性的属性值,方法就是通过给当前Page
和Component
对象的js文件
嵌入提前设置好的css变量
中,然后通过setData
的方法回显到对应的wxml文件
中。
-
采用 css变量 的方式替代原有 内联修改样式 的方式;
-
采用小程序原生提供的mixin解决方案 ——
Behavior
,对页面还有组件对象来说,虽有一定的侵害性,但是可以极大程度的降低重复代码的编写;
代码
1. 监听器模块
我们知道,接口返回的数据是异步的,所以,当我们进入到指定的 Page
和Component
对象内部的时候,有可能还没得到数据,就需要先注册一个监听函数,等到皮肤接口请求成功之后,然后再执行皮肤设值操作;
// observer.js
function Observer() {
this.actions = new Map()
}
// 监听事件
Observer.prototype.addNotice = function(key, action) {
// 因为同个Page(页面)或者Component(组件)对象有可能引入多个组件
// 这些组件都用到了同一个监听器,每个监听器的回调函数需要单独处理
// 因此,结果就是: key => [handler1, hander2, hander3....]
if (this.actions.has(key)) {
const handlers = this.actions.get(key)
this.actions.set(key, [...handlers, action])
} else {
this.actions.set(key, [action])
}
}
// 删除监听事件
Observer.prototype.removeNotice = function(key) {
this.actions.delete(key)
}
// 发送事件
Observer.prototype.postNotice = function(key, params) {
if (this.actions.has(key)) {
const handlers = this.actions.get(key)
// 皮肤接口获取数据成功,取出监听器处理函数,依次执行
handlers.forEach(handler => handler(params))
}
}
module.exports = new Observer()
2. 皮肤对象模型模块
因为皮肤接口只会在程序首次加载运行的时候执行,换言之,通过 发布-订阅
的方式来设置皮肤只会发生在第一次接口请求成功之后
,后期都不会再执行;因此,我们需要通过一个Model模型对象将数据存储起来,后面的皮肤设值操作都从该model对象中获取;
// viModel.js
/**
* @param {*} mainColor 主色值
* @param {*} subColor 辅色值
* @param {*} reset 重置
*/
function ViModel(mainColor, subColor, reset = false) {
// 如果当前实例已经设置过,直接返回该实例
if (typeof ViModel.instance == 'object' && !reset) {
return ViModel.instance
}
this.mainColor = mainColor
this.subColor = subColor
// 实例赋值动作触发在接口有数据返回的时候
if (this.mainColor || this.subColor) {
ViModel.instance = this
}
return this
}
module.exports = {
// 通过save方法来赋值要通过reset = true来重置对象
save: function(mainColor = '', subColor = '') {
return new ViModel(mainColor, subColor, true)
},
// 直接返回的都是已经有值的单例实例
get: function() {
return new ViModel()
}
}
3. 小程序Mixin模块 —— Behavior
这个就是这次分享的最为重要的模块 —— 注入 themeStyle 的css变量
我们直接来看这段代码:
setThemeStyle({ mainColor, subColor }) {
this.setData({
themeStyle: `
--main-color: ${mainColor};
--sub-color: ${subColor};
`
})
}
想必看到这里,大家应该猜到开篇说的实现原理了
这里的 themeStyle
就是我们接下来要注入到 Page
和 Component
的 data 属性,也就是需要在页面和组件中设置的动态css变量属性
//skinBehavior.js
const observer = require('./observer');
const viModel = require('./viModel');
module.exports = Behavior({
data: {
themeStyle: null
},
attached() {
// 1. 如果接口响应过长,创建监听,回调函数中读取结果进行换肤
observer.addNotice('kNoticeVi', function(res) {
this.setThemeStyle(res)
}.bind(this))
// 2. 如果接口响应较快,modal有值,直接赋值,进行换肤
const themeData = viModel.get()
if (themeData.mainColor || themeData.subColor) {
this.setThemeStyle(themeData)
}
},
detached() {
observer.removeNotice('kNoticeVi')
},
methods: {
setThemeStyle({ mainColor, subColor }) {
this.setData({
themeStyle: `
--main-color: ${mainColor};
--sub-color: ${subColor};
`
})
},
},
})
4. 【应用】—— Component模块
-
js 文件引入
skinBehavior.js
,通过Component对象
提供的behaviors
属性注入进去; -
wxml 文件根节点设置
style="{{themeStyle}}"
,设置css变量值; -
wxss 文件通过css变量设置皮肤色值
background: var(--main-color, #0366d6);
// wxButton2.js
const skinBehavior = require('../../js/skinBehavior');
Component({
behaviors: [skinBehavior],
properties: {
// 按钮文本
btnText: {
type: String,
value: ''
},
// 是否为辅助按钮,更换辅色皮肤
secondary: {
type: Boolean,
value: false
}
}
})
<!-- wxButton2.wxml -->
<view class="btn-default btn {{secondary ? 'btn-secondary' : ''}}" style="{{themeStyle}}">{{ btnText }}</view>
/* wxButton2.wxss */
.btn {
width: 200px;
height: 44px;
line-height: 44px;
text-align: center;
color: #fff;
}
.btn.btn-default {
background: var(--main-color, #0366d6);
}
.btn.btn-secondary {
background: var(--sub-color, #0366d6);
}
5. 【应用】 —— Page模块
使用方法跟Component模块一样,就不写了,下面贴一下代码:
// skin.js
const skinBehavior = require('../../js/skinBehavior');
Page({
behaviors: [skinBehavior],
onLoad() {
console.log(this.data)
}
})
<!--skin.wxml-->
<view class="page" style="{{themeStyle}}">
换肤终极篇
<view class="body">
<wxButton2 class="skinBtn" btnText="按钮1"></wxButton2>
<wxButton2 class="skinBtn"btnText="按钮2" secondary></wxButton2>
<wxButton2 class="skinBtn" btnText="按钮2" ></wxButton2>
</view>
</view>
/* skin.wxss */
.page {
padding: 20px;
color: var(--main-color);
}
.skinBtn {
margin-top: 10px;
float: left;
}
6. 【初始化】—— 接口调用
这里就是在小程序的启动文件 app.js 调用皮肤请求接口,初始化皮肤
// app.js
const { getSkinSettings } = require('./js/service');
App({
onLaunch: function () {
// 页面启动,请求接口
getSkinSettings().catch(err => {
console.log(err)
})
}
})
效果展示
总结
目前来看,【终极篇】无疑是小程序动态换肤的最佳解决方案,但是我也希望能给大家娓娓道来,一个功能的开发是跟业务需求有强依赖关系的,也就是说,我们应该根据业务来选择合适的技术方案,在满足业务方的需求之余,可以就目前功能可扩展性给业务方提供更多更好的优化思路和方向,这也是为了给产品的持续性迭代提供了可靠性。
项目地址
这是本文案例的项目地址,为了方便大家浏览项目,我把编译后的wxss文件也一并上传了,大家打开就能预览,码字不易,大家如果觉得好,希望大家都去点下star哈,谢谢大家。。。