小程式·雲開發實戰 - 迷你微博

騰訊雲開發TCB發表於2019-07-24

0. 前言

本文將手把手教你如何寫出迷你版微博的一行行程式碼,迷你版微博包含以下功能:

  • Feed 流:關注動態、所有動態
  • 傳送圖文動態
  • 搜尋使用者
  • 關注系統
  • 點贊動態
  • 個人主頁

使用到的雲開發能力:

  • 雲資料庫
  • 雲端儲存
  • 雲函式
  • 雲呼叫

沒錯,幾乎是所有的雲開發能力。也就是說,讀完這篇實戰,你就相當於完全入門了雲開發!

咳咳,當然,實際上這裡只是介紹核心邏輯和重點程式碼片段,完整程式碼建議下載檢視。

1. 取得授權

作為一個社交平臺,首先要做的肯定是經過使用者授權,獲取使用者資訊,小程式提供了很方便的介面:

<button open-type="getUserInfo" bindgetuserinfo="getUserInfo">
  進入小圈圈
</button>

這個 button 有個 open-type 屬性,這個屬性是專門用來使用小程式的開放能力的,而 getUserInfo 則表示 獲取使用者資訊,可以從bindgetuserinfo回撥中獲取到使用者資訊

於是我們可以在 wxml 裡放入這個 button 後,在相應的 js 裡寫如下程式碼:

Page({
  ...

  getUserInfo: function(e) {
    wx.navigateTo({
      url: "/pages/circle/circle"
    })
  },

  ...
})

這樣在成功獲取到使用者資訊後,我們就能跳轉到迷你微博頁面了。

需要注意,不能使用 wx.authorize({scope: "scope.userInfo"}) 來獲取讀取使用者資訊的許可權,因為它不會跳出授權彈窗。目前只能使用上面所述的方式實現。

2. 主頁設計

社交平臺的主頁大同小異,主要由三個部分組成:

  • Feed 流
  • 訊息
  • 個人資訊

那麼很容易就能想到這樣的佈局(注意新建一個 Page 哦,路徑:pages/circle/circle.wxml):

<view class="circle-container">
  <view
    style="display:{{currentPage === 'main' ? 'block' : 'none'}}"
    class="main-area"
  >
  </view>

  <view
    style="display:{{currentPage === 'msg' ? 'flex' : 'none'}}"
    class="msg-area"
  >
  </view>

  <view
    style="display:{{currentPage === 'me' ? 'flex' : 'none'}}"
    class="me-area"
  >
  </view>

  <view class="footer">
    <view class="footer-item">
      <button
        class="footer-btn"
        bindtap="onPageMainTap"
        style="background: {{currentPage === 'main' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'main' ? '#fff' : '#000'}}"
      >
        首頁
      </button>
    </view>
    <view class="footer-item">
      <button
        class="footer-btn"
        bindtap="onPageMsgTap"
        style="background: {{currentPage === 'msg' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'msg' ? '#fff' : '#000'}}"
      >
        訊息
      </button>
    </view>
    <view class="footer-item">
      <button
        class="footer-btn"
        bindtap="onPageMeTap"
        style="background: {{currentPage === 'me' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'me' ? '#fff' : '#000'}}"
      >
        個人
      </button>
    </view>
  </view>
</view>

很好理解,畫面主要被分為上下兩個部分:上面的部分是主要內容,下面的部分是三個 Tab 組成的 Footer。重點 WXSS 實現(完整的 WXSS 可以下載原始碼檢視):

.footer {
  box-shadow: 0 0 15rpx #ccc;
  display: flex;
  position: fixed;
  height: 120rpx;
  bottom: 0;
  width: 100%;
  flex-direction: row;
  justify-content: center;
  z-index: 100;
  background: #fff;
}

.footer-item {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  width: 33.33%;
  color: #333;
}

.footer-item:nth-child(2) {
  border-left: 3rpx solid #aaa;
  border-right: 3rpx solid #aaa;
  flex-grow: 1;
}

.footer-btn {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 0;
  font-size: 30rpx;
}

核心邏輯是通過 position: fixed 來讓 Footer 一直在下方。

讀者會發現有一個 currentPage 的 data ,這個 data 的作用其實很直觀:通過判斷它的值是 main/msg/me 中的哪一個來決定主要內容。同時,為了讓首次使用的使用者知道自己在哪個 Tab,Footer 中相應的 button 也會從白底黑字黑底白字,與另外兩個 Tab 形成對比。

