微信小程序实践

December 23, 2022

仅针对原生的微信小程序开发

代码如何模块化

随着业务的发展,如果把众多代码都挤在Page里,代码日益膨胀。而且当页面如果出现共有逻辑,依靠复制粘贴的方式十分不优雅, 因此需要模块化去管理。

mixins机制

借鉴Vue中mixins的机制,把可复用的代码混入当前的代码里。微信小程序里每个页面都有Page(options)函数, mixins的作用就可以是把多个option一起合并, option1,option2,option3……合并进Page函数里。

import account from './account';

Page(
  mixins({
    data: {},
    onLoad() {},
    onShow() {}
  },
  account
  )
)

代码实现

基本上就是合并对象的思路,注意函数的执行

// mixins.js
const events = [
  // 小程序支持事件
  'onLoad',
  'onShow',
  'onReady',
  'onHide',
  'onUnload',
  'onPullDownRefresh',
  'onReachBottom'
];

export default function(dest, ...targets) {
  if (!dest) {
    return dest;
  }
  let _dest = Object.assign({}, dest);
  for (let target of targets) { // 多个模块进行mixins
    mixins(_dest, target);
  }


  return _dest;
}
// 把target合并到dest 
function mixins(dest, target) {
  if (!target || !dest) {
    return dest;
  }

  for (let key in target) {
    const destValue = dest[key];
    const tValue = target[key];

    switch (getType(tValue)) {
      case 'object':
        dest[key] = mixins(destValue || {}, tValue);
        break;

      case 'array':
        dest[key] = (destValue || []).concat(Array.isArray(tValue) ? tValue : []);
        break;

      case 'function':
        if (events.includes(key)) { // events注册了小程序的生命周期还有一些自定义事件
          dest[key] = function() {
            if (getType(destValue) === 'function') { // 如果dest也定义了该方法,优先执行
              destValue.apply(this, arguments);
            }

            // target模块的方法执行
            tValue.apply(this, arguments);
          };
        } else if (tValue) { // 非小程序生命周期等通过显式调用触发的,直接赋值
          dest[key] = tValue;
        }
        break;

      default:
        dest[key] = tValue;
        break;
    }
  }

  return dest;
}

组件化

封装一个组件的UI与逻辑,在日常的开发中已经是非常熟悉的事情,在这里列举一下微信小程序特别的用法。

behaviors

behaviors 是用于组件间代码共享的特性,包含一组属性、数据、生命周期函数和方法。组件引用它时,它的属性、数据和方法会被合并到组件中,生命周期函数也会在对应时机被调用。 最常用的behavior就是微信提供的wx://component-export,使自定义组件支持export字段,这个字段可以用于组件被selectComponent调用时返回的值。

这个一个toast的自定义组件,组件内定义visible和toastTips两个数据,export暴露showToast方法

// toast.js
Component({
  behaviors: ['wx://component-export'],
  export() { 
    const self = this;
    return { showToast: function(tips, timeout) {
      self.setData({
          visible: true,
          toastTips: tips
        }, () => {
          setTimeout(() => {
            self.setData({ visible: false, toastTips: '' });
          }, timeout || 2000);
        });
      } 
    };
  },
  data: {
    visible: false, // toast是否展示
    toastTips: '' // toast文案
  }
});

调用时父组件/父页面添加toast组件,父组件/父页面通过selectComponent调用showToast。

// index.wxml
<toast id="toast" />
// index.js
this.selectComponent('#toast').showToast('hello');

通过wx://component-export和selectComponent,使得组件逻辑封装在组件内部,类似于React的useImperativeHandle+ref,避免在父组件做过多处理。

封装函数

