Notes About Recent Projects 3

The most stupid work
might be the most important one to cherish.

此处收录一些近期的项目笔记,
这次真的是最近正在干的事情了。

没上锁的原因?
是因为我从校会网络部光荣退休了吧。。。
讲点别的项目。

贝壳计通讲师团

项目访问方式:

QRCODE

  1. 扫描上方的小程序码
  2. 微信小程序搜索“贝壳计通讲师团”
  3. Github

项目简介

WEAPP1

小程序主界面,更多预览请直接打开小程序或阅读本文后续内容

这是北京科技大学计算机与通信工程学院学生讲师团的官方小程序,管理方是北京科技大学计算机与通信工程学院学生讲师团,开发和维护方是北京科技大学计算机与通信工程学院的计算机科学与技术专业大二学生本人以及我的搭档fafnir,本人作为小程序的主要开发者之一,完成了本小程序的数据库结构设计、前端小程序开发、Node.js后端开发工作,并进行了多次版本迭代。搭档fafnir完成的工作主要为开发基于Python的Django Xadmin搭建的小程序后台管理网站。

项目创建的具体时间应与本博客的创建时间相差不多,开发时间长达3个月,上线时间已达1个月,经历两次大改。目前最新版本为v0.4.1。
小程序前端基于腾讯微信小程序开发工具的原生组件,后端基于Node.js框架Express,数据库使用MySQL,数据库访问使用Node.js的MySQL库。其中,前端的通信模块以及后端的数据库访问模块均采用Promise异步编程封装。

注:我们计划在将本程序进行适当重构后,将本程序的前后端代码适时发布至GitHub。
当前程序内的敏感信息较多,公布后风险较大故暂不考虑。

后续:前端代码已发布至Github

项目技术细节

本项目的最初需求来源是:在2017秋季学期计通学院学生讲师团旧有线上预约平台网站开发维护人员即将毕业离校,讲师团负责人员联系辅导员提出了寻找学生进行下一代线上辅导预约平台的开发和维护工作的需求,最终确定采用小程序的形式进行开发,并招募了开发人员。原定计划为寒假一个多月时间内完成开发任务,但由于人员技术水平有限,以及在开发过程中遇到的种种挫折,我们前后花费了将近3个月的时间,经历两次大改才将目前接近成品的版本v0.4.1付诸上线使用。

项目第一版

WEAPP2

第一版小程序主界面,更多预览请阅读本文后续内容

项目的第一版完成了基本的需求分析、技术选型、数据库表设计、设备部署以及初步的技术实现等工作。其中需求分析与数据库表设计均由我来完成,并根据MySQL的通行命名规范,编写了本项目的第一份需求分析以及数据库表结构稿件。出于安全考虑,不在此处公布数据库各表的具体字段。由于我们与需求方之间初期的沟通较少,导致我们对于需求方的理解有一定的偏差,但根据我们之后的需求更改情况,可以看出大方向上是无误的。

需求分析