現在我們來看看 main 部分的程式碼(在上面程式碼的基礎上擴充):

...
<view
  class="main-header"
  style="display:{{currentPage === 'main' ? 'flex' : 'none'}};max-height:{{mainHeaderMaxHeight}}"
>
  <view class="group-picker-wrapper">
    <picker
      bindchange="bindGroupPickerChange"
      value="{{groupArrayIndex}}"
      range="{{groupArray}}"
      class="group-picker"
    >
      <button class="group-picker-inner">
        {{groupArray[groupArrayIndex]}}
      </button>
    </picker>
  </view>
  <view class="search-btn-wrapper">
    <button class="search-btn" bindtap="onSearchTap">搜尋使用者</button>
  </view>
</view>
<view
  class="main-area"
  style="display:{{currentPage === 'main' ? 'block' : 'none'}};height: {{mainAreaHeight}};margin-top:{{mainAreaMarginTop}}"
>
  <scroll-view scroll-y class="main-area-scroll" bindscroll="onMainPageScroll">
    <block
      wx:for="{{pageMainData}}"
      wx:for-index="idx"
      wx:for-item="itemName"
      wx:key="_id"
    >
      <post-item is="post-item" data="{{itemName}}" class="post-item-wrapper" />
    </block>
    <view wx:if="{{pageMainData.length === 0}}" class="item-placeholder"
      >無資料</view
    >
  </scroll-view>
  <button
    class="add-poster-btn"
    bindtap="onAddPosterTap"
    hover-class="add-poster-btn-hover"
    style="bottom:{{addPosterBtnBottom}}"
  >
    +
  </button>
</view>
...

這裡用到了 列表渲染條件渲染,還不清楚的可以點選進去學習一下。

可以看到,相比之前的程式碼,我新增一個 header,同時 main-area 的內部也新增了一個 scroll-view(用於展示 Feed 流) 和一個 button(用於編輯新迷你微博)。header 的功能很簡單:左側區域是一個 picker,可以選擇檢視的動態型別(目前有 關注動態所有動態 兩種);右側區域是一個按鈕,點選後可以跳轉到搜尋頁面,這兩個功能我們先放一下,先繼續看 main-area 的新增內容。

main-area 裡的 scroll-view 是一個可監聽滾動事件的列表,其中監聽事件的實現:

data: {
  ...
  addPosterBtnBottom: "190rpx",
  mainHeaderMaxHeight: "80rpx",
  mainAreaHeight: "calc(100vh - 200rpx)",
  mainAreaMarginTop: "80rpx",
},
onMainPageScroll: function(e) {
  if (e.detail.deltaY < 0) {
    this.setData({
      addPosterBtnBottom: "-190rpx",
      mainHeaderMaxHeight: "0",
      mainAreaHeight: "calc(100vh - 120rpx)",
      mainAreaMarginTop: "0rpx"
    })
  } else {
    this.setData({
      addPosterBtnBottom: "190rpx",
      mainHeaderMaxHeight: "80rpx",
      mainAreaHeight: "calc(100vh - 200rpx)",
      mainAreaMarginTop: "80rpx"
    })
  }
},
...

結合 wxml 可以知道,當頁面向下滑動 (deltaY < 0) 時,header 和 button 會 “突然消失”,反之它們則會 “突然出現”。為了視覺上有更好地過渡,我們可以在 WXSS 中使用 transition

...
.main-area {
  position: relative;
  flex-grow: 1;
  overflow: auto;
  z-index: 1;
  transition: height 0.3s, margin-top 0.3s;
}
.main-header {
  position: fixed;
  width: 100%;
  height: 80rpx;
  background: #fff;
  top: 0;
  left: 0;
  display: flex;
  justify-content: space-around;
  align-items: center;
  z-index: 100;
  border-bottom: 3rpx solid #aaa;
  transition: max-height 0.3s;
  overflow: hidden;
}
.add-poster-btn {
  position: fixed;
  right: 60rpx;
  box-shadow: 5rpx 5rpx 10rpx #aaa;
  display: flex;
  justify-content: center;
  align-items: center;
  color: #333;
  padding-bottom: 10rpx;
  text-align: center;
  border-radius: 50%;
  font-size: 60rpx;
  width: 100rpx;
  height: 100rpx;
  transition: bottom 0.3s;
  background: #fff;
  z-index: 1;
}
...

3. Feed 流

3.1 post-item

