前言

学习(入坑)前端想来已有三个月了,学习总是枯燥乏味的,写代码更是如此。但是,曾记否?高中时候的你可以因为解出了一道困扰了你几日的数学题而高兴一整天。在程序员的世界里,我想,没有比做出一个项目更开心的了。

是的,此前微信小程序学习了近一个月后,仿着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吧,当作是对作者学习的肯定。)