基于mpvue的仿滴滴出行小程序

15,561 阅读8分钟

初衷

本人最近一直在研究小程序,一个偶然的机会发现了mpvue框架,刚好本人对vue十分喜爱,就决定入坑了。曾经在掘金看过一个仿旧版滴滴的小程序,感觉挺不错的,但它是基于原生小程序的,所以就决定花了一段时间用mpvue进行重写。下面就开始正餐了。


效果图

更多效果请查看 github.com/QinZhen001/…



目录结构

┣━ api # 存放网络请求相关
┣━ common ●                
          ┣━ constant               //常量
          ┣━ css                    //weui.css
          ┣━ less                   //通用less样式与变量
          ┗━ lib                     //第三方库 qqmap-wx-jssdk.js
┣━ components  ● 抽取出来的组件
               ┣━ addressList.vue            
               ┣━ common-footer.vue                
               ┣━ driver-header.vue           
               ┣━ loading-sprinner.vue        
               ┣━ search-bar.vue              
               ┗━ star.vue               
┣━ pages ● 页面
         ┣━ cars                    //选择车    
         ┣━ cityChoose              //选择城市
         ┣━ destination             //选择目的地
         ┣━ evaluation              //评价    
         ┣━ index                   //主页面
         ┣━ login                   //登录
         ┣━ orderCancel             //订单取消
         ┣━ orderClose              //订单关闭
         ┣━ orderService            //订单服务
         ┣━ orderWhy                //询问原因
         ┣━ starting                //选择出发地点
         ┗━ wait                     //等待
┣━ store ● 存放vuex相关
         ┣━ index.js            
         ┣━ mutation-types.js               
         ┣━ mutations.js           
         ┗━ state.js        
┣━ utils 工具类
┣━ App.vue
┣━ main.js
┗━ static # 静态资源,存放图片


vuex数据

const state = {
  curNavIndex: 0,   //当前头部导航索引
  phone: '',         //登录号码
  curCity: '',      //当前所在的城市
  startPlace: '出发地', //出发地
  startFormattedPlace: '', //更具人性化的描述的出发地
  startPosition: [],        //包含startLatitude和startLongitude
  destination: '你要去哪儿', //目的地
  endPosition: [],      //包含endLatitude和endLongitude
  driver: {},       //司机信息 包含Cartnumber,cart,id,name,stars
  cost: 0       //花费
}


功能详情


头部导航自动滑动


为了让头部导航点击时能自动滑出,滑动swiper的同时头部导航跟着滑动,在cars页面选中车时回退到index页面时头部导航自动滑动,我在vuex中维护了一个索引值curNavIndex。根据不同的curNavIndex对scroll-view设置不同的scroll-left值。

那么如何设置准确的scroll-left值呢?

微信小程序无法进行Dom操作,所以无法动态拿到元素宽度。所以我根据头部导航每项的宽度维护了一个数组navOffsetArr

  //两个字宽度+2*margin 也就是 32+10*2 = 52
  const NAV_SMALL_WIDTH = 52;
  //三个字宽度+2*margin 也就是 48+10*2 = 68
  const NAV_BIG_WIDTH = 68;


  this.navOffsetArr = [
        0,
        0,
        NAV_SMALL_WIDTH,
        NAV_SMALL_WIDTH * 2,
        NAV_SMALL_WIDTH * 2 + NAV_BIG_WIDTH,
        NAV_SMALL_WIDTH * 2 + NAV_BIG_WIDTH * 2,
        NAV_SMALL_WIDTH * 3 + NAV_BIG_WIDTH * 2,
        NAV_SMALL_WIDTH * 4 + NAV_BIG_WIDTH * 2
      ]

获取索引值

 computed: {
      ...mapState([
        'curNavIndex'
      ])
    }

watch里监听索引值,当curNavIndex改变时,拿到不同的navScrollLeft值

 watch: {
      curNavIndex(newIndex){
        this.navScrollLeft = this.navOffsetArr[newIndex]
      }
    }

最后将scroll-left与navScrollLeft绑定,从而实现自动滑动

     <scroll-view
        class="nav"
        scroll-x="true"
        scroll-with-animation="true"
        :scroll-left="navScrollLeft">
       
       ......
       ......
       
      </scroll-view>


首页自动保存位置信息

在进入index首页的时候,就会自动将当前城市,当前经纬度,当前地址存入state中作为出发点信息。这里接入了腾讯地图api,还是比较方便的。

  wx.getLocation({
          type: 'gcj02',
          success: (res) => {
            reverseGeocoder(qqmapsdk, res).then(res => {
              this.saveStartPlace(res.result.address)
              this.saveFormattedStartPlace(res.result.formatted_addresses.recommend)
              this.saveCurCity(res.result.address_component.city)
            })
            this.saveStartPosition([res.latitude, res.longitude])
          }
        })

