健壮高效的小程序登陆方案

健壮高效的小程序登陆方案

登陆是一项核心基础功能,经过登陆对用户进行惟一标识,继而才能够提供各类跟踪服务,如收藏、下单、留言、消息、发布、个性化推荐等。小程序功能的方方面面大多会直接/间接涉及登陆,于是,登陆功能健壮与否高效与否是值得重点关注与保障的。html

登陆涉及的面比较多:触发场景上,各类页面各类交互路径均可能触发登陆;交互过程上,既须要用户提供/证实id,也须要后端记录维护,还须要保证安全性;复用场景上,既是通用功能,须要多场景多页面甚至多小程序复用,又是定制功能,须要各场景/页面/小程序区分处理。要作到各类情形下都有良好的交互体验,且健壮、高效、可复用、可扩展、可维护,仍是相对比较复杂的。前端

本文将探讨小程序登陆过程当中的一些主要需求和问题,以渐进迭代的方式提出并实现一个健壮、高效的登陆方案。git

顺带一提,es6语法中的async/await、Promise、decorator等特性对于复杂时序处理至关有增益,在本文中也会有所体现。es6

基础流程

如上图所示,基础登陆流程为:github

  • 调用微信登陆接口wx.login获取微信登陆态
  • 调用微信用户信息接口wx.getUserInfo获取微信用户信息
  • 调用后端登陆接口,根据微信用户标识及信息,记录维护本身的用户体系

该流程主要基于如下考虑:数据库

  • 交互上,用户只需在微信的受权弹窗上点击确认,不须要输入帐号密码等复杂操做;
  • 体验上,能够直接获取微信昵称头像等做为初始用户信息,使用起来更亲切,传播时好友辨识度也更高;
  • 开发上,能够直接使用或映射微信用户标识,无需本身进行标识的生成和验证;
  • 安全上,微信已经在用户信息的获取、传输、解密等环节作了许多处理,安全性相对有保障。

健壮流程

拒绝受权问题

问题:小程序

获取微信用户信息时,会出现一个受权弹窗,须要用户点击“容许”才能正常获取;
若用户点击“拒绝”,不只当次登陆会失败,必定时间内后续登陆也会失败,由于短时间内再次调用微信用户信息接口时,微信不会再向用户展现受权弹窗,而是直接按失败返回。
这样致使用户只要拒绝过一次,即便后来对小程序感兴趣了愿意受权了,也难以再次操做。后端

方案:
api

如上图所示,增长如下流程以处理拒绝受权问题:安全

  • 获取微信用户信息失败时,判断是否近期内拒绝受权致使;
  • 若为拒绝受权致使,则提示并打开权限面板,供用户再次操做;
  • 若用户依然未受权,则本次登陆失败,不然继续后续流程。

这样,用户拒绝受权只会影响本次登陆,不至于没法进行下次尝试。

登陆态过时问题

问题:

  • 微信登陆态有效期不可控

上图截自微信官方文档,从中能够看出:
+ 后端session_key随时可能失效,何时失效开发者不可控;
+ 要保证调用接口时后端session_key不失效,只能在每次调用前先使用wx.checkSession检查有效期或直接从新执行微信登陆接口;
+ 前端不能随便从新执行微信登陆接口,可能致使正在进行的其它后端任务session_key失效;

此外,实践中发现,wx.checkSession平均耗时约需200ms,每次接口调用前都先检查一遍,开销仍是蛮大的。  

如何既保证接口功能正确有效,又不用每次耗费高额的查询开销,成为了一个问题。
  • 后端登陆态过时
    后端自身的登陆态有效期也存在相似的问题,有可能在调用接口时才发现后端登陆态已过时。

方案:

如上图所示,增长如下流程以处理登陆态过时问题:

  • 调用数据接口时显式指明是否须要登陆态,若须要则在接口调用先后自动加入登陆态校验逻辑;
  • 接口调用前只校验前端登陆态,不校验后端登陆态,也不校验微信登陆态,以节省每次校验开销;
  • 接口调用后校验后端及微信登陆态,若后端返回登陆态相关错误码,则重置前端登陆态、从新登陆、从新调用数据接口。

这样,只有在真正须要从新登陆的时候(无前端登陆态/后端登陆态失效/后端被提示微信登陆态失效)才会从新执行登陆流程;而且,一旦须要从新登陆,就会自动从新触发登陆流程。

并发问题

问题:

如上图所示,页面各组件各功能有可能同时触发登陆流程,可能会致使:

  • 额外性能开销,登陆流程重复进行,登陆接口重复调用;
  • 体验问题,连续屡次弹窗,影响用户交互;
  • 逻辑问题,后一次登陆刷新了前一次登陆的session_key,致使前一次登陆接口解码失败,返回异常结果。

方案:

如上图所示,加入免并发逻辑:若登陆流程正在进行,则不重复触发登陆流程,而是加入当前流程的监听队列,待登陆结束时再一并处理。这样,任一时刻最多只有一个登陆流程正在进行。

流程实现

时序控制

如上图所示,目前登陆流程已较为复杂,步骤较多,且大可能是异步操做,每步成功失败须要区分处理,处理过程又会相互交织。若是直接在微信接口/网络接口提供的success/fail回调中进行逻辑处理,会形成:

  • 回调层层嵌套,影响代码书写和阅读;
  • 不一样路径公共步骤难以统一提取;
  • 时序逻辑不直观,不易管理。

于是采用Promise+async/await进行时序管理:

  • 将每一个步骤Promise化:
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形式返回
        //... 
      });
    },
    //...
  }
}
  • 使用async/await管理总体时序:
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、unionid等用户标识;
  • 再次受权时,虽然用户信息可能有更新,但彻底能够等用户进我的主页/编辑信息时再进行同步,不必刚进小程序就弹窗;
  • 再次受权时,用户标识并不会变化;
  • 只调用微信登陆接口,不触发受权,已经能够得到openid了,经过openid就能够从数据库中查找使用其上次受权时的用户信息和unionid等其它用户标识。

于是,增长如下流程以优化二次受权场景:

如上图所示,在微信登陆接口调用成功以后,先尝试直接根据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元素集中在登陆页,减小维护成本和页面侵入。两者各有优劣,能够按需采用或混合使用。


这样,咱们的登陆模块可使用自定义受权界面,从而支持设计实现更雅观更精致的受权引导。


复用优化

多小程序间复用&定制

问题:
开发方可能同时维护着多个小程序,这些小程序使用着相同的后端接口和后端用户体系,又有着各自的小程序标识和使用诉求。
一方面,但愿登陆模块能够统一维护,不须要每一个小程序各自开发;另外一方面,又但愿各小程序能够进行差别化定制,包括小程序前端标识不一致等刚性差别,和受权提示文案、埋点、受权交互等个性差别。

方案&实现:

  • 统一流程+个性化配置
    公共&默认流程由登陆模块统一维护,各小程序直接复用;差别流程支持各小程序以配置的形式自定义扩展&覆盖。 e.g.:
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 = ...;
    
      //....
    }
  }
}
  • 配置检查
    引入配置过程会存在一个潜在风险:触发登陆时,小程序还没有完成登陆模块配置。
    理论上,只要全局都使用同一个登陆实例并在app.js顶部进行配置,应该就没有这样的时序风险。但复用方是不会自觉的,不必定会使用同一个实例,配置过程也不必定会被放在顶部,甚至有可能被放在某些异步数据返回以后。于是登陆模块只导出惟一实例并加入配置检查环节以保证该逻辑健壮性:
/**
 * 类修饰器,确保调用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);
    //...
  }
});

这样,就能够实现全部页面共用登陆模块的同时,支持每一个页面进行定制化修改。


这样,咱们的登陆模块能够在多小程序、多页面中复用,并支持各小程序、各页面进行差别性定制。从而实现更好的可维护性可扩展性:

  • 公共&默认流程统一维护,避免维护过程重复、分化、膨胀,减小总体维护成本,并下降各方迭代不及时风险;
  • 差别&定制流程各自扩展,扩展入口下放至各小程序各页面,扩展过程相互独立互不干扰,不会对其它小程序/其它页面形成任何影响。

总结

  • 完整登陆流程

  • 功能
    • 经过微信受权一键登陆
    • 支持静默登陆,用户受权一次事后不会再次被要求受权
    • 支持多种登陆场景:通用、静默、强制
    • 支持自定义受权界面
  • 健壮性
    • 曾经拒绝受权,会提示&打开权限面板供二次操做
    • 登陆态过时,会自动从新登陆从新发送数据请求并正常返回请求数据
    • 登陆流程&重试机制对调用方彻底透明,页面使用时流程遗漏风险基本为0
  • 性能
    • 后端登陆态惰性检测,减小每次查询开销
    • 公共步骤并合,减小并发成本
    • 登陆操做与后续接口调用无缝衔接,减小返回刷新/用户重复操做成本
  • 可复用性、可扩展性、可维护性
    • 支持多小程序复用,公共流程统一维护,差别特性各小程序各自扩展;
    • 支持多页面复用,公共流程小程序统一配置,差别特性各页面各自扩展。

转转的开源库fancy-mini上附有实现源码,欢迎参阅;有更好的设计思路或实现方案,欢迎交流探讨。

顺带一提,es6语法对于复杂时序管理至关有增益,推荐深刻学习。
顺带二提,文中流程图是用ProcessOn作的,挺方便的一个小工具,并且是在线、免费的,顺手分享下。

相关文章
相关标签/搜索