登陆是一项核心基础功能,经过登陆对用户进行惟一标识,继而才能够提供各类跟踪服务,如收藏、下单、留言、消息、发布、个性化推荐等。小程序功能的方方面面大多会直接/间接涉及登陆,于是,登陆功能健壮与否高效与否是值得重点关注与保障的。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作的,挺方便的一个小工具,并且是在线、免费的,顺手分享下。