登陸是一項核心基礎功能,經過登陸對用戶進行惟一標識,繼而才能夠提供各類跟蹤服務,如收藏、下單、留言、消息、發佈、個性化推薦等。小程序功能的方方面面大多會直接/間接涉及登陸,於是,登陸功能健壯與否高效與否是值得重點關注與保障的。html
登陸涉及的面比較多:觸發場景上,各類頁面各類交互路徑均可能觸發登陸;交互過程上,既須要用戶提供/證實id,也須要後端記錄維護,還須要保證安全性;複用場景上,既是通用功能,須要多場景多頁面甚至多小程序複用,又是定製功能,須要各場景/頁面/小程序區分處理。要作到各類情形下都有良好的交互體驗,且健壯、高效、可複用、可擴展、可維護,仍是相對比較複雜的。前端
本文將探討小程序登陸過程當中的一些主要需求和問題,以漸進迭代的方式提出並實現一個健壯、高效的登陸方案。git
順帶一提,es6語法中的async/await、Promise、decorator等特性對於複雜時序處理至關有增益,在本文中也會有所體現。es6
如上圖所示,基礎登陸流程爲:github
該流程主要基於如下考慮:數據庫
問題:小程序
獲取微信用戶信息時,會出現一個受權彈窗,須要用戶點擊「容許」才能正常獲取;
若用戶點擊「拒絕」,不只當次登陸會失敗,必定時間內後續登陸也會失敗,由於短時間內再次調用微信用戶信息接口時,微信不會再向用戶展現受權彈窗,而是直接按失敗返回。
這樣致使用戶只要拒絕過一次,即便後來對小程序感興趣了願意受權了,也難以再次操做。後端
方案:
api
如上圖所示,增長如下流程以處理拒絕受權問題:安全
這樣,用戶拒絕受權只會影響本次登陸,不至於沒法進行下次嘗試。
問題:
上圖截自微信官方文檔,從中能夠看出:
+ 後端session_key隨時可能失效,何時失效開發者不可控;
+ 要保證調用接口時後端session_key不失效,只能在每次調用前先使用wx.checkSession檢查有效期或直接從新執行微信登陸接口;
+ 前端不能隨便從新執行微信登陸接口,可能致使正在進行的其它後端任務session_key失效;
此外,實踐中發現,wx.checkSession平均耗時約需200ms,每次接口調用前都先檢查一遍,開銷仍是蠻大的。 如何既保證接口功能正確有效,又不用每次耗費高額的查詢開銷,成爲了一個問題。
方案:
如上圖所示,增長如下流程以處理登陸態過時問題:
這樣,只有在真正須要從新登陸的時候(無前端登陸態/後端登陸態失效/後端被提示微信登陸態失效)纔會從新執行登陸流程;而且,一旦須要從新登陸,就會自動從新觸發登陸流程。
問題:
如上圖所示,頁面各組件各功能有可能同時觸發登陸流程,可能會致使:
方案:
如上圖所示,加入免併發邏輯:若登陸流程正在進行,則不重複觸發登陸流程,而是加入當前流程的監聽隊列,待登陸結束時再一併處理。這樣,任一時刻最多隻有一個登陸流程正在進行。
如上圖所示,目前登陸流程已較爲複雜,步驟較多,且大可能是異步操做,每步成功失敗須要區分處理,處理過程又會相互交織。若是直接在微信接口/網絡接口提供的success/fail回調中進行邏輯處理,會形成:
於是採用Promise+async/await進行時序管理:
class Login { static _loginSteps = { //各登陸步驟 /** * 微信登陸:調用微信相關API,獲取用戶標識(openid,某些狀況下也能得到unionid) * @return {Promise<Object>} 微信用戶標識 */ wxLogin(){ return new Promise((resolve,reject)=>{ //結果以Promise形式返回 wx.login({ success(res){ resolve(Object.assign(res, {succeeded: true})); //成功失敗都resolve,並經過succeeded字段區分 }, fail(res){ resolve(Object.assign(res, {succeeded: false})); //成功失敗都resolve,並經過succeeded字段區分 }, }) }); }, /** * 獲取微信用戶信息:調用微信相關API,請求用戶受權訪問我的信息 * @return {Promise<Object>} 微信用戶信息 */ requestUserInfo(){ return new Promise((resolve,reject)=>{ //結果以Promise形式返回 //... }); }, //... } }
class Login { static async _login(){ //管理總體時序 //.... let steps = Login._loginSteps; //微信登陸 let wxLoginRes = await steps.wxLogin(); if (!wxLoginRes.succeeded) //微信登陸接口異常,登陸失敗 return { code: -1}; //獲取微信用戶信息 let userInfoRes = await steps.requestUserInfo(); if (!userInfoRes.succeeded && userInfoRes.failType==='userDeny'){ //用戶近期內曾經拒絕受權致使獲取信息失敗 await steps.tipAuth(); //提示受權 let settingRes = await steps.openSetting(); //打開權限面板 if (!settingRes.succeeded) //用戶依然拒絕受權,登陸失敗 return {code: -2}; userInfoRes = await steps.requestUserInfo(); //用戶贊成受權,從新獲取用戶信息 } if (!userInfoRes.succeeded) //其它緣由致使的獲取用戶信息失敗 return {code: -3}; //獲取用戶信息成功,進行後續流程 //.... } }
如以上代碼所示,微信登陸、獲取微信用戶信息、提示受權、打開權限面板等每一步都是異步操做,都要等待success/fail回調才能得到操做結果併發起下一個操做;但利用Promise+async/await,能夠像普通流程同樣,將這些操做線性組合,順序處理。
這樣,就能夠實現直觀清晰的時序管理了。
class Login { /** *登陸 */ static async login(options){ if (Login.checkLogin()) //若已有前端登陸態,則直接按登陸成功返回 return {code: 0}; //不然執行登陸流程 //... } /** * 普通數據請求,不進行登陸態檢查,結果以Promise形式返回 * @param {Object}options 參數,格式同wx.request * @return {Promise} 請求結果,resolve時爲數據接口返回內容, reject時爲請求詳情 */ static async request(options){ return new Promise((resolve, reject)=>{ wx.request(Object.assign({}, options, { success(res){ resolve(res.data); }, fail(res){ reject(res); } }); }); } /** * 要求登陸態的數據請求,封裝了登陸態邏輯 * @param {Object} options 請求參數,格式同wx.request * @param {Object} options.loginOpts 登陸選項,格式同login函數 * @return {Promise} 返回結果,resolve時爲數據接口返回內容, reject時爲請求詳情 */ static async requestWithLogin(options){ //先校驗/獲取前端登陸態,保證大部分狀況下請求發出時已登陸 let loginRes = await Login.login(options.loginOpts); if (loginRes.code != 0) throw new Error('login failed, request not sent:'+options.url); //發送數據請求 let resp = await Login.request(options); //若後端登陸態正常,則正常返回數據請求結果 if(!Login._config.apiAuthFail(resp, options)) //根據後端統一錯誤碼判斷登陸態是否過時 return resp; //若後端登陸態過時 Login._clearLoginInfo(); //重置前端登陸態,保證後續再次調用login時會真正執行登陸環節 return Login.requestWithLogin(options); //從新登陸,從新發送請求,並將從新發送的請求的返回結果做爲本次調用結果予以返回 } }
如以上代碼所示,單獨封裝一個requestWithLogin函數,在數據請求先後加入登陸態處理邏輯,能夠保證數據請求會在有後端登陸態時被髮送/從新發送。
而且,從新登陸過程對數據接口調用方是徹底透明的,調用方只須要知道本身的接口需不須要登陸態,而無需進行任何登陸態相關判斷處理,重登陸過程也不會對接口調用返回結果形成任何影響。
這樣,就能夠實現登陸態過時自動從新登陸了。
class Login { static _loginSingleton = null; //正在進行的登陸流程 static async _login(){ //登陸流程... } //封裝了免併發邏輯的登陸函數 static async login(){ if (Login._loginSingleton) //若當前有登陸流程正在進行,則直接使用其結果做爲本次登陸結果 return Login._loginSingleton; //不然觸發登陸流程 Login._loginSingleton = Login._login(); //並在登陸結束時釋放併發限制 Login._loginSingleton.then(()=>{Login._loginSingleton = null}).catch(()=>{Login._loginSingleton = null}); //返回登陸結果 return Login._loginSingleton; } }
如以上代碼所示,利用Promise能夠被屢次then/catch的特性(亦即,一個async函數調用結果能夠被await屢次),可使用一個Promise來記錄當前登陸流程,後續調用直接對該Promise進行監聽。
這樣,就能夠實現登陸流程免併發了。
至此,咱們就獲得了一個功能可用、相對健壯、相對高效的登陸模塊。但依然仍是存在優化空間的。
問題:
用戶贊成受權後,小程序能夠訪問到微信用戶信息,而且一段時間內再次訪問時,也不會從新出現受權彈窗;
可是,若是用戶長時間未使用小程序,或將小程序刪除重進,則登陸時會再次出現受權彈窗。
一方面會對用戶形成干擾,影響其瀏覽效率;另外一方面,不利於流失用戶召回。
方案:
再次受權場景其實並非很必要:
於是,增長如下流程以優化二次受權場景:
如上圖所示,在微信登陸接口調用成功以後,先嚐試直接根據openid完成登陸過程,若失敗再去請求用戶受權。
這樣,只有新用戶纔會出現受權彈窗;老用戶、迴歸用戶,均可以直接靜默完成登陸過程。
問題:
不一樣場景對登陸行爲可能有不一樣的指望:
單一的登陸流程很難知足這種多元的場景需求。
方案:
調用登陸/要求登陸的數據接口時支持指定場景模式:
如上圖所示,登陸流程支持指定不一樣場景模式:
場景優化方案主要是增長了一些流程&判斷,使用上文中的「時序控制」基本能夠解決。
主要難點在於,上文中的免併發機制再也不適用。好比,靜默模式正在進行時又觸發了一個強制模式的請求,此時,應觸發受權彈窗正常登陸而不是監聽使用靜默模式的登陸結果。
若是拆成每一個模式各自免併發,一方面,登陸流程需重複書寫,不便複用;另外一方面,模式之間併發也存在風險。
於是,引入公共步驟併合機制:
/** * 步驟併合修飾器,避免公共步驟併發進行 * 將公共步驟單例化:若步驟未在進行,則發起該步驟;若步驟正在進行,則監聽並使用其執行結果,而不是從新發起該步驟 */ function mergingStep(target, name, descriptor) { let oriFunc = descriptor.value; let runningInstance = null; descriptor.value = function (...args) { if (runningInstance) //若步驟正在進行,則監聽並使用其執行結果,而不是從新發起該步驟 return runningInstance; let res = oriFunc.apply(this, args); if (!(res instanceof Promise)) return res; runningInstance = res; runningInstance.then(function () { runningInstance = null; }).catch(function () { runningInstance = null; }); return runningInstance; } } class Login { static _loginSteps = { @mergingStep //步驟併合修飾器,避免公共步驟併發重複進行 wxLogin(){ return new Promise((resolve,reject)=>{ //... }); }, @mergingStep //步驟併合修飾器,避免公共步驟併發重複進行 async silentLogin({wxLoginRes}){ //... }, ... } static async login(options){ //.... //嘗試靜默登陸 let silentRes = await steps.silentLogin({wxLoginRes}); if (silentRes.succeeded) { //靜默登陸成功,結束 return {code: 0, errMsg: 'ok'}; } if (options.mode==='silent') //靜默模式,只嘗試靜默登陸,不觸發受權彈窗;無論成功失敗都不影響頁面功能和後續接口調用 return {code: 0, errMsg: 'login failed silently'}; //其它模式繼續嘗試受權登陸 //... } }
如以上代碼所示,將登陸免併發改成每一個公共步驟免併發,登陸流程中就能夠根據場景模式自由地進行步驟管理。
這樣,就能夠實現對不一樣登陸場景進行定製化支持。
簡潔起見,如下代碼使用wepy框架寫法,原生小程序/其它框架可相似參考。
import Login from '../../lib/Login'; export default class extends wepy.page { async onLoad(){ //頁面初始化 let dataRes = await Login.requestWithLogin({ //調用頁面數據接口 url: 'xxx/xxx', loginOpts: {mode: 'silent'} //使用靜默模式,若爲老用戶/迴歸用戶,會自動悄悄登陸,後端返回數據時能夠包含一些個性化推薦;若爲新用戶,也不會觸發彈窗,後端返回數據時只包含常規元素 }); //... } methods = { async onComment(){ //用戶點擊了評論按鈕 let addRes = await Login.requestWithLogin({ //調用添加評論接口 url: 'xxx/addComment', data: {comment: 'xxx'}, loginOpts: {mode: 'common'} //使用通用模式,若已登陸,會直接發送請求;若未登陸,會自動調起登陸併發送請求 }); //... } } }
如以上代碼所示,能夠作到老用戶/迴歸用戶進入頁面時自動悄悄登陸,以提供更多個性化服務;新用戶進入頁面時不進行任何干擾,直到進行留言等操做時才自動出現受權彈窗,且受權完成後自動完成該次行爲,無需用戶再次操做。
而且,這些過程對業務代碼是徹底透明的,業務代碼只須要知道本身調用的接口是 必須登陸/最好登陸/必須第一次調用就登陸/不用登陸,並相應地指定 mode=common/silent/force/不使用requestWithLogin,便可。
這樣,咱們的登陸模塊能夠在不一樣場景指定不一樣登陸邏輯,從而支持設計實現更多元更精細更流暢的登陸交互。
問題:
獲取微信用戶信息時,直接出現系統受權彈窗有時候是很突兀的;使用自定義受權界面和價值文案進行引導,得當的話能夠有效提升受權成功率。
並且,從10月10號起,小程序將再也不支持自動彈窗受權用戶信息和自動打開權限面板,這兩種操做必須使用<button>組件由用戶主動觸發。彼時起,自定義界面將再也不是優化,而會是必需。
這意味着登陸過程必須與頁面dom耦合,以前的純js邏輯再也不適用。
方案1:登陸浮層
在全部頁面放置登陸浮層,頁面須要登陸時則調起該浮層,經由浮層按鈕完成受權及後續流程。
實現
浮層引入
各個頁面都須要存在登陸浮層。能夠將各類頁面公共dom元素,包括登陸浮層、網絡異常界面、返回首頁快捷導航、公衆號關注組件等統一抽離成一個父公共組件,編寫eslint規則要求全部頁面統一引入,以此實現&保證登陸時浮層存在。
浮層無縫時序
受權浮層AuthModal.wpy:
<template> <view class="modal" wx:if="{{show}}"> <button open-type="getUserInfo" bindgetuserinfo="onGetUserInfo">登陸</button> </view> </template> <script> import wepy from 'wepy'; export default class extends wepy.component { data = { show: false, listener: null, //結果監聽 } computed = {} methods = { onGetUserInfo(ev){ //用戶點擊了受權按鈕 this.listener && this.listener({ //回調受權結果 succeeded: ev.detail.errMsg.includes('ok'), data: ev.detail, }); this.show = false; //關閉受權浮層 this.$apply(); } } //打開受權浮層 open(){ return new Promise((resolve, reject)=>{ this.listener = resolve; //設置監聽 this.show = true; //打開浮層 this.$apply(); //用戶操做結束後會觸發監聽回調'resolve',使當前Promise resolve,從而自動繼續執行後續登陸步驟 }); } onUnload(){ //頁面卸載,用戶未點擊按鈕直接返回 在此處理 this.listener && this.listener({ //受權失敗回調 succeeded: false, data: null, }); } } </script>
登陸模塊login.js:
_loginSteps = { async requestUserInfo(){ let page = getCurrentWepyPage(); //獲取當前頁面實例 let userInfoRes = await page.$invoke('AuthModal', 'open'); //打開受權浮層,並監聽其操做結果 //正常進行後續處理 if (userInfoRes.succeeded) //受權成功後續處理... else //受權失敗後續處理... } }
如以上代碼所示,雖然自定義浮層須要展現按鈕、等待用戶點擊、處理點擊、考慮用戶不點擊直接返回,交互流程相對複雜,但依然能夠利用Promise使交互細節對外透明。打開浮層時返回一個Promise,在各個交互出口對Promise進行resolve,則使用時只需將其做爲一個普通的異步過程對待。
這樣,就能夠實現無縫接入自定義浮層受權。
方案2:獨立登陸頁
須要受權用戶信息時,跳轉至一個專門的登陸頁面,頁面中展現引導內容和受權<button>,用戶操做完畢後再自動返回先前頁面。
實現
元素引入
登陸所需dom元素只在登陸頁引入便可。
頁面無縫時序
因爲小程序的代碼包特性,各頁面能夠共享全局變量和全局函數;而且後一頁面打開時,前一頁面依然駐留在內存中,前一頁面遺留的異步任務也依然會繼續執行。於是,能夠在前一頁面設置監聽,在登陸頁進行回調:
受權全局數據模塊userAuthHub.js:
export default { _listeners : [], subscribe(listener){ //前一頁面設置監聽 this._listeners.push(listener); }, notify(res){ //登陸頁進行結果回調 this._listeners.forEach(listener=>listener(res)); this._listeners = []; }, }
登陸模塊login.js:
import userAuthHub from '../lib/userAuthHub'; _loginSteps = { async requestUserInfo(){ let userInfoRes = await new Promise((resolve, reject)=>{ userAuthHub.subscribe(resolve); //監聽受權結果 wx.navigateTo({url: '/pages/login/login'}); //打開登陸頁 //登陸頁操做結束後會觸發監聽回調'resolve',使當前Promise resolve,從而自動繼續執行後續登陸步驟 }); //正常進行後續處理 if (userInfoRes.succeeded) //受權成功後續處理... else //受權失敗後續處理... } }
登陸頁login.wpy:
<template> <button open-type="getUserInfo" bindgetuserinfo="onGetUserInfo">登陸</button> </template> <script> import wepy from 'wepy' import userAuthHub from '../../lib/userAuthHub'; export default class extends wepy.page { data = { userInfoRes: { //記錄受權信息 succeeded: false, data: null, } } methods = { onGetUserInfo(ev){ //用戶點擊了受權按鈕 this.userInfoRes = { //記錄結果 succeeded: ev.detail.errMsg.includes('ok'), data: ev.detail, }; wx.navigateBack(); //返回原先頁面 } } onUnload(){ //頁面卸載,用戶未點擊按鈕直接返回 和 點擊按鈕受權後頁面自動返回 兩種場景在此處統一處理 userAuthHub.notify(this.userInfoRes); //回調受權結果 } } </script>
如以上代碼所示,雖然受權過程須要進行跨頁面交互,但利用Promise和小程序代碼包特性,能夠在前一頁面設置監聽,登陸頁面進行回調。登陸頁面交互結束後,前一頁面會自動繼續執行登陸流程,調用方無需進行返回刷新等額外處理,數據接口也會繼續調用,用戶無需再次操做。
這樣,就能夠實現無縫接入跨頁面受權交互。
兩種方案均可以實現自定義受權界面。內嵌浮層會增長必定維護成本和少許資源開銷,但能夠直接在當前頁面完成登陸交互,頁面自定義空間也相對更大;獨立登陸頁會來回跳轉犧牲必定的交互體驗,但能夠把登陸所需dom元素集中在登陸頁,減小維護成本和頁面侵入。兩者各有優劣,能夠按需採用或混合使用。
這樣,咱們的登陸模塊可使用自定義受權界面,從而支持設計實現更雅觀更精緻的受權引導。
問題:
開發方可能同時維護着多個小程序,這些小程序使用着相同的後端接口和後端用戶體系,又有着各自的小程序標識和使用訴求。
一方面,但願登陸模塊能夠統一維護,不須要每一個小程序各自開發;另外一方面,又但願各小程序能夠進行差別化定製,包括小程序前端標識不一致等剛性差別,和受權提示文案、埋點、受權交互等個性差別。
方案&實現:
class Login { static _config = { //可配置項 /** * 剛需:小程序編號,用於區分不一樣的小程序,由後端分配 */ source: '', /** * 個性化:自定義用戶受權交互 * @return {Promise<Object>} 格式同wx.getUserInfo,或返回null使用默認受權邏輯 */ userAuthHandler: null, //.... } static _loginSteps = { //靜默登陸 async _silentLogin({wxLoginRes}){ let silentRes = await Login.request({ url: 'xxx/mpSilenceLogin', data: { code: wxLoginRes.code, source: Login._config.source, //小程序須要配置自身編號,後端根據編號找到正確的解碼密鑰和id映射表,進行靜默登陸 } }); //... }, //獲取微信用戶信息 async requestUserInfo(){ //小程序能夠配置自定義受權交互,如:將受權交互改成自定義浮層/自定義登陸頁/... let userInfoRes = Login._config.userAuthHandler && await Login._config.userAuthHandler(); if (!userInfoRes) //若未配置自定義交互,亦提供默認受權交互 userInfoRes = ...; //.... } } }
/** * 類修飾器,確保調用API時已完成小程序信息配置 * @param target Login */ function requireConfig(target) { for (let prop of Object.getOwnPropertyNames(target)){ if (['arguments', 'caller', 'callee', 'name', 'length'].includes(prop)) //內置屬性,不予處理 continue; if (typeof target[prop] !== "function") //非函數,不予處理 continue; if (['config','install','checkConfig'].includes(prop) || prop[0]==='_') //配置/安裝/檢查函數、私有函數,不予處理 continue; target[prop] = (function (oriFunc, funcName) { //對外接口,增長配置檢查步驟 return function (...args) { if (!target.checkConfig()){ //若未進行項目信息配置,則報錯 console.error('[Login] 請先執行Login.config配置小程序信息,後使用Login相關功能:',funcName); return; } return oriFunc.apply(this, args); //不然正常執行原函數 } }(target[prop], prop)); } } /** * 登陸模塊命名空間 */ @requireConfig //確保調用API時已完成項目信息配置 class Login { /** *登陸 * @param {Object} options 登陸選項 * @param {string} options.mode 登陸模式 * @return {Promise<Object>} res 登陸結果 */ static async login(options){ //... } /** * 要求登陸態的數據請求 * @param {Object} options 請求參數,格式同wx.request * @param {Object} options.loginOpts 登陸選項,格式同login函數 * @return {Promise} 返回結果,resolve時爲數據接口返回內容, reject時爲請求詳情 */ static async requestWithLogin(options){ //... } //@requireConfig修飾器會在login、requestWithLogin等對外API被調用時,自動檢查模塊配置狀態,若未進行適當配置(如未提供source值),則直接報錯;從而避免編碼疏漏致使的潛在時序風險 } export default Login;
這樣,就能夠實如今多個小程序間複用登陸模塊,由登陸模塊統一維護總體時序和默認流程,同時支持各小程序進行差別性定製&擴展。
問題:
不一樣頁面對登陸過程有時也存在定製需求,好比受權引導文案,有些頁面可能但願提示「受權後能夠免費領紅包」,有些頁面多是「受權後能夠爲好友助力」/「受權後能夠得到智能推薦」/... 諸如此類。
方案&實現:
在頁面中設置鉤子供其提供個性化配置。e.g.:
頁面xxx.wpy:
<script> import wepy from 'wepy'; export default class extends wepy.page { //登陸受權文案配置函數,能夠覆蓋受權界面的默認提示文案 $loginUserAuthTips(){ return { title: '贊成受權後你能夠', content: '查看附近的人,免費領紅包,低價淘好貨。受權僅供體驗產品功能,咱們保證毫不會泄露您的隱私。', confirmTxt: '確認受權' } } } </script>
小程序級登陸配置:
Login.config({ async userAuthHandler(){ let page = getCurrentWepyPage(); let defaultTips = { //小程序級默認文案 title: '', content: '小程序須要您的受權才能提供更好的服務哦~', confirmTxt: '知道了' }; let tips = Object.assign({}, defaultTips, page.$loginUserAuthTips && page.$loginUserAuthTips()); //支持頁面提供頁面級自定義文案以覆蓋小程序默認文案 let userInfoRes = await page.$invoke('AuthModal', 'open', tips); //... } });
這樣,就能夠實現全部頁面共用登陸模塊的同時,支持每一個頁面進行定製化修改。
這樣,咱們的登陸模塊能夠在多小程序、多頁面中複用,並支持各小程序、各頁面進行差別性定製。從而實現更好的可維護性可擴展性:
完整登陸流程
轉轉的開源庫fancy-mini上附有實現源碼,歡迎參閱;有更好的設計思路或實現方案,歡迎交流探討。
順帶一提,es6語法對於複雜時序管理至關有增益,推薦深刻學習。
順帶二提,文中流程圖是用ProcessOn作的,挺方便的一個小工具,並且是在線、免費的,順手分享下。