组件化部分我们实现了一个toast组件,一个页面需要调用一次this.selectComponent(‘#toast’).showToast(‘hello’)方法,十个页面那就需要调用十次,有没有更加快捷方法呢? 微信小程序里封装函数有个技巧,可以利用getCurrentPages方法获取到页面栈,页面栈获取当前页面,就能拿到当前页面的数据、方法。

因此可以通过getCurrentPages来封装一个showToast方法, 如果当前页面有toast组件,调起toast组件的showToast方法

// util.js
function getContext() {
  const pages = getCurrentPages();
  return pages[pages.length - 1]; //获取当前页面
}

export function showToast(tips) {
  const context = getContext();
  const { showToast } = context.selectComponent('#toast') || {};
  if(!showToast) return;
  showToast(tips);
}

// index.js
import { showToast } from 'util.js'
……
showToast('hello');
……

这样就可以封装更多的公共方法供页面/组件直接调用,比方说获取页面路径参数

let curRouteOptions = null; //缓存 避免多次请求
let curPageInfoPromise = null;
let curRoute = '';
export function getPageInfo(defalutOptions) {
  const pages = getCurrentPages();
  const currentPage = pages[pages.length - 1];
  let options = currentPage?.options || defalutOptions || {};
  const route = currentPage?.route || '';
  if(!hasOptionsChange(curRouteOptions, options) && !!curPageInfoPromise && curRoute === route) { 
    return curPageInfoPromise;
  }
  curRouteOptions = options;
  curRoute = route;
  
  curPageInfoPromise = new Promise(reolve => {
    let sceneInfo = {};
    let {
      scene
    } = options || {};
    if (scene) {
      sceneInfo = parseUrl(decodeURIComponent(scene));
    }
    return reolve({...options, ...sceneInfo });
  }).then(res => {
    if(res?.urlQueryObj) {
      let decodeQuery = {};
      try {
        decodeQuery = JSON.parse(res.urlQueryObj);
      } catch(err) { console.log(err); }
  
      res = {
        ...res,
        ...decodeQuery
      };
      delete res.urlQueryObj;
    }
    console.log('getPageInfo', res);
    return Promise.resolve(res);
  });
  return curPageInfoPromise;
}

function hasOptionsChange(pre, cur) {
  if(!pre) {
    return true;
  }
  let hasChange = false;
  Object.entries(cur).forEach(([curKey, curValue]) => {
    if (curValue !== pre[curKey]) {
      hasChange = true;
    }
  });
  return hasChange;
}

工程化实践

微信小程序工程化围绕本地项目运行持续集成两方面进行实践,通过工程化提高开发效率和质量。

开发环境优化

现有主流的构建工具有webpack、gulp、grunt等, 最终采用的是gulp,gulp是基于文件流的工具,对文件读取是流式操作,一次 I/O 可以处理多个任务,还有就是代码驱动,写任务就是写代码。grunt虽然也是基于文件流的工具,但它是配置驱动,需要了解各种插件的功能再配置进去,学习成本比较高。webpack侧重的是前端资源模块化管理和打包,不太适用于小程序的场景。

支持scss

引入gulp-dart-sass和rename包,支持scss编译出wxss

const sass = require('gulp-dart-sass');
const rename = require('gulp-rename');

gulp.task('sass', () => {
  return gulp
    .src('./**/*.scss')
    .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError))
    .pipe(
      rename({
        extname: '.wxss'
      })
    )
    .pipe(gulp.dest('./'));
});

支持px转rpx

UI稿是px的单位,但是微信小程序的样式单位是rpx,通过自动化转换避免手动修改,支持720/750基准。自定义一个gulp插件,使插件更贴合业务。 写自定义gulp插件需要through2、plugin-error包,通过through2传递文件流,PluginError暴露gulp插件错误。

// px2rpx.js
const PluginError = require('plugin-error');
const through = require('through2');

const PLUGIN_NAME = 'gulp-px2rpx';

const defaultConfig = {
  unit: 'rpx', // 单位
  replaceUnit: 'px', // 被替换的
  wxappScreenWidth: 750, // 微信小程序屏幕
  remPrecision: 6
};
const ScssRuleItems = [72, 75];

function gulpPx2Rpx(options) {
  options = Object.assign({}, defaultConfig, options);

  const reg = new RegExp('([\\d.]*\\d)' + options.replaceUnit, 'g');

  const remPrecision = options.remPrecision;

  function getValue(val) {
    return `${parseFloat(val.toFixed(remPrecision))}${options.unit}`;
  }

  return through.obj(function(file, enc, cb) {
    if(file.isNull()) {
      this.push(file);
      return cb();
    }

    const filePath = file.path;
    const screenWidth = ScssRuleItems.filter(item => filePath.includes(item))[0] || 72;
    const radio = options.wxappScreenWidth / (screenWidth * 10);

    if(file.isStream()) {
      this.emit('error', new PluginError(PLUGIN_NAME, 'Streaming not supported'));
      return cb();
    }

    try {
      const newContent = file.contents.toString().replace(reg, function(m,p1) {
        return getValue(p1 * radio);
      });
      file.contents = Buffer.from(newContent);
      file.path = filePath.split(`.${screenWidth}`).join('');
    } catch(err) {
      this.emit('error', new PluginError(PLUGIN_NAME, err));
    }
    this.push(file);
    return cb();
  });
}

