简介:这份资源是一套微信小程序仿滴滴打车的完整项目源码,面向具备小程序基础、希望进阶实战出行类应用的开发者。项目围绕叫车核心流程展开,涵盖首页、叫车页、订单页、个人中心等页面模块,并整合地图定位、路线规划、实时通信、微信支付与订单状态管理等关键技术点,接口部分需自行替换填写,适合作为学习演示与二次开发底稿。压缩包为rar格式,共151个文件,约652KB,其中80个png图片资源、20个wxss样式文件、20个js逻辑文件、16个json配置文件与15个wxml结构文件,另含工具函数库、可复用模板与第三方地图SDK封装,目录按页面、样式、资源、工具分层组织,便于按模块检索。目前已有3810人学习下载,读者可借此梳理小程序项目结构,理解位置服务、订单流转与支付集成的实现思路,并参考工具函数与模板组件的写法,积累出行类应用的排错与调试经验。
1. 小程序模仿滴滴打车:从订单状态机到地图选点的最小闭环
很多人第一次听到「小程序模仿滴滴打车」这个需求,脑子里蹦出来的是地图、定位、实时轨迹、司机接单推送这一整套东西,然后打开编辑器就卡住了——因为不知道从哪一行开始写。我做过几个类似的小程序出行 Demo,血泪经验是:真正难的不是地图,而是订单状态怎么流转。你把状态机想清楚了,地图只是渲染层;状态机没想清楚,地图画得再漂亮,乘客点了「确认上车」司机那边还是「待接单」,整个流程就是玄学。
这篇笔记面向的是想在小程序里跑通一套「乘客下单 → 司机接单 → 行程中 → 完成支付」最小闭环的开发者。不追求做成商业级打车平台,而是把订单状态、地图选点、司机乘客双端数据同步这三件事拆开讲透,让你能在本地开发环境里复现一个能点、能走、能结束的 Demo。适合两类人:一类是刚接触小程序、想找个有真实业务感的项目练手;另一类是已经会写页面、但没处理过「两端同时操作同一订单」这种并发场景的熟手。下面所有代码都是可抄的骨架,参数我会标清楚为什么这么设。
2. 订单状态机怎么设计才不翻车
2.1 为什么先画状态图再写页面
出行类小程序的核心不是 UI,是订单。乘客和司机看到的是同一个订单的两个视角,任何一方的操作都要让另一方的界面跟着变。如果你把状态散落在各个页面的 data 里,比如乘客页存一个isAccepted,司机页存一个orderStatus,两边一旦对不上,就会出现「司机已接单但乘客还在转圈」的经典翻车现场。
常见做法是:订单只存一个status字段,所有界面根据这个字段派生显示。状态枚举我一般会定成下面这几个,够一个 Demo 用,也留了扩展位:
| 状态值 | 含义 | 谁能触发进入 |
|---|---|---|
| 0 | 待接单 | 乘客下单成功 |
| 1 | 已接单 | 司机点击接单 |
| 2 | 司机已到达 | 司机点击到达上车点 |
| 3 | 行程中 | 乘客确认上车 |
| 4 | 待支付 | 司机点击结束行程 |
| 5 | 已完成 | 乘客支付成功 |
| 9 | 已取消 | 乘客或司机取消 |
这张表看着简单,但每个状态之间的跃迁条件必须写死。比如状态 2 只能由状态 1 进入,状态 3 只能由状态 2 进入,不能从 1 直接跳到 3。为什么?因为「司机已到达」这个动作是乘客确认上车的前提,少了这一步,乘客可能在司机还没到的时候就点了上车,后面行程时长和费用全乱。
2.2 用云函数做状态跃迁的唯一入口
小程序端直接改数据库是大忌,因为两端都能改,谁后写谁覆盖。我一般把状态变更收口到一个云函数里,所有跃迁都走它,顺便做权限校验。下面是一个最小可用的云函数骨架:
// cloudfunctions/updateOrder/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() // 合法跃迁表:key 是当前状态,value 是允许到达的下一个状态数组 const TRANSITIONS = { 0: [1, 9], // 待接单 -> 已接单 / 已取消 1: [2, 9], // 已接单 -> 司机到达 / 已取消 2: [3, 9], // 司机到达 -> 行程中 / 已取消 3: [4], // 行程中 -> 待支付 4: [5], // 待支付 -> 已完成 5: [], 9: [] } exports.main = async (event) => { const { orderId, nextStatus, role } = event const wxContext = cloud.getWXContext() const openid = wxContext.OPENID // 1. 取出当前订单 const orderRes = await db.collection('orders').doc(orderId).get() const order = orderRes.data if (!order) return { code: 404, msg: '订单不存在' } // 2. 校验角色:乘客只能取消,司机才能推进行程 if (role === 'passenger' && nextStatus !== 9) { return { code: 403, msg: '乘客无权推进该状态' } } // 3. 校验跃迁合法性 const allowed = TRANSITIONS[order.status] || [] if (!allowed.includes(nextStatus)) { return { code: 400, msg: `不允许从 ${order.status} 到 ${nextStatus}` } } // 4. 写入新状态,并记录操作人 await db.collection('orders').doc(orderId).update({ data: { status: nextStatus, updateTime: db.serverDate(), lastOperator: openid } }) return { code: 0, msg: 'ok' } }逻辑说明:TRANSITIONS是整段代码的灵魂,它把「什么状态能到哪」变成数据而不是散落的 if-else,以后加状态只改这张表。角色校验放在跃迁校验之前,是因为乘客和司机的权限边界不同——乘客能取消,但不能替司机点「已到达」。db.serverDate()用服务端时间而不是客户端时间,避免用户改手机时间导致订单时间线错乱。
参数说明:orderId是订单文档 ID,nextStatus是目标状态数字,role是调用方身份。这三个参数必须由前端传,但服务端要重新校验,不能信前端。lastOperator存 openid 是为了后面排查「谁把订单改乱了」时有据可查。
2.3 前端怎么根据状态渲染不同按钮
状态机定好之后,前端就轻松了。乘客页和司机页各自维护一个「状态 → 按钮文案 → 点击调用」的映射,不要写一堆if (status === 1) ... else if (status === 2)。我一般写成配置对象:
// pages/passenger/order.js const PASSENGER_ACTIONS = { 0: { text: '等待司机接单', disabled: true }, 1: { text: '司机正在赶来', disabled: true }, 2: { text: '确认上车', next: 3, role: 'passenger' }, 3: { text: '行程中', disabled: true }, 4: { text: '去支付', next: 5, role: 'passenger' }, 5: { text: '已完成', disabled: true }, 9: { text: '已取消', disabled: true } } Page({ data: { order: null, action: null }, onLoad(options) { this.orderId = options.orderId this.watchOrder() }, watchOrder() { // 用 watch 监听订单变化,司机端一改,乘客端自动刷新 const db = wx.cloud.database() this.watcher = db.collection('orders').doc(this.orderId).watch({ onChange: snapshot => { const order = snapshot.docs[0] this.setData({ order, action: PASSENGER_ACTIONS[order.status] }) }, onError: err => console.error('订单监听失败', err) }) }, async onActionTap() { const { action, order } = this.data if (!action || action.disabled) return await wx.cloud.callFunction({ name: 'updateOrder', data: { orderId: order._id, nextStatus: action.next, role: action.role } }) }, onUnload() { // 页面卸载必须关掉监听,否则会内存泄漏 if (this.watcher) this.watcher.close() } })逻辑说明:PASSENGER_ACTIONS把状态和 UI 绑在一起,新增状态只加一行。watch是云开发数据库的实时监听能力,司机端改了状态,乘客端不用轮询就能收到推送,这是小程序做双端同步最省事的方案。onUnload里关监听是必须的,我见过有人忘了关,来回切页面几次之后小程序直接卡死。
参数说明:snapshot.docs[0]是监听结果里的订单文档,action.next是目标状态,action.role传给云函数做权限判断。注意disabled: true的按钮不要绑点击事件,或者绑了也要在函数里再拦一次,防止用户通过开发者工具强行触发。
3. 地图选点和距离计算:别让用户手动输地址
3.1 用 chooseLocation 拿到起终点坐标
出行小程序最影响体验的一步是地址输入。让用户手打「XX 路 88 号」既慢又容易错,常见做法是调wx.chooseLocation让用户在地图上点选,拿到经纬度和名称。这个 API 会打开一个地图选点界面,用户拖动地图或搜索后确认,返回{ name, address, latitude, longitude }。
// pages/passenger/create.js Page({ data: { start: null, end: null }, async chooseStart() { const res = await wx.chooseLocation({ latitude: 39.908, // 默认中心点纬度,一般给城市中心 longitude: 116.397, // 默认中心点经度 scale: 16 // 缩放级别,16 能看到街道 }).catch(() => null) if (!res) return // 用户取消 this.setData({ start: res }) }, async chooseEnd() { const res = await wx.chooseLocation({ latitude: this.data.start ? this.data.start.latitude : 39.908, longitude: this.data.start ? this.data.start.longitude : 116.397, scale: 16 }).catch(() => null) if (!res) return this.setData({ end: res }) } })逻辑说明:chooseLocation返回的latitude/longitude是 GCJ-02 坐标系,小程序地图组件用的也是这套,所以直接存直接渲染,不需要转换。默认中心点给城市中心是为了让用户打开时不是一片空白。选终点时如果已经选了起点,就把地图中心挪到起点附近,减少用户拖动距离。
参数说明:scale控制缩放,16 左右适合看街道,太小看不到路名,太大视野又不够。latitude/longitude是必填,不传会报错。用户取消时chooseLocation会 reject,所以要用.catch(() => null)兜住,不然控制台一片红。
3.2 直线距离和预估价格怎么算
Demo 阶段不需要接真实路径规划,用两点间球面距离乘一个系数就够了。下面这个函数算的是 Haversine 距离,单位公里:
// utils/distance.js function toRad(d) { return d * Math.PI / 180 } // 计算两个经纬度点之间的球面距离,返回公里 function getDistanceKm(lat1, lng1, lat2, lng2) { const R = 6371 // 地球平均半径,公里 const dLat = toRad(lat2 - lat1) const dLng = toRad(lng2 - lng1) const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * c } // 预估价格:起步价 + 里程费,最低不低于起步价 function estimatePrice(distanceKm) { const BASE = 8 // 起步价,元 const PER_KM = 2.5 // 每公里单价,元 const BASE_KM = 3 // 起步价包含的公里数 if (distanceKm <= BASE_KM) return BASE return BASE + (distanceKm - BASE_KM) * PER_KM } module.exports = { getDistanceKm, estimatePrice }逻辑说明:Haversine 公式把地球当球体算两点大圆距离,对城市内几公里的订单来说误差可以忽略。estimatePrice用「起步价含 N 公里,超出部分按公里加价」的常见出租车计费模型,比单纯距离 × 单价更接近真实。注意这里算的是直线距离,实际道路距离通常是直线的 1.3 到 1.5 倍,Demo 里可以在结果上乘 1.4 做粗略修正。
参数说明:R = 6371是地球平均半径,单位公里,换成米就乘 1000。BASE_KM是起步价覆盖的公里数,设 3 公里比较符合多数城市。PER_KM是超出部分单价,设 2.5 元。这些数字都应该是配置项,不要硬编码在业务逻辑里,后面调价方便。
3.3 把选点结果写进订单并展示路线
选完起终点后,下单时把坐标和名称一起写进订单文档,司机端就能直接拿到。展示路线用小程序map组件的polyline属性,把起终点连一条直线:
// pages/passenger/create.js 下单部分 async submitOrder() { const { start, end } = this.data if (!start || !end) { wx.showToast({ title: '请选择起终点', icon: 'none' }) return } const { getDistanceKm, estimatePrice } = require('../../utils/distance') const km = getDistanceKm(start.latitude, start.longitude, end.latitude, end.longitude) const price = estimatePrice(km) const db = wx.cloud.database() const res = await db.collection('orders').add({ data: { status: 0, start, end, distanceKm: Number(km.toFixed(2)), price, createTime: db.serverDate(), passengerOpenid: '{openid}' // 云开发会自动替换成当前用户 } }) wx.navigateTo({ url: `/pages/passenger/order?orderId=${res._id}` }) }逻辑说明:distanceKm保留两位小数,避免存一长串浮点数。price在下单时就算好存进去,后面支付直接用,不要在支付时重算,否则用户看到的价格和下单时不一致会投诉。passengerOpenid用'{openid}'占位符,云开发写入时会自动替换成当前用户 openid,这是云数据库的便捷写法。
参数说明:start和end是chooseLocation返回的完整对象,包含 name、address、latitude、longitude。status: 0对应待接单。createTime用服务端时间。订单 ID 由云数据库自动生成,通过res._id拿到后跳转到订单详情页。
4. 双端同步与司机接单:轮询还是实时监听
4.1 watch 实时监听和定时轮询的取舍
双端同步有两种做法:一是前端定时调接口拉最新订单,二是用云数据库的watch实时监听。轮询实现简单,但间隔设短了费流量、设长了司机接单后乘客要等好几秒才看到。watch是长连接推送,状态一变立刻到,体验好很多。
但watch有它的边界:单个小程序同时监听的文档数量有限制,而且页面卸载必须关。我一般只在订单详情页用watch,列表页用轮询或者下拉刷新。下面是一个司机端监听「待接单列表」的简化写法,实际项目里待接单列表更适合用轮询,因为它是集合查询不是单文档:
// pages/driver/hall.js 司机接单大厅 Page({ data: { orders: [] }, onShow() { this.loadPending() // 每 5 秒刷新一次待接单列表 this.timer = setInterval(() => this.loadPending(), 5000) }, onHide() { clearInterval(this.timer) }, async loadPending() { const db = wx.cloud.database() const res = await db.collection('orders') .where({ status: 0 }) .orderBy('createTime', 'desc') .limit(20) .get() this.setData({ orders: res.data }) }, async grabOrder(e) { const orderId = e.currentTarget.dataset.id const { result } = await wx.cloud.callFunction({ name: 'updateOrder', data: { orderId, nextStatus: 1, role: 'driver' } }) if (result.code !== 0) { wx.showToast({ title: result.msg, icon: 'none' }) return } wx.navigateTo({ url: `/pages/driver/order?orderId=${orderId}` }) } })逻辑说明:待接单列表是「多个订单」的集合,watch对集合查询的支持不如单文档稳定,所以这里用 5 秒轮询。onHide里清定时器是必须的,否则用户切到后台还在跑,耗电。grabOrder调云函数而不是直接改数据库,是为了让状态跃迁走统一入口,防止两个司机同时接单时都写成功。
参数说明:where({ status: 0 })只查待接单。limit(20)限制一次拉 20 条,避免数据量大时卡顿。orderBy('createTime', 'desc')让最新的订单排前面。e.currentTarget.dataset.id是列表项上绑的订单 ID。
4.2 防止两个司机同时接单的并发处理
上面grabOrder有个隐患:两个司机几乎同时点接单,两个云函数实例都读到status: 0,都判断可以跃迁到 1,然后都写成功,结果一个订单被两个司机接了。这是出行类 Demo 最典型的并发坑。
解决办法是在云函数里用数据库的原子更新,把「检查状态」和「更新状态」合成一步:
// cloudfunctions/updateOrder/index.js 接单部分改造 if (nextStatus === 1) { // 只有当前 status 还是 0 时才更新,返回更新条数 const updateRes = await db.collection('orders') .where({ _id: orderId, status: 0 }) .update({ data: { status: 1, driverOpenid: openid, acceptTime: db.serverDate() } }) if (updateRes.stats.updated === 0) { return { code: 409, msg: '手慢了,订单已被接走' } } return { code: 0, msg: 'ok' } }逻辑说明:where({ _id: orderId, status: 0 })把状态作为更新条件,数据库保证这条更新是原子的——如果另一个司机已经把它改成 1,这次where就匹配不到,updated为 0,直接返回失败。这样就不需要加锁,也不用担心竞态。driverOpenid记录接单司机,后面司机端查自己的订单靠它。
参数说明:updateRes.stats.updated是实际更新的文档数,0 表示没更新到。acceptTime记录接单时间,用于后面算司机等待时长。返回码 409 表示冲突,前端拿到后提示用户刷新列表。
4.3 司机端和乘客端的状态展示差异
同一个订单,乘客和司机看到的信息不一样。乘客关心「司机到哪了、还有多久」,司机关心「乘客在哪、路线怎么走」。我一般用同一个订单文档,但两端渲染时取不同字段:
| 字段 | 乘客端展示 | 司机端展示 |
|---|---|---|
| start | 起点名称 | 导航目标 |
| end | 终点名称 | 终点名称 |
| status | 状态文案 | 操作按钮 |
| driverOpenid | 不展示 | 判断是否是自己接的单 |
| price | 预估价格 | 行程结束后展示 |
乘客端在状态 1 时可以展示一个「司机正在赶来」的动画,司机端在状态 1 时展示「已接单,前往上车点」和导航按钮。两端都监听同一个订单文档,任何一方操作,另一方通过watch立刻收到。这里有个细节:司机端在状态 2 点「已到达」后,乘客端按钮才变成「确认上车」,所以状态 2 是两端交互的同步点,不能省。
5. 避坑与常见问题排查
5.1 地图选点返回 null 导致下单失败
现象:用户点了选点,也确认了,但chooseLocation返回null,后面读res.latitude直接报错。
原因:chooseLocation在用户取消时会 reject,如果没 catch,Promise 链断掉,res就是 undefined。另外部分机型在权限没给的情况下也会返回失败。
解决:所有chooseLocation调用都加.catch(() => null),拿到结果先判空再往下走。权限问题在app.json里声明requiredPrivateInfos,并在首次进入时引导用户授权。
5.2 watch 监听不触发或触发多次
现象:司机改了状态,乘客端界面没反应;或者乘客端一次操作触发了两次回调。
原因:watch不触发通常是没在onLoad里建立监听,或者订单 ID 传错。触发多次一般是页面被重复创建,比如navigateTo叠了好几层,每层都建了监听。
解决:监听只在onLoad建一次,onUnload关掉。跳转订单详情用redirectTo而不是navigateTo,避免页面栈里堆多个相同页面。如果确实需要navigateTo,在onShow里判断监听是否已存在,存在就不重复建。
5.3 云函数里拿不到 openid
现象:云函数里cloud.getWXContext().OPENID是 undefined,导致权限校验全失败。
原因:云函数必须由小程序端通过wx.cloud.callFunction调用才会带上用户上下文。如果你在云函数控制台直接测试,或者用 HTTP 触发,是拿不到 openid 的。
解决:本地调试时在云函数控制台测试要手动传一个模拟 openid,或者干脆在小程序开发者工具里点按钮触发。正式逻辑里不要依赖控制台测试的结果,以真机调用为准。
5.4 订单状态跳跃导致流程错乱
现象:乘客还没确认上车,订单直接到了「待支付」。
原因:某个云函数调用没走TRANSITIONS校验,或者前端传了错误的nextStatus。也可能是数据库被手动改过。
解决:所有状态变更必须走updateOrder云函数,云函数里TRANSITIONS校验不能省。数据库权限设成「仅创建者可读写」不够,因为司机也要读,正确做法是设成「所有用户可读,仅管理端可写」,写操作全部收口到云函数。
5.5 真机上地图不显示或定位偏移
现象:开发者工具里地图正常,真机上白屏或者定位点偏到几百米外。
原因:真机需要用户授权定位权限,没授权时地图可能不显示。定位偏移通常是坐标系问题,小程序地图用 GCJ-02,如果你从其他来源拿了 WGS-84 坐标直接塞进去就会偏。
解决:在app.json里声明permission的scope.userLocation,首次进入地图页时调wx.authorize引导授权。坐标统一用chooseLocation返回的,不要自己从别处拼。如果必须用外部坐标,先做坐标系转换再渲染。
6. 把 Demo 跑起来之后还能往哪走
一个能跑通的最小闭环做完之后,你会发现真正有意思的部分才刚开始。我一般会先加一个「模拟司机移动」的功能来验证实时轨迹:在云函数里起一个定时任务,每隔几秒把司机的坐标往乘客方向挪一点,写进订单的driverLocation字段,乘客端地图上就能看到小车在动。这个功能不需要真实司机,但能把「实时位置同步」这条链路完整跑一遍。
// cloudfunctions/mockDriverMove/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() // 把司机位置向终点推进一步,step 是每次移动的经纬度增量 exports.main = async (event) => { const { orderId, step = 0.001 } = event const orderRes = await db.collection('orders').doc(orderId).get() const order = orderRes.data if (!order || order.status !== 1) return { code: 0, msg: 'skip' } const cur = order.driverLocation || order.start const target = order.start // 简单线性插值,朝起点移动 const nextLat = cur.latitude + (target.latitude - cur.latitude) * 0.2 const nextLng = cur.longitude + (target.longitude - cur.longitude) * 0.2 await db.collection('orders').doc(orderId).update({ data: { driverLocation: { latitude: nextLat, longitude: nextLng }, updateTime: db.serverDate() } }) return { code: 0, msg: 'moved' } }逻辑说明:这里用 0.2 的插值系数,每次调用把司机位置往起点挪剩余距离的 20%,越接近越慢,看起来比较自然。step参数留着是为了以后换成按固定距离移动。判断order.status !== 1就跳过,避免订单已经进入行程中还在挪位置。乘客端地图上把driverLocation渲染成一个 marker,配合watch就能看到小车动起来。
参数说明:orderId是订单 ID,step是预留的步长参数,当前用插值系数代替。driverLocation初始值取order.start,也就是司机从起点开始。这个云函数需要配合定时触发器,在云开发控制台配一个每分钟触发一次的定时器,或者在小程序端用setInterval调,但后者切后台会停,只适合演示。
再往后走,可以加的东西包括:行程结束后的费用结算(把distanceKm和实际时长结合算最终价)、订单评价、历史订单列表。但我的习惯是每加一个功能之前先问自己:这个功能会不会引入新的状态?如果会,先改TRANSITIONS表,再写代码。状态机是这类小程序的骨架,骨架歪了,后面加什么都是补丁摞补丁。我踩过最深的坑就是一开始图省事,把「司机到达」和「行程中」合成一个状态,结果乘客端和司机端的按钮逻辑纠缠不清,最后花了两倍时间拆回来。希望帮到你。
本文还有配套的精品资源,点击获取