記一次基於mpvue的小程式開發及上線實戰

Jrain發表於2019-01-31

寫於 2018.04.20

記一次基於mpvue的小程式開發及上線實戰

經過為期兩個晚上下班時間的努力,終於把我第一個小程式開發完成併發布上線了。整個過程還算順利,由於使用了mpvue方案進行開發,故可以享受和vue一致的流暢開發體驗;後臺系統使用了python3+flask框架進行,使用最少的程式碼完成了小程式的後臺邏輯。除了開發之外,還實實在在地體驗了一把微信小程式的開發流程,包括開發者工具的使用、體驗版的釋出、上線的申請等等。這些開發體驗都非常值得被記錄下來,於是便趁熱打鐵,寫下這篇文章。

一、需求&功能

由於公司裡有相當多的同事都住在同一個小區,所以上下班的時候經常會在公司群裡組織拼車。但是由於完全依賴聊天記錄,且上下班拼車的同事也很多,依賴群聊很容易把訊息刷走,而且容易造成資訊錯亂。既然如此,那麼完全可以開發一個小工具把這些問題解決。

發起拼車的人把出發地點、目的地點、叫車資訊以卡片的形式分享出來,參與拼車的人點選卡片就能選擇參加拼車,並且能看到同車拼友是誰,拼單的資訊等等內容。

互動流程如下:

記一次基於mpvue的小程式開發及上線實戰

可以看到,邏輯是非常簡單的,我們只需要保證生成拼單、分享拼單、進入拼單和退出拼單這四個功能就好。

需求和功能已經確定好,首先按照小程式官網的介紹,註冊好小程式並拿到appId,接下來可以開始進行後臺邏輯的開發。

二、後臺邏輯開發

由於時間倉促,功能又簡單,所以並沒有考慮任何高併發等複雜場景,僅僅考慮功能的實現。從需求的邏輯可以知道,其實後臺只需要維護兩個列表,分別儲存當前所有拼車單以及當前所有參與了拼車的使用者即可,其資料結構如下:

  • 當前所有拼單列表billsList
記一次基於mpvue的小程式開發及上線實戰
  • 當前所有參與了拼車的使用者列表inBillUsers
記一次基於mpvue的小程式開發及上線實戰

當使用者確定並分享了一個拼單之後,會直接新建一個拼單,同時把該使用者新增到當前所有參與了拼車的使用者列表列表裡面,並且新增到該拼單的成員列表當中:

記一次基於mpvue的小程式開發及上線實戰

只要維護好這兩個列表,接下來就是具體的業務邏輯了。

為了快速開發,這裡我使用了python3+flask框架的方案。不懂python的讀者看到這裡也不用緊張,程式碼非常簡單且直白,看看也無妨。

首先新建一個BillController類:

class BillController:
	billsList = []
	inBillUsers = []
複製程式碼

接下來會在這個類的內部新增建立拼單獲取拼單參與拼單退出拼單判斷使用者是否在某一拼單中圖片上傳的功能。

1、獲取拼單getBill()

該方法接收客戶端傳來的拼單ID,然後拿這個ID去檢索是否存在對應的拼單。若存在則返回對應的拼單,否則報錯給客戶端。

    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、建立拼單createBill()

該方法會接收來自客戶端的使用者資訊拼單資訊,分別新增到billsListinBillUsers當中。

    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`: `使用者已經在拼單中!`
			}, 1)

		self.billsList.append(bill)
		self.inBillUsers.append(user)
		return response({
			`billsList`: self.billsList,
			`inBillUsers`: self.inBillUsers
		})
複製程式碼

建立完成後,會返回當前的billsListinBillUsers到客戶端。

3、參與拼單joinBill()

接收客戶端傳來的使用者資訊拼單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`: `使用者已經在拼單中!`
			}, 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()

接收客戶端傳來的使用者ID拼單ID,然後刪除掉兩個列表裡面的該使用者。

這個函式還有一個功能,如果判斷到這個拼單ID所對應的拼單成員為空,會認為該拼單已經作廢,會直接刪除掉這個拼單以及所對應的車輛資訊圖片。

    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()

接收客戶端傳來的使用者ID,接下來會根據這個使用者ID去inBillUsers裡面去檢索該使用者所對應的拼單,如果能檢索到,會返回其所在的拼單。

    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()

接收客戶端傳來的拼單ID圖片資源,先儲存圖片,然後把該圖片的路徑寫入對應拼單ID的拼單當中。

    def uploadImg(self, ctx):
		billId = ctx.form[`billId`]
		file = ctx.files[`file`]
		filename = file.filename
		file.save(os.path.join(`./imgs`, filename))
		# 把圖片資訊掛載到對應的拼單
		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
		})
複製程式碼

完成了業務邏輯的功能,接下來就是把它們分發給不同的路由了:

@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)
複製程式碼

完整的程式碼可以直接到倉庫檢視,這裡僅展示關鍵的內容。

三、前端業務開發

前端藉助vue-cli直接使用了mpvue的mpvue-quickstart來初始化專案,具體過程不再細述,直接進入業務開發部分。

首先,微信小程式的API都是callback風格,為了使用方便,我把用到的小程式API都包裝成了Promise,統一放在src/utils/wx.js內部,類似下面這樣:

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、註冊全域性Store

由於開發習慣,我喜歡把所有介面請求都放在store裡面的actions當中,所以這個小程式也是需要用到Vuex。但由於小程式每一個Page都是一個新的Vue例項,所以按照Vue的方式,用全域性Vue.use(Vuex)是不會把$store註冊到例項當中的,這一步要手動來。

