微信小程序登錄的前端設(shè)計(jì)與實(shí)現(xiàn)

歡迎來我的博客閱讀:「微信小程序登錄的前端設(shè)計(jì)與實(shí)現(xiàn)」

一. 前言

對(duì)于登錄/注冊(cè)的設(shè)計(jì)如此精雕細(xì)琢的目的佃延,當(dāng)然是想讓這個(gè)作為應(yīng)用的基礎(chǔ)能力昭雌,有足夠的健壯性成洗,避免出現(xiàn)全站性的阻塞池户。

同時(shí)要充分考慮如何解耦和封裝,在開展新的小程序的時(shí)候日戈,能更快的去復(fù)用能力效扫,避免重復(fù)采坑展懈。

登錄注冊(cè)這模塊,就像個(gè)冰山幌陕,我們以為它就是「輸入賬號(hào)密碼诵姜,就完成登錄了」,但實(shí)際下面還有各種需要考慮的問題搏熄。

image

在此棚唆,跟在座的各位分享一下暇赤,最近做完一個(gè)小程序登錄/注冊(cè)模塊之后,沉淀下來的一些設(shè)計(jì)經(jīng)驗(yàn)和想法瑟俭。

二. 業(yè)務(wù)場(chǎng)景

在用戶瀏覽小程序的過程中翎卓,由業(yè)務(wù)需要,往往需要獲取用戶的一些基本信息摆寄,常見的有:

  1. 微信昵稱
  2. 微信手機(jī)號(hào)

而不同的產(chǎn)品失暴,對(duì)于用戶的信息要求不盡相同,也會(huì)有不一樣的授權(quán)流程微饥。

第一種逗扒,常見于電商系統(tǒng)中,用戶購(gòu)買商品的時(shí)候欠橘,為了識(shí)別用戶多平臺(tái)的賬號(hào)矩肩,往往用手機(jī)號(hào)去做一個(gè)聯(lián)系,這時(shí)候需要用戶去授權(quán)手機(jī)號(hào)肃续。

授權(quán)手機(jī)號(hào)

第二種黍檩,為了讓用戶信息得到基本的初始化,往往需要更進(jìn)一步獲取用戶信息:如微信昵稱始锚,unionId 等刽酱,就需要詢問用戶授權(quán)。

授權(quán)用戶信息

第三種瞧捌,囊括第一種棵里,第二種。

完整授權(quán)流程

三. 概念

秉著沉淀一套通用的小程序登錄方案和服務(wù)為目標(biāo)姐呐,我們?nèi)シ治鲆幌聵I(yè)務(wù)殿怜,得出變量。

在做技術(shù)設(shè)計(jì)之前曙砂,講點(diǎn)必要的廢話头谜,對(duì)一些概念進(jìn)行基本調(diào)頻。

2.1 關(guān)于「登錄」

登錄在英文中是 「login」鸠澈,對(duì)應(yīng)的還有 「logout」乔夯。而登錄之前,你需要擁有一個(gè)賬號(hào)款侵,就要 「register」(or sign up)末荐。

話說一開始的產(chǎn)品是沒有登錄/注冊(cè)功能的,用的人多了就慢慢有了新锈。出于產(chǎn)品本身的需求甲脏,需要對(duì)「用戶」進(jìn)行身份識(shí)別。

在現(xiàn)實(shí)社會(huì)中,我們每個(gè)人都有一個(gè)身份ID:身份證块请。當(dāng)我到了16歲的時(shí)候娜氏,第一次去公安局領(lǐng)身份證的時(shí)候,就完成了一次「注冊(cè)」行為墩新。然后我去網(wǎng)吧上網(wǎng)贸弥,身份證刷一下,完成了一次「登錄」行為海渊。

那么對(duì)于虛擬世界的互聯(lián)網(wǎng)來說绵疲,這個(gè)身份證明就是「賬號(hào)+密碼」。

常見的登錄/注冊(cè)方式有:

  1. 賬號(hào)密碼注冊(cè)

    在互聯(lián)網(wǎng)的早期臣疑,個(gè)人郵箱和手機(jī)覆蓋度小盔憨。所以,就需要用戶自己想一個(gè)賬號(hào)名讯沈,我們注冊(cè)個(gè)QQ號(hào)郁岩,就是這種形式。

    from 汽車之家
  2. 郵箱地址注冊(cè)

    千禧年之后缺狠,PC互聯(lián)網(wǎng)時(shí)代快速普及问慎,我們都創(chuàng)建了屬于自己的個(gè)人郵箱。加上QQ也自帶郵箱賬號(hào)挤茄。由于郵箱具有個(gè)人私密性如叼,且能夠進(jìn)行信息的溝通,因此驮樊,大部分網(wǎng)站開始采用郵箱賬號(hào)作為用戶名來進(jìn)行注冊(cè),并且會(huì)在注冊(cè)的過程中要求登錄到相應(yīng)郵箱內(nèi)查收激活郵件片酝,驗(yàn)證我們對(duì)該注冊(cè)郵箱的所有權(quán)囚衔。

    from 支付寶
  3. 手機(jī)號(hào)碼注冊(cè)

    在互聯(lián)網(wǎng)普及之后,智能手機(jī)與移動(dòng)互聯(lián)網(wǎng)發(fā)展迅猛雕沿。手機(jī)也成為每個(gè)人必不可少的移動(dòng)設(shè)備练湿,同時(shí)移動(dòng)互聯(lián)網(wǎng)也已經(jīng)深深融入每個(gè)人的現(xiàn)代生活當(dāng)中元践。所以温鸽,相較于郵箱,目前手機(jī)號(hào)碼與個(gè)人的聯(lián)系更加緊密坐榆,而且越來越多的移動(dòng)應(yīng)用出現(xiàn)疾渣,采用手機(jī)號(hào)碼作為用戶名的注冊(cè)方式也得到了廣泛的使用篡诽。

    from 知乎