module.exports = gulpPx2Rpx;
// gulpfile.js
const px2rpx = require('./px2rpx');
const sass = require('gulp-dart-sass');
const rename = require('gulp-rename');

gulp.task('sass', () => {
  return gulp
    .src('./**/*.scss')
    .pipe(px2rpx()) // 执行px转rpx
    .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError))
    .pipe(
      rename({
        extname: '.wxss'
      })
    )
    .pipe(gulp.dest('./'));
});

增量编译

gulp.watch时是当某个目录下的任一一个文件有改动时,会一次性地把目录下的文件全部执行一次task,相当于目录下的全量编译。文件日益增多,稍有改动就会引起全量编译,这是不太合理的。 这时可以引用gulp-cached插件,这个插件将构建过的文件生成一个hash,缓存在内存中,当该文件更新的时候,才会返回这个文件流,给gulp执行下一步。

拿pxTorpx的例子来说,在px2rpx执行之前使用gulp-cached即可

// gulpfile.js
const px2rpx = require('./px2rpx');
const cached = require('gulp-cached');
const rename = require('gulp-rename');

gulp.task('sass', () => {
  return gulp
    .src('./**/*.scss')
    .pipe(cached('cacheSass')); // cacheSass是一个任务命名,区别其他的cache
    .pipe(px2rpx()) // 执行px转rpx
    .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError))
    .pipe(
      rename({
        extname: '.wxss'
      })
    )
    .pipe(gulp.dest('./'));
});

eslint检查

工程化不仅可以用于提高开发效率,也可以提高开发质量。eslint是最主流的代码检测工具,webpack中在rules里配置eslint-loader,而在gulp中可以使用gulp-eslint插件,添加一个gulp任务执行eslint检查。 因为项目是到后期才添加eslint, 已经有很多的代码,如果对所有文件都执行eslint检查,修改错误就要修改好几天,因此这里做了一个特殊处理,与主分支的代码有区别才进行检测。相当于改了什么文件,就顺带把eslint错误都一起处理了吧~ 再加上husky,支持本地运行eslint和commit代码前的强制检测。

  1. 引入gulp-eslint插件,配置.eslintrc.js规则
  2. 引入husky和lint-staged, husky是让配置git钩子变得更简单的工具,支持所有的git钩子。lint-staged是在git暂存区上运行linters的工具,在package.json文件中配置husky和lint-staged字段
  "husky": {
    "hooks": {
      "pre-commit": "npx lint-staged --no-stash",
    }
  },
  "lint-staged": {
    "miniapp/**/*.js": [
      "eslint  --fix --color"
    ]
  }
  1. 添加gulp-eslint任务
const eslint = require('gulp-eslint');
const exec = require('child_process').exec;
const GITDIFF = 'git add . && git diff HEAD --diff-filter=ACMR --name-only'; // 与主分支的文件比较

gulp.task('checkEslint', function(done) {
  exec(GITDIFF, (error, stdout) => {
    if (error) {
        console.error(`exec error: ${error}`);
    }
    const diffFileArray = stdout.split('\n').filter(diffFile => (
        /(\.js)(\n|$)/gi.test(diffFile)
    ));
    if(!diffFileArray.length) {
      done();
      return '';
    } else {
      return gulp
      .src(diffFileArray)
      .pipe(eslint())
      .pipe(eslint.format())
      .pipe(eslint.failAfterError()); // 在控制台输出错误
    }
  }); 
});
  1. 一些es6以上的语法例如可选链?.也会被eslint检测为有问题的代码,因此还需要添加babel-eslint来支持。 在.eslintrc.js文件中添加对babel的配置
...
  "parser": "babel-eslint",
  "parserOptions": {
    "ecmaVersion": 6,
    "sourceType": "module"
  },
...

CI/CD实践

微信提供了miniprogram-ci供开发者调用,进行小程序代码的上传、预览等操作,减少频繁的手动处理。可以结合gitlab+miniprogram-ci实现自动化上传体验版

  • 微信公众平台-开发 - 开发设置里设置秘钥和IP白名单,只在白名单里的IP才能调用相关接口。
  • 下载秘钥到项目里,添加上传代码
const ci = require('miniprogram-ci');

