小程序名稱:一起打車吧
-
項(xiàng)目地址:
客戶端:https://github.com/jrainlau/t...
服務(wù)端:https://github.com/jrainlau/t...
小程序二維碼:
經(jīng)過(guò)為期兩個(gè)晚上下班時(shí)間的努力肉津,終于把我第一個(gè)小程序開(kāi)發(fā)完成并發(fā)布上線了施敢。整個(gè)過(guò)程還算順利样刷,由于使用了
mpvue
方案進(jìn)行開(kāi)發(fā),故可以享受和vue
一致的流暢開(kāi)發(fā)體驗(yàn);后臺(tái)系統(tǒng)使用了python3
+flask
框架進(jìn)行岩遗,使用最少的代碼完成了小程序的后臺(tái)邏輯吩坝。除了開(kāi)發(fā)之外,還實(shí)實(shí)在在地體驗(yàn)了一把微信小程序的開(kāi)發(fā)流程缠诅,包括開(kāi)發(fā)者工具的使用溶浴、體驗(yàn)版的發(fā)布、上線的申請(qǐng)等等管引。這些開(kāi)發(fā)體驗(yàn)都非常值得被記錄下來(lái)士败,于是便趁熱打鐵,寫(xiě)下這篇文章褥伴。
一谅将、需求&功能
由于公司里有相當(dāng)多的同事都住在同一個(gè)小區(qū)漾狼,所以上下班的時(shí)候經(jīng)常會(huì)在公司群里組織拼車。但是由于完全依賴聊天記錄饥臂,且上下班拼車的同事也很多邦投,依賴群聊很容易把消息刷走,而且容易造成信息錯(cuò)亂擅笔。既然如此志衣,那么完全可以開(kāi)發(fā)一個(gè)小工具把這些問(wèn)題解決。
發(fā)起拼車的人把出發(fā)地點(diǎn)猛们、目的地點(diǎn)念脯、打車信息以卡片的形式分享出來(lái),參與拼車的人點(diǎn)擊卡片就能選擇參加拼車弯淘,并且能看到同車拼友是誰(shuí)绿店,拼單的信息等等內(nèi)容。
交互流程如下:
可以看到庐橙,邏輯是非常簡(jiǎn)單的假勿,我們只需要保證生成拼單、分享拼單态鳖、進(jìn)入拼單和退出拼單這四個(gè)功能就好转培。
需求和功能已經(jīng)確定好,首先按照小程序官網(wǎng)的介紹浆竭,注冊(cè)好小程序并拿到appId
浸须,接下來(lái)可以開(kāi)始進(jìn)行后臺(tái)邏輯的開(kāi)發(fā)。
二邦泄、后臺(tái)邏輯開(kāi)發(fā)
由于時(shí)間倉(cāng)促删窒,功能又簡(jiǎn)單,所以并沒(méi)有考慮任何高并發(fā)等復(fù)雜場(chǎng)景顺囊,僅僅考慮功能的實(shí)現(xiàn)肌索。從需求的邏輯可以知道,其實(shí)后臺(tái)只需要維護(hù)兩個(gè)列表特碳,分別存儲(chǔ)當(dāng)前所有拼車單以及當(dāng)前所有參與了拼車的用戶即可诚亚,其數(shù)據(jù)結(jié)構(gòu)如下:
- 當(dāng)前所有拼單列表
billsList
- 當(dāng)前所有參與了拼車的用戶列表
inBillUsers
當(dāng)用戶確定并分享了一個(gè)拼單之后,會(huì)直接新建一個(gè)拼單测萎,同時(shí)把該用戶添加到當(dāng)前所有參與了拼車的用戶列表列表里面亡电,并且添加到該拼單的成員列表當(dāng)中:
只要維護(hù)好這兩個(gè)列表,接下來(lái)就是具體的業(yè)務(wù)邏輯了硅瞧。
為了快速開(kāi)發(fā)份乒,這里我使用了python3
+flask
框架的方案。不懂python
的讀者看到這里也不用緊張,代碼非常簡(jiǎn)單且直白或辖,看看也無(wú)妨瘾英。
首先新建一個(gè)BillController
類:
class BillController:
billsList = []
inBillUsers = []
接下來(lái)會(huì)在這個(gè)類的內(nèi)部添加創(chuàng)建拼單、獲取拼單颂暇、參與拼單缺谴、退出拼單、判斷用戶是否在某一拼單中耳鸯、圖片上傳的功能湿蛔。
1、獲取拼單getBill()
該方法接收客戶端傳來(lái)的拼單ID县爬,然后拿這個(gè)ID去檢索是否存在對(duì)應(yīng)的拼單阳啥。若存在則返回對(duì)應(yīng)的拼單,否則報(bào)錯(cuò)給客戶端财喳。
def getBill(self, ctx):
ctxBody = ctx.form
billId = ctxBody['billId']
try:
return response([item for item in self.billsList if item['billId'] == billId][0])
except IndexError:
return response({
'errMsg': '拼單不存在察迟!',
'billsList': self.billsList,
}, 1)
2、創(chuàng)建拼單createBill()
該方法會(huì)接收來(lái)自客戶端的用戶信息和拼單信息耳高,分別添加到billsList
和inBillUsers
當(dāng)中扎瓶。
def createBill(self, ctx):
ctxBody = ctx.form
user = {
'userId': ctxBody['userId'],
'billId': ctxBody['billId'],
'name': ctxBody['name'],
'avatar': ctxBody['avatar']
}
bill = {
'billId': ctxBody['billId'],
'from': ctxBody['from'],
'to': ctxBody['to'],
'time': ctxBody['time'],
'members': [user]
}
if ctxBody['userId'] in [item['userId'] for item in self.inBillUsers]:
return response({
'errMsg': '用戶已經(jīng)在拼單中!'
}, 1)
self.billsList.append(bill)
self.inBillUsers.append(user)
return response({
'billsList': self.billsList,
'inBillUsers': self.inBillUsers
})
創(chuàng)建完成后泌枪,會(huì)返回當(dāng)前的billsList
和inBillUsers
到客戶端概荷。
3、參與拼單joinBill()
接收客戶端傳來(lái)的用戶信息和拼單ID工闺,把用戶添加到拼單和inBillUsers
列表中乍赫。
def joinBill(self, ctx):
ctxBody = ctx.form
billId = ctxBody['billId']
user = {
'userId': ctxBody['userId'],
'name': ctxBody['name'],
'avatar': ctxBody['avatar'],
'billId': ctxBody['billId']
}
if ctxBody['userId'] in [item['userId'] for item in self.inBillUsers]:
return response({
'errMsg': '用戶已經(jīng)在拼單中!'
}, 1)
theBill = [item for item in self.billsList if item['billId'] == billId]
if not theBill:
return response({
'errMsg': '拼單不存在'
}, 1)
theBill[0]['members'].append(user)
self.inBillUsers.append(user)
return response({
'billsList': self.billsList,
'inBillUsers': self.inBillUsers
})
4陆蟆、退出拼單leaveBill()
接收客戶端傳來(lái)的用戶ID和拼單ID,然后刪除掉兩個(gè)列表里面的該用戶惋增。
這個(gè)函數(shù)還有一個(gè)功能叠殷,如果判斷到這個(gè)拼單ID所對(duì)應(yīng)的拼單成員為空,會(huì)認(rèn)為該拼單已經(jīng)作廢诈皿,會(huì)直接刪除掉這個(gè)拼單以及所對(duì)應(yīng)的車輛信息圖片林束。
def leaveBill(self, ctx):
ctxBody = ctx.form
billId = ctxBody['billId']
userId = ctxBody['userId']
indexOfUser = [i for i, member in enumerate(self.inBillUsers) if member['userId'] == userId][0]
indexOfTheBill = [i for i, bill in enumerate(self.billsList) if bill['billId'] == billId][0]
indexOfUserInBill = [i for i, member in enumerate(self.billsList[indexOfTheBill]['members']) if member['userId'] == userId][0]
# 刪除拼單里面的該用戶
self.billsList[indexOfTheBill]['members'].pop(indexOfUserInBill)
# 刪除用戶列表里面的該用戶
self.inBillUsers.pop(indexOfUser)
# 如果拼單里面用戶為空,則直接刪除這筆拼單
if len(self.billsList[indexOfTheBill]['members']) == 0:
imgPath = './imgs/' + self.billsList[indexOfTheBill]['img'].split('/getImg')[1]
if os.path.exists(imgPath):
os.remove(imgPath)
self.billsList.pop(indexOfTheBill)
return response({
'billsList': self.billsList,
'inBillUsers': self.inBillUsers
})
5稽亏、判斷用戶是否在某一拼單中inBill()
接收客戶端傳來(lái)的用戶ID壶冒,接下來(lái)會(huì)根據(jù)這個(gè)用戶ID去inBillUsers
里面去檢索該用戶所對(duì)應(yīng)的拼單,如果能檢索到截歉,會(huì)返回其所在的拼單胖腾。
def inBill(self, ctx):
ctxBody = ctx.form
userId = ctxBody['userId']
if ctxBody['userId'] in [item['userId'] for item in self.inBillUsers]:
return response({
'inBill': [item for item in self.inBillUsers if ctxBody['userId'] == item['userId']][0],
'billsList': self.billsList,
'inBillUsers': self.inBillUsers
})
return response({
'inBill': False,
'billsList': self.billsList,
'inBillUsers': self.inBillUsers
})
6、圖片上傳uploadImg()
接收客戶端傳來(lái)的拼單ID和圖片資源,先存儲(chǔ)圖片咸作,然后把該圖片的路徑寫(xiě)入對(duì)應(yīng)拼單ID的拼單當(dāng)中锨阿。
def uploadImg(self, ctx):
billId = ctx.form['billId']
file = ctx.files['file']
filename = file.filename
file.save(os.path.join('./imgs', filename))
# 把圖片信息掛載到對(duì)應(yīng)的拼單
indexOfTheBill = [i for i, bill in enumerate(self.billsList) if bill['billId'] == billId][0]
self.billsList[indexOfTheBill]['img'] = url_for('getImg', filename=filename)
return response({
'billsList': self.billsList
})
完成了業(yè)務(wù)邏輯的功能,接下來(lái)就是把它們分發(fā)給不同的路由了:
@app.route('/create', methods = ['POST'])
def create():
return controller.createBill(request)
@app.route('/join', methods = ['POST'])
def join():
return controller.joinBill(request)
@app.route('/leave', methods = ['POST'])
def leave():
return controller.leaveBill(request)
@app.route('/getBill', methods = ['POST'])
def getBill():
return controller.getBill(request)
@app.route('/inBill', methods = ['POST'])
def inBill():
return controller.inBill(request)
@app.route('/uploadImg', methods = ['POST'])
def uploadImg():
return controller.uploadImg(request)
@app.route('/getImg/<filename>')
def getImg(filename):
return send_from_directory('./imgs', filename)
完整的代碼可以直接到倉(cāng)庫(kù)查看记罚,這里僅展示關(guān)鍵的內(nèi)容墅诡。
三、前端業(yè)務(wù)開(kāi)發(fā)
前端借助vue-cli
直接使用了mpvue的mpvue-quickstart來(lái)初始化項(xiàng)目桐智,具體過(guò)程不再細(xì)述末早,直接進(jìn)入業(yè)務(wù)開(kāi)發(fā)部分。
首先说庭,微信小程序的API都是callback風(fēng)格荐吉,為了使用方便,我把用到的小程序API都包裝成了Promise
口渔,統(tǒng)一放在src/utils/wx.js
內(nèi)部样屠,類似下面這樣:
export const request = obj => new Promise((resolve, reject) => {
wx.request({
url: obj.url,
data: obj.data,
header: { 'content-type': 'application/x-www-form-urlencoded', ...obj.header },
method: obj.method,
success (res) {
resolve(res.data.data)
},
fail (e) {
console.log(e)
reject(e)
}
})
})
1、注冊(cè)全局Store
由于開(kāi)發(fā)習(xí)慣缺脉,我喜歡把所有接口請(qǐng)求都放在store里面的actions
當(dāng)中痪欲,所以這個(gè)小程序也是需要用到Vuex
。但由于小程序每一個(gè)Page都是一個(gè)新的Vue實(shí)例攻礼,所以按照Vue的方式业踢,用全局Vue.use(Vuex)
是不會(huì)把$store
注冊(cè)到實(shí)例當(dāng)中的,這一步要手動(dòng)來(lái)礁扮。
在src/
目錄下新建一個(gè)store.js
文件知举,然后在里面進(jìn)行使用注冊(cè):
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({})
接下來(lái)在src/main.js
當(dāng)中,手動(dòng)在Vue的原型里注冊(cè)一個(gè)$store
:
import Vue from 'vue'
import App from './App'
import Store from './store'
Vue.prototype.$store = Store
這樣太伊,以后在任何的Page里都可以通過(guò)this.$store
來(lái)操作這個(gè)全局Store了雇锡。
2、構(gòu)建好請(qǐng)求的API接口
和后臺(tái)系統(tǒng)的邏輯對(duì)應(yīng)僚焦,前端也要構(gòu)造好各個(gè)請(qǐng)求的API接口锰提,這樣的做法能夠避免把API邏輯分散到頁(yè)面四處,具有清晰芳悲、易維護(hù)的優(yōu)勢(shì)立肘。
/**
* @param {} {commit}
* 獲取用戶公開(kāi)信息
*/
async getUserInfo ({ commit }) {
const { userInfo } = await getUserInfo({
withCredenitals: false
})
userInfo.avatar = userInfo.avatarUrl
userInfo.name = userInfo.nickName
userInfo.userId = encodeURIComponent(userInfo.nickName + userInfo.city + userInfo.gender + userInfo.country)
commit('GET_USER_INFO', userInfo)
return userInfo
},
/**
* @param {} {commit}
* @param { String } userId 用戶ID
* 檢查用戶是否已經(jīng)存在于某一拼單中
*/
async checkInBill ({ commit }, userId) {
const res = await request({
method: 'post',
url: `${apiDomain}/inBill`,
data: {
userId
}
})
return res
},
/**
* @param {} {commit}
* @param { String } userId 用戶ID
* @param { String } name 用戶昵稱
* @param { String } avatar 用戶頭像
* @param { String } time 出發(fā)時(shí)間
* @param { String } from 出發(fā)地點(diǎn)
* @param { String } to 目的地點(diǎn)
* @param { String } billId 拼單ID
* 創(chuàng)建拼單
*/
async createBill ({ commit }, { userId, name, avatar, time, from, to, billId }) {
const res = await request({
method: 'post',
url: `${apiDomain}/create`,
data: {
userId,
name,
avatar,
time,
from,
to,
billId
}
})
commit('GET_BILL_INFO', res)
return res
},
/**
* @param {} {commit}
* @param { String } billId 拼單ID
* 獲取拼單信息
*/
async getBillInfo ({ commit }, billId) {
const res = await request({
method: 'post',
url: `${apiDomain}/getBill`,
data: {
billId
}
})
return res
},
/**
* @param {} {commit}
* @param { String } userId 用戶ID
* @param { String } name 用戶昵稱
* @param { String } avatar 用戶頭像
* @param { String } billId 拼單ID
* 參加拼單
*/
async joinBill ({ commit }, { userId, name, avatar, billId }) {
const res = await request({
method: 'post',
url: `${apiDomain}/join`,
data: {
userId,
name,
avatar,
billId
}
})
return res
},
/**
* @param {} {commit}
* @param { String } userId 用戶ID
* @param { String } billId 拼單ID
* 退出拼單
*/
async leaveBill ({ commit }, { userId, billId }) {
const res = await request({
method: 'post',
url: `${apiDomain}/leave`,
data: {
userId,
billId
}
})
return res
},
/**
* @param {} {commit}
* @param { String } filePath 圖片路徑
* @param { String } billId 拼單ID
* 參加拼單
*/
async uploadImg ({ commit }, { filePath, billId }) {
const res = await uploadFile({
url: `${apiDomain}/uploadImg`,
header: {
'content-type': 'multipart/form-data'
},
filePath,
name: 'file',
formData: {
'billId': billId
}
})
return res
}
3、填寫(xiě)拼單并實(shí)現(xiàn)分享功能實(shí)現(xiàn)
新建一個(gè)src/pages/index
目錄名扛,作為小程序的首頁(yè)谅年。
該首頁(yè)的業(yè)務(wù)邏輯如下:
- 進(jìn)入首頁(yè)的時(shí)候先獲取用戶信息,得到userId
- 然后用userId去請(qǐng)求判斷是否已經(jīng)處于拼單
- 若是肮韧,則跳轉(zhuǎn)到對(duì)應(yīng)拼單Id的詳情頁(yè)
- 若否融蹂,才允許新建拼單
在onShow
的生命周期鉤子中實(shí)現(xiàn)上述邏輯:
async onShow () {
this.userInfo = await this.$store.dispatch('getUserInfo')
const inBill = await this.$store.dispatch('checkInBill', this.userInfo.userId)
if (inBill.inBill) {
wx.redirectTo(`../join/main?billId=${inBill.inBill.billId}&fromIndex=true`)
}
},
當(dāng)用戶填寫(xiě)完拼單后旺订,會(huì)點(diǎn)擊一個(gè)帶有open-type="share"
屬性的button,然后會(huì)觸發(fā)onShareAppMessage
生命周期鉤子的邏輯把拼單構(gòu)造成卡片分享出去殿较。當(dāng)分享成功后會(huì)跳轉(zhuǎn)到對(duì)應(yīng)拼單ID的參加拼單頁(yè)耸峭。
onShareAppMessage (result) {
let title = '一起拼車'
let path = '/pages/index'
if (result.from === 'button') {
this.billId = 'billId-' + new Date().getTime()
title = '我發(fā)起了一個(gè)拼車'
path = `pages/join/main?billId=${this.billId}`
}
return {
title,
path,
success: async (res) => {
await this.$store.dispatch('createBill', { ...this.userInfo, ...this.billInfo })
// 上傳圖片
await this.$store.dispatch('uploadImg', {
filePath: this.imgSrc,
billId: this.billId
})
// 分享成功后,會(huì)帶著billId跳轉(zhuǎn)到參加拼單頁(yè)
wx.redirectTo(`../join/main?billId=${this.billId}`)
},
fail (e) {
console.log(e)
}
}
},
4淋纲、參與拼單&退出拼單功能實(shí)現(xiàn)
新建一個(gè)src/pages/join
目錄劳闹,作為小程序的“參加拼單頁(yè)”。
該頁(yè)面的運(yùn)行邏輯如下:
- 首先會(huì)獲取從url里面帶來(lái)的billId
- 其次會(huì)請(qǐng)求一次userInfo洽瞬,獲取userId
- 然后拿這個(gè)userId去檢查該用戶是否已經(jīng)處于拼單
- 如果已經(jīng)處于拼單本涕,那么就會(huì)獲取一個(gè)新的billId代替從url獲取的
- 拿當(dāng)前的billId去查詢對(duì)應(yīng)的拼單信息
- 如果billId都無(wú)效,則redirect到首頁(yè)
由于要獲取url攜帶的內(nèi)容伙窃,親測(cè)onShow()
是不行的菩颖,只能在onLoad()
里面獲取:
async onLoad (options) {
// 1\. 首先會(huì)獲取從url里面帶來(lái)的billId
this.billId = options.billId
// 2\. 其次會(huì)請(qǐng)求一次userInfo为障,獲取userId
this.userInfo = await this.$store.dispatch('getUserInfo')
// 3\. 然后拿這個(gè)userId去檢查該用戶是否已經(jīng)處于拼單
const inBill = await this.$store.dispatch('checkInBill', this.userInfo.userId)
// 4\. 如果已經(jīng)處于拼單晦闰,那么就會(huì)有一個(gè)billId
if (inBill.inBill) {
this.billId = inBill.inBill.billId
}
// 5\. 如果沒(méi)有處于拼單,那么將請(qǐng)求當(dāng)前billId的拼單
// 6\. 如果billId都無(wú)效鳍怨,則redirect到首頁(yè)呻右,否則檢查當(dāng)前用戶是否處于該拼單當(dāng)中
await this.getBillInfo()
}
此外,當(dāng)用戶點(diǎn)擊“參與拼車”后鞋喇,需要重新請(qǐng)求拼單信息声滥,以刷新視圖拼車人員列表;當(dāng)用戶點(diǎn)擊“退出拼車”后侦香,要重定向到首頁(yè)落塑。
經(jīng)過(guò)上面幾個(gè)步驟,客戶端的邏輯已經(jīng)完成罐韩,可以進(jìn)行預(yù)發(fā)布了憾赁。
四、預(yù)發(fā)布&申請(qǐng)上線
如果要發(fā)布預(yù)發(fā)布版本伴逸,需要運(yùn)行npm run build
命令缠沈,打包出一個(gè)生產(chǎn)版本的包,然后通過(guò)小程序開(kāi)發(fā)者工具的上傳按鈕上傳代碼错蝴,并填寫(xiě)測(cè)試版本號(hào):
接下來(lái)可以在小程序管理后臺(tái)→開(kāi)發(fā)管理→開(kāi)發(fā)版本當(dāng)中看到體驗(yàn)版小程序的信息,然后選擇發(fā)布體驗(yàn)版即可:
當(dāng)確定預(yù)發(fā)布測(cè)試無(wú)誤之后颓芭,就可以點(diǎn)擊“提交審核”顷锰,正式把小程序提交給微信團(tuán)隊(duì)進(jìn)行審核。審核的時(shí)間非惩鑫剩快官紫,在3小時(shí)內(nèi)基本都能夠有答復(fù)肛宋。
值得注意的是,小程序所有請(qǐng)求的API束世,都必須經(jīng)過(guò)域名備案和使用https證書(shū)酝陈,同時(shí)要在設(shè)置→開(kāi)發(fā)設(shè)置→服務(wù)器域名里面把API添加到白名單才可以正常使用。
五毁涉、后記
這個(gè)小程序現(xiàn)在已經(jīng)發(fā)布上線了沉帮,算是完整體驗(yàn)了一把小程序的開(kāi)發(fā)樂(lè)趣。小程序得到了微信團(tuán)隊(duì)的大力支持贫堰,以后的生態(tài)只會(huì)越來(lái)越繁榮穆壕。當(dāng)初小程序上線的時(shí)候我也對(duì)它有一些抵觸,但后來(lái)想了想其屏,這只不過(guò)是前端工程師所需面對(duì)的又一個(gè)“端“而已喇勋,沒(méi)有必要為它戴上有色眼鏡,多掌握一些總是好的偎行。
“一起打車吧”微信小程序依然是一個(gè)玩具般的存在川背,僅供自己學(xué)習(xí)和探索,當(dāng)然也歡迎各位讀者能夠貢獻(xiàn)代碼蛤袒,參與開(kāi)發(fā)~