到了 2020 年,微信用戶規(guī)模達(dá) 12 億榴捡。那么杈女,微信賬號(hào),起碼在中國(guó),已成為新一代互聯(lián)網(wǎng)世界的「身份標(biāo)識(shí)」达椰。

而對(duì)微信小程序而言翰蠢,天然就能知道當(dāng)前用戶的微信賬號(hào)ID。微信允許小程序應(yīng)用啰劲,能在用戶無感知的情況下梁沧,悄無聲息的「登錄」到我們的小程序應(yīng)用中去,這個(gè)就是我們經(jīng)常稱之為的「靜默登錄」蝇裤。

其實(shí)微信小程序的登錄廷支,跟傳統(tǒng) Web 應(yīng)用的「單點(diǎn)登錄」本質(zhì)是一樣的概念。

  1. 單點(diǎn)登錄:在 A 站登錄了猖辫,C 站和 B 站能實(shí)現(xiàn)快速的「靜默登錄」酥泞。
  2. 微信小程序登錄:在微信中,登錄了微信賬號(hào)啃憎,那么在整個(gè)小程序生態(tài)中芝囤,都可以實(shí)現(xiàn)「靜默登錄」。

由于 Http 本來是無狀態(tài)的辛萍,業(yè)界基本對(duì)于登錄態(tài)的一般做法:

  1. cookie-session:常用于瀏覽器應(yīng)用中
  2. access token:常用于移動(dòng)端等非瀏覽器應(yīng)用

在微信小程序來說悯姊,對(duì)于「JS邏輯層」并不是一個(gè)瀏覽器環(huán)境,自然沒有 Cookie贩毕,那么通常會(huì)使用 access token 的方式悯许。

2.2 關(guān)于「授權(quán)」

對(duì)于需要更進(jìn)一步獲取用的用戶昵稱、用戶手機(jī)號(hào)等信息的產(chǎn)品來說辉阶。微信出于用戶隱私的考慮先壕,需要用戶主動(dòng)同意授權(quán)。小程序應(yīng)用才能獲取到這部分信息谆甜,這就有了目前流行的小程序「授權(quán)用戶信息」垃僚、「授權(quán)手機(jī)號(hào)」的交互了。

出于不同的用戶信息敏感度不同的考慮规辱,微信小程序?qū)τ诓煌挠脩粜畔⑻峁甘跈?quán)」的方式不盡相同:

  1. 調(diào)用具體 API 方式谆棺,彈窗授權(quán)。
    1. 例如調(diào)用 wx.getLocation() 的時(shí)候罕袋,如果用戶未授權(quán)改淑,則會(huì)彈出地址授權(quán)界面。
    2. 如果拒絕了浴讯,就不會(huì)再次彈窗朵夏,wx.getLocation()直接返回失敗。
  2. <button open-type="xxx" /> 方式榆纽。
    1. 僅支持:用戶敏感信息侍郭,用戶手機(jī)號(hào)询吴,需要配合后端進(jìn)行對(duì)稱加解密,方能拿到數(shù)據(jù)亮元。
    2. 用戶已拒絕猛计,再次點(diǎn)擊按鈕,仍然會(huì)彈窗爆捞。
  3. 通過 wx.authorize()奉瘤,提前詢問授權(quán),之后需要獲取相關(guān)信息的時(shí)候不用再次彈出授權(quán)煮甥。

四. 詳細(xì)設(shè)計(jì)

梳理清楚了概念之后盗温,我們模塊的劃分上,可以拆分為兩大塊:

  1. 登錄:負(fù)責(zé)與服務(wù)端創(chuàng)建起一個(gè)會(huì)話成肘,這個(gè)會(huì)話實(shí)現(xiàn)靜默登錄以及相關(guān)的容錯(cuò)處理等卖局,模塊命名為:Session
  2. 授權(quán):負(fù)責(zé)與用戶交互,獲取與更新信息双霍,以及權(quán)限的控制處理等砚偶,模塊命名為:Auth

3.1 登錄的實(shí)現(xiàn)

3.1.1 靜默登錄

微信登錄

微信官方提供的登錄方案,總結(jié)為三步:

  1. 前端通過 wx.login() 獲取一次性加密憑證 code洒闸,交給后端染坯。
  2. 后端把這個(gè) code 傳輸給微信服務(wù)器端,換取用戶唯一標(biāo)識(shí) openId 和授權(quán)憑證 session_key丘逸。(用于后續(xù)服務(wù)器端和微信服務(wù)器的特殊 API 調(diào)用单鹿,具體看:微信官方文檔-服務(wù)端獲取開放數(shù)據(jù))。
  3. 后端把從微信服務(wù)器獲取到的用戶憑證與自行生成的登錄態(tài)憑證(token)深纲,傳輸給前端仲锄。前端保存起來,下次請(qǐng)求的時(shí)候帶給后端湃鹊,就能識(shí)別哪個(gè)用戶儒喊。

如果只是實(shí)現(xiàn)這個(gè)流程的話,挺簡(jiǎn)單的涛舍。