我们在第一版设计时的具体需求(大部分为开发方在开发过程中,帮助需求方总结的需求)为:

  • 小程序前端搭载学生端和讲师端两套代码,在用户登录过程中,使用微信提供的用户id查询数据库结果决定显示哪一界面,普通用户默认为学生用户。(虽然在历次提交审查中,微信方面的小程序测试人员并未对此提出任何疑问,但可以说确实是一种逃避审查的潜在手段,希望微信方面改进审查机制加以防范)
  • 讲师发布课程内容,包括课程名称、日期、时间、地点、人数上限、备注等,其中人数上限、地点、备注为选填项。(早期版本中未考虑到人数上限问题,是后期加入的字段
  • 学生可以进入课程列表对讲师发布的课程进行预约或取消预约,其中达到人数上限、课程取消等情况下提示学生不得预约,课程列表发生的更改将在触发课程列表本身更改的同时,实时触发首页列表的刷新。(课程超时不得预约的功能较为复杂,也是后期加入的字段
  • 学生端以及讲师端首页均显示自己已预约的课程或已发布的课程情况,以及对课程进行相应的编辑操作:学生可以取消课程预约,讲师可以取消、删除、编辑课程,讲师的编辑操作也将触发其首页列表的刷新。
  • 在课程列表以及首页中点击单个课程卡片可以查看课程详情。
  • “我的”页面中普通学生用户可以申请成为讲师,需提交真实姓名以及电话号码,通过后台管理网站的管理员核对后通过认证成为讲师。
  • 后台管理网站应该能自由编辑、删除任何讲师发布的课程,应在开发后期对讲师每月授课情况统计,并进行展示(截至文章发布,授课情况统计功能暂未全部完成)。
数据库表

根据以上的需求分析,大致能够分成以下的数据库表(具体字段不予公布)

  1. 用户预约总表
  2. 讲师课程列表
  3. 管理员认证讲师资格列表
  4. 管理员账户列表
程序功能

从这些数据库表可以分析得出的功能表如下:

  1. 用户
    1. 查看当前可预约课程列表
    2. 提交预约
    3. 取消预约
    4. 查看自己当前的预约
    5. 提交讲师认证申请
  2. 讲师
    1. 查看当前已发布课程以及预约情况(预约人数)
    2. 提交课程
    3. 取消课程
    4. 修改课程
  3. 管理员
    1. 查看并编辑当前所有课程以及预约
    2. 操作讲师认证申请
    3. 查看当前所有讲师每月的授课情况
技术选型

项目第一版的技术选型由fafnir完成,总体情况是采用了腾讯云提供的wafer小程序一站式解决方案,具体来说应该是wafer1,选择的理由是相比于wafer2中服务器无法取得完整访问权的形式,wafer1可以直接在服务器上部署后台管理网站。(虽然后来的经费结算显示,使用wafer2方案可能会更经济一些,而且截至文章发布,腾讯云已经不再主推wafer1,并撤换下了多个wafer小程序一站式解决方案的访问入口,当前能够全新购买的解决方案的只剩下基于开发者工具的wafer2方案,两者之间的不同以及基本架构可以看这里)当时的具体项目选型如下:

技术模块 采用技术 备注
小程序前端 wafer小程序一站式解决方案小程序demo 项目地址,与后端通信采用的是wafer自带的腾讯云SDK,采用的是基于socket的全双工信道通信,部分界面元素直接复用了demo中的界面
服务器后端 wafer小程序站式解决方案Node.js后端demo 项目地址,部署于wafer一站式解决方案的业务服务器上,基于Node.js框架Express,与前端通信采用的同样是wafer自带的腾讯云SDK,采用的是基于socket的全双工信道通信,前后端的会话通信可以直接通过API地址进行,但是信道通信必须经过一站式解决方案的信道服务器进行(请记住这一点,在之后的版本迭代中就发生了问题),与数据库通信采用的是Node.js的MySQL库的线程池模式(此时并未对其进行任何的封装
数据库 MySQL 5.6 部署于wafer一站式解决方案的云数据库上,通过wafer一站式解决方案的信道服务器进行远程访问
后台管理网站 基于Python的Django Xadmin 部署于wafer一站式解决方案的业务服务器上,与后端访问操作同一数据库
开发难点及笔记
JavaScript的异步单线程特性

由于对Node.js乃至JavaScript的异步单线程的特性,尤其是回调函数的理解还较为浅薄(可能也是在之前并未直接接触过前后端通信以及数据库通信的原因造成的。是的我之前的工作真的就是改改开源PHP项目的代码,没怎么认真研读过代码以及文档),所以在设计后端服务器与数据库通信模块时,仍然将思路停留在C/C++之类的线性思路上,例如有如下代码:

1
2
3
4
5
6
7
8
9
10
var res = 'nothing';
connection.query("USE "+database);
connection.query('SELECT * FROM '+databaseForm, function (error, results, fields) {
if (error) throw error;
if (results) {
res = results;
console.log(res);
}
});
console.log(res);

其执行结果按照我的想象应该是:
1
2
3
nothing
(查询的结果)
(查询的结果)

结果是:
1
2
3
nothing
(查询的结果)
nothing

相当于查询结果并未真正传给变量res,若我想在第二个console.log(res);的位置进行查询结果向前端的回传,则回传的结果将仍是nothing。具体原因?简单来说就是JavaScript作为一种在浏览器引擎中工作的语言,在大多数情况下只能单线程运行,此时只能先将一些阻塞整个线程运行的工作进行挂起处理(就例如前后端通信,若后端在某次查询时迟迟不回传,不应该将这个查询之外的其他工作全部停止,选择等待查询结果的到来,而是将其挂起,当后端查询结果回传时,再回过头来进行查询结果的处理等与查询结果相关的工作),这个挂起处理就是通过回调函数callback实现的,也就是上面第二个connection.query中的function函数。因此,正确的实现应该是:
1
2
3
4
5
6
7
connection.query("USE "+database);
connection.query('SELECT * FROM '+databaseForm, function (error, results, fields) {
if (error) throw error;
if (results) {
TunnelService.emit(tunnelId, messageId, results); //直接在回调函数中进行回传
}
});

Node.js中MySQL库的单语句查询、参数化查询等防注入机制

后端服务器与MySQL通信使用的库为Node.js通用的MySQL库,安装命令为npm install mysql。根据我们后期的开发经验,事实上不应该使用该库而应该使用更加专业的ORM框架(ORM的定义)来方便我们对数据库操作命令进行js化的直接编写,而非只用SQL语句进行直接查询,虽然学习SQL语句也不是一件坏事。是的,本项目基本上用到的也就是增删改查、左联右联内联、COUNT计数、建表建库等基本SQL语句。

但是,问题在于该MySQL库本身的最佳实践中提到了其参数化查询、单语句查询的等防注入攻击的机制。其中参数化查询并非开发难点,此处可以略过,但是其默认单语句查询的功能实在是增加了开发难度。也就是必须在单条SQL语句当中完成所有查询,不允许进行多次查询后通过中间变量进行合并得到最终结果。这一设定的出发点是好的,万一API接口被传入一些带“;”的参数,且允许多语句查询,我们并不知道这些参数是否会导致SQL注入攻击的发生。

诚然,大多数查询通过本人的努力都实现了单语句查询的效果,虽然SQL语句看起来又臭又长,外人难以读懂(这也是我反思之后决定日后学习ORM的主要原因之一)。但是若出现某些根据上一次查询结果进行分支操作的情况,单语句查询就显得十分吃力了。例如,我们遇到了这一种情况:

1
2
3
4
5
6
7
8
9
10
@startuml
"开始查询"-->"查询某记录是否存在"
if "该记录存在吗?" then
--> [yes] "将原记录的删除状态解除并修改其内容"
--> "返回结果"
else
--> [no] "新增一条记录"
--> "返回结果"
endif
@enduml

如果因SSL证书问题无法查看上方的流程图,可以使用其他非Chrome内核的浏览器或使用桌面端浏览器阅读本文

所以在项目的第一版中,我们采用了Node.js的async库中的waterfall进行同步顺序编程,
之后的版本我发现了Promise是个好东西(虽然理解起来有难度)
然后就把通信模块统统重写了个遍
在MySQL通信模块中解决这一问题的一个库函数实例如下:
也可以看出采用了参数化查询的防注入机制,以及MySQL的线程池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
static mysqlReserveClassStu(tunnelId, messageId, openId, classId, nickName) {
var tasks = [function(callback) {
pool.getConnection(function(error,connection) {
connection.query("SELECT * FROM user_reserve WHERE class_id=? AND user_id=?", [classId, openId], function (error, results_1, fields) {
if (error) throw error;
if (results_1) {
connection.release();
callback(error, results_1);
}
});
});
}, function(results_1, callback) {
if(results_1[0] == null){
pool.getConnection(function(error,connection) {
connection.query("INSERT INTO user_reserve (user_id,user_nickname,class_id,submission_date) VALUES(?,?,?,NOW())",
[openId,nickName,classId], function(error, results_2, fields) {
if(error) throw error;
if(results_2) {
connection.release();
TunnelService.emit(tunnelId, messageId, results_2);
callback(error);
}
});
});
} else {
pool.getConnection(function(error,connection) {
connection.query("UPDATE user_reserve SET status=1 WHERE class_id=? AND user_id=?",
[classId,openId], function(error,results_3, fields){
if(error) throw error;
if(results_3) {
connection.release();
TunnelService.emit(tunnelId, messageId, results_3);
callback(error);
}
})
})
}
}];

async.waterfall(tasks, function(error, results) {
if(error) throw error;
});
}

这个代码块确定没把数据库表的字段抖出来了吗。。。
emmmm,还好吧。各位高抬贵手,高抬贵手。。。

前端、后端、数据库三者之间的时间不统一以及时间格式的处理问题

这里由于我自己也记不大清楚当初的处理思路(尤其是小程序前端在处理过程中使用的“幻数”),
很可能都是我无意识情况下的“瞎调试”的成果。
这个说实话我是极其不提倡这么干的,虽然有的时候的确有用
此处提供各模块的关键代码供大家参阅:
服务器后端MySQL通信模块上的初始化操作,关键就是设置时区到正确的时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
process.env.TZ = 'Asia/Shanghai';
var pool;

class MysqlExecute{

static mysqlInit() {
pool = mysql.createPool({
connectionLimit: 10,
host : mysqlHost,
user : mysqlUser,
password : mysqlPassword,
database : mysqlDatabase,
timezone : process.env.TZ
})
}

小程序前端的时间处理相关代码format,关键就是正则表达式+暴力剪切+暴力连接
(其中用了微信开发者工具的默认小程序demo里面的util.js时间处理函数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const utils = require('./util');

var currentDate = utils.formatTime(new Date());
var currentDateAnnual = new Date();
var classContentStr;

const timeFormat = (str) => {
for (var i = 0; i < str.length; i++) {
var start = str[i].class_timestart;
var end = str[i].class_timend;
var date = new Date(str[i].class_date.slice(0, 10));
date = date.getFullYear() + "年" +
(parseInt(date.getMonth()) + 1).toString() + "月" +
date.getDate() + "日";
start = start.slice(0, 5)
if (start.slice(0, 1) == "0") {
start = start.slice(1, 5)
}
end = end.slice(0, 5)
if (end.slice(0, 1) == "0") {
end = end.slice(1, 5)
}
str[i].class_date = date;
str[i].class_timestart = start;
str[i].class_timend = end
}
return str;
}

const dateFormat = (options, that) => {
that.setData({
dateIndex: currentDate,
ateLimitStart: currentDate,
});
currentDateAnnual.setFullYear(currentDateAnnual.getFullYear() + 1);
currentDateAnnual.setDate(currentDateAnnual.getDate() - 1);
that.setData({ dateLimitEnd: currentDateAnnual });
if (options.class_content != null) {
classContentStr = JSON.parse(options.class_content);
if (classContentStr.student_limit == '0') {
that.setData({
studentLimit: ''
})
} else {
that.setData({
studentLimit: classContentStr.student_limit
})
}
classContentStr.class_date = classContentStr.class_date.replace("年", "-");
classContentStr.class_date = classContentStr.class_date.replace("月", "-");
classContentStr.class_date = classContentStr.class_date.replace("日", "");
that.setData({
className: classContentStr.class_name,
classIntro: classContentStr.class_intro,
dateIndex: classContentStr.class_date,
classPlace: classContentStr.class_place,
timeEndIndex: classContentStr.class_timend,
timeStartIndex: classContentStr.class_timestart,
})
}
return classContentStr;
}

module.exports = {
timeFormat: timeFormat,
dateFormat: dateFormat
}

看着相当的难受啊,这x一样的代码风格😂
没毛病,(下一版)会改的会改的🙏
(没错,之后的版本我直接把那个又臭又长的classContentStr给改了。。。)

1
2
3
4
5
var date = new Date(str[i].class_date.slice(0, 10));
//这里得到的结果格式应该类似于yyyy-mm-dd
date = date.getFullYear() + "年" +
(parseInt(date.getMonth()) + 1).toString() + "月" +
date.getDate() + "日";

想看幻数的同学看上面,我把它截取下来了。
是这样的:月份数诡异地被我加了一个1,然后居然就对了。。。
我也不知道这个到底是怎么一回事,在JavaScript里有什么奇异的原理导致了这个结果,有人知道的话可以告诉我吗?

后续:我查到了,因为getMonth()是以数组形式来存储月份的,下标是0~11

人数上限的数据格式转换,以及人数已满等状态下阻止用户预约

你还别说,我一边写这个笔记,一边还在最新版本的小程序里发现各种蜜汁有趣的bug呢😂

人数上限作为讲师发布课程时的一个选填项,可以说是本项目数据处理的一个难点,其处理方式在本项目中也起到了一种模范的形式
难点在于:人数上限分为两种情况:“无上限”和存在数字上限,我们只能利用0这个数字来表示“无上限”,因为基本上不可能开设一个人也没有的课程,至少的人数上限也应该是1。但是反过来说,用户在填写表单时不可能特别将无上限填写为0,这在用户体验上只有留空才更加符合一般的表单填写习惯。

所以我们在用户点击上传按钮触发的函数中就将人数上限进行处理转换:

1
2
3
4
5
6
7
var studentLimitFormat;
if(this.data.studentLimit == ''){
studentLimitFormat = '0';
} else {
studentLimitFormat = this.data.studentLimit;
}
//之后传到后端的就是studentLimitFormat

并在从后端回传的过程中也一样进行相应的处理,这里以课程内容页代码为例:

1
2
3
4
5
6
7
8
9
if(classContentStr.student_limit == '0'){
this.setData({
studentLimit: '无上限'
})
} else {
this.setData({
studentLimit: classContentStr.student_limit
})
}

同时,也应当在人数已满时阻止用户预约。在微信小程序中,我们使用<block wx:if>的wxml标签形式进行分类,通过条件判断来决定显示何种按钮,并只在“预约”和“取消预约”按钮上添加相应的函数钩子,这里以课程列表的上传按钮为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<block wx:if="{{item.student_sum >= item.student_limit && item.student_limit > 0}}">
<view class="reserve-button" data-content='{{item}}'>
人数已满
</view>
</block>
<block wx:elif="{{item.status == 0}}">
<view class="reserve-button" data-content='{{item}}'>
已取消
</view>
</block>
<block wx:elif="{{item.reserve_status == null || item.reserve_status != 1}}">
<view class="reserve-button" bindtap="bindReserve" data-content='{{item}}'>
预约
</view>
</block>
<block wx:elif="{{item.reserve_status == 1}}">
<view class="reserve-button" bindtap="bindCancelReserve" data-content='{{item}}'>
取消预约
</view>
</block>

当然,我们也在后端数据库表的设计中,将讲师课程表的人数上限字段的默认值设置为0。这算是最后一道防线吧,防止其他非法输入对数据的影响。

提交表单前的各种格式检查

是的,以人数上限的数据上传前进行处理为范本,我们普遍采用了if() { return; }的形式对非法输入进行检查,而这些非法输入的多样性之丰富,远远超出了我们的想象。例如:
有时间的非法输入,直接用正则表达式替换掉时间中的冒号+暴力的数字比较(new Date说实话多此一举了):

1
2
3
4
5
6
7
8
9
10
if(new Date(this.data.timeStartIndex.replace(/:/g, "")) > new Date(this.data.timeEndIndex.replace(/:/g, ""))){
wx.showModal({
title: '提示',
content: '开始时间应小于结束时间',
showCancel: false,
confirmColor: '#17abe3',
confirmText: '好的'
})
return;
}

有人数上限输入非数字时,调用isNaN()函数的同时防止将留空代表“无上限”也拦截:

1
2
3
4
5
6
7
8
9
10
if (isNaN(this.data.studentLimit) && !(this.data.studentLimit == undefined)) {
wx.showModal({
title: '提示',
content: '人数上限应输入数字',
showCancel: false,
confirmColor: '#17abe3',
confirmText: '好的'
})
return;
}

甚至对是否产生了无效的预约时间也进行了合法性检查:

1
2
3
4
5
6
7
8
9
10
if ((currentDate > selectedDate) || ((currentDate == selectedDate) && (currentTime > selectedTime)) {
wx.showModal({
title: '提示',
content: '预约时间应大于当前时间',
showCancel: false,
confirmColor: '#17abe3',
confirmText: '好的'
})
return;
}

我们在合法性检查上花费了大量的时间,但也只能够对非法情况进行枚举性质的检测,若有一些我们不了解的业界最佳实践,欢迎联系我们探讨这一问题。

程序测试

程序测试确实是开发过程当中的重要一环,由于团队资源有限,且微信账号确实具有不可模拟性,所以我们在不足以拿到足够的微信测试账号以及测试机时,借助微信开发者工具和自己的手机号,建立了一个仅有两个核心测试账号、一台安卓测试机的测试体系(后期在发现iOS独有bug时,我们也找了临时的iPhone测试机和测试微信账号)。

  • 两个测试账号一个默认为普通学生用户,另一个通过后台管理网站通过讲师认证注册为讲师(在后台管理网站还未部署时,其实是通过手工向数据库表加入记录实现的),两号均在微信公众平台上注册为开发者
  • 一般情况下,在PC端微信开发者工具上登录其中一个用户,手机端也登录这一用户,以测试学生端或讲师端在开发者工具的模拟器和实机上效果是否一致,也可以通过远程调试定位实机上的bug
  • 若想测试讲师端与学生端的数据互动效果,可以在开发者工具登录一个用户,另一个用户在手机上通过微信最新版本的“切换用户”功能登录小程序
  • 若想测试多个教师或多个学生产生数据的效果,可以通过后台管理网站同时认证讲师或取消讲师认证来实现身份上的同一性。
  • 若想在临时的iPhone测试机上进行远程调试,记得先将该机的测试微信号加入开发者列表,如此方能远程调试成功,测试结束后记得再删除即可。
小程序最终界面

UI设计上大量采用了腾讯云一站式小程序解决方案小程序demo的配色和界面元素。
(其实就是没精力去设计UI啦。。。)
基本设计思想更偏向WP式的平面风格

WEAPP2

第一版小程序主界面(此时小程序名称还没改)

WEAPP3

第一版小程序主界面(无预约时显示的欢迎+提示语)

WEAPP4

第一版小程序课程列表

WEAPP5

第一版小程序讲师端主界面

WEAPP6

第一版小程序讲师端编辑课程界面

WEAPP7

第一版小程序“我的”页面

项目第二版

项目第二版的迭代原因是十分偶然的。由于微信官方对于小程序用户登录API的调整影响了wafer1一站式解决方案中的腾讯云小程序SDK以及Node.js服务器端SDK通过信道服务器对用户身份进行认证的正常操作进行,导致了SDK提供的信道全双工通信对于新注册用户不再可用,最终使小程序的大多数功能处于不可用状态。(据悉,wafer2的SDK信道登录方式暂未受到影响,估计是腾讯方面在wafer1逐渐下架的情况下忽视了使用wafer1的老用户,测试不全面而导致这一情况发生)

为了解决这一重大bug,我们团队仔细研读了微信官方的登录API调整公告以及腾讯云SDK文档,最终采用了“添加首次登录用户认证界面+全面弃用信道通信方式并采用原生通信方式全面重写”的改进方案。值得一提的是,在重写过程中我们着重采用了JavaScript中的异步Promise编程,对小程序前端通信模块、后端服务器MySQL通信模块进行封装重写。在开发过程中,本人收获了更多的JS异步编程经验,并对Promise为代表的异步编程解决方案有了更加深刻的理解

由于第二版着重于bug的修复和代码的重写,并未对UI界面设计做出太多调整,所以此处不再展示小程序主界面截图。若想知道第一版与最新版UI变化为何如此之大,请继续往下阅读,感谢您的理解!

开发难点及笔记
微信登录API调整后小程序前端后端相应的修复解决方案

根据微信官方的说法,若想像之前那样获得完备的用户基本信息:

必须使用<button>组件,并将open-type指定为getUserInfo类型,用户允许授权后,可获取用户基本信息。

而另一种使用<open-data>组件展示用户信息的方式,就真的只有展示功能了。。。可能也是我太菜,根本没办法在JS获取到组件内部加载出来的用户信息。

所以就相当于只能让用户点击一次按钮来完成整个用户信息获取的工作。根据我们当初设计的数据库表结构,用户信息,尤其是其唯一标识码openId,在本项目中起到了相当关键的作用,若不能获取这些信息,则根本无法正常使用小程序的各项基本功能,所以我们在小程序的首页设计了一个遮罩层,若未进行用户信息授权的话,用户看见的只有遮罩层上的提示和用户授权登录的按钮。

我们具体的实现结果如下所示:
wxml代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<block wx:if="{{!hasUserInfo}}" >
<view class="auth-page">
<view class="auth-page-note">
<image src="../../images/reserve-hl.png"></image>
<text>请允许微信授权登录后\n继续使用小程序</text>
</view>
<view class="auth-page-button">
<button wx:if="{{canIUse}}" open-type="getUserInfo" bindgetuserinfo="bindGetUserInfo">
授权登录
</button>
<view class="auth-page-uncomp-note" wx:else>
不支持授权登录,请升级微信版本
</view>
</view>
</view>
</block>

JS代码(index页面内的钩子函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bindGetUserInfo: function (e) {
if(e.detail.userInfo){

var userInfo = e.detail.userInfo;
console.log('用户授权:', userInfo);

wx.setStorageSync('nickName',userInfo.nickName);
wx.setStorageSync('avatarUrl', userInfo.avatarUrl);
auth.showAuthPage(this);

wx.showToast({
title: "正在登录",
icon: "loading",
duration: 1500,
mask: true
})

//说实话有点蠢这里,设置了一个硬性的1.5s时间,主要是因为貌似有点bug,
//我如果设置wx.showToast一直显示,然后在用户信息拿到后再调用wx.hideToast,
//经常性失灵,很绝望。可能真的是只能在当前页面中的js调用。但是很奇怪的是,wx.stopPullDownRefresh就不用这么干。。。

} else {
console.log('用户授权:拒绝');
}
},

JS代码(上面调用的auth所在的auth.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const showAuthPage = that => {
if(wx.getSetting) {
wx.getSetting({
success: res => {
var auth = res.authSetting,
nickName = wx.getStorageSync('nickName'),
hasUserInfo;
console.log("授权情况:", auth);

if (auth['scope.userInfo'] && nickName)
hasUserInfo = true;
else
hasUserInfo = false;

console.log("授权标记:", hasUserInfo);
that.setData({
hasUserInfo: hasUserInfo
})
}
})
}
}

module.exports = {
showAuthPage: showAuthPage
}

感觉上小程序的底层应该也是像Vue、Angular、React那样写了一个有DOM更新之类功能的前端引擎,基本上hasUserInfo更新了之后,那个遮罩层直接就消失了,DOM更新的速度相当快。也有人吐槽小程序的JS风格就像Vue+React。。。

WEAPP8

第二版小程序用户登录授权页面(请忽略那个远程调试用的黑框😂)

前端通信模块以及后端MySQL通信模块的重写和Promise封装

有人说,你们不是又重新实现了用户信息获取了吗?为什么还是不能用原来的信道通信方式?而且再不济重新写一个socket类型的通信方式岂不美哉(可以实现全局广播,这样可以及时通知用户是否有数据发生了更改)?

emmmm,技术菜,只是主要原因之一。(我承认我确实还不会写socket。。。)

关键是那个腾讯云SDK它就是用原来的登录方式(划重点)获取用户信息的啊,现在微信方面彻头彻尾地改了,你不去重写它,还有其他办法吗?

第一步,先别急着把采用信道通信的代码全删了,至少通信时数据的格式你得看看吧。

然后,我确实菜,所以只能在前端通信模块乖乖地上原生wx.request请求了。。。真的,我就觉得这就是AJAX啊。
首先还是先写一个简单的post函数,把wx.request定制化封装一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const post = (obj) => {
return new Promise((resolve, reject) => {
wx.request({
url: config.service.testUrl,
data: obj,
success: res => {
if(res.data.results) {
resolve(res.data.results);
} else {
reject(res.data.error);
}
},
error: error => {
reject('网络出错');
}
});
});
}

为什么要用promise对wx.request进行封装呢?理由很简单,依然是我们之前提到的JavaScript的单线程特性,需要使用回调函数callback()对一些可能阻塞整个JS代码执行的操作进行封装,让它们先挂起,让代码先继续执行下去,等需要进行这些操作的时候再回过头来执行——这就是异步非阻塞的编程模式。而大部分可以调用的函数都提供了回调的使用方法,以及你自己定义的函数也可以提供回调。

回调作为一种异步编程的解决方法,看起来很美好。但如果在这样的一种场景下你估计就笑不出来了:

例如,你向后端的一个API请求一个数据。好,数据拿到了,现在你要根据这个数据再去请求后端的另一个API的数据……
如此下去,你请求了3个API,OK,你终于拿到了想要的最终数据,然后你还要将这个数据处理一下才能展示到界面里面

这样的话,你写的代码大概像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
wx.request({
//...
success: res => {
wx.request({
//...
success: res => {
//...
wx.request({
//...
success: res => {
//format your final data.
}
})
}
})
}
})

如果再多几次回调函数的嵌套,估计你自己看这代码也差不多要阵亡了。没错,这就是所谓的回调地狱
后端与MySQL之类的数据库通信也同理,你输入了一条SQL语句的结果是下一条SQL语句的内容……

那么除了疯狂地筑起一个回调金字塔之外,还有什么别的办法能够解决异步非阻塞编程问题呢?Promise就是其中之一。当然我之前用的async也是一种,但是那个写起来说实话更加别扭,至少Promise允许你用封装函数的方式进行编程,显然比写一些蜜汁有趣的函数数组正常多了。

好了,我之前提到了我用Promise封装了一个post函数,现在我就展示一个使用Promise解决异步问题的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
const initUserInfo = (that) => {
wx.login({
success: res => {
if(res.code) {

console.log('获取用户登录凭证:', res.code);

post({
'msgType': 'wxAuth',
"code": res.code
}).then(res => {

console.log("收到消息:", res);
getApp().data.openId = res;

return post({
'msgType': 'checkIsTeachAuth',
'openId': getApp().data.openId
})

}).then(res => {

console.log("收到消息:", res);
if (res.isTeachAuth == true) {

getApp().data.isTeachmodeGlobal = 2;
getApp().data.teacherRealName = res.realName;
getApp().data.teacherAuthId = res.teacherId;
getApp().data.teachAuthStatus = res.status;

that.setData({
isTeachMode: 2
})

return post({
'msgType': 'getClassDataTeach',
'openId': getApp().data.openId
})

} else {

getApp().data.isTeachmodeGlobal = 1;
getApp().data.teachAuthStatus = res.status;

that.setData({
isTeachMode: 1
})

return post({
'msgType': 'getReservedClass',
'openId': getApp().data.openId
})

}

}).then(res => {

console.log("收到消息:", res);
if (getApp().data.isTeachmodeGlobal == 1){

that.setData({
reserveArray: format.timeFormat(res).reverse(),
emptyNote: '',
emptyIntro: '',
emptyUserName: true
})
if (res[0] == null) {
that.setData({
emptyNote: welcomeQuote,
emptyIntro: userWelcomeIntro,
emptyUserName: false
})
}
wx.stopPullDownRefresh();

} else {

that.setData({
classArray: format.timeFormat(res).reverse(),
emptyNote: '',
emptyIntro: '',
emptyUserName: true
})
if (res[0] == null) {
that.setData({
emptyNote: getApp().data.teacherRealName + " 欢迎!",
emptyIntro: teacherWelcomeIntro,
emptyUserName: false
})
}
wx.stopPullDownRefresh();

}
}).catch(error => {
console.log('发生错误:', error);
})
} else {
console.log('获取用户登录态失败:', res.errMsg);
}
}
})
}

是不是超长无比。。。再联想一下刚才我演示的回调地狱,用回调不知道要套多少层了。。。
而且一个post函数可以反复使用,因为其传入的参数只有一个obj,就是发送到后端的json数据包,除此之外其他的操作都可以快速的复用,并且从后端返回的数据结果也可以由Promise传到下一个.then函数中。

除了post之外,我也仿造了信道通信方式,搞了一个emit函数。信道通信方式其实更加地先进,它是将所有的信道监听函数在初始化页面的时候就规定好了,也就是说把所有接收到后端数据之后的success操作都先写好了,之后再到需要向后端服务器发送数据的地方调用emit函数,这样也更加地灵活,发送数据时只管输入数据的格式和内容就OK了。

但是,本项目基本上除了用户在初始化数据或表单时需要将后端返回的数据进行存储和展示操作外,其他的通信操作基本上属于更新数据的范畴,也就是后端返回数据更新成功的结果后,只需调用一下数据刷新函数让服务器将更新好的数据回传即可。既然emit函数的功能如此确定,我也就直接将它封装好了,当然也得用用Promise了,既然都写好了,再多写个回调版本的函数就浪费了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const emit = (obj,that) => {
post(obj).then(res => {
console.log('收到消息:', res);
if (getApp().data.isTeachModeGlobal == 2)
getApp().data.isTeachDataUpdated = true;
else
getApp().data.isStuDataUpdated = true;
if (obj.msgType == 'reserveClass' || obj.msgType == 'editClass'
|| obj.msgType == 'classDataUpload') {
wx.showToast({
icon: 'success',
title: '数据上传成功',
duration: 3000
})
}
wx.startPullDownRefresh({
success: that.onPullDownRefresh
})
}).catch(error => {
console.log('发生错误:', error);
})
}

emit函数实际用起来也就是这样的,多传了一个this指针而已:

1
2
3
4
5
6
req.emit({
'msgType': 'reserveClass',
'openId': getApp().data.openId,
'nickName': wx.getStorageSync('nickName'),
'classId': e.currentTarget.dataset.content.id
},this);

既然后端MySQL通信模块也要Promise封装,那么肯定也是要先定义一个用Promise封装的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
static queryProm(sql, params) {
return new Promise((resolve, reject) => {
pool.getConnection((error,connection) => {
if(error) { reject(error); throw error; }
connection.query(sql, params, (error, results, fields) => {
if(results) {
resolve(results);
connection.release();
}
})
})
})
}

当然,我后来也发现其实大部分的操作其实都只需要一步回调就能解决问题了,所以我也写了一个回调版本的

1
2
3
4
5
6
7
8
9
10
11
static query(sql, params, callback) {
pool.getConnection((error,connection) => {
connection.query(sql, params, (error, results, fields) => {
if (error) throw error;
if (results) {
callback(error, results);
connection.release();
}
})
})
}

然后这里也有一个比较模棱两可的经验,就是前端传到后端的json数据包内定义了msgType,可以在传入后端的地址是同一个时,根据msgType消息的类型进行不同的操作。
具体操作在后端是怎样分类的,我这里用了比较原始的switch-case语句,但是说实话,这样会造成代码整体的可读性下降。因为消息类型一多,全挤在一层switch里面了,修改和查找都相当困难,这也是我需要改进的地方——代码的合理化、层次化和结构化。

最后用Promise的效果就是这样的(这个就是之前在项目第一版中用async写过的那个操作):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
case 'reserveClass': 
sql.queryProm("SELECT * FROM user_reserve WHERE class_id=? AND user_id=?",
[req.query.classId, req.query.openId]
).then(response => {
if(response[0] == null)
return sql.queryProm(
"INSERT INTO user_reserve (user_id,user_nickname,class_id,submission_date) VALUES(?,?,?,NOW())",
[req.query.openId, req.query.nickName, req.query.classId]
);
else
return sql.queryProm(
"UPDATE user_reserve SET status=1 WHERE class_id=? AND user_id=?",
[req.query.classId, req.query.openId]
);
}).then(response => {
res.send({results: response});
}).catch(err => {
res.send({error: err});
});

//不要啥都写res,想啥呢
break;

这个“想啥呢”的注释是这样的,Express框架本身有一个回传数据功能的对象参数叫res,然后我写函数也习惯把数据本身叫res,这下好了,相当于我用回传的数据去调用他的成员函数send(),这一个数据哪儿来的send()函数啊?当然前端就没有收到任何回传的数据了。我纳闷了很久怎么Promise好好的就不能用了呢,最后登了服务器上去翻了翻log才发现问题,这也充分说明log在debug中的极端重要性。

当然用回调的效果是这样的:

1
2
3
4
5
6
7
8
case 'cancelReserve':
sql.query("UPDATE user_reserve SET status=0 WHERE class_id=? AND user_id=?",[req.query.classId, req.query.openId], (error, results) => {
if(error)
res.send({error: error});
else
res.send({results: results});
});
break;

那么既然也在服务器后端弃用了信道通信所在的腾讯云SDK,我也采用了Express原生的路由方式来将请求定位到以上MySQL通信模块所在的文件上。

最终效果

别看我,我就是凑个小节数的,要不然就一个笔记太尴尬了。。。

本次版本迭代,通过添加用户授权登录界面、从底层用原生请求方式重写前端通信模块和后端MySQL通信模块,并使用Promise进行异步编程封装,基本上修复了信道通信因登录API调整而无法使用,导致整个程序无法正常运行的bug。

项目第三版

WEAPP1

小程序第三版主界面,更多预览请直接打开小程序或阅读本文后续内容

项目第三版的迭代原因是需求方提议加入普通学生用户端也能够发起一对一辅导预约,然后讲师能够对此进行接单的“辅导预约”功能。
我们开发方也趁着本次迭代的机会,对小程序的前端界面UI进行了大范围的重写,从而能够彻底弃用原先大范围采用腾讯云一站式小程序解决方案小程序demo的配色和界面元素的旧UI。

在此特别感谢Jason Gao同学以及他的“有通知”小程序对本项目UI重写提供的设计参考和技术支持!

在新UI的开发过程中的技术难点在于:

  1. 取消了微信小程序的顶部、底部菜单栏后,小程序界面对于不同尺寸以及刘海屏手机的适配;
  2. 取消了底部菜单栏后,自行开发的底部菜单栏的路由结构问题;
  3. 取消了顶部菜单栏后,下拉刷新、返回导航、页面标题等顶部菜单栏功能不再实用的情况下的自主开发。
  4. tab标签式导航栏的实现

同时,我们也修复了众多之前两个版本未发现的、以及在本版本开发过程中遇到的逻辑功能上的bug,例如:

  1. 预约时间相对于当前时间已经过期的未采取过期处理;
  2. 未对辅导预约进行一对一绑定而造成的多个讲师抢单重复预约的情况;
  3. 对于人数上限、备注等留空项目的前端数据处理不当;
  4. iOS系统下“我的页面”用户头像被背景图案覆盖的问题;
  5. 还有其他的一些细节小bug;
开发难点及笔记

在谈UI开发之前,我首先得回答这个问题:为什么要隐藏顶部菜单栏以及底部菜单栏呢?

理由有两个:

  • 功能上的需要:主要是微信小程序自带的底部菜单栏定制性奇差,必须得每一个菜单项对应的路径、图标、颜色、文字,乃至菜单项的数量,全部都在app.json里写死了,而且样式清一色都是死板的文字/图标/文字+图标,无法进行更高级别的个性化定制。就像本项目这样加一个高度明显超出菜单栏本身的大大的加号按钮,或者加一点其他的特殊样式,用微信小程序自带的底部菜单栏都是无法实现的。同理,微信小程序自带的顶部菜单栏同样也无法像本项目这样放置一个可点击的刷新按钮
  • 设计上的需要:从本文中的小程序界面效果图可以看出,这种底色完全一致的、通透的视觉效果,明显区别于直接采用微信小程序自带方案的其他大多数小程序的界面,是十分夺人眼球的设计(虽然直接采用微信小程序自带方案也可以做得相当美观)。
UI难点之一:屏幕尺寸适配

微信小程序事实上就是一种webview套壳应用的变体,这个是众所周知的事情了。所以不难联想到当使用微信小程序自带的顶部菜单栏时,小程序的wxml界面自上而下渲染的起点,应当是在顶部菜单栏的下方的,就像一般的带标题栏的安卓webview页面,都是顶部的元素帮助撑起了手机系统顶部的状态栏以及顶部的菜单栏在内的一个相当大的高度。
如果隐藏了顶部菜单栏的话,就会出现wxml界面直接从状态栏下方开始渲染的情况,而且一般状态栏都是最顶层的,也就是说状态栏会遮挡一部分wxml内容。。。大概像下面这样:

WEAPP9

在iPhone X上有刘海的话就更加尴尬了。。。

所以需要的就是将这一部分的位置空出来,尤其是对iPhone X的刘海要额外进行适配(后来在开发者工具中的测试我们也发现了iPhone 4/iPhone 5这一类小尺寸屏幕的手机也需要额外适配)。所以我们的思路就是动态定义包裹所有其他元素的<view class="root">padding-topwxss属性。尽管wxss无法使用JS进行动态更改,wxml还是能用JS进行动态更改的。所以就想出了动态定义class属性的内容就OK了,代码如下——
wxml代码:

1
2
3
<view class="root {{isIpx?'root-ipx':''}} {{isIp4?'root-ip4':''}}">
<!-- content -->
</view>

JS代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var that = this;
wx.getSystemInfo({
success: function(res) {
if(res.model == 'iPhone X'){
getApp().data.isIpx = true;
that.setData({
isIpx: getApp().data.isIpx
})
} else if(res.model == 'iPhone 5' || res.model == 'iPhone 4'){
getApp().data.isIp4 = true;
that.setData({
isIp4: getApp().data.isIp4
})
}
},
})

wxss代码:
1
2
3
4
5
6
7
.root-ip4 {
padding-top: 30rpx;
}

.root-ipx {
padding-top: 60rpx;
}

但是在之后的测试中发现,一旦预约课程的表单变长,可以滚动起来了以后,状态栏底下会出现本来应该被遮罩了的表单。。这是因为padding-top只是把顶部元素下移了,状态栏本身是透明的,所以肯定无法遮罩滚动到顶部的表单。解决方法和上面是一样的,自己再定义一个<view>元素,用来遮挡状态栏底部的其他元素就OK了,同样要对特殊尺寸的屏幕做适配,此处就不再赘述了。

UI难点之二:自行开发的底部菜单栏的路由结构

为什么要如此强调路由结构呢?因为你需要知道你当前用底部菜单栏打开的页面是哪一个。否则底部菜单栏如何将当前打开页面对应的按钮进行高亮或者其他处理,来对用户形成一种辅助的标识呢?我们在这里使用了一个相当讨巧的办法来解决这个问题:

我们并不删除底部菜单栏在app.json中的代码使之彻底消失,只是通过微信小程序API函数wx.hideTabBar对其进行隐藏,这样其基本的路由结构依然存在,无需另外写一个公共的路由代码。页面跳转可以使用wx.switchTab。然后由于自定义的底部菜单栏是重复出现在页面上的,准确来说应该是首页和“我的”页面上,所以我们采用了微信小程序的模板类型元素<template>来进行代码的复用:
wxml代码的写法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template name="tabbar">
<view class="tabbar-wrap">
<view class="tabbar-index" bindtap="tabbarRoute" data-index="0">
<image src="{{indexActive?'/images/index-hl.png':'/images/index.png'}}"></image>
<view style="color: {{indexActive?'#17abe3':'#bfbfbf'}}">
首页
</view>
</view>
<view class="tabbar-reserve">
<image src="/images/new-hl.png" bindtap="tabbarRoute" data-index="1" ></image>
</view>
<view class="tabbar-user" bindtap="tabbarRoute" data-index="2">
<image src="{{userpageActive?'/images/user-hl.png':'/images/user.png'}}"></image>
<view style="color: {{userpageActive?'#17abe3':'#bfbfbf'}}">
我的
</view>
</view>
</view>
</template>

在对应的页面中引用的方法也很简单
1
2
3
<import src="/template/tabbar" />
<!-- content -->
<template is="tabbar" data="{{...tabStatus}}"></template>

其中,三点运算符表示传进tabStatus的全部子成员(这个tabStatus有两个成员:indexActiveuserpageActive),也就意味着上面代码块里的<template>中的所有indexActiveuserpageActive不用再写成tabStatus.indexActivetabStatus.userpageActive了,很方便吧,这可是ES6的特性哦!
wxss的代码也贴一下,这样也可以直接套用样式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
.tabbar-wrap {
display: flex;
flex-direction: row;
justify-content: space-around;
width: 100%;
position: fixed;
height: 90rpx;
bottom: 0;
padding-top: 20rpx;
padding-bottom: 35rpx;
border-top: .5px solid #cccccc;
background-color: rgba(256,256,256,0.9);
}

.tabbar-wrap view {
width: 30%;
display: flex;
flex-direction: column;
justify-content: space-around;
}

.tabbar-wrap .tabbar-reserve {
position:fixed;
bottom:30rpx;
}

.tabbar-wrap view image {
width: 60rpx;
height: 60rpx;
margin: 0 auto;
}

.tabbar-wrap .tabbar-reserve image{
width: 115rpx;
height: 115rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 50%;
}

.tabbar-wrap view view {
font-size: 25rpx;
width: 100%;
text-align: center;
margin-top: 1rpx;
font-weight: bold;
}

以上操作的教程来源是这里

UI难点之三:重写返回导航、页面标题和刷新组件

既然隐藏了顶部菜单栏,可以说也相当于在打开新页面时也失去了微信小程序自动生成的标题和返回按钮,然后下拉刷新也别扭了很多(尤其是在iPhone X上,你下拉刷新的时候根本看不到那个刷新动画。。。),这就意味着以上功能全部都得自主开发。

我的解决方案也异常简单,返回导航直接使用微信小程序的API函数wx.navigateBack,刷新也不过是在图标上绑定钩子函数,这里的主要难点在于刷新动画的协调性
具体怎么说呢?wxss本质上就是CSS,刷新动画的一般实现都是一个圆形刷新图标的旋转,而这个旋转一般都是CSS的效果。但是若像本项目一样使用带箭头的圆环,则会出现一个很尴尬的情况:
当你正在“加载数据”这一状态时,圆环是不停旋转的,而当“数据加载结束”时,圆环需要处于一个静止的状态。若将静止状态设置为一个固定的图片,例如说刷新图标的箭头处于图标的正12点方向,则你会发现,”加载数据”这一状态结束时,箭头并不一定处于正12点,而在切换到“数据加载结束”这一状态时,箭头突然就跳到了正12点方向。

可以先看看“有通知”小程序的刷新动画实现方法,基本上就是点击刷新后固定地转一圈,这样既避免了上述尴尬的情况,也可以让用户体验到类似于“转了一圈就加载了”的“快速加载”的观感。

那么我们是如何实现的呢?可以说是一次很成功的尝试吧:让“数据加载结束”这一静止状态不再是一张固定的图片,而是在下一次加载时箭头直接从之前停下的方向继续开始转动!这样给用户的体验就不再是十分突兀的,反而有一种很自然自然的流畅感和美感。

实现方法也很简单,设定好不同状态下的CSS属性即可,只不过需要JS在与后端通信的加载过程中向wxml里刷新图标的style=""传入不同的变量,以启用或关闭不同的动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.line .title-wrap .refresh-button {
font-size: 45rpx !important;
line-height: 90rpx;
padding-left: 10rpx;
padding-top: 15rpx;
color: #6d6d72;
animation: spin 800ms infinite linear;
animation-play-state: paused;
}

.line .title-wrap .refresh-button.active {
animation-play-state: running;
}

@keyframes spin {
0% {
transform: rotate(360deg);
transform-origin: 60% 55%;
-webkit-transform: rotate(360deg);
-webkit-transform-origin: 60% 55%;
}
100% {
transform: rotate(0deg);
transform-origin: 60% 55%;
-webkit-transform: rotate(0deg);
-webkit-transform-origin: 60% 55%;
}
}

可以看到这个animation-play-state相当关键,就是这一属性支持了我们的刷新开始和结束的自然切换。

哦对了,貌似还有页面标题没讲,这个其实就是自己添加标题写在相应的位置,如果需要动态标题则往wxml中添加变量。注意给返回、刷新之类的按钮留好位置即可。

UI难点之四:tab标签式导航栏的实现

这个说实话网络上教程相当多,但是这里仍然有一些亮点,例如在高亮标签下的“下划线”。这并不是简单的用CSS的下划线属性实现的,而是使用了CSS的伪类概念。说实话,在后来其他项目的开发过程中,我才真正开始理解并有意识地使用起了伪类,给某一页面元素的正上方或正下方添加一些附属元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<view class="navbar">
<!-- tabbar标签式导航栏 -->
<text wx:for="{{navArrayStu}}" data-index="{{index}}" class="item {{currentNavTab==index?'active':''}}" wx:key="unique" bindtap="bindNavbarTap">
{{item}}
</text>
</view>

<block wx:if="{{currentNavTab==0}}">
<!-- 当currentNavTab==0时显示这里的内容 -->
</block>

<block wx:if="{{currentNavTab==1}}">
<!-- 当currentNavTab==1时显示这里的内容 -->
</block>
1
2
3
4
5
bindNavbarTap(e) {
this.setData({
currentNavTab: e.currentTarget.dataset.index
})
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

.navbar .item {
position: relative;
text-align: center;
line-height: 30rpx;
font-size: 40rpx;
font-weight: lighter;
}

.navbar .item.active {
font-weight: bolder;
}

/* 伪类的使用 */
.navbar .item.active::after {
content: "";
display: block;
position: absolute;
bottom: -20rpx;
left: 0;
right: 0;
height: 5rpx;
background: #6d6d72;
}
BUG解决之一:预约时间相对于当前时间已经过期的未采取过期处理

过期处理说实话确实是个败笔,因为这个东西本来应该是后端完成的东西,我却非常不厚道的在小程序里面加入了这个功能(不是在批评某些“大前端”思想,但是这个确实后端来做会更好一点,毕竟数据量一大还不如后端处理好了再发给前端,某些过期数据的体积也可以适当压缩一下,况且我到现在都还没做分页,感觉药丸。。。)。而且这个过期处理确实挺重要的,在这种预约类小程序里面,所以我也在寻找更好的解决方案,希望(如果有坚持读到这里的)大佬能够联系我提供一些建议,不胜感激!

我的想法是一拿到数据就交给某个工具函数去处理数据,处理完之后再返回数据。这里我直接把过期处理添加到了时间处理函数里面,具体工具函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
var curDate= utils.formatTime(new Date());
var curDateFull = new Date();

const timeFormat = (str, contentType) => {

for (var i = 0; i < str.length; i++) {
if (contentType == 'class') {
var start = str[i].class_timestart;
var end = str[i].class_timend;
var date = new Date(str[i].class_date.slice(0, 10));
var itemDate = str[i].class_date;
} else if (contentType == 'course') {
var start = str[i].course_timestart;
var end = str[i].course_timend;
var date = new Date(str[i].course_date.slice(0, 10));
var itemDate = str[i].course_date;
}

var curTime = curDateFull.toLocaleString('chinese', { hour12: false }).slice(10, 18).replace(/:/g, "");
var itemTime = start.replace(/:/g, "");

// 过期处理在这儿⬇️
// 如果该记录的日期本身就小于当前的日期,一定过期
// 如果该记录的日期与当前日期相同,但时间比当前时间要早,也一定过期
if((itemDate < curDate) || ((itemDate == curDate) && (curTime > itemTime)) ){
str[i].overtime = 1;
} else {
str[i].overtime = 0;
}

date = date.getFullYear() + "年" +
(parseInt(date.getMonth()) + 1).toString() + "月" +
date.getDate() + "日";
start = start.slice(0, 5)

if (start.slice(0, 1) == "0") {
start = start.slice(1, 5)
}
end = end.slice(0, 5)

if (end.slice(0, 1) == "0") {
end = end.slice(1, 5)
}

if (contentType == 'class') {
str[i].class_date = date;
str[i].class_timestart = start;
str[i].class_timend = end
} else if (contentType == 'course') {
str[i].course_date = date;
str[i].course_timestart = start;
str[i].course_timend = end
}

}

console.log("时间处理后:", str);
return str;
}

BUG解决之二:未对辅导预约进行一对一绑定而造成的多个讲师抢单重复预约的情况

“辅导预约”这个功能事实上也就是用户“课程预约”功能的一个翻转:讲师自由发布课程,多个用户预约一个讲师的课程,人数上限可以有也可以不设置。反之,用户自由发布辅导需求,多个讲师预约一个用户的课程,但是是一对一的课程,所以人数上限其实是1。但我这里没有再使用人数上限的功能了,而是采用了一个很清奇的绑定思路:多表左联合查询。

这个说实话也是个败笔😂(没错,包括上面那个在内,你在本文看到的所有bug解决的思路,都是些让你觉得很滑稽的解决方式,因为我当时是真的没办法快速找到一些最佳实践的。。。)
正常情况下的思路应该是要去维护一个新的数据库字段,就是“是否已经有讲师预约”这样的一个标志字段。
但是我这里的处理思路就很清奇,既然已经被讲师预约了的话,那是不是可以让用户的预约数据库表和讲师的接单数据库表进行一个左联合查询,然后如果某个字段联合查询后查询不到讲师的信息(例如昵称nickname之类的)就可以认为是未被讲师接单呢?反之是不是就可以被认为是已经被接单呢?
这个清奇的思路事实上是很差劲的,因为这个涉及到一个查询效率的问题,联合查询总的来说肯定要比单表查询要慢很多,数据一多肯定影响性能,而且这样返回前台数据不可避免地泄露了讲师的信息。

当然,还是那句老话,安全起见,后端数据库表结构以及相应的SQL查询语句我是不可能公开的。所以这里就只有描述,没有代码了。

BUG解决之三:对于人数上限、备注等留空项目的前端数据处理不当

这是个相当玄学的问题,什么叫“处理不当”呢?这涉及到用户体验与数据库管理之间的矛盾。用户当然希望这样的功能实现:在填写的时候,“人数上限”一栏留空,就代表人数上限为无上限,填入数字再表示有一个确定的上限,“备注”留空,就代表没有备注,填入备注就代表有一段备注。但是数据库管理的时候,一个字段的格式一般是固定的,我不可能为了存储“无上限”这一信息就让一个人数上限的字段同时支持整型数和字符或者别的什么,所以我只能无奈地让数字0代表无上限。同时,备注也可以存储为一个“NULL”来代表无备注。但是问题来了,当上传到后端时,前端至少需要对数据做一个预处理:把人数上限从undefined改成0,把备注从undefined改成NULL。我当时就考虑到这里,但是后来才发现:等等,那后端返回到前端呢?不是也得再经历一次相反的转换吗?

大概就是这样一个逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if (classItem.student_limit == '0') {
this.setData({
studentLimit: '无上限'
})
} else {
this.setData({
studentLimit: classItem.student_limit
})
}
if (classItem.student_sum == null) {
this.setData({
studentSum: '0'
})
} else {
this.setData({
studentSum: classItem.student_sum
})
}
if (classItem.class_intro == "undefined") {
classItem.class_intro = "无";
}

BUG解决之四:iOS系统下“我的页面”用户头像被背景图案覆盖的问题

这个确实是个意想不到的BUG,在正式上线之后才发现Safari浏览器的渲染引擎存在着这样的bug:当一个具有transform的CSS属性的元素作为背景,而另外一个图片元素在其上方时,将不能够通过z-index属性来控制它们的层级关系。

之后的解决方案是从网上搜索得出的“以毒攻毒”法。是的,你没有看错,这个方法就是用transform来解决transform带来的问题的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

.avatar-img{
width: 140rpx;
height: 140rpx;
margin:50rpx auto 30rpx;
background-color: #bfbfbf;
border-radius: 50%;
z-index: 99;
border: 2px solid #fff;
transform: translateZ(100rpx) /* 这个就是解决办法,“以毒攻毒”,简单粗暴 */
}

.colored-top {
position: fixed;
top: 0;
left: -35rpx;
width: 300%;
background-color: #17abe3;
height: 40%;
z-index: 0;
transform: rotate(8deg);
}
其他小BUG

这里将会根据项目当前进度,及时更新一些其他的小BUG以及处理方式,也就相当于一些后续了~

小程序最终界面

WEAPP1

小程序第三版主界面

WEAPP1

用户课程预约界面

WEAPP1

用户辅导预约界面

WEAPP1

讲师辅导接单界面

WEAPP1

讲师辅导接单界面

WEAPP1

讲师发布课程界面

WEAPP1

“我的”界面

WEAPP1

“关于”界面

结语

先。。。先容我吐槽一下吧。
讲了挺多的,确实,一看发现上千行了😂(至少在markdown里面是这样,1.3k),我打算以后有机会的话拆成两篇文章发布。
写的时间跨度一个月吧,因为各种事情,写写停停,甚至在某几次提笔重新开始继续写下去的时候,都发现自己都不知道之前到底写了什么,现在该写什么,写的初心是什么。都快被各种事情给搞忘了。
所以说,要想系统性的总结一个东西,很难。
况且我这个小程序至少前端代码是必须要放到GitHub上去的,要想再系统性地整理并分享一个东西,更难。

首要的,我还是非常感谢明导和郑导、感谢搭档王云程同学(@fafnir)、感谢提供过帮助的高亦非同学(Jason Gao)以及感谢计通学院学生讲师团,给予了我这次项目实战的宝贵机会。如果没有这次实战机会的话,估计我也很难得出如此系统的经验,并写出内容如此(冗长而)丰富的文章了吧。这是一次从零开始、至少是从需求开始的一次系统性的开发,虽然过程不免因为个人水平仍处于成长期、个人其他事务的干扰等各种原因有着种种波折起伏,但是所有的过程都是在从宏观到微观、从代码开发到客户沟通再到界面设计,几乎是全方位地锻炼我的各种能力。

所以,再次感谢在开发过程中给予了我各种帮助和指导的所有人,谢谢大家!

最后,这是本站的第七篇正式发文,感谢阅读。
如有意见和建议,欢迎通过首页的联系方式联系作者。
本文参考资料均来源于网络,作者保留相关权利,转载请注明出处。