本項(xiàng)目基于ztaro腳手架快速搭建開(kāi)發(fā)環(huán)境,在看這篇文章之前建議首先查看ztaro文檔,或者ztaro介紹,并且對(duì)于taro,zoro有所了解
本系列文章后臺(tái)api部分均以數(shù)據(jù)mock方式完成,僅保證整體流程跑通,不阻礙前端系統(tǒng)開(kāi)發(fā)即可,同時(shí)也希望有經(jīng)驗(yàn)后臺(tái)開(kāi)發(fā)人員可以完善該系統(tǒng)api部分
該項(xiàng)目代碼托管于github,weapp-clover,該項(xiàng)目用到了阿里云oss,請(qǐng)自行解決賬號(hào),配置方法查看ztaro
前言
微信小程序業(yè)務(wù)愈加龐大,像那種脫離用戶信息的小程序已經(jīng)越來(lái)越少了,更多時(shí)候,我們希望能增加用戶的粘性,基于用戶信息做推薦,基于這個(gè)原因,當(dāng)我們?cè)谠O(shè)計(jì)系統(tǒng)時(shí),會(huì)發(fā)現(xiàn)絕大部分的API接口設(shè)計(jì)都是基于用戶已經(jīng)登錄的前提下,這會(huì)對(duì)我們前端設(shè)計(jì)造成極大的影響
大多數(shù)情況下,我們會(huì)在微信onLoad函數(shù)中去發(fā)起request請(qǐng)求,獲取當(dāng)前頁(yè)面的數(shù)據(jù),如果在此時(shí),用戶還未登錄完成,那獲取接口將會(huì)是失敗的
我們通常的解決辦法如下:
- 專門為此設(shè)計(jì)一個(gè)登錄頁(yè)面,當(dāng)小程序加載時(shí)跳轉(zhuǎn)登錄頁(yè),登錄完成后跳轉(zhuǎn)回來(lái)
這種方式優(yōu)點(diǎn)是,我們無(wú)需在每個(gè)頁(yè)面監(jiān)聽(tīng)登錄回調(diào)后在調(diào)用數(shù)據(jù)接口,缺點(diǎn)是用戶對(duì)于登錄的感知明顯,無(wú)法做到靜默登錄
- 在每個(gè)頁(yè)面中注冊(cè)登錄回調(diào)事件,僅當(dāng)?shù)卿浗涌诔晒Ψ祷睾蟛奴@取數(shù)據(jù)
該方式優(yōu)點(diǎn)是,無(wú)需額外的登錄頁(yè)面,靜默登錄,用戶無(wú)感知,缺點(diǎn)是開(kāi)發(fā)維護(hù)成本較大,需要每個(gè)頁(yè)面多做額外的處理,系統(tǒng)龐大之后,容易遺忘
- 第三種方式,也是我們今天主要介紹的方式,通過(guò)異步阻塞的方式等待登錄完成,優(yōu)點(diǎn)是靜默登錄,用戶絕大部分時(shí)間無(wú)感知,開(kāi)發(fā)維護(hù)成本較小,缺點(diǎn)是需要兜底重登錄頁(yè)
流程
微信登錄流程這里不再給出了,不了解微信登錄的直接查看官方文檔
微信登錄
登錄整體設(shè)計(jì)流程