mapMutations

methods: {
  ...mapMutations({
        saveCurNavIndex: 'SET_CUR_NAV_INDEX',
        saveStartPlace: 'SET_START_PLACE',
        saveFormattedStartPlace: 'SET_FORMATTED_START_PLACE',
        saveCurCity: 'SET_CUR_CITY',
        saveStartPosition: 'SET_START_POSITION',
        saveCost: 'SET_COST'
      })
} 

其中reverseGeocoder()就是一个位置转换为地址的函数,是对qqmapsdk.reverseGeocoder()进行了一次封装

function reverseGeocoder(qqmapsdk, {latitude, longitude}) {
  return new Promise((resolve, reject) => {
    qqmapsdk.reverseGeocoder({
      location: {
        latitude: latitude,
        longitude: longitude,
      },
      success: (res) => resolve(res),
      fail: (res) => reject(res)
    })
  })
}

这样当我们进入index首页的时,就可以在Console中就看到数据成功保存到vuex里


选择出发点



在mpvue中使用map组件时会有一些坑,这里先缓一缓,坑稍后再说。


地图map

 <map class="map-didi"
         id="map-didi"
         :latitude="latitude"
         :longitude="longitude"
         :markers="markers"
         @regionchange="regionChange"
         @begin="begin"
         @end="end"
         show-location
    >
    ...
</map>

初始化地图时将地图中心移动至startPosition,如果startPosition不存在,就将地图中心移动至wx.getLocation()获取的当前位置坐标

 initLocation(){
        if (this.startPosition.length) {
          this.latitude = this.startPosition[0]
          this.longitude = this.startPosition[1]
        } else {
          wx.getLocation({
            type: "gcj02",
            success: (res) => {
              this.longitude = res.longitude
              this.latitude = res.latitude
            }
          })
        }
      }

采用随机数据模拟附近的车,然后添加到this.markers中,车的图标根据curNavIndex动态设置,这样就可以在选择不同的服务时展示不同的车图标

   this.markers = []
        const carNum = getRandomNum(3, 8)
        for (let i = 1; i <= carNum; i++) {
          // 定义一个车对象
          let car = {
            id: 0,
            iconPath: "/static/img/car/cart1.png",
            latitude: 0,
            longitude: 0,
            width: 35,
            height: 15
          }

          //随机值
          const lon_dis = (Math.ceil(Math.random() * 99)) * 0.00012;
          const lat_dis = (Math.ceil(Math.random() * 99)) * 0.00012;

          car.id = 2 + i
          car.latitude = this.latitude + lat_dis
          car.longitude = this.longitude + lon_dis
          car.iconPath = `/static/img/car/cart${this.curNavIndex + 1}.png`
          this.markers.push(car)
        }

地图中心的红色定位图标以及接驾时间的文字是用cover-view包裹cover-image实现

   <cover-view class="center-marker">
        <cover-view class="text-center">最快{{minutes}}分钟接驾</cover-view>
        <cover-image class="inverted-triangle" src="/static/img/triangle-down.png"></cover-image>
        <cover-image class="img-center" src="/static/img/marker2.png"></cover-image>
      </cover-view>

其中inverted-triangle是一个倒三角形图片,因为cover-view无法实现复杂css样式,所以底部的倒三角形效果只能用图片实现。


map这里不推荐使用controls,官方也说明 controls即将废弃,请使用 cover-view


选择目的地


这里首先获取到state中的curCity,利用qqmapsdk.getSuggestion(),并将其参数region设置为curCity, 就可以进行地址模糊检索。选中地址时,利用qqmapsdk.geocoder()进行地址解析,得到目的地的相关数据,再将数据通过mapMutations存入state中

 computed: {
      ...mapState([
        'curCity'
      ])
    }


模糊检索

 qqmapsdk.getSuggestion({
          keyword: value,
          region: this.curCity,
          success: (res) => {
            this.addresses = res.data
          }
        })

点击地址时,解析地址保存数据

choosePlace(item){
        //item.address详细地址
        //item.title简短语义化地址
        console.log(item)
        qqmapsdk.geocoder({
          address: item.address,
          success: (res) => {
            this.saveEndPosition([res.result.location.lat, res.result.location.lng])
            this.saveDestination(item.title)
            this.goBack()
          },
          fail: (err) => {
            console.log(err)
          }
        })
      }

mapMutations

methods: {
 ...mapMutations({
        saveDestination: 'SET_DESTINATION',
        saveEndPosition: 'SET_END_POSITION'
      })
}   


选择城市