但要實(shí)現(xiàn)一個(gè)健壯的登錄過程澄惊,還需要注意更多的邊界情況:

  1. 收攏 wx.login() 的調(diào)用

    由于 wx.login() 會(huì)產(chǎn)生不可預(yù)測(cè)的副作用唆途,例如會(huì)可能導(dǎo)致session_key失效富雅,從而導(dǎo)致后續(xù)的授權(quán)解密場(chǎng)景中的失敗。我們這里可以提供一個(gè)像 session.login() 的方法肛搬,掌握 wx.login() 控制權(quán)没佑,對(duì)其做一系列的封裝和容錯(cuò)處理。

  2. 調(diào)用的時(shí)機(jī)

    通常我們會(huì)在應(yīng)用啟動(dòng)的時(shí)候( app.onLaunch() )温赔,去發(fā)起靜默登錄蛤奢。但這里會(huì)由小程序生命周期設(shè)計(jì)問題而導(dǎo)致的一個(gè)異步問題:加載頁面的時(shí)候,去調(diào)用一個(gè)需要登錄態(tài)的后端 API 的時(shí)候,前面異步的靜態(tài)登錄過程有可能還沒有完成啤贩,從而導(dǎo)致請(qǐng)求失敗待秃。

    當(dāng)然也可以在第一個(gè)需要登錄態(tài)的接口調(diào)用的時(shí)候以異步阻塞的方式發(fā)起登錄調(diào)用,這個(gè)需要結(jié)合良好設(shè)計(jì)的接口層痹屹。

    以上講到的兩種場(chǎng)景的詳細(xì)設(shè)計(jì)思路下文會(huì)講到章郁。

  3. 并發(fā)調(diào)用的問題

    在業(yè)務(wù)場(chǎng)景中,難免會(huì)出現(xiàn)多處代碼需要觸發(fā)登錄志衍,如果遇到極端情況暖庄,這多處代碼同時(shí)間發(fā)起調(diào)用。那就會(huì)造成短時(shí)間多次發(fā)起登錄過程楼肪,盡管之前的請(qǐng)求還沒有完成培廓。針對(duì)這種情況,我們可以以第一個(gè)調(diào)用為阻塞春叫,后續(xù)調(diào)用等待結(jié)果肩钠,就像精子和卵子結(jié)合的過程。

  4. 未過期調(diào)用的問題

    如果我們的登錄態(tài)未過期象缀,完全可以正常使用的蔬将,默認(rèn)情況就不需再去發(fā)起登錄過程了。這時(shí)候我們可以默認(rèn)情況下先去檢查登錄態(tài)是否可用央星,不能用霞怀,我們?cè)侔l(fā)起請(qǐng)求。然后還可以提供一個(gè)類似 session.login({ force: true })的參數(shù)去強(qiáng)行發(fā)起登錄莉给。

3.1.2 靜默登錄異步狀態(tài)的處理

1. 應(yīng)用啟動(dòng)的時(shí)候調(diào)用

因?yàn)榇蟛糠智闆r都需要依賴登錄態(tài)毙石,我們會(huì)很自然而然的想到把這個(gè)調(diào)用的時(shí)機(jī)放到應(yīng)用啟動(dòng)的時(shí)候( app.onLaunch() )來調(diào)用。

但是由于原生的小程序啟動(dòng)流程中颓遏, App徐矩,PageComponent 的生命周期鉤子函數(shù)叁幢,都不支持異步阻塞滤灯。

那么我們很容易會(huì)遇到 app.onLaunch 發(fā)起的「登錄過程」在 page.onLoad 的時(shí)候還沒有完成,我們就無法正確去做一些依賴登錄態(tài)的操作曼玩。

針對(duì)這種情況鳞骤,我們?cè)O(shè)計(jì)了一個(gè)狀態(tài)機(jī)的工具:status

狀態(tài)機(jī)

基于狀態(tài)機(jī),我們就可以編寫這樣的代碼:

import { Status } from '@beautywe/plugin-status';

// on app.js
App({
    status: {
       login: new Status('login');
    },

    onLaunch() {
        session
            // 發(fā)起靜默登錄調(diào)用
            .login()

            // 把狀態(tài)機(jī)設(shè)置為 success
            .then(() => this.status.login.success())
      
            // 把狀態(tài)機(jī)設(shè)置為 fail
            .catch(() => this.status.login.fail());
    },
});


// on page.js
Page({
    onLoad() {
      const loginStatus = getApp().status.login;
      
      // must 里面會(huì)進(jìn)行狀態(tài)的判斷黍判,例如登錄中就等待豫尽,登錄成功就直接返回,登錄失敗拋出等顷帖。
      loginStatus().status.login.must(() => {
        // 進(jìn)行一些需要登錄態(tài)的操作...
      });
    },
});

2. 在「第一個(gè)需要登錄態(tài)接口」被調(diào)用的時(shí)候去發(fā)起登錄

更進(jìn)一步美旧,我們會(huì)發(fā)現(xiàn)渤滞,需要登錄態(tài)的更深層次的節(jié)點(diǎn)是在發(fā)起的「需要登錄態(tài)的后端 API 」的時(shí)候。

那么我們可以在調(diào)用「需要登錄態(tài)的后端 API」的時(shí)候再去發(fā)起「靜默登錄」榴嗅,對(duì)于并發(fā)的場(chǎng)景妄呕,讓其他請(qǐng)求等待一下就好了。

fly.js 作為 wx.request() 封裝的「網(wǎng)絡(luò)請(qǐng)求層」嗽测,做一個(gè)簡(jiǎn)單的例子:

// 發(fā)起請(qǐng)求趴腋,并表明該請(qǐng)求是需要登錄態(tài)的
fly.post('https://...', params, { needLogin: true });

// 在 fly 攔截器中處理邏輯
fly.interceptors.request.use(async (req)=>{

  // 在請(qǐng)求需要登錄態(tài)的時(shí)候
  if (req.needLogin !== false) {

    // ensureLogin 核心邏輯是:判斷是否已登錄,如否發(fā)起登錄調(diào)用论咏,如果正在登錄优炬,則進(jìn)入隊(duì)列等待回調(diào)。
    await session.ensureLogin();
    
    // 登錄成功后厅贪,獲取 token蠢护,通過 headers 傳遞給后端。
    const token = await session.getToken();
    Object.assign(req.headers, { [AUTH_KEY_NAME]: token });
  }
  
  return req;
});