前面提到,scroll-view 的內容是 Feed 流,那麼首先就要想到使用 列表渲染。而且,為了方便在個人主頁複用,列表渲染中的每一個 item 都要抽象出來。這時就要使用小程式中的 Custom-Component 功能了。

新建一個名為 post-itemComponent,其中 wxml 的實現(路徑:pages/circle/component/post-item/post-item.js):

<view
  class="post-item"
  hover-class="post-item-hover"
  bindlongpress="onItemLongTap"
  bindtap="onItemTap"
>
  <view class="post-title">
    <view class="author" hover-class="author-hover" catchtap="onAuthorTap"
      >{{data.author}}</view
    >
    <view class="date">{{data.formatDate}}</view>
  </view>
  <view class="msg-wrapper">
    <text class="msg">{{data.msg}}</text>
  </view>
  <view class="image-outer" wx:if="{{data.photoId !== ''}}" catchtap="onImgTap">
    <image-wrapper is="image-wrapper" src="{{data.photoId}}" />
  </view>
</view>

可見,一個 poster-item 最主要有以下資訊:

  • 作者名
  • 傳送時間
  • 文字內容
  • 圖片內容

其中,圖片內容因為是可選的,所以使用了 條件渲染,這會在沒有圖片資訊時不讓圖片顯示區域佔用螢幕空間。另外,圖片內容主要是由 image-wrapper 組成,它也是一個 Custom-Component,主要功能是:

  • 強制長寬 1:1 裁剪顯示圖片
  • 點選檢視大圖
  • 未載入完成時顯示 載入中

具體程式碼這裡就不展示了,比較簡單,讀者可以在 component/image-wrapper 裡找到。

回過頭看 main-area 的其他新增部分,細心的讀者會發現有這麼一句:

<view wx:if="{{pageMainData.length === 0}}" class="item-placeholder"
  >無資料</view
>

這會在 Feed 流暫時沒有獲取到資料時給使用者一個提示。

3.2 collections: poster、poster_users

展示 Feed 流的部分已經編寫完畢,現在就差實際資料了。根據上一小節 poster-item 的主要資訊,我們可以初步推斷出一條迷你微博在 雲資料庫 的 collection poster 裡是這樣儲存的:

{
  "username": "Tester",
  "date": "2019-07-22 12:00:00",
  "text": "Ceshiwenben",
  "photo": "xxx"
}

先來看 username。由於社交平臺一般不會限制使用者的暱稱,所以如果每條迷你微博都儲存暱稱,那將來每次使用者修改一次暱稱,就要遍歷資料庫把所有迷你微博項都改一遍,相當耗費時間,所以我們不如儲存一個 userId,並另外把 id 和 暱稱 的對應關係存在另一個叫 poster_users 的 collection 裡。

{
  "userId": "xxx",
  "name": "Tester",
  ...(其他使用者資訊)
}

userId 從哪裡拿呢?當然是通過之前已經授權的獲取使用者資訊介面拿到了,詳細操作之後會說到。

接下來是 date,這裡最好是伺服器時間(因為客戶端傳過來的時間可能會有誤差),而云開發文件裡也有提供相應的介面:serverDate。這個資料可以直接被 new Date() 使用,可以理解為一個 UTC 時間。

text 即文字資訊,直接儲存即可。

photo 則表示附圖資料,但是限於小程式 image 元素的實現,想要顯示一張圖片,要麼提供該圖片的 url,要麼提供該圖片在 雲端儲存 的 id,所以這裡最佳的實踐是:先把圖片上傳到雲端儲存裡,然後把回撥裡的檔案 id 作為資料儲存。

綜上所述,最後 poster 每一項的資料結構如下:

{
  "authorId": "xxx",
  "date": "utc-format-date",
  "text": "Ceshiwenben",
  "photoId": "yyy"
}

確定資料結構後,我們就可以開始往 collection 新增資料了。但是,在此之前,我們還缺少一個重要步驟。

3.3 使用者資訊錄入 與 雲資料庫

沒錯,我們還沒有在 poster_users 裡新增一條新使用者的資訊。這個步驟一般在 pages/circle/circle 頁面首次載入時判斷即可:

getUserId: function(cb) {
  let that = this
  var value = this.data.userId || wx.getStorageSync("userId")
  if (value) {
    if (cb) {
      cb(value)
    }
    return value
  }
  wx.getSetting({
    success(res) {
      if (res.authSetting["scope.userInfo"]) {
        wx.getUserInfo({
          withCredentials: true,
          success: function(userData) {
            wx.setStorageSync("userId", userData.signature)
            that.setData({
              userId: userData.signature
            })
            db.collection("poster_users")
              .where({
                userId: userData.signature
              })
              .get()
              .then(searchResult => {
                if (searchResult.data.length === 0) {
                  wx.showToast({
                    title: "新使用者錄入中"
                  })
                  db.collection("poster_users")
                    .add({
                      data: {
                        userId: userData.signature,
                        date: db.serverDate(),
                        name: userData.userInfo.nickName,
                        gender: userData.userInfo.gender
                      }
                    })
                    .then(res => {
                      console.log(res)
                      if (res.errMsg === "collection.add:ok") {
                        wx.showToast({
                          title: "錄入完成"
                        })
                        if (cb) cb()
                      }
                    })
                    .catch(err => {
                      wx.showToast({
                        title: "錄入失敗,請稍後重試",
                        image: "/images/error.png"
                      })
                      wx.navigateTo({
                        url: "/pages/index/index"
                      })
                    })
                } else {
                  if (cb) cb()
                }
              })
          }
        })
      } else {
        wx.showToast({
          title: "登陸失效,請重新授權登陸",
          image: "/images/error.png"
        })
        wx.navigateTo({
          url: "/pages/index/index"
        })
      }
    }
  })
}

程式碼實現比較複雜,整體思路是這樣的:

  1. 判斷是否已儲存了 userId,如果有直接返回並呼叫回撥函式,如果沒有繼續 2
  2. 通過 wx.getSetting 獲取當前設定資訊
  3. 如果返回裡有 res.authSetting["scope.userInfo"] 說明已經授權讀取使用者資訊,繼續 3,沒有授權的話就跳轉回首頁重新授權
  4. 呼叫 wx.getUserInfo 獲取使用者資訊,成功後提取出 signature(這是每個微信使用者的唯一簽名),並呼叫 wx.setStorageSync 將其快取
  5. 呼叫 db.collection().where().get() ,判斷返回的資料是否是空陣列,如果不是說明該使用者已經錄入(注意 where() 中的篩選條件),如果是說明該使用者是新使用者,繼續 5
  6. 提示新使用者錄入中,同時呼叫 db.collection().add() 來新增使用者資訊,最後通過回撥判斷是否錄入成功,並提示使用者

不知不覺我們就使用了雲開發中的 雲資料庫 功能,緊接著我們就要開始使用 雲端儲存 和 雲函式了!

3.4 addPoster 與 雲端儲存

傳送新的迷你微博,需要一個編輯新迷你微博的介面,路徑我定為 pages/circle/add-poster/add-poster

<view class="app-poster-container">
  <view class="body">
    <view class="text-area-wrapper">
      <textarea bindinput="bindTextInput" placeholder="在此填寫" value="{{text}}" auto-focus="true" />
      <view class="text-area-footer">
        <text>{{remainLen}}/140</text>
      </view>
    </view>
    <view bindtap="onImageTap" class="image-area">
      <view class="image-outer">
        <image-wrapper is="image-wrapper" src="{{imageSrc}}" placeholder="選擇圖片上傳" />
      </view>
    </view>
  </view>
  <view class="footer">
    <button class="footer-btn" bindtap="onSendTap">傳送</button>
  </view>
</view>

wxml 的程式碼很好理解:textarea 顯示編輯文字,image-wrapper 顯示需要上傳的圖片,最下面是一個傳送的 button。其中,圖片編輯區域的 bindtap 事件實現:

onImageTap: function() {
  let that = this
  wx.chooseImage({
    count: 1,
    success: function(res) {
      const tempFilePaths = res.tempFilePaths
      that.setData({
        imageSrc: tempFilePaths[0]
      })
    }
  })
}

直接通過 wx.chooseImage 官方 API 獲取本地圖片的臨時路徑即可。而當傳送按鈕點選後,會有如下程式碼被執行:

onSendTap: function() {
  if (this.data.text === "" && this.data.imageSrc === "") {
    wx.showModal({
      title: "錯誤",
      content: "不能傳送空內容",
      showCancel: false,
      confirmText: "好的"
    })
    return
  }
  const that = this
  wx.showLoading({
    title: "傳送中",
    mask: true
  })
  const imageSrc = this.data.imageSrc
  if (imageSrc !== "") {
    const finalPath = imageSrc.replace("//", "/").replace(":", "")
    wx.cloud
      .uploadFile({
        cloudPath: finalPath,
        filePath: imageSrc // 檔案路徑
      })
      .then(res => {
        that.sendToDb(res.fileID)
      })
      .catch(error => {
        that.onSendFail()
      })
  } else {
    that.sendToDb()
  }
},
sendToDb: function(fileId = "") {
  const that = this
  const posterData = {
    authorId: that.data.userId,
    msg: that.data.text,
    photoId: fileId,
    date: db.serverDate()
  }
  db.collection("poster")
    .add({
      data: {
        ...posterData
      }
    })
    .then(res => {
      wx.showToast({
        title: "傳送成功"
      })
      wx.navigateBack({
        delta: 1
      })
    })
    .catch(error => {
      that.onSendFail()
    })
    .finally(wx.hideLoading())
}
  1. 首先判斷文字和圖片內容是否都為空,如果是則不執行傳送,如果不是繼續 2
  2. 提示傳送中,上傳圖片到雲端儲存,注意需要將圖片中的臨時 url 的一些特殊字元組合替換一下,原因見 檔名命名限制
  3. 上傳成功後,呼叫 db.collection().add(),傳送成功後退回上一頁(即首頁),如果失敗則執行 onSendFail 函式,後者見原始碼,邏輯較簡單這裡不贅述

於是,我們就這樣建立了第一條迷你微博。接下來就讓它在 Feed 流中顯示吧!

3.5 雲函式 getMainPageData

這個函式的主要作用如前所述,就是通過處理雲資料庫中的資料,將最終資料返回給客戶端,後者將資料視覺化給使用者。我們先做一個初步版本,因為現在 poster_users 中只有一條資料,所以僅先展示自己的迷你微博。getMainPageData 雲函式程式碼如下:

// 雲函式入口檔案
const cloud = require("wx-server-sdk")
cloud.init()
const db = cloud.database()

// 雲函式入口函式
exports.main = async (event, context, cb) => {
  // 通過 event 獲取入參
  const userId = event.userId
  let followingResult
  let users
  // idNameMap 負責儲存 userId 和 name 的對映關係
  let idNameMap = {}
  let followingIds = []
  // 獲取使用者資訊
  followingResult = await db
      .collection("poster_users")
      .where({
        userId: userId
      })
      .get()
    users = followingResult.data
    followingIds = users.map(u => {
      return u.userId
    })
  users.map(u => {
    idNameMap[u.userId] = u.name
  })
  // 獲取動態
  const postResult = await db
    .collection("poster")
    .orderBy("date", "desc")
    .where({
      // 通過高階篩選功能篩選出符合條件的 userId
      authorId: db.command.in(followingIds)
    })
    .get()
  const postData = postResult.data
  // 向返回的資料新增 儲存使用者暱稱的 author 屬性、儲存格式化後的時間的 formatDate 屬性
  postData.map(p => {
    p.author = idNameMap[p.authorId]
    p.formatDate = new Date(p.date).toLocaleDateString("zh-Hans", options)
  })
  return postData
}

最後在 pages/circle/circle.js 裡補充雲呼叫:

getMainPageData: function(userId) {
  const that = this
  wx.cloud
    .callFunction({
      name: "getMainPageData",
      data: {
        userId: userId,
        isEveryOne: that.data.groupArrayIndex === 0 ? false : true
      }
    })
    .then(res => {
      that.setData({
        pageMainData: res.result,
        pageMainLoaded: true
      })
    })
    .catch(err => {
      wx.showToast({
        title: "獲取動態失敗",
        image: "/images/error.png"
      })
      wx.hideLoading()
    })
}

即可展示 Feed 流資料給使用者。

之後,getMainPageData 還會根據使用場景的不同,新增了查詢所有使用者動態、查詢關注使用者動態的功能,但是原理是一樣的,看原始碼可以輕易理解,後續就不再說明。

4. 關注系統

上一節中我們一口氣把雲開發中的大部分主要功能:雲資料庫、雲端儲存、雲函式、雲呼叫都用了一遍,接下來其他功能的實現也基本都依賴它們。

4.1 poster_user_follows

首先我們需要建一個新的 collection poster_user_follows,其中的每一項資料的資料結構如下:

{
  "followerId": "xxx",
  "followingId": "xxx"
}

很簡單,followerId 表示關注人,followingId 表示被關注人。

