微信小程序電商實戰-登錄模塊設計

本項目基于ztaro腳手架快速搭建開發環境,在看這篇文章之前建議首先查看ztaro文檔,或者ztaro介紹,并且對于tarozoro有所了解

本系列文章后臺api部分均以數據mock方式完成,僅保證整體流程跑通,不阻礙前端系統開發即可,同時也希望有經驗后臺開發人員可以完善該系統api部分

該項目代碼托管于github,weapp-clover,該項目用到了阿里云oss,請自行解決賬號,配置方法查看ztaro

前言

微信小程序業務愈加龐大,像那種脫離用戶信息的小程序已經越來越少了,更多時候,我們希望能增加用戶的粘性,基于用戶信息做推薦,基于這個原因,當我們在設計系統時,會發現絕大部分的API接口設計都是基于用戶已經登錄的前提下,這會對我們前端設計造成極大的影響

大多數情況下,我們會在微信onLoad函數中去發起request請求,獲取當前頁面的數據,如果在此時,用戶還未登錄完成,那獲取接口將會是失敗的

我們通常的解決辦法如下:

  1. 專門為此設計一個登錄頁面,當小程序加載時跳轉登錄頁,登錄完成后跳轉回來

這種方式優點是,我們無需在每個頁面監聽登錄回調后在調用數據接口,缺點是用戶對于登錄的感知明顯,無法做到靜默登錄

  1. 在每個頁面中注冊登錄回調事件,僅當登錄接口成功返回后才獲取數據

該方式優點是,無需額外的登錄頁面,靜默登錄,用戶無感知,缺點是開發維護成本較大,需要每個頁面多做額外的處理,系統龐大之后,容易遺忘

  1. 第三種方式,也是我們今天主要介紹的方式,通過異步阻塞的方式等待登錄完成,優點是靜默登錄,用戶絕大部分時間無感知,開發維護成本較小,缺點是需要兜底重登錄頁

流程

微信登錄流程這里不再給出了,不了解微信登錄的直接查看官方文檔
微信登錄

登錄整體設計流程


登錄控制流程.png

實現

要完成一個完整的前端模塊功能,我們總需要經歷三個步驟:

  1. 完成模擬接口
  2. 編寫數據模型
  3. 組織界面

依照上面的步驟,我們首先編寫登錄接口模擬,代碼及解釋如下:(mocks/user.js)

const faker = require('faker')

function userLogin(req, res) {
  // 獲取前端傳遞過來的code
  // 然后根據小程序appid,appsecret,code訪問微信服務器api獲取session_key,openid,這一步驟,無需模擬
  // 根據session_key,openid關聯自定義登錄態,生成token,并創建匿名用戶
  const { code } = req.body

  res.status(200).json({
    code: 'success',
    message: '登錄成功',
    // token已經利用express啟動時設置于locals中,這里只需獲取即可
    token: req.app.locals.token,
  })
}

module.exports = {
  'POST /v1/user/login': userLogin,
}

接下來我們需要實現登錄攔截器,新增effect攔截器(app.js)

// 利用taro全局事件機制,等待登錄事件觸發
function waitLogin() {
  return new Promise(resolve => {
    Taro.eventCenter.on('login', resolve)
  })
}

/**
 * 由于后臺絕大部分接口都需要用戶預先登錄才可以獲取數據
 * 并且前端所有的接口調用都發生在頁面中,難以在頁面中統一控制接口必須在登錄完成后才觸發調用
 * 因此在這里設置登錄攔截器,攔截所有需要預先登錄的接口,等待登錄完成后返回
 */
app.intercept.effect(async action => {
  // 我們通過action.meta字段來標記是否需要進行授權
  if (action.meta && action.meta.noAuth) return action

  try {
    // 檢測本地是否存在token,存在則無需等待登錄
    const token = Taro.getStorageSync(TOKEN_KEY)
    if (!token) {
      await waitLogin()
    }
  } catch (error) {
    await waitLogin()
  }
})