3.1.3 自定義登錄態(tài)過期的容錯(cuò)處理

當(dāng)自定義登錄態(tài)過期的時(shí)候养涮,后端需要返回特定的狀態(tài)碼葵硕,例如:AUTH_EXPIREDAUTH_INVALID 等贯吓。

前端可以在「網(wǎng)絡(luò)請(qǐng)求層」去監(jiān)聽所有請(qǐng)求的這個(gè)狀態(tài)碼懈凹,然后發(fā)起刷新登錄態(tài),再去重放失敗的請(qǐng)求:

// 添加響應(yīng)攔截器
fly.interceptors.response.use(
    (response) => {
      const code = res.data;
        
      // 登錄態(tài)過期或失效
      if ( ['AUTH_EXPIRED', 'AUTH_INVALID'].includes(code) ) {
      
        // 刷新登錄態(tài)
        await session.refreshLogin();
        
        // 然后重新發(fā)起請(qǐng)求
        return fly.request(request);
      }
    }
)

那么如果并發(fā)的發(fā)起多個(gè)請(qǐng)求悄谐,都返回了登錄態(tài)失效的狀態(tài)碼介评,上述代碼就會(huì)被執(zhí)行多次。

我們需要對(duì) session.refreshLogin() 做一些特殊的容錯(cuò)處理:

  1. 請(qǐng)求鎖:同一時(shí)間爬舰,只允許一個(gè)正在過程中的網(wǎng)絡(luò)請(qǐng)求们陆。
  2. 等待隊(duì)列:請(qǐng)求被鎖定之后,調(diào)用該方法的所有調(diào)用情屹,都推入一個(gè)隊(duì)列中坪仇,等待網(wǎng)絡(luò)請(qǐng)求完成之后共用返回結(jié)果。
  3. 熔斷機(jī)制:如果短時(shí)間內(nèi)多次調(diào)用垃你,則停止響應(yīng)一段時(shí)間椅文,類似于 TCP 慢啟動(dòng)。

示例代碼:

class Session {
  // ....
  
  // 刷新登錄保險(xiǎn)絲惜颇,最多重復(fù) 3 次皆刺,然后熔斷,5s 后恢復(fù)
  refreshLoginFuseLine = REFRESH_LOGIN_FUSELINE_DEFAULT;
  refreshLoginFuseLocked = false;
  refreshLoginFuseRestoreTime = 5000;

  // 熔斷控制
  refreshLoginFuse(): Promise<void> {
    if (this.refreshLoginFuseLocked) {
      return Promise.reject('刷新登錄-保險(xiǎn)絲已熔斷官还,請(qǐng)稍后');
    }
    if (this.refreshLoginFuseLine > 0) {
      this.refreshLoginFuseLine = this.refreshLoginFuseLine - 1;
      return Promise.resolve();
    } else {
      this.refreshLoginFuseLocked = true;
      setTimeout(() => {
        this.refreshLoginFuseLocked = false;
        this.refreshLoginFuseLine = REFRESH_LOGIN_FUSELINE_DEFAULT;
        logger.info('刷新登錄-保險(xiǎn)絲熔斷解除');
      }, this.refreshLoginFuseRestoreTime);
      return Promise.reject('刷新登錄-保險(xiǎn)絲熔斷!!');
    }
  }

  // 并發(fā)回調(diào)隊(duì)列
  refreshLoginQueueMaxLength = 100;
  refreshLoginQueue: any[] = [];
  refreshLoginLocked = false;

  // 刷新登錄態(tài)
  refreshLogin(): Promise<void> {
    return Promise.resolve()
    
      // 回調(diào)隊(duì)列 + 熔斷 控制
      .then(() => this.refreshLoginFuse())
      .then(() => {
        if (this.refreshLoginLocked) {
          const maxLength = this.refreshLoginQueueMaxLength;
          if (this.refreshLoginQueue.length >= maxLength) {
            return Promise.reject(`refreshLoginQueue 超出容量:${maxLength}`);
          }
          return new Promise((resolve, reject) => {
            this.refreshLoginQueue.push([resolve, reject]);
          });
        }
        this.refreshLoginLocked = true;
      })

      // 通過前置控制之后芹橡,發(fā)起登錄過程
      .then(() => {
        this.clearSession();
        wx.showLoading({ title: '刷新登錄態(tài)中', mask: true });
        return this.login()
          .then(() => {
            wx.hideLoading();
            wx.showToast({ icon: 'none', title: '登錄成功' });
            this.refreshLoginQueue.forEach(([resolve]) => resolve());
            this.refreshLoginLocked = false;
          })
          .catch(err => {
            wx.hideLoading();
            wx.showToast({ icon: 'none', title: '登錄失敗' });
            this.refreshLoginQueue.forEach(([, reject]) => reject());
            this.refreshLoginLocked = false;
            throw err;
          });
      });

  // ...
}

3.1.4 微信 session_key 過期的容錯(cuò)處理

我們從上面的「靜默登錄」之后毒坛,微信服務(wù)器端會(huì)下發(fā)一個(gè) session_key 給后端望伦,而這個(gè)會(huì)在需要獲取微信開放數(shù)據(jù)的時(shí)候會(huì)用到林说。

微信開放數(shù)據(jù)

session_key 是有時(shí)效性的,以下摘自微信官方描述:

會(huì)話密鑰 session_key 有效性

開發(fā)者如果遇到因?yàn)?session_key 不正確而校驗(yàn)簽名失敗或解密失敗屯伞,請(qǐng)關(guān)注下面幾個(gè)與 session_key 有關(guān)的注意事項(xiàng)腿箩。

  1. wx.login 調(diào)用時(shí),用戶的 session_key 可能會(huì)被更新而致使舊 session_key 失效(刷新機(jī)制存在最短周期劣摇,如果同一個(gè)用戶短時(shí)間內(nèi)多次調(diào)用 wx.login珠移,并非每次調(diào)用都導(dǎo)致 session_key 刷新)。開發(fā)者應(yīng)該在明確需要重新登錄時(shí)才調(diào)用 wx.login末融,及時(shí)通過 auth.code2Session 接口更新服務(wù)器存儲(chǔ)的 session_key钧惧。
  2. 微信不會(huì)把 session_key 的有效期告知開發(fā)者。我們會(huì)根據(jù)用戶使用小程序的行為對(duì) session_key 進(jìn)行續(xù)期勾习。用戶越頻繁使用小程序浓瞪,session_key 有效期越長(zhǎng)。
  3. 開發(fā)者在 session_key 失效時(shí)巧婶,可以通過重新執(zhí)行登錄流程獲取有效的 session_key乾颁。使用接口 wx.checkSession可以校驗(yàn) session_key 是否有效,從而避免小程序反復(fù)執(zhí)行登錄流程艺栈。
  4. 當(dāng)開發(fā)者在實(shí)現(xiàn)自定義登錄態(tài)時(shí)英岭,可以考慮以 session_key 有效期作為自身登錄態(tài)有效期,也可以實(shí)現(xiàn)自定義的時(shí)效性策略湿右。