4.2 user-data 頁面

關注或者取消關注需要進入他人的個人主頁操作,我們在 pages/circle/user-data/user-data.wxml 中放一個 user-info 的自定義元件,然後新建該元件編輯:

<view class="user-info">
  <view class="info-item" hover-class="info-item-hover">使用者名稱: {{userName}}</view>
  <view class="info-item" hover-class="info-item-hover" bindtap="onPosterCountTap">動態數: {{posterCount}}</view>
  <view class="info-item" hover-class="info-item-hover" bindtap="onFollowingCountTap">關注數: {{followingCount}}</view>
  <view class="info-item" hover-class="info-item-hover" bindtap="onFollowerCountTap">粉絲數: {{followerCount}}</view>
  <view class="info-item" hover-class="info-item-hover" wx:if="{{originId && originId !== '' && originId !== userId}}"><button bindtap="onFollowTap">{{followText}}</button></view>
</view>

這裡注意條件渲染的 button:如果當前訪問個人主頁的使用者 id (originId) 和 被訪問的使用者 id (userId)的值是相等的話,這個按鈕就不會被渲染(自己不能關注/取消關注自己)。

我們重點看下 onFollowTap 的實現:

onFollowTap: function() {
  const that = this
  // 判斷當前關注狀態
  if (this.data.isFollow) {
    wx.showLoading({
      title: "操作中",
      mask: true
    })
    wx.cloud
      .callFunction({
        name: "cancelFollowing",
        data: {
          followerId: this.properties.originId,
          followingId: this.properties.userId
        }
      })
      .then(res => {
        wx.showToast({
          title: "取消關注成功"
        })
        that.setData({
          isFollow: false,
          followText: "關注"
        })
      })
      .catch(error => {
        wx.showToast({
          title: "取消關注失敗",
          image: "/images/error.png"
        })
      })
      .finally(wx.hideLoading())
  } else if (this.data.isFollow !== undefined) {
    wx.showLoading({
      title: "操作中",
      mask: true
    })
    const data = {
      followerId: this.properties.originId,
      followingId: this.properties.userId
    }
    db.collection("poster_user_follows")
      .add({
        data: {
          ...data
        }
      })
      .then(res => {
        wx.showToast({
          title: "關注成功"
        })
        that.setData({
          isFollow: true,
          followText: "取消關注"
        })
      })
      .catch(error => {
        wx.showToast({
          title: "關注失敗",
          image: "/images/error.png"
        })
      })
      .finally(wx.hideLoading())
    }
  }
}

這裡讀者可能會有疑問:為什麼關注的時候直接呼叫 db.collection().add() 即可,而取消關注卻要呼叫雲函式呢?這裡涉及到雲資料庫的設計問題:刪除多個資料的操作,或者說刪除使用 where 篩選的資料,只能在服務端執行。如果確實想在客戶端刪除,則在查詢使用者關係時,將唯一標識資料的 _idsetData 存下來,之後再使用 db.collection().doc(_id).delete() 刪除即可。這兩種實現方式讀者可自行選擇。當然,還有一種實現是不實際刪除資料,只是加個 isDelete 欄位標記一下。

查詢使用者關係的實現很簡單,雲函式的實現方式如下:

// 雲函式入口檔案
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()

// 雲函式入口函式
exports.main = async(event, context) => {
  const followingResult = await db.collection("poster_user_follows")
    .where({
      followingId: event.followingId,
      followerId: event.followerId
    }).get()
  return followingResult
}

客戶端只要檢查返回的資料長度是否大於 0 即可。

另外附上 user-data 頁面其他資料的獲取雲函式實現:

// 雲函式入口檔案
const cloud = require("wx-server-sdk")
cloud.init()
const db = cloud.database()

async function getPosterCount(userId) {
  return {
    value: (await db.collection("poster").where({
      authorId: userId
    }).count()).total,
    key: "posterCount"
  }
}

async function getFollowingCount(userId) {
  return {
    value: (await db.collection("poster_user_follows").where({
      followerId: userId
    }).count()).total,
    key: "followingCount"
  }
}

async function getFollowerCount(userId) {
  return {
    value: (await db.collection("poster_user_follows").where({
      followingId: userId
    }).count()).total,
    key: "followerCount"
  }
}


async function getUserName(userId) {
  return {
    value: (await db.collection("poster_users").where({
      userId: userId
    }).get()).data[0].name,
    key: "userName"
  }
}

