微信小程序动态换肤解决方案

3,357 阅读14分钟

本地篇

需求说明

在开发小程序的时候,尤其是开发第三方小程序,我们作为开发者,只需要开发一套模板,客户的小程序对我们进行授权管理,我们需要将这套模板应用到对方的小程序上,然后由我们进行发版审核即可;

但是个别客户的小程序需要做 定制化配色方案,也就是说,不同的小程序个体需要对页面的元素(比如:按钮,字体等)进行不同的配色设置,接下来我们来讨论一下怎么实现它。

方案和问题

一般来说,有两种解决方案可以解决小程序动态换肤的需求:

  1. 小程序内置几种主题样式,通过更换类名来实现动态改变小程序页面的元素色值;

  2. 后端接口返回色值字段,前端通过 内联 方式对页面元素进行色值设置。

当然了,每种方案都有一些问题,问题如下:

  • 方案1较为死板,每次更改主题样式都需要发版小程序,如果主题样式变动不大,可以考虑这种;

  • 方案2对于前端的改动很大,内联 也就是通过 style 的方式内嵌到wxml 代码中,代码的阅读性会变差,但是可以解决主题样式变动不用发版小程序的问题。

前期准备

本文采用的是 gulp + stylus 引入预编译语言来处理样式文件,大家需要全局安装一下 gulp,然后安装两个 gulp 的插件

  1. gulp-stylus(stylus文件转化为css文件)
  2. 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

这里会分为两个文件,一个是主题样式变量定义文件,一个是页面皮肤样式文件,依次如下所示:

  1. 主题样式变量设置
// 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)
  1. 页面皮肤样式
@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 预先定义好的变量,是不是很简单,哈哈哈。

具体使用

但是在具体页面中需要怎么使用呢,接下来我们来讲解一下

  1. 页面的 wxss 文件导入编译后的 vi.wxss文件
@import '/wxss/vi.wxss';
  1. 页面的 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>
  1. 页面 js 文件动态改变 theme变量值
  data: {
    theme: ''
  },

  handleChange(e) {
    const { theme } = e.target.dataset
    this.setData({ theme })
  }

效果预览

接口篇

需求说明

但是产品经理觉得每次改主题配置文件,都要发版,觉得太麻烦了,于是发话了:我想在管理后台有一个界面,可以让运营自行设置颜色,然后小程序这边根据运营在后台设置的色值来实现动态换肤,你们来帮我实现一下。

方案和问题

首先我们知道小程序是不能动态引入 wxss 文件的,这时候的色值字段是需要从后端接口获取之后,然后通过 style 内联的方式动态写入到需要改变色值的页面元素的标签上; 工作量之大,可想而知,因此,我们需要思考下面几个问题,然后尽可能写出可维护性,可扩展性的代码来:

  1. 页面元素组件化 —— 像按钮 标签 选项卡 价格字体 模态窗等组件抽离出来,认真考虑需要换肤的页面元素,避免二次编写;

  2. 避免内联样式直接编写,提高代码可阅读性 —— 内联编写样式会导致大量的 wxmlwxss 代码耦合一起,可考虑采用 wxs 编写模板字符串,动态引入,减少耦合;

  3. 避免色值字段频繁赋值 —— 页面或者组件引入 behaviors 混入色值字段,减少色值赋值代码编写;

实现

接下来具体来详细详解一下我的思路和如何实现这一过程:

  1. 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()
  }
}
  1. 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,
}
  1. view层: 视图层,这只是一个内联css属性转化字符串的过程,我美其名曰视图层,正如我开篇所说的,内联 样式的编写会导致大量的 wxmlwxss代码冗余在一起,如果换肤的元素涉及到的 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及以下的语法进行编写

  1. mixin: 上面解决完 wxmlwxss 代码混合的问题之后,接下来就是 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')
  }
})

到这里为止,基本的功能性代码就已经完成了,接下来我们来看一下具体的使用方法吧

具体使用

  1. 小程序启动,我们就需要去请求色值配置接口,获取主题样式,如果是需要从后台返回前台的时候也要考虑主题变动,可以在 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)
    })
  }
})
  1. 混入主题样式字段
  • 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
    }
  }
})
  1. 内联样式动态换肤
  • 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 等预编译语言的变量机制,动态修改其变量,改变主题样式,方为上策;

效果预览

  1. 接口响应较快 —— ViModel 取值换肤

  1. 接口响应过慢 —— observer 监听器回调取值换肤

终极篇

回顾

早些日子,我写过两篇文章介绍过在微信小程序内,如何实现换肤功能,下面贴出链接,没看过的同学可以先看看

  1. 小程序动态换肤解决方案 -- 本地篇

  2. 小程序动态换肤解决方案 -- 接口篇

但是上面两种方案都有不足之处,所以我在文末也备注了会出 终极篇解决方案,拖延了一些时间,今天看到评论区有人cue我说什么时候出终极篇,于是,今天花了写时间整理了一下,希望可以帮助到大家。

方案

其实这篇文章提供的解决方案,更多是 接口篇的优化版本。

解决思路就是:

将接口获取到的皮肤色值属性,动态设置到需要换肤的元素的某个属性上,本质上就是替换元素的css属性的属性值,方法就是通过给当前PageComponent对象的js文件嵌入提前设置好的css变量中,然后通过setData的方法回显到对应的wxml文件中。

  1. 采用 css变量 的方式替代原有 内联修改样式 的方式;

  2. 采用小程序原生提供的mixin解决方案 —— Behavior,对页面还有组件对象来说,虽有一定的侵害性,但是可以极大程度的降低重复代码的编写;

代码

1. 监听器模块

我们知道,接口返回的数据是异步的,所以,当我们进入到指定的 PageComponent 对象内部的时候,有可能还没得到数据,就需要先注册一个监听函数,等到皮肤接口请求成功之后,然后再执行皮肤设值操作;

// 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 就是我们接下来要注入到 PageComponent 的 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)
    })
  }
})

效果展示

总结

目前来看,【终极篇】无疑是小程序动态换肤的最佳解决方案,但是我也希望能给大家娓娓道来,一个功能的开发是跟业务需求有强依赖关系的,也就是说,我们应该根据业务来选择合适的技术方案,在满足业务方的需求之余,可以就目前功能可扩展性给业务方提供更多更好的优化思路和方向,这也是为了给产品的持续性迭代提供了可靠性。

项目地址

项目地址:github.com/csonchen/wx…

这是本文案例的项目地址,为了方便大家浏览项目,我把编译后的wxss文件也一并上传了,大家打开就能预览,码字不易,大家如果觉得好,希望大家都去点下star哈,谢谢大家。。。