翻譯成簡(jiǎn)單的兩句話:

  1. session_key 時(shí)效性由微信控制诅妹,開發(fā)者不可預(yù)測(cè)。
  2. wx.login 可能會(huì)導(dǎo)致 session_key 過期毅人,可以在使用接口之前用wx.checkSession 檢查一下漾唉。

而對(duì)于第二點(diǎn),我們通過實(shí)驗(yàn)發(fā)現(xiàn)堰塌,偶發(fā)性的在 session_key 已過期的情況下赵刑,wx.checkSession 會(huì)概率性返回 true

社區(qū)也有相關(guān)的反饋未得到解決:

所以結(jié)論是:wx.checkSession可靠性是不達(dá) 100% 的般此。

基于以上,我們需要對(duì) session_key 的過期做一些容錯(cuò)處理:

  1. 發(fā)起需要使用 session_key 的請(qǐng)求前牵现,做一次 wx.checkSession 操作铐懊,如果失敗了刷新登錄態(tài)。
  2. 后端使用 session_key 解密開放數(shù)據(jù)失敗之后瞎疼,返回特定錯(cuò)誤碼(如:DECRYPT_WX_OPEN_DATA_FAIL)科乎,前端刷新登錄態(tài)。

示例代碼:

// 定義檢查 session_key 有效性的操作
const ensureSessionKey = async () => {
  const hasSession = await new Promise(resolve => {
    wx.checkSession({
      success: () => resolve(true),
      fail: () => resolve(false),
    });
  });
  
  if (!hasSession) {
    logger.info('sessionKey 已過期贼急,刷新登錄態(tài)');

    // 接上面提到的刷新登錄邏輯
    return session.refreshLogin();
  }

  return Promise.resolve();
}

// 在發(fā)起請(qǐng)求的時(shí)候茅茂,先做一次確保 session_key 最新的操作(以 fly.js 作為網(wǎng)絡(luò)請(qǐng)求層為例)
const updatePhone = async (params) => {
  await ensureSessionKey();
  const res = await fly.post('https://xxx', params);
}

// 添加響應(yīng)攔截器, 監(jiān)聽網(wǎng)絡(luò)請(qǐng)求返回
fly.interceptors.response.use(
    (response) => {
      const code = res.data;
        
      // 登錄態(tài)過期或失效
      if ( ['DECRYPT_WX_OPEN_DATA_FAIL'].includes(code)) {

        // 刷新登錄態(tài)
        await session.refreshLogin();
        
        // 由于加密場(chǎng)景的加密數(shù)據(jù)由用戶點(diǎn)擊產(chǎn)生捏萍,session_key 可能已經(jīng)更改,需要用戶重新點(diǎn)擊一遍空闲。
        wx.showToast({ title: '網(wǎng)絡(luò)出小差了令杈,請(qǐng)稍后重試', icon: 'none' });
      }
    }
)

3.2 授權(quán)的實(shí)現(xiàn)

3.2.1 組件拆分與設(shè)計(jì)

在用戶信息和手機(jī)號(hào)獲取的方式上,微信是以 <button open-type='xxx' /> 的方式碴倾,讓用戶主動(dòng)點(diǎn)擊授權(quán)的逗噩。

那么為了讓代碼更解耦,我們?cè)O(shè)計(jì)這樣三個(gè)組件:

  1. <user-contaienr getUserInfo="onUserInfoAuth">: 包裝點(diǎn)擊交互跌榔,通過 <slot> 支持點(diǎn)擊區(qū)域的自定義UI异雁。
  2. <phone-container getPhonenNmber="onPhoneAuth"> : 與 <user-container> 同理。
  3. <auth-flow>: 根據(jù)業(yè)務(wù)需要僧须,組合 <user-container>片迅、<phone-container> 組合來定義不同的授權(quán)流程。

以開頭的業(yè)務(wù)場(chǎng)景的流程為例皆辽,它有這樣的要求:

  1. 有多個(gè)步驟柑蛇。
  2. 如果中途斷掉了,可以從中間接上驱闷。
  3. 有些場(chǎng)景中耻台,只要求達(dá)到「用戶信息授權(quán)」,而不需要完成「用戶手機(jī)號(hào)」空另。
完整授權(quán)流程

那么授權(quán)的階段可以分三層:

// 用戶登錄的階段
export enum AuthStep {
  // 階段一:只有登錄態(tài)盆耽,沒有用戶信息,沒有手機(jī)號(hào)
  ONE = 1,