實(shí)現(xiàn)
要完成一個(gè)完整的前端模塊功能,我們總需要經(jīng)歷三個(gè)步驟:
- 完成模擬接口
- 編寫數(shù)據(jù)模型
- 組織界面
依照上面的步驟,我們首先編寫登錄接口模擬,代碼及解釋如下:(mocks/user.js)
const faker = require('faker')
function userLogin(req, res) {
// 獲取前端傳遞過(guò)來(lái)的code
// 然后根據(jù)小程序appid,appsecret,code訪問(wèn)微信服務(wù)器api獲取session_key,openid,這一步驟,無(wú)需模擬
// 根據(jù)session_key,openid關(guān)聯(lián)自定義登錄態(tài),生成token,并創(chuàng)建匿名用戶
const { code } = req.body
res.status(200).json({
code: 'success',
message: '登錄成功',
// token已經(jīng)利用express啟動(dòng)時(shí)設(shè)置于locals中,這里只需獲取即可
token: req.app.locals.token,
})
}
module.exports = {
'POST /v1/user/login': userLogin,
}
接下來(lái)我們需要實(shí)現(xiàn)登錄攔截器,新增effect攔截器(app.js)
// 利用taro全局事件機(jī)制,等待登錄事件觸發(fā)
function waitLogin() {
return new Promise(resolve => {
Taro.eventCenter.on('login', resolve)
})
}
/**
* 由于后臺(tái)絕大部分接口都需要用戶預(yù)先登錄才可以獲取數(shù)據(jù)
* 并且前端所有的接口調(diào)用都發(fā)生在頁(yè)面中,難以在頁(yè)面中統(tǒng)一控制接口必須在登錄完成后才觸發(fā)調(diào)用
* 因此在這里設(shè)置登錄攔截器,攔截所有需要預(yù)先登錄的接口,等待登錄完成后返回
*/
app.intercept.effect(async action => {
// 我們通過(guò)action.meta字段來(lái)標(biāo)記是否需要進(jìn)行授權(quán)
if (action.meta && action.meta.noAuth) return action
try {
// 檢測(cè)本地是否存在token,存在則無(wú)需等待登錄
const token = Taro.getStorageSync(TOKEN_KEY)
if (!token) {
await waitLogin()
}
} catch (error) {
await waitLogin()
}
})
登錄請(qǐng)求實(shí)現(xiàn)(src/requests/user.js)
import request from '../utils/request'
export function userLogin(data) {
return request({
url: '/v1/user/login',
data,
header: {
noAuth: true,
},
method: 'POST',
})
}
user model實(shí)現(xiàn)(src/models/user.js)
import Taro from '@tarojs/taro'
import { userLogin } from '../requests/user'
import { TOKEN_KEY } from '../constants/common'
import { getAuthorize } from '../utils/tools'
export default {
namespace: 'user',
mixins: ['common'],
state: {
memberId: '',
memberInfo: {},
},
// 該函數(shù)會(huì)在model初始化的時(shí)候調(diào)用setup
async setup({ put }) {
try {
// 首先檢測(cè)微信登錄是否過(guò)期,過(guò)期則重新登錄
// 也因此我們?cè)谠O(shè)計(jì)后臺(tái)token有效期時(shí),應(yīng)大于微信登錄態(tài)有效期
await Taro.checkSession()
// 微信登錄未過(guò)期,嘗試獲取本地token
const token = await Taro.getStorage({ key: TOKEN_KEY })
if (!token) {
// 本地?zé)otoken存在時(shí)重新發(fā)起登錄,并在登錄完成后觸發(fā)login全局事件,打通攔截器
await put({ type: 'login', meta: { noAuth: true } })
Taro.eventCenter.trigger('login')
} else {
// 存在則直接觸發(fā)login全局事件
Taro.eventCenter.trigger('login')
}
} catch (error) {
// 調(diào)用失敗時(shí)重新發(fā)起登錄,并在登錄完成后觸發(fā)login全局事件,打通攔截器
await put({ type: 'login', meta: { noAuth: true } })
Taro.eventCenter.trigger('login')
}
},
effects: {
async login() {
const { code } = await Taro.login()
const { token } = await userLogin({ code })
await Taro.setStorage({ key: TOKEN_KEY, data: token })
},
}
注冊(cè)u(píng)ser model即可(src/models/index.js)
import user from './user'
export default [user]
修改request帶上token(src/utils/request.js)
export default function request(options) {
const { url } = options
Taro.showNavigationBarLoading()
// 獲取token
const token = Taro.getStorageSync(TOKEN_KEY)
return Taro.request(
resolveParams({
...options,
url: `${CONFIG.SERVER}${url}`,
mode: 'cors',
header: {
'content-type': 'application/json',
// 給每一個(gè)請(qǐng)求帶上token字段
Authorization: `Bearer ${token}`,
...options.header,
},
}),
)
.then(checkHttpStatus)
.then(checkSuccess)
.catch(throwError)
}
登錄兜底
登錄主流程已完成,但是我們還沒(méi)有處理異常情況,當(dāng)本地存儲(chǔ)的token過(guò)期時(shí),我們需要重新登錄小程序,這只是極少數(shù)情況下會(huì)發(fā)生,因?yàn)槲覀兣袛嗔宋⑿舠ession,并且服務(wù)器的token有效期設(shè)置大于微信有效期,這個(gè)異常處理只是一個(gè)兜底
首先我們需要屏蔽所有的因授權(quán)失敗的報(bào)出的錯(cuò)誤(app.js)
const app = zoro({
onError(error) {
// 屏蔽用戶登錄過(guò)期信息,因?yàn)楫?dāng)用戶登錄過(guò)期時(shí)會(huì)跳轉(zhuǎn)自動(dòng)登錄
if (error.response && error.response.statusCode === 401) return
if (error.message) {
Taro.showToast({
icon: 'none',
title: error.message,
duration: 2000,
})
}
},
})
接下來(lái)需要攔截接口401狀態(tài)(src/utils/request.js)
// 通過(guò)截流函數(shù),確保1秒內(nèi)僅觸發(fā)一次
const redirectToRelogin = throttle(async function() {
// 這里的輪詢是為了確保,頁(yè)面已經(jīng)ready,然后進(jìn)行跳轉(zhuǎn)
// 因?yàn)榈卿洐z測(cè)發(fā)生在app onLaunch,此時(shí)頁(yè)面并沒(méi)有真正ready,調(diào)用跳轉(zhuǎn)會(huì)失敗
while (true) {
const { url, isTabbar } = getCurrentPageTypeAndUrlWithArgs()
const redirectUrl = encodeURIComponent(`/${url}`)
if (url) {
Taro.redirectTo({
url: `/pages/relogin/relogin?isTabbar=${isTabbar}&redirectUrl=${redirectUrl}`,
})
break
}
await delay(500)
}
}, 1000)
function checkHttpStatus(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
Taro.hideNavigationBarLoading()
return response.data
}
// 新增攔截401狀態(tài)進(jìn)行跳轉(zhuǎn)登錄
if (response.statusCode === 401) {
redirectToRelogin()
}
const message =
HTTP_ERROR[response.statusCode] || `ERROR CODE: ${response.statusCode}`
const error = new Error(message)
error.response = response
throw error
}
最后編寫我們的relogin頁(yè)面(src/pages/relogin/relogin.js)
頁(yè)面樣式直接查看源碼,在此不列出,僅列出關(guān)鍵函數(shù)
class PageRelogin extends Component {
state = {
error: false,
}
componentWillMount() {
this.handleLogin()
}
handleLogin = () => {
this.setState({ error: false })
// 獲取登錄完成回跳地址
const { params: { redirectUrl, isTabbar } = {} } = this.$router
const url = decodeURIComponent(redirectUrl)
dispatcher.user
.login()
.then(() => {
// 登錄完成后回跳
if (isTabbar) {
Taro.switchTab({ url })
} else {
Taro.redirectTo({ url })
}
})
.catch(() => {
this.setState({ error: true })
})
}
}
用戶實(shí)名
上面我們僅僅是實(shí)現(xiàn)了登錄,但是并未獲取到用戶信息,也就是登錄僅僅是個(gè)匿名用戶而已
微信獲取用戶信息授權(quán)wx.getUserInfo接口已經(jīng)逐步在放棄,取而代之的是通過(guò)button開(kāi)放能力來(lái)實(shí)現(xiàn),因此我們需要實(shí)現(xiàn)一個(gè)通用授權(quán)組件,引入在必要用戶信息的頁(yè)面中
明確了目標(biāo),我們依舊按照功能模塊三步驟:
編寫上傳用戶信息的接口模擬(mocks/user.js)
const faker = require('faker')
function userUploadInfo(req, res) {
// 前端傳遞rawData, signature, encryptedData, iv校驗(yàn)和解析用戶信息
// 詳見(jiàn)https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html
const { rawData, signature, encryptedData, iv } = req.body
res.status(200).json({
code: 'success',
message: '上傳成功',
})
}
function userGetInfo(req, res) {
res.status(200).json({
code: 'success',
message: '獲取用戶信息成功',
memberInfo: {
memberId: faker.random.uuid(),
nickName: 'Faure',
avatarUrl: 'https://wx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTJS8AiaqOQqE1j3qHCbiaNKF9D9BgtQuE6gFXoXPKUibRMeWvTO55TSeblaMIzFfp3lGdJt3qUPCibBTQ/132',
city: '成都',
province: '四川',
country: '中國(guó)',
gender: 1,
}
})
}
module.exports = {
'POST /v1/user/info': userUploadInfo,
'GET /v1/user/info': userGetInfo,
}
編寫上傳用戶信息的request請(qǐng)求(src/requests/user.js)
import request from '../utils/request'
export function userUploadInfo(data) {
return request({
url: '/v1/user/info',
data,
method: 'POST',
})
}
export function userGetInfo() {
return request({
url: '/v1/user/info',
method: 'GET',
})
}
編寫user model中的上傳用戶信息部分(src/models/user.js)
import Taro from '@tarojs/taro'
import { userUploadInfo, userGetInfo } from '../requests/user'
import { getAuthorize } from '../utils/tools'
export default {
namespace: 'user',
mixins: ['common'],
state: {
authorize: true, // 新增授權(quán)字段,默認(rèn)初始化為已授權(quán)
memberInfo: {}, // 新增用戶信息
},
async setup({ put }) {
try {
// 檢測(cè)用戶是否授權(quán)
const authorize = await getAuthorize('userInfo')
if (!authorize) {
Taro.hideTabBar()
} else {
// 已授權(quán),則調(diào)用獲取用戶數(shù)據(jù)
put({ type: 'getInfo' })
}
put({ type: 'update', payload: { authorize } })
} catch (error) {
Taro.hideTabBar()
put({ type: 'update', payload: { authorize: false } })
}
// 省略之前的登錄部分
},
effects: {
// 上傳用戶信息
async uploadInfo(
{
payload: { rawData, signature, encryptedData, iv },
},
{ put },
) {
await userUploadInfo({ rawData, signature, encryptedData, iv })
put({ type: 'getInfo' })
},
// 獲取用戶信息
async getInfo(action, { put }) {
const { memberInfo } = await userGetInfo()
put({ type: 'update', payload: { memberInfo } })
},
},
}
編寫界面,僅列出關(guān)鍵代碼,樣式及界面請(qǐng)查看倉(cāng)庫(kù)源碼(src/components/login/login.js)
import Taro, { Component } from '@tarojs/taro'
import { View, Text, Button } from '@tarojs/components'
import { connect } from '@tarojs/redux'
import { dispatcher } from '@opcjs/zoro'
import ComponentCommonModal from '../modal/modal'
import { weappApiFail } from '../../../utils/tools'
import './login.scss'
@connect(({ user }) => ({
authorize: user.authorize,
}))
class ComponentCommonLogin extends Component {
handleUploadUserInfo = ({
detail: { errMsg, rawData, signature, encryptedData, iv },
}) => {
if (!weappApiFail(errMsg)) {
dispatcher.user.uploadInfo({ rawData, signature, encryptedData, iv })
dispatcher.user.update({ authorize: true })
Taro.showTabBar()
}
}
render() {
const { authorize } = this.props
return (
<ComponentCommonModal visible={!authorize}>
<View className="login">
<View className="logo" />
<Text className="title">歡迎加入四葉草莊園</Text>
<Button
className="btn"
openType="getUserInfo"
lang="zh_CN"
onGetUserInfo={this.handleUploadUserInfo}
>
微信登錄
</Button>
</View>
</ComponentCommonModal>
)
}
}
export default ComponentCommonLogin
最后只需將login組件引入到頁(yè)面中(src/pages/home/home.js)
import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import ComponentCommonLogin from '../../components/common/login/login'
import './home.scss'
class PageHome extends Component {
config = {
navigationBarTitleText: '四葉草莊園',
}
state = {
// 請(qǐng)到README.md中查看此參數(shù)說(shuō)明
__TAB_PAGE__: true, // eslint-disable-line
}
componentDidMount() {}
render() {
return (
<View className="home">
<ComponentCommonLogin />
<Text>首頁(yè)</Text>
</View>
)
}
}
export default PageHome
最后來(lái)看看最終效果吧

該系列文章會(huì)持續(xù)更新,由于工作忙碌,為了保證文章質(zhì)量,更新較為緩慢,如果你是小程序愛(ài)好者,歡迎加我微信Faure5,備注小程序開(kāi)發(fā)交流,這里主要是探討小程序開(kāi)發(fā)遇到的各種填坑辦法