这里的样式是按照现在的滴滴小程序实现,只要将选中的城市保存在state中的curCity就好了,搜索功能暂未开发。获取城市列表数据用到了腾讯地图的api中的qqmapsdk.getCityList()。这里其实就是数据的过滤与处理,先初始化了一个空对象temp_citys,然后根据城市的拼音的首字母的大写建立key,对应value为一个数组,数组里面包含所有以这个拼音字母开头的城市,最后将temp_citys赋值给this.cityList

 qqmapsdk.getCityList({
        success: (res) => {
          const result = res.result[1]
          let temp_citys = {} //使用temp_citys 避免频繁改动data里面的数据
          for (let i = 0; i < result.length; i++) {
            let key = result[i].pinyin[0].charAt(0).toLocaleUpperCase()
            if (!temp_citys[key]) {
              temp_citys[key] = []
            }
            temp_citys[key].push(result[i].fullname)
          }
          this.cityList = temp_citys
        }
      })

其他的一些页面就不提了,感兴趣的小伙伴可以去看下源码

使用mpvue的一些好处


可以使用vuex

使用vuex进行状态管理,可以更方便地构建复杂应用。这里讲一个调试小技巧,使用createLogger(),使用之后就可以在Console中清楚地看到state的变化

在store下的index.js

import Vue from 'vue'
import Vuex from 'vuex'
import state from './state'
import mutations from './mutations'
import createLogger from 'vuex/dist/logger'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

export default new Vuex.Store({
  state,
  mutations,
  strict: debug,
  plugins: debug ? [createLogger()] : []
})

使用vuex时要记得在对应页面的main.js引入store,并将store赋给Vue.prototype.$store

例如:

import Vue from 'vue'
import App from './wait.vue'
import store from '../../store/index'

Vue.prototype.$store = store

const app = new Vue(App)
app.$mount()


组件化开发

使用mpvue组件化开发更加方便,也方便将组件移植到其他项目中,完整的Vue开发体验,提高了代码复用性。

例如 这里的search-bar:

<template>
  <div class="search-bar">
    <div class="text-location" @click.stop="chooseCity">{{curCity}}</div>
    <input type="text"
           v-model="search"
           class="input-location"
           placeholder="你在哪儿上车"
           placeholder-style="color:#cccccc">
    <div class="cancel-location" @click.stop="cancel">取消</div>
  </div>
</template>

<script type="text/ecmascript-6">
  import {debounce} from '../utils/index'

  export default{
    props: {
      curCity: {
        type: String,
        default: '暂无'
      }
    },
    data(){
      return {
        search: ''
      }
    },
    methods: {
      cancel(){
        this.$emit('cancel')
      },
      clear(){
        this.search = ''
      },
      chooseCity(){
        this.$emit('chooseCity')
      }
    },
    watch: {
      search(newVal){
        debounce(() => {
          this.$emit('search', newVal)
        }, 500)()
      }
    }
  }
</script>

这里为了节流处理,引入了debounce()函数


可以使用Async/await

原生小程序已经支持Promise,但对于async/await还不支持,利用mpvue框架我们可以封装一些异步函数,避免回调地狱。

例如:网络请求

export function request(url, method = 'GET', data, header = {}) {
  return new Promise((resolve, reject) => {
    wx.showLoading({title: '玩命加载中...'})
    wx.request({
      url: baseUrl + url,
      method,
      data,
      header: {'Content-Type': 'json'},
      success: function (res) {
        if (res.statusCode === 200) {
          resolve(res.data)
        } else {
          showToast('发生未知错误!')
          reject(res.data)
        }
      },
      fail: function () {
        showToast('获取数据失败!')
      },
      complete:function () {
        wx.hideLoading()
      }
    })
  })
}
async getInitData(){
    const res = await request('/comments')
    ...
}


使用mpvue的一些坑

年轻人比较冲动,愣头青,说多了都是眼泪,官方文档一定要好好看,首先提一下常规的一些坑。


嵌套列表渲染

只是需要注意一点,嵌套列表渲染,必须指定不同的索引!


示例:

<!-- 在这种嵌套循环的时候, index 和 itemIndex 这种索引是必须指定,且别名不能相同,正确的写法如下 -->
<template>
    <ul v-for="(card, index) in list">
        <li v-for="(item, itemIndex) in card">
            {{item.value}}
        </li>
    </ul>
</template>


regionchange

bindregionchange 事件直接在 dom 上将bind改为@regionchange,同时这个事件也非常特殊,它的 event type 有 begin 和 end 两个,导致我们无法在handleProxy 中区分到底是什么事件,所以你在监听此类事件的时候要同时监听事件名和事件类型