  // 階段二:有用戶信息扼菠,沒有手機(jī)號(hào)
  TWO = 2,

  // 階段三:有用戶信息摄杂,有手機(jī)號(hào)
  THREE = 3,
}

AuthStep 的推進(jìn)過程是不可逆的,我們可以定義一個(gè) nextStep 函數(shù)來封裝 AuthStep 更新的邏輯循榆。外部使用的話析恢,只要無腦調(diào)用 nextStep 方法,等待回調(diào)結(jié)果就行秧饮。

示例偽代碼:

// auth-flow component

Component({
  // ...
  
  data: {
    // 默認(rèn)情況下映挂,只需要到達(dá)階段二。
    mustAuthStep: AuthStep.TWO
  },
  
  // 允許臨時(shí)更改組件的需要達(dá)到的階段盗尸。
  setMustAuthStep(mustAuthStep: AuthStep) {
    this.setData({ mustAuthStep });
  },
  
  // 根據(jù)用戶當(dāng)前的信息柑船,計(jì)算用戶處在授權(quán)的階段
  getAuthStep() {
    let currAuthStep;
    
    // 沒有用戶信息,尚在第一步
    if (!session.hasUser() || !session.hasUnionId()) {
      currAuthStep = AuthStepType.ONE;
    }

    // 沒有手機(jī)號(hào)泼各,尚在第二步
    if (!session.hasPhone()) {
      currAuthStep = AuthStepType.TWO;
    }

    // 都有鞍时,尚在第三步
    currAuthStep = AuthStepType.THREE;
    return currAuthStep;
  }
  
  // 發(fā)起下一步授權(quán),如果都已經(jīng)完成,就直接返回成功逆巍。
  nextStep(e) {
    const { mustAuthStep } = this.data;
    const currAuthStep = this.updateAuthStep();
  
    // 已完成授權(quán)
    if (currAuthStep >= mustAuthStep || currAuthStep === AuthStepType.THREE) {
      // 更新全局的授權(quán)狀態(tài)機(jī)及塘,廣播消息給訂閱者。
      return getApp().status.auth.success();
    }

    // 第一步:更新用戶信息
    if (currAuthStep === AuthStepType.ONE) {
      // 已有密文信息蒸苇,更新用戶信息
      if (e) session.updateUser(e);

      // 更新到視圖層,展示對(duì)應(yīng)UI吮旅,等待獲取用戶信息
      else this.setData({ currAuthStep });
      return;
    }

    // 第二步:更新手機(jī)信息
    if (currAuthStep === AuthStepType.TWO) {
      // 已有密文信息溪烤,更新手機(jī)號(hào)
      if (e) this.bindPhone(e);

      // 未有密文信息,彈出獲取窗口
      else this.setData({ currAuthStep });
      return;
    }

    console.warn('auth.nextStep 錯(cuò)誤', { currAuthStep, mustAuthStep });
  },
  
  // ...
});

那么我們的 <auth-flow> 中就可以根據(jù) currAuthStepmustAuthStep 來去做不同的 UI 展示庇勃。需要注意的是使用 <user-container>檬嘀、<phone-container> 的時(shí)候連接上 nextStep(e) 函數(shù)。

示例偽代碼:

<view class="auth-flow">

  <!-- 已完成授權(quán) -->
  <block wx:if="{{currAuthStep === mustAuthStep || currAuthStep === AuthStep.THREE}}">
    <view>已完成授權(quán)</view>
  </block>

  <!-- 未完成授權(quán)责嚷,第一步:授權(quán)用戶信息 -->
  <block wx:elif="{{currAuthStep === AuthStep.ONE}}">
    <user-container bind:getuserinfo="nextStep">
      <view>授權(quán)用戶信息</view>
    </user-container>
  </block>

  <!-- 未完成授權(quán)鸳兽,第二步:授權(quán)手機(jī)號(hào) -->
  <block wx:elif="{{currAuthStep === AuthStep.TWO}}">
    <phone-container bind:getphonenumber="nextStep">
      <view>授權(quán)手機(jī)號(hào)</view>
    </phone-container>
  </block>
  
</view>

3.2.2 權(quán)限攔截的處理

到這里,我們制作好了用來承載授權(quán)流程的組件 <auth-flow> 罕拂,那么接下來就是決定要使用它的時(shí)機(jī)了揍异。

我們梳理需要授權(quán)的場(chǎng)景:

  1. 點(diǎn)擊某個(gè)按鈕,例如:購(gòu)買某個(gè)商品爆班。

    對(duì)于這種場(chǎng)景衷掷,常見的是通過彈窗完成授權(quán),用戶可以選擇關(guān)閉柿菩。

授權(quán)模型-彈窗
  1. 瀏覽某個(gè)頁面戚嗅,例如:訪問個(gè)人中心。

    對(duì)于這種場(chǎng)景枢舶,我們可以在點(diǎn)擊跳轉(zhuǎn)某個(gè)頁面的時(shí)候懦胞,進(jìn)行攔截,彈窗處理凉泄。但這樣的缺點(diǎn)是躏尉,跳轉(zhuǎn)到目標(biāo)頁面的地方可能會(huì)很多,每個(gè)都攔截后众,難免會(huì)錯(cuò)漏醇份。而且當(dāng)目標(biāo)頁面作為「小程序落地頁面」的時(shí)候,就避免不了吼具。

    這時(shí)候僚纷,我們可以通過重定向到授權(quán)頁面來完成授權(quán)流程,完成之后拗盒,再回來怖竭。

授權(quán)模型-頁面

那么我們定義一個(gè)枚舉變量:

// 授權(quán)的展示形式
export enum AuthDisplayMode {
  // 以彈窗形式
  POPUP = 'button',

