歡迎來個人博客閱讀: 「微信小程序登陸的前端設計與實現」」
對於登陸/註冊的設計如此精雕細琢的目的,固然是想讓這個做爲應用的基礎能力,有足夠的健壯性,避免出現全站性的阻塞。javascript
同時要充分考慮如何解耦和封裝,在開展新的小程序的時候,能更快的去複用能力,避免重複採坑。html
登陸註冊這模塊,就像個冰山,咱們覺得它就是「輸入帳號密碼,就完成登陸了」,但實際下面還有各類須要考慮的問題。前端
在此,跟在座的各位分享一下,最近作完一個小程序登陸/註冊模塊以後,沉澱下來的一些設計經驗和想法。java
在用戶瀏覽小程序的過程當中,由業務須要,每每須要獲取用戶的一些基本信息,常見的有:git
而不一樣的產品,對於用戶的信息要求不盡相同,也會有不同的受權流程。github
第一種,常見於電商系統中,用戶購買商品的時候,爲了識別用戶多平臺的帳號,每每用手機號去作一個聯繫,這時候須要用戶去受權手機號。小程序
第二種,爲了讓用戶信息獲得基本的初始化,每每須要更進一步獲取用戶信息:如微信暱稱,unionId
等,就須要詢問用戶受權。後端
第三種,囊括第一種,第二種。微信小程序
秉着沉澱一套通用的小程序登陸方案和服務爲目標,咱們去分析一下業務,得出變量。api
在作技術設計以前,講點必要的廢話,對一些概念進行基本調頻。
登陸在英文中是 「login」,對應的還有 「logout」。而登陸以前,你須要擁有一個帳號,就要 「register」(or sign up)。
話說一開始的產品是沒有登陸/註冊功能的,用的人多了就慢慢有了。出於產品自己的需求,須要對「用戶」進行身份識別。
在現實社會中,咱們每一個人都有一個身份ID:身份證。當我到了16歲的時候,第一次去公安局領身份證的時候,就完成了一次「註冊」行爲。而後我去網吧上網,身份證刷一下,完成了一次「登陸」行爲。
那麼對於虛擬世界的互聯網來講,這個身份證實就是「帳號+密碼」。
常見的登陸/註冊方式有:
在互聯網的早期,我的郵箱和手機覆蓋度小。因此,就須要用戶本身想一個帳號名,咱們註冊個QQ號,就是這種形式。
千禧年以後,PC互聯網時代快速普及,咱們都建立了屬於本身的我的郵箱。加上QQ也自帶郵箱帳號。因爲郵箱具備我的私密性,且可以進行信息的溝通,所以,大部分網站開始採用郵箱帳號做爲用戶名來進行註冊,而且會在註冊的過程當中要求登陸到相應郵箱內查收激活郵件,驗證咱們對該註冊郵箱的全部權。
在互聯網普及以後,智能手機與移動互聯網發展迅猛。手機也成爲每一個人必不可少的移動設備,同時移動互聯網也已經深深融入每一個人的現代生活當中。因此,相較於郵箱,目前手機號碼與我的的聯繫更加緊密,並且愈來愈多的移動應用出現,採用手機號碼做爲用戶名的註冊方式也獲得了普遍的使用。
到了 2020 年,微信用戶規模達 12 億。那麼,微信帳號,起碼在中國,已成爲新一代互聯網世界的「身份標識」。
而對微信小程序而言,自然就能知道當前用戶的微信帳號ID。微信容許小程序應用,能在用戶無感知的狀況下,悄無聲息的「登陸」到咱們的小程序應用中去,這個就是咱們常常稱之爲的「靜默登陸」。
其實微信小程序的登陸,跟傳統 Web 應用的「單點登陸」本質是同樣的概念。
因爲 Http 原本是無狀態的,業界基本對於登陸態的通常作法:
在微信小程序來講,對於「JS邏輯層」並非一個瀏覽器環境,天然沒有 Cookie
,那麼一般會使用 access token
的方式。
對於須要更進一步獲取用的用戶暱稱、用戶手機號等信息的產品來講。微信出於用戶隱私的考慮,須要用戶主動贊成受權。小程序應用才能獲取到這部分信息,這就有了目前流行的小程序「受權用戶信息」、「受權手機號」的交互了。
出於不一樣的用戶信息敏感度不一樣的考慮,微信小程序對於不一樣的用戶信息提供「受權」的方式不盡相同:
調用具體 API 方式,彈窗受權。
wx.getLocation()
的時候,若是用戶未受權,則會彈出地址受權界面。wx.getLocation()
直接返回失敗。<button open-type="xxx" />
方式。
wx.authorize()
,提早詢問受權,以後須要獲取相關信息的時候不用再次彈出受權。梳理清楚了概念以後,咱們模塊的劃分上,能夠拆分爲兩大塊:
Session
Auth
微信官方提供的登陸方案,總結爲三步:
wx.login()
獲取一次性加密憑證 code,交給後端。openId
和受權憑證 session_key
。(用於後續服務器端和微信服務器的特殊 API 調用,具體看:微信官方文檔-服務端獲取開放數據)。若是隻是實現這個流程的話,挺簡單的。
但要實現一個健壯的登陸過程,還須要注意更多的邊界狀況:
wx.login()
的調用:因爲 wx.login()
會產生不可預測的反作用,例如會可能致使session_key
失效,從而致使後續的受權解密場景中的失敗。咱們這裏能夠提供一個像 session.login()
的方法,掌握 wx.login()
控制權,對其作一系列的封裝和容錯處理。
一般咱們會在應用啓動的時候( app.onLaunch()
),去發起靜默登陸。但這裏會由小程序生命週期設計問題而致使的一個異步問題:加載頁面的時候,去調用一個須要登陸態的後端 API 的時候,前面異步的靜態登陸過程有可能尚未完成,從而致使請求失敗。
固然也能夠在第一個須要登陸態的接口調用的時候以異步阻塞的方式發起登陸調用,這個須要結合良好設計的接口層。
以上講到的兩種場景的詳細設計思路下文會講到。
在業務場景中,不免會出現多處代碼須要觸發登陸,若是遇到極端狀況,這多處代碼同時間發起調用。那就會形成短期屢次發起登陸過程,儘管以前的請求尚未完成。針對這種狀況,咱們能夠以第一個調用爲阻塞,後續調用等待結果,就像精子和卵子結合的過程。
若是咱們的登陸態未過時,徹底能夠正常使用的,默認狀況就不需再去發起登陸過程了。這時候咱們能夠默認狀況下先去檢查登陸態是否可用,不能用,咱們再發起請求。而後還能夠提供一個相似 session.login({ force: true })
的參數去強行發起登陸。
1. 應用啓動的時候調用
由於大部分狀況都須要依賴登陸態,咱們會很天然而然的想到把這個調用的時機放到應用啓動的時候( app.onLaunch()
)來調用。
可是因爲原生的小程序啓動流程中, App
,Page
,Component
的生命週期鉤子函數,都不支持異步阻塞。
那麼咱們很容易會遇到 app.onLaunch
發起的「登陸過程」在 page.onLoad
的時候尚未完成,咱們就沒法正確去作一些依賴登陸態的操做。
針對這種狀況,咱們設計了一個狀態機的工具:status
基於狀態機,咱們就能夠編寫這樣的代碼:
import { Status } from '@beautywe/plugin-status'; // on app.js App({ status: { login: new Status('login'); }, onLaunch() { session // 發起靜默登陸調用 .login() // 把狀態機設置爲 success .then(() => this.status.login.success()) // 把狀態機設置爲 fail .catch(() => this.status.login.fail()); }, }); // on page.js Page({ onLoad() { const loginStatus = getApp().status.login; // must 裏面會進行狀態的判斷,例如登陸中就等待,登陸成功就直接返回,登陸失敗拋出等。 loginStatus().status.login.must(() => { // 進行一些須要登陸態的操做... }); }, });
2. 在「第一個須要登陸態接口」被調用的時候去發起登陸
更進一步,咱們會發現,須要登陸態的更深層次的節點是在發起的「須要登陸態的後端 API 」的時候。
那麼咱們能夠在調用「須要登陸態的後端 API」的時候再去發起「靜默登陸」,對於併發的場景,讓其餘請求等待一下就行了。
以 fly.js 做爲 wx.request()
封裝的「網絡請求層」,作一個簡單的例子:
// 發起請求,並代表該請求是須要登陸態的 fly.post('https://...', params, { needLogin: true }); // 在 fly 攔截器中處理邏輯 fly.interceptors.request.use(async (req)=>{ // 在請求須要登陸態的時候 if (req.needLogin !== false) { // ensureLogin 核心邏輯是:判斷是否已登陸,如否發起登陸調用,若是正在登陸,則進入隊列等待回調。 await session.ensureLogin(); // 登陸成功後,獲取 token,經過 headers 傳遞給後端。 const token = await session.getToken(); Object.assign(req.headers, { [AUTH_KEY_NAME]: token }); } return req; });
當自定義登陸態過時的時候,後端須要返回特定的狀態碼,例如:AUTH_EXPIRED
、 AUTH_INVALID
等。
前端能夠在「網絡請求層」去監聽全部請求的這個狀態碼,而後發起刷新登陸態,再去重放失敗的請求:
// 添加響應攔截器 fly.interceptors.response.use( (response) => { const code = res.data; // 登陸態過時或失效 if ( ['AUTH_EXPIRED', 'AUTH_INVALID'].includes(code) ) { // 刷新登陸態 await session.refreshLogin(); // 而後從新發起請求 return fly.request(request); } } )
那麼若是併發的發起多個請求,都返回了登陸態失效的狀態碼,上述代碼就會被執行屢次。
咱們須要對 session.refreshLogin()
作一些特殊的容錯處理:
示例代碼:
class Session { // .... // 刷新登陸保險絲,最多重複 3 次,而後熔斷,5s 後恢復 refreshLoginFuseLine = REFRESH_LOGIN_FUSELINE_DEFAULT; refreshLoginFuseLocked = false; refreshLoginFuseRestoreTime = 5000; // 熔斷控制 refreshLoginFuse(): Promise<void> { if (this.refreshLoginFuseLocked) { return Promise.reject('刷新登陸-保險絲已熔斷,請稍後'); } 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('刷新登陸-保險絲熔斷解除'); }, this.refreshLoginFuseRestoreTime); return Promise.reject('刷新登陸-保險絲熔斷!!'); } } // 併發回調隊列 refreshLoginQueueMaxLength = 100; refreshLoginQueue: any[] = []; refreshLoginLocked = false; // 刷新登陸態 refreshLogin(): Promise<void> { return Promise.resolve() // 回調隊列 + 熔斷 控制 .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; }) // 經過前置控制以後,發起登陸過程 .then(() => { this.clearSession(); wx.showLoading({ title: '刷新登陸態中', 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; }); }); // ... }
咱們從上面的「靜默登陸」以後,微信服務器端會下發一個 session_key
給後端,而這個會在須要獲取微信開放數據的時候會用到。
而 session_key
是有時效性的,如下摘自微信官方描述:
會話密鑰 session_key 有效性
開發者若是遇到由於 session_key 不正確而校驗簽名失敗或解密失敗,請關注下面幾個與 session_key 有關的注意事項。
- wx.login 調用時,用戶的 session_key 可能會被更新而導致舊 session_key 失效(刷新機制存在最短週期,若是同一個用戶短期內屢次調用 wx.login,並不是每次調用都致使 session_key 刷新)。開發者應該在明確須要從新登陸時才調用 wx.login,及時經過 auth.code2Session 接口更新服務器存儲的 session_key。
- 微信不會把 session_key 的有效期告知開發者。咱們會根據用戶使用小程序的行爲對 session_key 進行續期。用戶越頻繁使用小程序,session_key 有效期越長。
- 開發者在 session_key 失效時,能夠經過從新執行登陸流程獲取有效的 session_key。使用接口 wx.checkSession能夠校驗 session_key 是否有效,從而避免小程序反覆執行登陸流程。
- 當開發者在實現自定義登陸態時,能夠考慮以 session_key 有效期做爲自身登陸態有效期,也能夠實現自定義的時效性策略。
翻譯成簡單的兩句話:
session_key
時效性由微信控制,開發者不可預測。wx.login
可能會致使 session_key
過時,能夠在使用接口以前用 wx.checkSession
檢查一下。而對於第二點,咱們經過實驗發現,偶發性的在 session_key
已過時的狀況下,wx.checkSession
會機率性返回 true
社區也有相關的反饋未獲得解決:
因此結論是:wx.checkSession
可靠性是不達 100% 的。
基於以上,咱們須要對 session_key
的過時作一些容錯處理:
session_key
的請求前,作一次 wx.checkSession
操做,若是失敗了刷新登陸態。session_key
解密開放數據失敗以後,返回特定錯誤碼(如:DECRYPT_WX_OPEN_DATA_FAIL
),前端刷新登陸態。示例代碼:
// 定義檢查 session_key 有效性的操做 const ensureSessionKey = async () => { const hasSession = await new Promise(resolve => { wx.checkSession({ success: () => resolve(true), fail: () => resolve(false), }); }); if (!hasSession) { logger.info('sessionKey 已過時,刷新登陸態'); // 接上面提到的刷新登陸邏輯 return session.refreshLogin(); } return Promise.resolve(); } // 在發起請求的時候,先作一次確保 session_key 最新的操做(以 fly.js 做爲網絡請求層爲例) const updatePhone = async (params) => { await ensureSessionKey(); const res = await fly.post('https://xxx', params); } // 添加響應攔截器, 監聽網絡請求返回 fly.interceptors.response.use( (response) => { const code = res.data; // 登陸態過時或失效 if ( ['DECRYPT_WX_OPEN_DATA_FAIL'].includes(code)) { // 刷新登陸態 await session.refreshLogin(); // 因爲加密場景的加密數據由用戶點擊產生,session_key 可能已經更改,須要用戶從新點擊一遍。 wx.showToast({ title: '網絡出小差了,請稍後重試', icon: 'none' }); } } )
在用戶信息和手機號獲取的方式上,微信是以 <button open-type='xxx' />
的方式,讓用戶主動點擊受權的。
那麼爲了讓代碼更解耦,咱們設計這樣三個組件:
<user-contaienr getUserInfo="onUserInfoAuth">
: 包裝點擊交互,經過 <slot>
支持點擊區域的自定義UI。<phone-container getPhonenNmber="onPhoneAuth">
: 與 <user-container>
同理。<auth-flow>
: 根據業務須要,組合 <user-container>
、<phone-container>
組合來定義不一樣的受權流程。以開頭的業務場景的流程爲例,它有這樣的要求:
那麼受權的階段能夠分三層:
// 用戶登陸的階段 export enum AuthStep { // 階段一:只有登陸態,沒有用戶信息,沒有手機號 ONE = 1, // 階段二:有用戶信息,沒有手機號 TWO = 2, // 階段三:有用戶信息,有手機號 THREE = 3, }
AuthStep
的推動過程是不可逆的,咱們能夠定義一個 nextStep
函數來封裝 AuthStep 更新的邏輯。外部使用的話,只要無腦調用 nextStep
方法,等待回調結果就行。
示例僞代碼:
// auth-flow component Component({ // ... data: { // 默認狀況下,只須要到達階段二。 mustAuthStep: AuthStep.TWO }, // 容許臨時更改組件的須要達到的階段。 setMustAuthStep(mustAuthStep: AuthStep) { this.setData({ mustAuthStep }); }, // 根據用戶當前的信息,計算用戶處在受權的階段 getAuthStep() { let currAuthStep; // 沒有用戶信息,尚在第一步 if (!session.hasUser() || !session.hasUnionId()) { currAuthStep = AuthStepType.ONE; } // 沒有手機號,尚在第二步 if (!session.hasPhone()) { currAuthStep = AuthStepType.TWO; } // 都有,尚在第三步 currAuthStep = AuthStepType.THREE; return currAuthStep; } // 發起下一步受權,若是都已經完成,就直接返回成功。 nextStep(e) { const { mustAuthStep } = this.data; const currAuthStep = this.updateAuthStep(); // 已完成受權 if (currAuthStep >= mustAuthStep || currAuthStep === AuthStepType.THREE) { // 更新全局的受權狀態機,廣播消息給訂閱者。 return getApp().status.auth.success(); } // 第一步:更新用戶信息 if (currAuthStep === AuthStepType.ONE) { // 已有密文信息,更新用戶信息 if (e) session.updateUser(e); // 更新到視圖層,展現對應UI,等待獲取用戶信息 else this.setData({ currAuthStep }); return; } // 第二步:更新手機信息 if (currAuthStep === AuthStepType.TWO) { // 已有密文信息,更新手機號 if (e) this.bindPhone(e); // 未有密文信息,彈出獲取窗口 else this.setData({ currAuthStep }); return; } console.warn('auth.nextStep 錯誤', { currAuthStep, mustAuthStep }); }, // ... });
那麼咱們的 <auth-flow>
中就能夠根據 currAuthStep
和 mustAuthStep
來去作不一樣的 UI 展現。須要注意的是使用 <user-container>
、<phone-container>
的時候鏈接上 nextStep(e)
函數。
示例僞代碼:
<view class="auth-flow"> <!-- 已完成受權 --> <block wx:if="{{currAuthStep === mustAuthStep || currAuthStep === AuthStep.THREE}}"> <view>已完成受權</view> </block> <!-- 未完成受權,第一步:受權用戶信息 --> <block wx:elif="{{currAuthStep === AuthStep.ONE}}"> <user-container bind:getuserinfo="nextStep"> <view>受權用戶信息</view> </user-container> </block> <!-- 未完成受權,第二步:受權手機號 --> <block wx:elif="{{currAuthStep === AuthStep.TWO}}"> <phone-container bind:getphonenumber="nextStep"> <view>受權手機號</view> </phone-container> </block> </view>
到這裏,咱們製做好了用來承載受權流程的組件 <auth-flow>
,那麼接下來就是決定要使用它的時機了。
咱們梳理須要受權的場景:
對於這種場景,常見的是經過彈窗完成受權,用戶能夠選擇關閉。
對於這種場景,咱們能夠在點擊跳轉某個頁面的時候,進行攔截,彈窗處理。但這樣的缺點是,跳轉到目標頁面的地方可能會不少,每一個都攔截,不免會錯漏。並且當目標頁面做爲「小程序落地頁面」的時候,就避免不了。
這時候,咱們能夠經過重定向到受權頁面來完成受權流程,完成以後,再回來。
那麼咱們定義一個枚舉變量:
// 受權的展現形式 export enum AuthDisplayMode { // 以彈窗形式 POPUP = 'button', // 以頁面形式 PAGE = 'page', }
咱們能夠設計一個 mustAuth
方法,在點擊某個按鈕,或者頁面加載的時候,進行受權控制。
僞代碼示例:
class Session { // ... mustAuth({ mustAuthStep = AuthStepType.TWO, // 須要受權的LEVEL,默認須要獲取用戶資料 popupCompName = 'auth-popup', // 受權彈窗組件的 id mode = AuthDisplayMode.POPUP, // 默認以彈窗模式 } = {}): Promise<void> { // 若是當前的受權步驟已經達標,則返回成功 if (this.currentAuthStep() >= mustAuthStep) return Promise.resolve(); // 嘗試獲取當前頁面的 <auth-popup id="auth-popup" /> 組件實例 const pages = getCurrentPages(); const curPage = pages[pages.length - 1]; const popupComp = curPage.selectComponent(`#${popupCompName}`); // 組件不存在或者顯示指定頁面,跳轉到受權頁面 if (!popupComp || mode === AuthDisplayMode.PAGE) { const curRoute = curPage.route; // 跳轉到受權頁面,帶上當前頁面路由,受權完成以後,回到當前頁面。 wx.redirectTo({ url: `authPage?backTo=${encodeURIComponent(curRoute)}` }); return Promise.resolve(); } // 設置受權 LEVEL,而後調用 <auth-popup> 的 nextStep 方法,進行進一步的受權。 popupComp.setMustAuthStep(mustAuthStep); popupComp.nextStep(); // 等待成功回調或者失敗回調 return new Promise((resolve, reject) => { const authStatus = getApp().status.auth; authStatus.onceSuccess(resolve); authStatus.onceFail(reject); }); } // ... }
那麼咱們就能在按鈕點擊,或者頁面加載的時候進行受權攔截:
Page({ onLoad() { session.mustAuth().then(() => { // 開始初始化頁面... }); } onClick(e) { session.mustAuth().then(() => { // 開始處理回調邏輯... }); } })
固然,若是項目使用了 TS 的話,或者支持 ES7 Decorator 特性的話,咱們能夠爲 mustAuth
提供一個裝飾器版本:
export function mustAuth(option = {}) { return function( _target, _propertyName, descriptor, ) { // 劫持目標方法 const method = descriptor.value; // 重寫目標方法 descriptor.value = function(...args: any[]) { return session.mustAuth(option).then(() => { // 登陸完成以後,重放原來方法 if (method) return method.apply(this, args); }); }; }; }
那麼使用方式就簡單一些了:
Page({ @mustAuth(); onLoad() { // 開始初始化頁面... } @mustAuth(); onClick(e) { // 開始處理回調邏輯... } });
做爲一套可複用的小程序登陸方案,固然須要去定義好先後端的交互協議。
那麼整套登陸流程下來,須要的接口有這麼幾個:
靜默登陸 silentLogin
入參:
出參:
說明:
token
給前端token
前端會存起來,每一個請求都會帶上nickname
和phone
字段,前端用於計算當前用戶的受權階段。固然這個狀態的記錄能夠放在後端,可是咱們認爲放在前端,會更加靈活。更新用戶信息 updateUser
入參:
iv
, encryptedData
出參:
說明:
unionId
等nickname
等用戶基本信息。session
中,用於計算受權階段。更新用戶手機號 updatePhone
入參:
iv
, encryptedData
出參:
說明:
session
中,用於計算受權階段。解綁手機號 unbindPhone
登陸 logout
最後咱們來梳理一下總體的「登陸服務」的架構圖:
由「登陸服務」和「底層建設」組合提供的通用服務,業務層只須要去根據產品需求,定製受權的流程 <auth-flow>
,就能知足大部分場景了。
本篇文章經過一些常見的登陸受權場景來展開來描述細節點。
整理了「登陸」、「受權」的概念。
而後分別針對「登陸」介紹了一些關鍵的技術實現:
session_key
過時的容錯處理而對於「受權」,會有設計UI部分的邏輯,還須要涉及到組件的拆分:
而後,梳理了這套登陸受權方案所依賴的後端接口,和給出最簡單的參考協議。
最後,站在「秉着沉澱一套通用的小程序登陸方案和服務爲目標」的角度,梳理了一下架構層面上的分層。