登錄請求實現(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實現(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: {},
  },
  
  // 該函數會在model初始化的時候調用setup
  async setup({ put }) {
    try {
      // 首先檢測微信登錄是否過期,過期則重新登錄
      // 也因此我們在設計后臺token有效期時,應大于微信登錄態有效期
      await Taro.checkSession()
      // 微信登錄未過期,嘗試獲取本地token
      const token = await Taro.getStorage({ key: TOKEN_KEY })
      if (!token) {
        // 本地無token存在時重新發起登錄,并在登錄完成后觸發login全局事件,打通攔截器
        await put({ type: 'login', meta: { noAuth: true } })
        Taro.eventCenter.trigger('login')
      } else {
        // 存在則直接觸發login全局事件
        Taro.eventCenter.trigger('login')
      }
    } catch (error) {
      // 調用失敗時重新發起登錄,并在登錄完成后觸發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 })
    },
}

注冊user 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',
        // 給每一個請求帶上token字段
        Authorization: `Bearer ${token}`,
        ...options.header,
      },
    }),
  )
    .then(checkHttpStatus)
    .then(checkSuccess)
    .catch(throwError)
}

登錄兜底

登錄主流程已完成,但是我們還沒有處理異常情況,當本地存儲的token過期時,我們需要重新登錄小程序,這只是極少數情況下會發生,因為我們判斷了微信session,并且服務器的token有效期設置大于微信有效期,這個異常處理只是一個兜底

首先我們需要屏蔽所有的因授權失敗的報出的錯誤(app.js)

const app = zoro({
  onError(error) {
    // 屏蔽用戶登錄過期信息,因為當用戶登錄過期時會跳轉自動登錄
    if (error.response && error.response.statusCode === 401) return

    if (error.message) {
      Taro.showToast({
        icon: 'none',
        title: error.message,
        duration: 2000,
      })
    }
  },
})

接下來需要攔截接口401狀態(src/utils/request.js)

// 通過截流函數,確保1秒內僅觸發一次
const redirectToRelogin = throttle(async function() {
  // 這里的輪詢是為了確保,頁面已經ready,然后進行跳轉
  // 因為登錄檢測發生在app onLaunch,此時頁面并沒有真正ready,調用跳轉會失敗
  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狀態進行跳轉登錄
  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頁面(src/pages/relogin/relogin.js)

頁面樣式直接查看源碼,在此不列出,僅列出關鍵函數

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

用戶實名

上面我們僅僅是實現了登錄,但是并未獲取到用戶信息,也就是登錄僅僅是個匿名用戶而已

微信獲取用戶信息授權wx.getUserInfo接口已經逐步在放棄,取而代之的是通過button開放能力來實現,因此我們需要實現一個通用授權組件,引入在必要用戶信息的頁面中

明確了目標,我們依舊按照功能模塊三步驟:

編寫上傳用戶信息的接口模擬(mocks/user.js)

const faker = require('faker')

function userUploadInfo(req, res) {
  // 前端傳遞rawData, signature, encryptedData, iv校驗和解析用戶信息
  // 詳見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: '中國',
      gender: 1,
    }
  })
}

module.exports = {
  'POST /v1/user/info': userUploadInfo,
  'GET /v1/user/info': userGetInfo,
}

編寫上傳用戶信息的request請求(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, // 新增授權字段,默認初始化為已授權
    memberInfo: {}, // 新增用戶信息
  },

  async setup({ put }) {
    try {
      // 檢測用戶是否授權
      const authorize = await getAuthorize('userInfo')
      if (!authorize) {
        Taro.hideTabBar()
      } else {
        // 已授權,則調用獲取用戶數據
        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 } })
    },
  },
}

編寫界面,僅列出關鍵代碼,樣式及界面請查看倉庫源碼(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組件引入到頁面中(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 = {
    // 請到README.md中查看此參數說明
    __TAB_PAGE__: true, // eslint-disable-line
  }

  componentDidMount() {}

  render() {
    return (
      <View className="home">
        <ComponentCommonLogin />
        <Text>首頁</Text>
      </View>
    )
  }
}

export default PageHome

最后來看看最終效果吧


QQ20181207-131915-HD.gif

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。