  // 以頁面形式
  PAGE = 'page',
}

我們可以設(shè)計(jì)一個(gè) mustAuth 方法,在點(diǎn)擊某個(gè)按鈕陡蝇,或者頁面加載的時(shí)候痊臭,進(jìn)行授權(quán)控制哮肚。

偽代碼示例:

class Session {
  // ...
  
  mustAuth({
    mustAuthStep = AuthStepType.TWO, // 需要授權(quán)的LEVEL,默認(rèn)需要獲取用戶資料
    popupCompName = 'auth-popup',   // 授權(quán)彈窗組件的 id
    mode = AuthDisplayMode.POPUP, // 默認(rèn)以彈窗模式
  } = {}): Promise<void> {
    
    // 如果當(dāng)前的授權(quán)步驟已經(jīng)達(dá)標(biāo)广匙,則返回成功
    if (this.currentAuthStep() >= mustAuthStep) return Promise.resolve();

    // 嘗試獲取當(dāng)前頁面的 <auth-popup id="auth-popup" /> 組件實(shí)例
    const pages = getCurrentPages();
    const curPage = pages[pages.length - 1];
    const popupComp = curPage.selectComponent(`#${popupCompName}`);

    // 組件不存在或者顯示指定頁面允趟,跳轉(zhuǎn)到授權(quán)頁面
    if (!popupComp || mode === AuthDisplayMode.PAGE) {
      const curRoute = curPage.route;

      // 跳轉(zhuǎn)到授權(quán)頁面,帶上當(dāng)前頁面路由鸦致,授權(quán)完成之后潮剪,回到當(dāng)前頁面。
      wx.redirectTo({ url: `authPage?backTo=${encodeURIComponent(curRoute)}` });
      return Promise.resolve();
    }
    
    // 設(shè)置授權(quán) LEVEL分唾,然后調(diào)用 <auth-popup> 的 nextStep 方法抗碰,進(jìn)行進(jìn)一步的授權(quán)。
    popupComp.setMustAuthStep(mustAuthStep);
    popupComp.nextStep();

    // 等待成功回調(diào)或者失敗回調(diào)
    return new Promise((resolve, reject) => {
      const authStatus = getApp().status.auth;
      authStatus.onceSuccess(resolve);
      authStatus.onceFail(reject);
    });
  }
  
  // ...
}

那么我們就能在按鈕點(diǎn)擊绽乔,或者頁面加載的時(shí)候進(jìn)行授權(quán)攔截:

Page({
  onLoad() {
    session.mustAuth().then(() => {
      // 開始初始化頁面...
    })弧蝇;
  }
  
  onClick(e) {
    session.mustAuth().then(() => {
      // 開始處理回調(diào)邏輯...
    });
  }
})

當(dāng)然折砸,如果項(xiàng)目使用了 TS 的話看疗,或者支持 ES7 Decorator 特性的話,我們可以為 mustAuth 提供一個(gè)裝飾器版本:

export function mustAuth(option = {}) {
  return function(
    _target,
    _propertyName,
    descriptor,
  ) {
    // 劫持目標(biāo)方法
    const method = descriptor.value;
    
    // 重寫目標(biāo)方法
    descriptor.value = function(...args: any[]) {
      return session.mustAuth(option).then(() => {
        // 登錄完成之后睦授,重放原來方法
        if (method) return method.apply(this, args);
      });
    };
  };
}

那么使用方式就簡(jiǎn)單一些了:

Page({
  @mustAuth();
  onLoad() {
    // 開始初始化頁面...
  }
  
  @mustAuth();
  onClick(e) {
    // 開始處理回調(diào)邏輯...
  }
});

3.3. 前后端交互協(xié)議整理

作為一套可復(fù)用的小程序登錄方案鹃觉,當(dāng)然需要去定義好前后端的交互協(xié)議。

那么整套登錄流程下來睹逃,需要的接口有這么幾個(gè):

登錄注冊(cè)前后端接口協(xié)議
  1. 靜默登錄 silentLogin

    1. 入?yún)ⅲ?
      1. code: 產(chǎn)自 wx.login()
    2. 出參:
      1. token: 自定義登錄態(tài)憑證
      2. userInfo: 用戶信息
    3. 說明:
      1. 后端利用 code 跟微信客戶端換取用戶標(biāo)識(shí)盗扇,然后注冊(cè)并登錄用戶,返回自定義登錄態(tài) token 給前端
      2. token 前端會(huì)存起來沉填,每個(gè)請(qǐng)求都會(huì)帶上
      3. userInfo 需要包含nicknamephone字段疗隶,前端用于計(jì)算當(dāng)前用戶的授權(quán)階段。當(dāng)然這個(gè)狀態(tài)的記錄可以放在后端翼闹,但是我們認(rèn)為放在前端斑鼻,會(huì)更加靈活。
  2. 更新用戶信息 updateUser

    1. 入?yún)ⅲ?
      1. nickname: 用戶昵稱
      2. encrypt: 微信開放數(shù)據(jù)相關(guān)的 iv, encryptedData
      3. 以及其他如性別地址等非必要字段
    2. 出參:
      1. userInfo:更新后的最新用戶信息
    3. 說明:
      1. 后端解密微信開放數(shù)據(jù)猎荠,獲取隱蔽數(shù)據(jù)坚弱,如:unionId
      2. 后端支持更新包括 nickname等用戶基本信息。
      3. 前端會(huì)把 userInfo 信息更新到 session 中关摇,用于計(jì)算授權(quán)階段荒叶。
  3. 更新用戶手機(jī)號(hào) updatePhone

    1. 入?yún)ⅲ?
      1. encrypt:微信開放數(shù)據(jù)相關(guān)的 iv, encryptedData
    2. 出參:
      1. userInfo:更新后的最新用戶信息
    3. 說明:
      1. 后端解密開放式局,獲取手機(jī)號(hào)输虱,并更新到用戶信息中些楣。
      2. 前端會(huì)把 userInfo 信息更新到 session 中,用于計(jì)算授權(quán)階段。
  4. 解綁手機(jī)號(hào) unbindPhone

    1. 入?yún)ⅲ?
    2. 出參:-
    3. 說明:后端解綁用戶手機(jī)號(hào)愁茁,成功與否蚕钦,走業(yè)務(wù)定義的前后端協(xié)議。
  5. 登錄 logout

    1. 入?yún)ⅲ?
    2. 出參:-
    3. 說明:后端主動(dòng)過期登錄態(tài)鹅很,成功與否嘶居,走業(yè)務(wù)定義的前后端協(xié)議。