<map 
    @regionchange="functionName"
    @end="functionName" 
    @begin="functionName">
<map>


事件触发问题

举个简单的例子,slider组件有一个bindchange属性,它是完成一次拖动后触发的事件,那么如果我们想取对应的值该怎么操作。

在小程序中我们使用: event.detail

但在 mpvue中要这样写: event.mp.detail


map闪动

动态更新markers时,地图会闪动,导致无法移动地图,这个可是一个大坑

地图组件bindregionchange的bug:

https://github.com/Meituan-Dianping/mpvue/issues/401


原因:mpvue在更新某个属性时都会更新整个data, 在数据量比较大的情况下效率低下,而且频繁改动data里面的数据也会导致卡顿问题


解决方案:每次更新数据时使用脏检查优化

github.com/Meituan-Dia…


但是个人觉得这种直接改源码的方式还是比较妖怪的,于是找到了另一种办法

    <map class="map-didi"
         id="map-didi"
         @regionchange="regionChange"
         @begin="begin"
         @end="end" >
    </map>
let touchTimeStamp = 0

      regionChange(){ 
      
      },
      begin({timeStamp}){
        touchTimeStamp = timeStamp
      },
      end({timeStamp}){
//       加入时间判断
        if (timeStamp - touchTimeStamp > 50) {
          this.mapCtx.getCenterLocation({
            success: (res) => {
              reverseGeocoder(qqmapsdk, res).then(res => {
                this.saveStartPlace(res.result.address)
                this.saveFormattedStartPlace(res.result.formatted_addresses.recommend)
              })
              const lon_distance = res.longitude - this.longitude
              const lat_distance = res.latitude - this.latitude
              // 更新当前位置坐标
              this.longitude = res.longitude
              this.latitude = res.latitude
              //判断屏幕移动的距离,如果超过阀值
              if (Math.abs(lon_distance) >= 0.0022 || Math.abs(lat_distance) >= 0.0022) {
                //刷新附近的车
                this.updateCars()
                //刷新等待时间
                this.minutes = getRandomNum(3, 12)
              }
            }
          })
        }
      }

为了防止map不断地触发begin,end事件导致data频繁更新,这里做了双重判断,当end事件的触发时间减去start事件的触发时间超过一个设定的时间,当中心点移动的距离超过一个阀值,我们才去更新data数据,这里其实相当于做了节流处理。


小程序的一些坑

常规的坑就不提了,这里说一下奇葩的坑。

cover-view的坑

cover-view覆盖在原生组件之上的文本视图,可覆盖的原生组件包括map、video、canvas、camera、live-player、live-pusher,只支持嵌套cover-view、cover-image。

只支持基本的定位、布局、文本样式。不支持设置单边的border、background-image、shadow、overflow: visible等


那如果我们想在cover-view里实现单边的border应该怎么做?


可以在cover-view里再增加一个宽度1px的cover-view来模拟单边border

<cover-view class="footer-bar">
    <cover-view class="text" @click.stop="cancel">取消订单
    </cover-view>
    <cover-view class="right-border"></cover-view>
    <cover-view class="text" @click.stop="endTrip">结束行程
    </cover-view>
    <cover-view class="right-border"></cover-view>
    <cover-view class="text">下载滴滴APP</cover-view>
</cover-view>
.footer-bar {
        padding: 0 12px;
        display: flex;
        align-items: center;
        height: 44px;
        color: rgba(0, 0, 0, .7);
        background: #fff;
        font-size: 0;
        .text {
          flex: 1 1 auto;
          display: inline-block;
          height: 22px;
          line-height: 22px;
          text-align: center;
          font-size: 18px;
        }
        .right-border {
          flex: 0 0 1px;
          height: 22px;
          width: 1px;
          background-color: #d9d9d9;
        }
      }


map组件的层级最高,如何在map组件上做出阴影效果呢?

实现方式其实也是类似,利用cover-image添加一张能覆盖在map组件之上的图片来模拟阴影


具体实现请看这篇文章: juejin.cn/post/684490…


项目地址

欢迎小伙伴来一起交流学习,喜欢项目的话请给颗小星星


总结

学习之路漫漫,不必急于求成。技术日新月异,掌握不变的核心才是王道。不断打磨作品的感觉也挺好的,如果以后有机会的话再慢慢完善。


另外本人目前大三,准备暑假后找实习,有没有广州的大大愿意收留下我。。。


友情链接

滴滴一夏, 小程序专车来了  https://juejin.cn/post/6844903616961052679

网络请求request的坑  www.cnblogs.com/huangenai/p…

mpvue + vuex 开发微信小程序 mapState、mapGetters不可用问题

blog.csdn.net/wp_boom/art…