src/目錄下新建一個store.js檔案,然後在裡面進行使用註冊:

import Vue from `vue`
import Vuex from `vuex`


Vue.use(Vuex)

export default new Vuex.Store({})
複製程式碼

接下來在src/main.js當中,手動在Vue的原型裡註冊一個$store

import Vue from `vue`
import App from `./App`
import Store from `./store`

Vue.prototype.$store = Store
複製程式碼

這樣,以後在任何的Page裡都可以通過this.$store來操作這個全域性Store了。

2、構建好請求的API介面

和後臺系統的邏輯對應,前端也要構造好各個請求的API介面,這樣的做法能夠避免把API邏輯分散到頁面四處,具有清晰、易維護的優勢。

    /**
     * @param  {} {commit}
     * 獲取使用者公開資訊
     */
    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
     * 檢查使用者是否已經存在於某一拼單中
     */
    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   出發時間
     * @param  { String } from   出發地點
     * @param  { String } to     目的地點
     * @param  { String } billId 拼單ID
     * 建立拼單
     */
    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、填寫拼單並實現分享功能實現

新建一個src/pages/index目錄,作為小程式的首頁。

該首頁的業務邏輯如下:

  1. 進入首頁的時候先獲取使用者資訊,得到userId
  2. 然後用userId去請求判斷是否已經處於拼單
  3. 若是,則跳轉到對應拼單Id的詳情頁
  4. 若否,才允許新建拼單

onShow的生命週期鉤子中實現上述邏輯:

  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`)
    }
  },
複製程式碼

當使用者填寫完拼單後,會點選一個帶有open-type="share"屬性的button,然後會觸發onShareAppMessage生命週期鉤子的邏輯把拼單構造成卡片分享出去。當分享成功後會跳轉到對應拼單ID的參加拼單頁。

  onShareAppMessage (result) {
    let title = `一起拼車`
    let path = `/pages/index`
    if (result.from === `button`) {
      this.billId = `billId-` + new Date().getTime()
      title = `我發起了一個拼車`
      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
        })
        
        // 分享成功後,會帶著billId跳轉到參加拼單頁
        wx.redirectTo(`../join/main?billId=${this.billId}`)
      },
      fail (e) {
        console.log(e)
      }
    }
  },
複製程式碼

4、參與拼單&退出拼單功能實現

新建一個src/pages/join目錄,作為小程式的“參加拼單頁”。

該頁面的執行邏輯如下:

  1. 首先會獲取從url裡面帶來的billId
  2. 其次會請求一次userInfo,獲取userId
  3. 然後拿這個userId去檢查該使用者是否已經處於拼單
  4. 如果已經處於拼單,那麼就會獲取一個新的billId代替從url獲取的
  5. 拿當前的billId去查詢對應的拼單資訊
  6. 如果billId都無效,則redirect到首頁

由於要獲取url攜帶的內容,親測onShow()是不行的,只能在onLoad()裡面獲取:

  async onLoad (options) {
    // 1. 首先會獲取從url裡面帶來的billId
    this.billId = options.billId
    // 2. 其次會請求一次userInfo,獲取userId
    this.userInfo = await this.$store.dispatch(`getUserInfo`)
    // 3. 然後拿這個userId去檢查該使用者是否已經處於拼單
    const inBill = await this.$store.dispatch(`checkInBill`, this.userInfo.userId)
    // 4. 如果已經處於拼單,那麼就會有一個billId
    if (inBill.inBill) {
      this.billId = inBill.inBill.billId
    }
    // 5. 如果沒有處於拼單,那麼將請求當前billId的拼單
    // 6. 如果billId都無效,則redirect到首頁,否則檢查當前使用者是否處於該拼單當中
    await this.getBillInfo()
  }
複製程式碼

此外,當使用者點選“參與拼車”後,需要重新請求拼單資訊,以重新整理檢視拼車人員列表;當使用者點選“退出拼車”後,要重定向到首頁。

經過上面幾個步驟,客戶端的邏輯已經完成,可以進行預釋出了。

四、預釋出&申請上線

如果要釋出預釋出版本,需要執行npm run build命令,打包出一個生產版本的包,然後通過小程式開發者工具的上傳按鈕上傳程式碼,並填寫測試版本號:

記一次基於mpvue的小程式開發及上線實戰

接下來可以在小程式管理後臺→開發管理→開發版本當中看到體驗版小程式的資訊,然後選擇釋出體驗版即可:

記一次基於mpvue的小程式開發及上線實戰

當確定預釋出測試無誤之後,就可以點選“提交稽核”,正式把小程式提交給微信團隊進行稽核。稽核的時間非常快,在3小時內基本都能夠有答覆。

值得注意的是,小程式所有請求的API,都必須經過域名備案使用https證照,同時要在設定→開發設定→伺服器域名裡面把API新增到白名單才可以正常使用。

五、後記

這個小程式現在已經發布上線了,算是完整體驗了一把小程式的開發樂趣。小程式得到了微信團隊的大力支援,以後的生態只會越來越繁榮。當初小程式上線的時候我也對它有一些牴觸,但後來想了想,這只不過是前端工程師所需面對的又一個“端“而已,沒有必要為它戴上有色眼鏡,多掌握一些總是好的。

“一起叫車吧”微信小程式依然是一個玩具般的存在,僅供自己學習和探索,當然也歡迎各位讀者能夠貢獻程式碼,參與開發~

相關文章