五. 架構(gòu)圖

最后我們來梳理一下整體的「登錄服務(wù)」的架構(gòu)圖:

微信小程序登錄服務(wù)架構(gòu)圖

由「登錄服務(wù)」和「底層建設(shè)」組合提供的通用服務(wù)促煮,業(yè)務(wù)層只需要去根據(jù)產(chǎn)品需求邮屁,定制授權(quán)的流程 <auth-flow> ,就能滿足大部分場(chǎng)景了污茵。

六. 總結(jié)

本篇文章通過一些常見的登錄授權(quán)場(chǎng)景來展開來描述細(xì)節(jié)點(diǎn)樱报。

整理了「登錄」葬项、「授權(quán)」的概念泞当。

然后分別針對(duì)「登錄」介紹了一些關(guān)鍵的技術(shù)實(shí)現(xiàn):

  1. 靜默登錄
  2. 靜默登錄異步狀態(tài)的處理
  3. 自定義登錄態(tài)過期的容錯(cuò)處理
  4. 微信 session_key 過期的容錯(cuò)處理

而對(duì)于「授權(quán)」,會(huì)有設(shè)計(jì)UI部分的邏輯民珍,還需要涉及到組件的拆分:

  1. 組件拆分與設(shè)計(jì)
  2. 權(quán)限攔截的處理

然后襟士,梳理了這套登錄授權(quán)方案所依賴的后端接口,和給出最簡(jiǎn)單的參考協(xié)議嚷量。

最后陋桂,站在「秉著沉淀一套通用的小程序登錄方案和服務(wù)為目標(biāo)」的角度,梳理了一下架構(gòu)層面上的分層蝶溶。

  1. 業(yè)務(wù)定制層
  2. 登錄服務(wù)層
  3. 底層建設(shè)

七. 參考

  1. fly.js 官網(wǎng)
  2. 微信官方文檔-授權(quán)
  3. 微信官方文檔-服務(wù)端獲取開放數(shù)據(jù)
  4. 微信官方社區(qū)
    1. 小程序解密手機(jī)號(hào),隔一小段時(shí)間后,checksession:ok,但是解密失敗
    2. wx.checkSession有效嗜历,但是解密數(shù)據(jù)失敗
    3. checkSession判斷session_key未失效,但是解密手機(jī)號(hào)失敗
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末抖所,一起剝皮案震驚了整個(gè)濱河市梨州,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌田轧,老刑警劉巖暴匠,帶你破解...
    沈念sama閱讀 219,427評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異傻粘,居然都是意外死亡每窖,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,551評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門弦悉,熙熙樓的掌柜王于貴愁眉苦臉地迎上來窒典,“玉大人,你說我怎么就攤上這事稽莉〕绨埽” “怎么了?”我有些...
    開封第一講書人閱讀 165,747評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)后室。 經(jīng)常有香客問我缩膝,道長(zhǎng),這世上最難降的妖魔是什么岸霹? 我笑而不...
    開封第一講書人閱讀 58,939評(píng)論 1 295
  • 正文 為了忘掉前任疾层,我火速辦了婚禮,結(jié)果婚禮上贡避,老公的妹妹穿的比我還像新娘痛黎。我一直安慰自己,他們只是感情好刮吧,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,955評(píng)論 6 392
  • 文/花漫 我一把揭開白布湖饱。 她就那樣靜靜地躺著,像睡著了一般杀捻。 火紅的嫁衣襯著肌膚如雪井厌。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,737評(píng)論 1 305
  • 那天致讥,我揣著相機(jī)與錄音仅仆,去河邊找鬼。 笑死垢袱,一個(gè)胖子當(dāng)著我的面吹牛墓拜,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播请契,決...
    沈念sama閱讀 40,448評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼咳榜,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了爽锥?” 一聲冷哼從身側(cè)響起涌韩,我...
    開封第一講書人閱讀 39,352評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎救恨,沒想到半個(gè)月后贸辈,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,834評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡肠槽,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,992評(píng)論 3 338
  • 正文 我和宋清朗相戀三年擎淤,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片秸仙。...
    茶點(diǎn)故事閱讀 40,133評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡嘴拢,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出寂纪,到底是詐尸還是另有隱情席吴,我是刑警寧澤赌结,帶...
    沈念sama閱讀 35,815評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站孝冒,受9級(jí)特大地震影響柬姚,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜庄涡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,477評(píng)論 3 331
  • 文/蒙蒙 一量承、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧穴店,春花似錦撕捍、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,022評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至球凰,卻和暖如春狮腿,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背弟蚀。 一陣腳步聲響...
    開封第一講書人閱讀 33,147評(píng)論 1 272
  • 我被黑心中介騙來泰國(guó)打工蚤霞, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留酗失,地道東北人义钉。 一個(gè)月前我還...
    沈念sama閱讀 48,398評(píng)論 3 373
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像规肴,于是被迫代替她去往敵國(guó)和親捶闸。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,077評(píng)論 2 355