const project = new ci.Project({
  appid: 'wxsomeappid',
  type: 'miniProgram',
  projectPath: 'the/project/path',
  privateKeyPath: 'the/privatekey/path',
  ignores: ['node_modules/**/*'],
});

function upload() {
  return ci.upload({
    project: project,
    version: '1.0.0',
    desc: 'xxxx',
    setting: {
      es6: true,
      es7: true,
      minify: true,
      codeProtect: true,
      autoPrefixWXSS: true,
    },
    onProgressUpdate: console.log,
  });
}
  • gitlab中项目设置-tiggers-add trigger, 在该页面查询项目的token和url
  • 配置deploy keys, gitlab项目设置中-deploy keys
  • 配置项目 gitlab runner 客户端实例
  • 项目里添加.gitlab-ci.yml文件,大致分为这几个步骤
stages:
  - 安装依赖
  - 构建npm
  - 上传
  - 通知
  • 给项目增加webhooks用来触发ci/cd

    • gitlab pipeline钩子 gitlab 中”项目设置“ - ”Integrations“ 增加以下 WebHook
    Merge Requests Events | Push Events
    Push Events
    Merge Requests Events
    • 添加统一账号为仓库Master
  • 跟企业微信机器人项目联动,通知到个人

接口环境切换

项目有开发环境、预发布环境和正式环境,但是开发版/体验版的小程序只有一个。当需要不同接口环境时,可以在某个隐藏入口(只有开发版和体验版可以)设置长按,弹出选择环境的选项,选择完后storage记住该选项。重新打开时读取storage选项,把接口切换到storage记录的环境

性能优化

分包

微信小程序单个分包或者是主包,大小不可以超过2M。业务在持续迭代的过程中可能很容易超过这个大小,因此需要对小程序进行分包。 在app.json的subpackages 字段配置分包结构,就可以将小程序划分成不同的子包,在使用过程中按需加载,优化小程序首次启动的下载时间。

// app.json
{
    "subpackages": [
    {
      "root": "packageA",
      "pages": [
        "pages/a/a",
        "pages/a/b"
      ]
    },
    {
      "root": "packageB",
      "pages": [
        "pages/b/a"
      ]
    },
    {
      "root": "packageC",
      "pages": [
        "pages/c/a"
      ]
    }
  ]
}

图片优化

  • 图片资源使用CDN非项目内引用: 需要控制包的大小,图片的大小会增加包的体积
  • 图片推荐用webp格式:与png相比,同样是无损的压缩,但是图片文件大小能减少50%
  • image元素设置lazyload
<image lazy-load="{{true}}" />
  • 图片裁剪大小:缩小尺寸

代码按需注入

在app.json中开启按需注入,降低小程序的启动时间和运行时间

{
  "lazyCodeLoading": "requiredComponents"
}

使用wxs

wxs运行在视图层,对一些数据的格式化例如时间戳转日期、倒计时等可以优先考虑使用wxs函数。如果是在js文件中特地开辟字段赋值,再经过逻辑层和视图层的setData数据交互,会有一定的耗时,如果是频繁触发的例如像倒计时,就会有更多的消耗。 不过使用wxs文件也有一个弊端,语法跟js不一样,数据类型能支持的函数也不同,比如不支持es6语法,最坑就是不支持Object遍历! 有一定的学习成本。 记录下在项目中用到wxs函数。

用wxs实现classnames

在react开发中经常使用classnames,可以写个小程序版的classnames,支持不同条件选择不同类名

function getObjectKeyAndVal(obj, cb) {
  var str = JSON.stringify(obj);
  var reg = getRegExp('"(\w)+":', 'g');
  var matchArr = str.match(reg);
  if (matchArr) {
    for (var i = 0; i < matchArr.length; i++) {
      var objKey = matchArr[i];
      objKey = objKey.substring(1);
      objKey = objKey.replace('":', '');
      cb && cb(objKey, obj[objKey]);
    }
  }
}


var classNames = function () {
  var result = [];
  for (var i = 0; i < arguments.length; i++) {
    if ("String" === arguments[i].constructor) {
      result.push(arguments[i]);
    } else if("Object" === arguments[i].constructor) {
      getObjectKeyAndVal(arguments[i], function(key, value) {
        if(value) {
          result.push(key);
        }
      })
    }
  }
  var res = result.join(' ');
  return res;
}

module.exports.classNames = classNames;
数字格式化