// 雲函式入口函式
exports.main = async (event, context) => {
  const userId = event.userId
  const tasks = []
  tasks.push(getPosterCount(userId))
  tasks.push(getFollowerCount(userId))
  tasks.push(getFollowingCount(userId))
  tasks.push(getUserName(userId))
  const allData = await Promise.all(tasks)
  const finalData = {}
  allData.map(d => {
    finalData[d.key] = d.value
  })
  return finalData
}

很好理解,客戶端獲取返回後直接使用即可。

5. 搜尋頁面

這部分其實很好實現。關鍵的搜尋函式實現如下:

// 雲函式入口檔案
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()

const MAX_LIMIT = 100
async function getDbData(dbName, whereObj) {
  const totalCountsData = await db.collection(dbName).where(whereObj).count()
  const total = totalCountsData.total
  const batch = Math.ceil(total / 100)
  const tasks = []
  for (let i = 0; i < batch; i++) {
    const promise = db
      .collection(dbName)
      .where(whereObj)
      .skip(i * MAX_LIMIT)
      .limit(MAX_LIMIT)
      .get()
    tasks.push(promise)
  }
  const rrr = await Promise.all(tasks)
  if (rrr.length !== 0) {
    return rrr.reduce((acc, cur) => {
      return {
        data: acc.data.concat(cur.data),
        errMsg: acc.errMsg
      }
    })
  } else {
    return {
      data: [],
      errMsg: "empty"
    }
  }
}

// 雲函式入口函式
exports.main = async (event, context) => {
  const text = event.text
  const data = await getDbData("poster_users", {
    name: {
      $regex: text
    }
  })
  return data
}

這裡參考了官網所推薦的分頁檢索資料庫資料的實現(因為搜尋結果可能有很多),篩選條件則是正則模糊匹配關鍵字。

搜尋頁面的原始碼路徑是 pages/circle/search-user/search-user,實現了點選搜尋結果項跳轉到對應項的使用者的 user-data 頁面,建議直接閱讀原始碼理解。

6. 其他擴充套件

6.1 poster_likes 與 點贊

由於轉發、評論、點讚的原理基本相同,所以這裡只介紹點贊功能如何編寫,另外兩個功能讀者可以自行實現。

毫無疑問我們需要新建一個 collection poster_likes,其中每一項的資料結構如下:

{
  "posterId": "xxx",
  "likeId": "xxx"
}

這裡的 posterId 就是 poster collection 裡每條記錄的 _id 值,likeId 就是 poster_users 裡的 userId 了。

然後我們擴充套件一下 poster-item 的實現:

<view class="post-item" hover-class="post-item-hover" bindlongpress="onItemLongTap" bindtap="onItemTap">
  ...
  <view class="interact-area">
    <view class="interact-item">
      <button class="interact-btn" catchtap="onLikeTap" style="color:{{liked ? '#55aaff' : '#000'}}">贊 {{likeCount}}</button>
    </view>
  </view>
</view>

即,新增一個 interact-area,其中 onLikeTap 實現如下:

onLikeTap: function() {
  if (!this.properties.originId) return
  const that = this
  if (this.data.liked) {
    wx.showLoading({
      title: "操作中",
      mask: true
    })
    wx.cloud
      .callFunction({
        name: "cancelLiked",
        data: {
          posterId: this.properties.data._id,
          likeId: this.properties.originId
        }
      })
      .then(res => {
        wx.showToast({
          title: "取消成功"
        })
        that.refreshLike()
        that.triggerEvent('likeEvent');
      })
      .catch(error => {
        wx.showToast({
          title: "取消失敗",
          image: "/images/error.png"
        })
      })
      .finally(wx.hideLoading())
  } else {
    wx.showLoading({
      title: "操作中",
      mask: true
    })
    db.collection("poster_likes").add({
        data: {
          posterId: this.properties.data._id,
          likeId: this.properties.originId
        }
      }).then(res => {
        wx.showToast({
          title: "已贊"
        })
        that.refreshLike()
        that.triggerEvent('likeEvent');
      })
      .catch(error => {
        wx.showToast({
          title: "贊失敗",
          image: "/images/error.png"
        })
      })
      .finally(wx.hideLoading())
  }

}

細心的讀者會發現這和關注功能原理幾乎是一樣的。

6.2 資料重新整理

我們可以使用很多方式讓主頁面重新整理資料:

onShow: function() {
  wx.showLoading({
    title: "載入中",
    mask: true
  })
  const that = this
  function cb(userId) {
    that.refreshMainPageData(userId)
    that.refreshMePageData(userId)
  }
  this.getUserId(cb)
}

第一種是利用 onShow 方法:它會在頁面每次從後臺轉到前臺展示時呼叫,這個時候我們就能重新整理頁面資料(包括 Feed 流和個人資訊)。但是這個時候使用者資訊可能會丟失,所以我們需要在 getUserId 裡判斷,並將重新整理資料的函式們整合起來,作為回撥函式。

第二種是讓使用者手動重新整理:

onPageMainTap: function() {
  if (this.data.currentPage === "main") {
    this.refreshMainPageData()
  }
  this.setData({
    currentPage: "main"
  })
}

如圖所示,當目前頁面是 Feed 流時,如果再次點選 首頁 Tab,就會強制重新整理資料。

第三種是關聯資料變更觸發重新整理,比如動態型別選擇、刪除了一條動態以後觸發資料的重新整理。這種可以直接看原始碼學習。

6.3 首次載入等待

當使用者第一次進入主頁面時,我們如果想在 Feed 流和個人資訊都載入好了再允許使用者操作,應該如何實現?

如果是類似 Vue 或者 React 的框架,我們很容易就能想到屬性監控,如 watchuseEffect 等等,但是小程式目前 Page 並沒有提供屬性監控功能,怎麼辦?

除了自己實現,還有一個方法就是利用 Componentobservers,它和上面提到的屬性監控功能差不多。雖然官網文件對其說明比較少,但摸索了一番還是能用來監控的。

首先我們來新建一個 Componentabstract-load,具體實現如下:

// pages/circle/component/abstract-load.js
Component({
  properties: {
    pageMainLoaded: {
      type: Boolean,
      value: false
    },
    pageMeLoaded: {
      type: Boolean,
      value: false
    }
  },
  observers: {
    "pageMainLoaded, pageMeLoaded": function (pageMainLoaded, pageMeLoaded) {
      if (pageMainLoaded && pageMeLoaded) {
        this.triggerEvent("allLoadEvent")
      }
    }
  }
})

然後在 pages/circle/circle.wxml 中新增一行:

<abstract-load is="abstract-load" pageMainLoaded="{{pageMainLoaded}}" pageMeLoaded="{{pageMeLoaded}}" bind:allLoadEvent="onAllLoad" />

最後實現 onAllLoad 函式即可。

另外,像這種沒有實際展示資料的 Component,建議在專案中都用 abstract 開頭來命名。

6.4 scroll-view 在 iOS 的 bug

如果讀者使用 iOS 系統除錯這個小程式,可能會發現 Feed 流比較短的時候,滾動 scroll-view header 和 button 會有鬼畜的上下抖動現象,這是因為 iOS 自己實現的 WebView 對於滾動檢視有回彈的效果,而該效果也會觸發滾動事件。

對於這個 bug,官方人員也表示暫時無法修復,只能先忍一忍了。

6.5 關於訊息 Tab

讀者可能會疑惑我為什麼沒有講解訊息 Tab 以及訊息提醒的實現。首先是因為原始碼沒有這個實現,其次是我覺得目前雲開發所提供的能力實現主動提醒比較麻煩(除了輪詢想不到其他辦法)。

希望未來雲開發可以提供 資料庫長連線監控 的功能,這樣通過訂閱者模式可以很輕鬆地獲取到資料更新的狀態,主動提醒也就更容易實現了。到那時我可能會再更新相關原始碼。

6.6 關於雲函式耗時

讀者可能會發現我有一個叫 benchmark 的雲函式,這個函式只是做了個查詢資料庫的操作,目的在於計算查詢耗時。

詭異的是,我前天在除錯的時候,發現查詢一次需要1秒鐘,而寫這篇文章時卻不到100ms。建議在一些需要多次運算元據庫的函式配置裡,把超時時間設定長一點吧。目前雲函式的效能不太穩定。

7. 結語

那麼關於迷你版微博開發實戰介紹就到此為止了,更多資料可以直接下載原始碼檢視哦。

原始碼連結

https://github.com/TencentCloudBase/Good-practice-tutorial-recommended

小程式·雲開發實戰 - 迷你微博
如果你有關於使用雲開發CloudBase相關的技術故事/技術實戰經驗想要跟大家分享,歡迎留言聯絡我們哦~比心!

相關文章