仅针对原生的微信小程序开发
代码如何模块化
随着业务的发展,如果把众多代码都挤在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代码前的强制检测。
- 引入gulp-eslint插件,配置.eslintrc.js规则
- 引入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"
]
}
- 添加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()); // 在控制台输出错误
}
});
});
- 一些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元素会被移出正常文档流,通过元素相对于屏幕视口的位置来指定元素位置。当元素祖先的transform、perspective或filter非none时,容器由视口改为该祖先
因此小程序只能给对应元素加,不能全局加
对image设置fliter:blur会有残影
具体表现就是image中间会有一条黑线,解决是设置translateZ,开启3d渲染 transform: translateZ(0);
相关阅读
……to be continued