共计 15295 个字符,预计需要花费 39 分钟才能阅读完成。
前言
学习 (入坑) 前端想来已有三个月了,学习总是枯燥乏味的,写代码更是如此。但是,曾记否?高中时候的你可以因为解出了一道困扰了你几日的数学题而高兴一整天。在程序员的世界里,我想,没有比做出一个项目更开心的了。
是的,此前微信小程序学习了近一个月后,仿着 APP- 运动世界校园写了一个微信小程序奔跑吧(取名为这个是因为看到跑步第一时间就想到了奔跑吧,兄弟)。其实我觉得自己写的这个项目着实不能称得上是一个项目,因为它只实现了原 APP 的一小部分功能,写得实在是简陋,要想完美,果然还是得一个团队写。好了,话不多说,我们言归正传。
这里先贴一个 github 地址:奔跑吧 里面有所有的源码。
项目展示
使用工具及开发前准备
- 微信开发者工具 +VScode
- Vant-weapp+Tencent/weui-wxss
- iconfont+ 网上图片资源
- 小程序云开发的数据库、存储及云函数
- 微信小程序官方文档 +Vant 官方文档 +weui 官方文档
- 网易云音乐 NodeJS 版 API
项目结构
大体结构
页面路径、页面的窗口表现、底部 tab
"pages": [
"pages/run/run",
"pages/map/map",
"pages/chat/chat",
"pages/mine/mine",
"pages/music/music",
"pages/share/share",
"pages/play/play"
],
"window": {
"backgroundColor": "#F6F6F6",
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#00B26A",
"navigationBarTitleText": "奔跑吧",
"navigationBarTextStyle": "white"
},
"tabBar": {
"color": "#B5B5B5",
"selectedColor": "#1296DB",
"list": [
{
"text": "运动",
"pagePath": "pages/run/run",
"iconPath": "images/run.png",
"selectedIconPath": "images/run-active.png"
},
{
"text": "动态",
"pagePath": "pages/chat/chat",
"iconPath": "images/chat.png",
"selectedIconPath": "images/chat-active.png"
},
{
"text": "我的",
"pagePath": "pages/mine/mine",
"iconPath": "images/mine.png",
"selectedIconPath": "images/mine-active.png"
}
]
},
不得不说微信小程序的 tabBar 是真的好用,不用写一句 js 逻辑就可以轻松实现。
我的
我先从最简单的开始说起吧,我的页面几乎没有写任何 js(除了获取已跑里程数据),都只是在切页面。(注:由于代码量太大,我就不一一贴出来阐述了,就讲讲关键点,以下叙述皆如此,想看全部代码的可以去上面贴出来的 github 网址上看)
头部这里我是直接用微信小程序提供的开放数据 open-data 标签直接展示了微信头像和昵称,比起用 Button 来是不是简单多了。
<view class="header">
<view class="left">
<open-data type="userAvatarUrl" class="left-ava"></open-data>
</view>
<view class="mid">
<view class="mid-top">
<open-data type="userNickName"></open-data>
</view>
<view class="mid-bottom"> 东华理工大学南昌广兰校区 软件学院 </view>
</view>
<view class="right">
<view class="arrow"></view>
</view>
</view>
右边的那个箭头也不用多说,就是简单的 css.
.right .arrow{
width: 30rpx;
height: 30rpx;
border-top: 1px solid #fff;
border-right: 1px solid #fff;
transform-origin: 0 0;
transform: rotate(45deg);
}
接着我们来关注一下挨着头部下面的那部分,这里使用了弹性布局和涉及到 1px 问题。
<view class="hd-footer">
<view class="ft-left">
<view class="num">120.00</view>
<view class="str">
<text> 学期目标 </text>
</view>
</view>
<view class="ft-mid">
<view class="num">{{num}}</view>
<view class="str">
<text> 已跑里程 </text>
</view>
</view>
<view class="ft-right">
<view class="num">0.00</view>
<view class="str">
<text> 计入成绩 </text>
</view>
</view>
</view>
.hd-footer{
display: flex;
padding: 30rpx;
background-color: #fff;
text-align: center;
font-size:14px;
}
.ft-left{
flex: 1;
position: relative;
}
.ft-left:after{
content:"";
position: absolute;
top: 0;left: 0;
width: 200%;
height: 200%;
box-sizing: border-box;
transform: scale(0.5);
transform-origin: 0 0;
border-right: 1px solid #aaa;
}
.ft-mid{
flex: 1;
position: relative;
}
.ft-mid:after{
content: '';
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
box-sizing: border-box;
transform: scale(0.5);
transform-origin: 0 0;
border-right: 1px solid #aaa;
}
.ft-right{flex: 1;}
给父容器.hd-footer 设置 display: flex; 给其三个子容器都设置为 flex: 1; 使每个子容器都占父容器的三分之一。我们知道 border 最小只能设置成 1px, 但是我们可以通过添加一个伪元素来实现 0.5px, 这是 css 的一个小技巧。
下面的 body 部分同样是使用了弹性布局,这里就不再赘述。底部的.footer 部分是使用了 Vant 组件的 Cell 单元格组件。
{
"usingComponents": {
"van-cell": "../vant-weapp/dist/cell/index",
"van-cell-group": "../vant-weapp/dist/cell-group/index"
}
}
<view class="footer">
<van-cell-group>
<van-cell title="联系客服" icon="setting-o" is-link url=""link-type="navigateTo"/>
<van-cell title="设置" icon="service-o" is-link border="true"url=""link-type="navigateTo"/>
</van-cell-group>
</view>
最后再讲讲这里唯一的 js 部分吧,{{num}}使用了 MVVM(数据驱动页面)思想,
通过完成一次跑步后设置全局数据 sum 的值,再在此界面获取全局数据 sum 的值并赋予此页面数据 num, 最后渲染到页面上。提到 MVVM 就不得不夸赞它,真是一个伟大的创造,让我们可以抛去繁琐的 DOM 操作。
this.globalData = {
sum: '0.00',
baseUrl: 'http://neteasecloudmusicapi.zhaoboy.com'
}
this.setData({num: app.globalData.sum})
动态
这个界面涉及的 js 逻辑较多,我会主讲 js 方面。
头部使用的依旧是弹性布局,这里就提一下文本超出则打省略号吧。
.hobby-title{
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
身体部分一看有很多相同的结构,于是这里我就写了一个 speak 组件,组件的结构和样式没什么好说的,就讲讲组件上的三个数据 (时间、文本内容、图片) 怎么来的。这就要说说这个通过 position: fixed; 来定位的按钮了,点击后可以跳转到分享页面。
<view class="add" bindtap='share'>
<image class="add-btn" src="../../images/add.png"></image>
</view>
share() {
wx.navigateTo({url: '../share/share',})
},
这里实现页面的跳转也是相当的简单。
一个 Button, 一个 input 框和 weui 的 Uploader 上传组件。
<view class="write">
<button type='primary' size='mini' class='btn' bindtap='send'> 发布 </button>
<input type='text' placeholder='分享...' class='towrite' bindconfirm='complete'></input>
<view class="page__bd">
<view class="weui-cells">
<view class="weui-cell">
<view class="weui-cell__bd">
<view class="weui-uploader">
<view class="weui-uploader__hd">
<view class="weui-uploader__title"> 图片上传 </view>
<view class="weui-uploader__info">{{files.length}}/2</view>
</view>
<view class="weui-uploader__bd">
<view class="weui-uploader__files" id="uploaderFiles">
<block wx:for="{{files}}" wx:key="*this">
<view class="weui-uploader__file" bindtap="previewImage" id="{{item}}">
<image class="weui-uploader__img" src="{{item}}" mode="aspectFill" />
</view>
</block>
</view>
<view class="weui-uploader__input-box">
<view class="weui-uploader__input" bindtap="chooseImage"></view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
在 app.wxss 里引入 weui 的样式。
@import './weui.wxss';
data: {files: [],
fileID: [],
content: ''
},
chooseImage() {
let that = this;
wx.chooseImage({sizeType: ['original','compressed'],
sourceType: ['album','camera'],
success(res) {console.log(res);
that.setData({files: that.data.files.concat(res.tempFilePaths)
})
// ------
for(let i = 0; i < res.tempFilePaths.length; i++) {const filePath = res.tempFilePaths[i];
let randString = Math.floor(Math.random() * 1000000).toString() + filePath.match(/\.[^.]+?$/);
wx.cloud.uploadFile({
cloudPath:randString,
filePath,
success: res => {console.log('上传成功',res);
that.data.fileID.push(res.fileID);
},
fail: err => {console.log(err);
}
})
}
}
})
},
previewImage(e) {console.log(e);
wx.previewImage({
current: e.currentTarget.id,
urls: this.data.files
})
},
complete(e) {
this.setData({content: e.detail.value})
},
send() {
wx.cloud.callFunction({
name: 'createDynamic',
data: {
content: this.data.content,
imagePath: this.data.fileID
},
success(res) {console.log(res.result);
wx.navigateBack();},
fail(error) {console.log(error);
}
})
},
这里用到了微信小程序的几个 API。通过 wx.chooseImage 的 success 回调函数来设置 data 里的数据(图片的路径)。用 wx.cloud.uploadFile 将图片资源上传到云开发的存储里,由于每次只能上传一张,所以这里用了 for 循环。通过 wx.previewImage 可以预览图片。具体每个参数是什么意思,大家可以去官方文档上看。
通过 input 上的 bindconfirm=’complete’ 来获取 input 框的输入内容,最后通过 Button 按钮绑定的 send 方法来调用 createDynamic 云函数。
// 云函数入口文件
const cloud = require('wx-server-sdk')
const env = 'lvwei666-pv2y1'
cloud.init()
const db = cloud.database({env})
// 云函数入口函数
exports.main = async (event, context) => {
const userInfo = event.userInfo;
return await db.collection('dynamic').add({
data: {
content: event.content,
images: event.imagePath,
createBy: userInfo.openId,
createTime: new Date()}
})
}
createDynamic 云函数拿到调用时传来的内容和图片路径并添加了时间这一条数据,把这三条数据存储到了 dynamic 这个数据库里。(这里真的要吐槽一下云函数的调试,一旦报错,每次调试都要上传一次云函数,非常的耗时、麻烦。)
数据创建好了,我们再去取数据。
const db = wx.cloud.database()
const dynamic = db.collection('dynamic')
onShow: function () {
let self = this;
wx.showLoading({title: '正在加载中'});
dynamic.get({success(res) {let every = res.data.reverse()
for (let n of every) {n.createTime = n.createTime.getFullYear() + '-' + (+n.createTime.getMonth() + 1) + '-' + n.createTime.getDate() + '' + n.createTime.getHours() +':'+ n.createTime.getMinutes() +':' + n.createTime.getSeconds();
}
self.setData({every})
},
fail(error) {console.log(error);
},
complete() {wx.hideLoading();
}
})
},
其实在 onPullDownRefresh 页面相关事件处理函数 – 监听用户下拉动作里我也放了同样的代码,在下拉刷新的时候也可以获取数据。
拿到数据数组后我进行了两个操作,一是使数组 reverse 一下,因为新添加的记录 (动态) 会存在 dynamic 数据库的最后;二是把时间这条数据进行了一顿字符串拼接操作来得到你想要的样子。
而后我们就接着讲动态页面的 body 部分吧,也就是 speak 组件。先在此页面引入 speak 组件。
<view class="body">
<block wx:for="{{every}}" wx:key="index">
<speak createTime="{{item.createTime}}" content="{{item.content}}" images="{{item.images}}"></speak>
</block>
</view>
"usingComponents": {"speak": "../../components/speak/speak"},
这里也使用了 for 循环去渲染 speak 组件,因为 dynamic 集合里可以添加多条记录,一个 speak 组件就渲染一条记录。组件又是如何拿到页面上的数据的呢?我们看看下面的代码就知道了。
properties: {
createTime: {
type: String,
value: ''
},
content: {
type: String,
value: ''
},
images: {
type: Array,
value: []}
},
properties 组件的属性列表 - 这个可以让组件从页面那里拿到数据。有了数据就直接在 html 里挖坑({{}}),把数据放上去就行了。
<view class="item">
<view class="header">
<view class="left">
<open-data type="userAvatarUrl"></open-data>
</view>
<view class="right">
<open-data class="right-top" type="userNickName"></open-data>
<view class="right-bottom">{{createTime}}</view>
</view>
</view>
<view class="body">
<view class="content">{{content}}</view>
<view class="img" wx:for="{{images}}" wx:key="index" bindtap="previewImage" id='{{item}}'>
<image src="{{item}}" mode="aspectFill" alt="" />
</view>
</view>
<view class="footer">
<view class="click">
<view class="click-left">
<image class="comment" src='../../images/comment.png'></image>
<text class="comment-num">0</text>
</view>
<view class="click-right">
<image class="support" src="{{like ?'../../images/support.png':'../../images/support-active.png'}}" bindtap='like'></image>
<text class="support-num">{{num}}</text>
</view>
</view>
</view>
</view>
data: {
like: true,
num : 0
},
methods: {like() {if (this.data.like) {
this.setData({num: this.data.num + 1})
}else {
this.setData({num: this.data.num - 1})
}
this.setData({like: !this.data.like})
},
previewImage(e) {// console.log(e);
wx.previewImage({
current: e.currentTarget.id,
urls: this.properties.images
})
}
}
通过 like 方法来控制数据 like 的 true 或 false 来达到点赞和取消点赞的效果, 这里也用了 wx.previewImage 来预览图片。
运动
这里就不贴图了,效果大家可以根据我说的去上面的项目展示看。这个页面首先可以看到的是一个滑屏效果,用的是 Vant 的 van-tab 组件,每屏的标题和内容我存在了 navData 数据库里。点击学期目标后弹出一个类似上拉菜单的用的是 Vant 的 van-action-sheet 组件。对于组件的引用和获取数据库的数据操作在前面都讲过了,这里就不讲了。
说一下中间这个开始按钮的 css 动画吧,比起原 APP 里的效果,我这个真的是差了许多,原 APP 里的看起来就像水滴一样流畅。
.anim{
width: 250rpx;
height: 250rpx;
background-color: white;
opacity: 0.3;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
transform-origin: 0 0;
animation: expend 2.5s ease-in-out both infinite;
}
@keyframes expend{
0% {
opacity: 0;
transform: scale(1) translate(-50%,-50%);
}
40% {
opacity: 0.2;
transform: scale(1.7) translate(-50%,-50%);
}
100% {
opacity: 0;
transform: scale(1.7) translate(-50%,-50%);
}
}
.start{
width: 250rpx;
height: 250rpx;
background-color: white;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
text-align: center;
line-height: 250rpx;
color: #7AD5C7;
transform-origin: 0 0;
animation: dd 2.5s linear both infinite;
}
@keyframes dd {
0%,8%,100%{transform: translate(-50%,-50%) scale(1);
}
5% {transform: translate(-50%,-50%) scale(.98);
}
}
.anim 是一边放大一边改变其透明度,而.start 就先缩小一点,然后立马恢复原样。
接着讲音乐部分,点击页面上的音乐按钮进入 music 页面。
wx.request({
url: 'http://neteasecloudmusicapi.zhaoboy.com/top/list',
data: {idx: 1},
success: res => {console.log('热歌', res);
const songLists = res.data.playlist.tracks;
this.setData({songLists});
wx.hideLoading();}
})
这里用 wx.request 发起请求来获取网易云音乐热歌榜接口的热歌信息并通过 for 循环将数据渲染到页面上,这里讲一下 data 里的 idx: 1 是什么,(说明 : 调用此接口 , 传入数字 idx, 可获取不同排行榜)这是官方说明,0 代表新歌榜,1 代表热歌榜等等。
<view class='songlist'>
<block wx:for="{{songLists}}" wx:key="index">
<view class='item' data-id="{{item.id}}" bindtap='toPlayAudio'>
<view class='index'>{{index + 1}}</view>
<view class='rightView'>
<view class='songTitle'>{{item.name}}</view>
</view>
</view>
</block>
</view>
toPlayAudio(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({url: `../play/play?id=${id}`
})
},
注意每个.item 都有 data-id=”{{item.id}}”,这是为了等下跳去播放音乐页面时知道播放的是哪首歌。
play 页面的界面和样式都很简单,我们就看怎么实现播放音乐的吧。
onLoad: function (options) {console.log(options);
wx.setNavigationBarTitle({title: '云音乐',})
wx.setNavigationBarColor({
frontColor: '#ffffff',
backgroundColor: '#3daed9',
})
const id = options.id
wx.request({
url: app.globalData.baseUrl + '/song/url',
data: {id: id},
success: res => {console.log('歌曲详情', res);
if (res.data.code === 200) {this.createBackgroundAudio(res.data.data[0] || {});
}
}
})
wx.request({
url: app.globalData.baseUrl + '/song/detail',
data: {ids: id},
success: (res) => {console.log('歌曲信息', res);
this.setData({song: res.data.songs[0]
})
}
})
},
createBackgroundAudio(songInfo) {const bgAudio = wx.getBackgroundAudioManager();
bgAudio.title = "title";
bgAudio.epname = "epname";
bgAudio.singer = "chris wu";
bgAudio.coverImgUrl = "";
bgAudio.src = songInfo.url;
bgAudio.onPlay(res => {
this.setData({isPlay: true})
})
},
通过 options 可以拿到跳转页面时传进来的 id, 再用 wx.request 获取歌曲的 Url 并调用 wx.getBackgroundAudioManager 背景音频播放管理进行播放音乐,还用 wx.request 获取了歌曲详情。
<view>
<button type='primary' bindtap='togglePlayStatus'>
{{isPlay ? '暂停' : '播放'}}
</button>
</view>
togglePlayStatus() {const bgAu = wx.getBackgroundAudioManager();
if (this.data.isPlay) {bgAu.pause();
this.setData({isPlay: false})
} else {bgAu.play();
this.setData({isPlay: true})
}
},
用 togglePlayStatus 方法来控制音乐的播放与暂停。
最后要讲的是跑步部分,里面的计算运动距离逻辑可以说是整个项目里最难解决的地方。修改了很多次,才让它可以比较粗糙的计算出跑了多少公里,为此我也是流下了许多的汗水。
先是引入腾讯地图。
<map id='myMap' scale='{{scale}}' latitude='{{latitude}}' longitude='{{longitude}}' polyline="{{polyline}}" show-location markers='{{markers}}'></map>
地图的 scale 缩放级别、经纬度、polyline 路线、show-location 显示带有方向的当前定位点、markers 标记点这些属性就不详细解释了,感兴趣的可以去官方文档看看。
onLoad: function(options) {let markers = [];
let marker = {
iconPath: "../../images/baseline.png",
id: 0,
width: 40,
height: 40
};
wx.getLocation({
type: 'gcj02',
success: (res) => {console.log(res)
marker.latitude = res.latitude;
marker.longitude = res.longitude;
markers.push(marker)
this.setData({
latitude: res.latitude,
longitude: res.longitude,
markers
})
},
fail: (error) => {console.log(error);
wx.showToast({
title: '获取地理位置失败',
icon: 'none'
})
}
})
},
onReady() {this.mapCtx = wx.createMapContext('myMap');
this.start();},
页面加载时就通过 wx.getLocation 来获取当前地理位置并添加一个起点作为标记点,在页面初次渲染完成时调用 start 函数,我们再看看 start 做了什么。
start() {
let that = this;
this.timer = setInterval(repeat, 1000);
function repeat() {console.log('re');
that.getLocation();
that.drawLine();}
cal = setInterval(() => {
let dis, sum = 0;
for (let i = 0; i < point.length - 1; i++) {dis = that.getDistance(point[i].latitude, point[i].longitude, point[i + 1].latitude, point[i + 1].longitude);
sum += (+dis);
}
that.setData({sum: that.format(sum.toFixed(2))
})
console.log(sum);
}, 3000)
that.countTime();
that.setData({switch: !this.data.switch})
},
我们看到 start 函数里有两个计时器,第一个用来持续获取经纬度和画线,第二个用来持续计算距离。涉及的函数是真的多,getLocation、drawLine、getDistance、format、countTime,再看看它们是怎么实现的。
// 获取经纬度
getLocation() {
var latitude1, longitude1;
wx.getLocation({
type: 'gcj02',
success: res => {
latitude1 = res.latitude;
longitude1 = res.longitude;
this.setData({
latitude: latitude1,
longitude: longitude1
})
point.push({
latitude: latitude1,
longitude: longitude1
});
console.log(point);
}
})
},
// 画线
drawLine() {
this.setData({
polyline: [{
points: point,
color: "#1298db",
width: 4
}]
})
},
// 进行经纬度转换为距离的计算
rad(d) {
// 经纬度转换成三角函数中度分表形式
return d * Math.PI / 180.0;
},
// 计算距离,参数分别为第一点的纬度,经度;第二点的纬度,经度
getDistance(lat1, lng1, lat2, lng2) {
let that = this;
var radLat1 = that.rad(lat1);
var radLat2 = that.rad(lat2);
var a = radLat1 - radLat2;
var b = that.rad(lng1) - that.rad(lng2);
var s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
// 地球半径
s = s * 6378.137;
// 输出为公里
s = Math.round(s * 10000) / 10000;
// s = s.toFixed(2);
return s;
},
format(str) {
str = '' + str;
return str.length === 1 ? `0.0${str}` : str;
},
format1(str) {
str = '' + str;
return str.length === 1 ? `0${str}` : str;
},
countTime() {this.tim = setInterval(() => {
cur++;
time.setMinutes(cur / 60);
time.setSeconds(cur % 60);
this.setData({time: '00:' + this.format1(time.getMinutes()) + ':' + this.format1(time.getSeconds())
})
}, 1000)
},
rad 和 getDistance 函数是用来将经纬度距离换算成公里的,这当然得在网上找,不要问我为什么。countTime 函数是用来计算时间的,通过 format 和 format1 函数把 sum(公里)和 time(时间)转化为你想要的格式。
end() {console.log("clear");
clearInterval(this.timer);
clearInterval(cal);
clearInterval(this.tim);
this.setData({switch: !this.data.switch})
},
stop() {let markers1 = [];
let marker1 = {
iconPath: "../../images/terminal.png",
id: 1,
width: 40,
height: 40
};
clearInterval(this.timer);
clearInterval(cal);
clearInterval(this.tim);
marker1.latitude = point[point.length - 1].latitude;
marker1.longitude = point[point.length - 1].longitude;
markers1.push(marker1);
this.setData({markers: this.data.markers.concat(markers1)
})
app.globalData.sum = this.data.sum;
// console.log(app.globalData.sum)
point = [];
cur = 0;
// wx.navigateBack();},
end 函数用来暂停跑步,而 stop 函数则用来结束跑步并添加终点标记点。
最后要提一下的是想要在地图上面显示其他的 dom 结构就必须得用 cover-view 标签,而且里面只能嵌套 cover-view, 所以用不了组件,这是最坑的,于是我便手写了一个类似于上拉菜单的组件,大家看到项目展示里的效果就知道写的非常的粗糙就不贴代码了。
结语
写的好像有点多了,就不说啥了。
再贴一次 github 地址吧:奔跑吧(喜欢的就给个 Star 吧,当作是对作者学习的肯定。)