12000 转换成1.2万

var formatNumber = function (num) {
  var re = getRegExp("(\d{1,3})(?=(\d{3})+(?:$|\.))", 'g');
  return num.toString().replace(re, '$1,');
}

module.exports.formatNumber = formatNumber;

var translateNumber = function(num, base = 10000, unit = '万', pointCount = 2) {
  if ('' === num || undefined == num || null === num) {
    return num;
  }

  if (typeof num === 'string') {
    num = parseInt(num, 10);
  }
  var result = '';
  if (num >= base) {
    result = Math.floor((num / base) * Math.pow(10, pointCount)) / Math.pow(10, pointCount) + unit;
  } else {
    result = num;
  }

  return result;
}

module.exports.translateNumber = translateNumber;
倒计时
/**
 * 时间格式化
 *
 * @example
 *   formatTime(60, 'hh:mm:ss')  // 00:01:00
 *
 * @param  {Number} second - 秒数
 * @param  {String} format - 格式化模板
 * @return {String}        - 格式化字符串
 */
var formatTimePro = function (second, format = "hh:mm:ss") {
  var values = ["(D+)", "(h+)", "(m+)", "(s+)"]

  var day = parseInt(second / 86400, 10);
  var hour = parseInt(second / 3600, 10);
  var minute = parseInt((second % 3600) / 60, 10);
  var seconds = parseInt(second % 60, 10);
  var times = [day, hour, minute, seconds];

  return values.reduce(function(result, key, index) {
    var reg = getRegExp(key);
    if (!reg.test(format)) {
      return result;
    }
    var match  = format.match(reg)[0];
    var value  = times[index].toString();
    var length = value.length;
    if (1 === length) {
      value  = "0" + value;
    }
    return result.replace(match, value);
  }, format);
}

module.exports.formatTimePro = formatTimePro;
日期格式化
/**
 * 日期时间格式化
 * @param {number} time 毫秒时间戳
 * @param {*} fmt 格式化
 */
var formatDate = function (time, fmt) {
  var date = getDate(parseInt(time));
  var regArr = ["(M+)", "(d+)", "(h+)", "(H+)", "(m+)", "(s+)", "(q+)", "(S+)"];
  var regRes = [date.getMonth() + 1, date.getDate(), date.getHours() % 12 == 0 ? 12 : date.getHours() % 12,date.getHours(),date.getMinutes(),date.getSeconds(),Math.floor((date.getMonth() + 3) / 3),date.getMilliseconds()];
  var week = {
    '0': '/u65e5',
    '1': '/u4e00',
    '2': '/u4e8c',
    '3': '/u4e09',
    '4': '/u56db',
    '5': '/u4e94',
    '6': '/u516d'
  };
  var reg = getRegExp("(y+)");

  if(reg.test(fmt)) {
    var match  = fmt.match(reg)[0];
    fmt = fmt.replace(match, (date.getFullYear() + '').substr(4 - match.length));
  }
  reg = getRegExp("(E+)");

  if (reg.test(fmt)) {
    var match  = fmt.match(reg)[0];
    fmt = fmt.replace(match, ((match.length > 1) ? (match.length > 2 ? '/u661f/u671f' : '/u5468') : '') + week[date.getDay() + '']);
  }

  return regArr.reduce(function(result, key, index) {
    var reg = getRegExp(key);
    if (!reg.test(fmt)) {
      return result;
    }
    var match  = fmt.match(reg)[0];
    var value  = regRes[index].toString();
    var length = value.length;
    if (1 === length) {
      value  = "0" + value;
    }
    return result.replace(match, value);
  }, fmt)
};

module.exports.formatDate = formatDate;

其他

其他的就贴官方建议吧 https://developers.weixin.qq.com/miniprogram/dev/framework/performance/tips/start.html

小程序坑点

filter导致position:fixed失效

如果子元素有absolute和fixed定位,加上filter导致样式混乱。 fixed元素会被移出正常文档流,通过元素相对于屏幕视口的位置来指定元素位置。当元素祖先的transformperspectivefilter非none时,容器由视口改为该祖先

因此小程序只能给对应元素加,不能全局加

对image设置fliter:blur会有残影

具体表现就是image中间会有一条黑线,解决是设置translateZ,开启3d渲染 transform: translateZ(0);

相关阅读

别再被小程序全页变灰给坑了

……to be continued


Profile picture

百事可乐

Let it snow, let it snow, let it snow