小程序登录案例

作者: DragonsLong | 来源:发表于2019-03-19 17:31 被阅读44次

    本文讲述的是自微信官方在 wx.getUserInfo API更新后,微信小程序该如何实现登录,以及在登录与用户授权逻辑方面遇到的种种矛盾做出的一些可行性分析,文中出现的源码都可在以下链接中Clone,仅供大家交流、参考。

    本文主要讲解的是小程序前端代码,但是Clone过来的源码包含前端与后台代码,并且只须简单几步安装即可在本地环境中运行起来。

    Github:https://github.com/wuliang9524/mini_app_login
    Gitee:https://gitee.com/wuliang924/demo_miniapp_login

    常见问题

    • Q:在小程序代码中遇到异步嵌套的问题,代码能读性非常差

    本文中采用ES6中的Promise对象解决异步嵌套问题,若有不了解者,建议先Google了解一番后再回头查阅本文。

    • Q:小程序Page.onLoad之后,App.onLaunch才返回用户登录态信息

    这是由于在App.onLaunch中,获取用户登录态信息请求后台接口是异步执行而导致的,我们只需要在Page.onLoad中定义一个App的回调函数即可,但是如果每一个需要先验证登录的Page都要定义这么一个函数则实在不理智,本文后面也会提到封装一个公共方法。

    • 更多经典常见问题等待您的留言...

    小程序前端

    1. 首先先对微信的API进行Promise对象的 “改造”,由于微信API的格式大都一致,在 /utils/ 文件夹中新建一个 wxapi.js 文件

      // /utils/wxapi.js
      
      const wxapi = {
        /**
         * 对微信Api Promise化的公共函数
         */
        wxapi: (wxApiName, obj) => {
          return new Promise((resolve, reject) => {
            wx[wxApiName]({
              ...obj,     //注意这里涉及的语法
              success: (res) => {
                resolve(res);
              },
              fail: (res) => {
                reject(res);
              }
            });
          });
        },
      
        /**
         * 以下是微信Api Promise化的特殊案例
         */
        wxsetData: (pageObj, obj) => {
          if(pageObj && obj){
            return new Promise((resolve, reject) => {
              pageObj.setData(obj, resolve(obj));
            });
          }
        },
      }
      
      module.exports = wxapi;
      
    2. 接着我们在 app.js 中封装一个 exeLogin() 方法,该方法主要做以下几件事情:

      • 调用 wx.login 获取到 code;
      • code 请求后台接口,后台接口返回自定义登录态信息(本文中包括登录态 token 及用户的基本信息);
      • 调用 wx.setStorage 缓存登录态信息

      注意代码中 exeLogin() 方法返回的是一个Promise对象,以及在 app.js 文件的开头,载入了上一步的 wxapi.js 中定义的方法。

      // app.js
      
      import {
        wxapi,
        wxsetData
      } from './utils/wxapi.js';
      
      /**
      * [exeLogin 执行登录流程]
      * @param  {[string]} loginKey  自定义登录态信息缓存的key
      * @param  {[string]} timeout   调用wx.login的超时时间
      * @return {[Promise]}          返回一个Promise对象
      */
      exeLogin: function(loginKey, timeout = 3000) {
          var _this = this;
          return new Promise((resolve, reject) => {
            wxapi('login', {
              'timeout': timeout
            }).then(function(res) {
              return wxapi('request', {
                'method': 'POST',
                'url': _this.gData.api.request + '/api/User/third',
                'header': {
                  'Content-type': 'application/x-www-form-urlencoded',
                },
                'data': {
                  'code': res.code,
                  'platform': 'miniwechat',
                }
              })
            }).then(function(res) {
              //当服务器内部错误500(或者其它目前我未知的情况)时,wx.request还是会执行success回调,所以这里还增加一层服务器返回的状态码的判断
              if (res.statusCode === 200 && res.data.code === 1) {
                //获取到自定义登录态信息后存入缓存,由于我们无需在意缓存是否成功(前面代码有相应的处理逻辑),所以这里设置缓存可以由它异步执行即可
                wxapi('setStorage', {
                  'key': loginKey,
                  'data': res.data.data.userinfo
                });
                //userinfo里面包含有用户昵称、头像、性别等信息,以及自定义登录态的token
                resolve(res.data.data.userinfo);
              } else {
                return Promise.reject({
                  'errMsg': (res.data.msg ? 'ServerApi error:' + res.data.msg : 'Fail to network request!') + ' Please feedback to manager and close the miniprogram manually.'
                });
              }
            }).catch(function(error) {
              reject(error);
            });
          });
      },
      
    3. OK,接着我们先继续看下去。在 app.js 中再定义一个方法 getLoginInfo(),主要做以下几件事情:

      • 调用 wx.checkSession() 验证当前的登录是否有效;
      • 若无效,则调用上一步的 exeLogin() ,执行登录并缓存登录态信息;
      • 若有效,则调用 wx.getStorage() 读取缓存;
      • 当然,调用读取缓存时我们还要判断是否成功,若是失败或读取到信息与预期的不符,也直接执行 exeLogin()

      由于同样是在 app.js 中,开头的导入 wxapi.js 这一段省略了,能理解文中代码里出现的 wxapi() 的含义即可,之后若没有特殊说明, wxapi() 的含义都一样。

      // app.js
      
      /**
      * [getLoginInfo 获得自定义登录态信息]
      * @param  {[string]]} loginKey [缓存的key值]
      * @return {[Promise]}          返回一个Promise对象
      */
      getLoginInfo: function(loginKey = 'loginInfo') {
          var _this = this;
          return new Promise((resolve, reject) => {
            wxapi('checkSession').then(function() {
              //登录态有效,从缓存中读取
              return wxapi('getStorage', {
                'key': loginKey
              }).then(function(res) {
                //获取loginKey缓存成功
                if (res.data) {
                  //缓存获取成功,并且值有效
                  return Promise.resolve(res.data);
                } else {
                  //缓存获取成功,但值无效,重新登录
                  return _this.exeLogin(loginKey, 3000);
                }
              }, function() {
                //获取loginKey缓存失败,重新登录
                return _this.exeLogin(loginKey, 3000);
              });
            }, function() {
              //登录态失效,重新调用登录
              return _this.exeLogin(loginKey, 3000);
            }).then(function(res) {
              resolve(res);
            }).catch(function(error) {
              reject(error);
            });
          });
      },
      
    4. 前面的这些,都是为接下来在 app.onLaunch() 中做准备。说说小程序注册时 onlaunch() 主要做些什么吧:

      • 调用上一步 getLoginInfo(),然后只需要对 resolve()reject() 做对应的逻辑即可;
      • resolve() 里面再调用 wx.getSetting() 获取到相关的授权列表,与登录态信息一并赋值给 app.gData

      代码中有这么一段 (_this.loginedCb && typeof(_this.loginedCb) === 'function') && _this.loginedCb();
      若无法理解可以先忽略,后面会重点说这就是为了解决文章开头 常见问题 第二个问题的解决方案。

      // app.js
      
      onLaunch: function() {
          var _this = this;
          
          // 获取登录态信息
          this.getLoginInfo().then(function(res) {
            if ((typeof res !== 'undefined') && res.token) {
              //获取用户全部的授权信息
              wxapi('getSetting').then(function(setting) {
                _this.gData.logined = true;
                _this.gData.userinfo = res;
                _this.gData.authsetting = setting.authSetting;
          
                //执行页面定义的回调方法
                (_this.loginedCb && typeof(_this.loginedCb) === 'function') && _this.loginedCb();
              }, function(error) {
                return Promise.reject(error);
              });
            } else {
              return Promise.reject({
                errMsg: 'LoginInfo miss token!',
              });
            }
          }).catch(function(error) {
            wx.showModal({
              title: 'Error',
              content: error.errMsg,
            });
            return false;
          });
      },
      
    5. 到此,小程序一进来开始的登录流程基本完成,在开始涉及页面 Page 相关的逻辑之前,我们先针对上述的代码提一个问题:

      上述代码中自始至终都没有提到 请求用户允许授权、获取用户昵称、头像等基本信息这一点;你甚至会发现,没有用户基本数据,在 exeLogin() 中我们登录请求后台接口只有一个 code 有效参数的情况下,后台怎么完用户注册的逻辑呢?

      说说我对这个问题的理解,也是本文创作的动力。

      首先明确一点,在逻辑设计上的确就只要 wx.login() 返回的 code 传给后台, 就能完成后台注册新用户、返回后台自定义登录态信息等等。

      实现上也不难,只需要后台API通过前端传过来的 code以及小程序 appid && secret 开发者管理的重要秘钥,即可在后台调用微信小程序服务端接口 code2Session,接口返回的 openid、unionid、session_key 就足够后台解决 用户唯一性 的问题了。

      至于用户注册需要的基本数据,先又系统随机生成。而真正要关心的是,设计一套自己的后台API token机制,机制里关联上当前注册的用户返回自定义登录态信息给前端。下一次请求业务接口时参数中带上自定义登录态信息即可验证登录,这样就已经完成了小程序登录流程。

      至于用户信息完善,则就涉及到小程序授权。我们只需要在某些需要用户授权的 Page 页面里,检验用户是否授权,若没有授权,统一跳转到 /pages/auth/auth 页面完成授权并请求后台更新用户信息的接口,而这个接口的前提是验证用户登录。

    6. 带上对上面答案的认知,我们开始说小程序页面 Page 方面的逻辑。分以下几点:

      • 由于在 App.onLaunch() 中,用户登录态信息是异步的方式请求后台接口的,接口返回登录态信息并赋值给全局变量 app.gData 前,很大可能小程序页面已经执行完了 onLoad() 方法,这样直接对我们页面里后面的写逻辑造成了致命的错误(页面中获取到的登录态信息是错误的)。

      • 还有就是用户授权方面的问题。对于那些业务逻辑要求必须有用户基本信息的页面,我们得在页面初始化时验证用户授权状态(在登录的时候我们为这一步做过准备),若未曾询问过或者用户拒绝授权,我们同意跳转到 /pages/auth/auth 页面进行用户授权步骤,同意后返回上一页并做相应的更新。

      我们很容易会想到,上面的这两点都是多页面中调用到的,必然会考虑到灵活封装好,之后每个页面调用即可。

      app.js 中先预定义全局控制字段,包括登录控制字段 logined, 授权列表 authsetting,以及用户信息(包含token,就是登录态信息):

      // app.js
      
      'gData': {
          'logined': false, //用户是否登录
          'authsetting': null, //用户授权结果
          'userinfo': null, //用户信息(包含自定义登录态token)
      },
      

      针对上面的第一点,我们在 app.js 下封装 pageGetLoginInfo() 方法,该方法主要做的事情有一下几点:

      • 判断登录控制字段 app.gData.logined,若已经登录——控制字段值为 true,直接把全局控制字段赋值给页面的控制字段;

      • 若全局登录状态控制字段值为 false,则我们完全可认为是由于异步请求后台的原因导致的全局登录控制字段未赋值(因为上文提到登录失败都可以认为是系统的一个Bug)。所以若为 false, 则在 app 对象中定义一个新函数 loginedCb(),供 app.onLaunch() 中异步获取到登录态信息后回调(在本文第四点有特意提过)。而 loginedCb() 方法要做的也是把全局控制字段赋值给页面的控制字段;

      代码中出现的 wxsetData() 方法是在 /utils/wxapi.js 定义的,这里我们导入进来

      // app.js
      
      import {
        wxsetData
      } from './utils/wxapi.js';
      
      /**
      * 获取小程序注册时返回的自定义登录态信息(小程序页面中调用)
      * 主要是解决pageObj.onLoad 之后app.onLaunch()才返回数据的问题
      */
      pageGetLoginInfo: function(pageObj) {
          var _this = this;
          return new Promise((resolve, reject) => {
            // console.log(_this.gData.logined);
            if (_this.gData.logined == true) {
              wxsetData(pageObj, {
                'logined': _this.gData.logined,
                'authsetting': _this.gData.authsetting,
                'userinfo': _this.gData.userinfo
              }).then(function(data) {
                //执行pageObj.onShow的回调方法
                (pageObj.authorizedCb && typeof(pageObj.authorizedCb) === 'function') && pageObj.authorizedCb(data);
                resolve(data);
              });
          
            } else {
              /**
               * 小程序注册时,登录并发起网络请求,请求可能会在 pageObj.onLoad 之后才返回数据
               * 这里加入loginedCb回调函数来预防,回调方法会在接收到请求后台返回的数据后执行,详看app.onLaunch()
               */
              _this.loginedCb = () => {
                wxsetData(pageObj, {
                  'logined': _this.gData.logined,
                  'authsetting': _this.gData.authsetting,
                  'userinfo': _this.gData.userinfo
                }).then(function(data) {
                  //执行pageObj.onShow的回调方法
                  (pageObj.authorizedCb && typeof(pageObj.authorizedCb) === 'function') && pageObj.authorizedCb(data);
                  resolve(data);
                });
              }
            }
          });
      },
      

      然后我们再封装一个 pageOnLoadInit() 方法,也简单说说方法的逻辑:

      • 调用上一步 pageGetLoginInfo() 方法,保证页面拿到有效准确的登录态信息;
      • 验证登录,同时通过参数来决定当前页面初始化时是否需要校验用户授权;
      • 若用户没有授权,则从当前页面跳转到 /pages/auth/auth 页面,auth 页面就是一个授权按钮,用户点击后弹窗提示用户确认授权(小程序官方已修改只能通过点击按钮弹窗用户授权);

      代码中若涉及到授权方面的我们放在后面讨论:

      // app.js
      
      /**
      * 封装小程序页面的公共方法
      * 在小程序页面onLoad里调用
      * @param {Object}  pageObj   小程序页面对象Page
      * @param {Boolean} needAuth  是否检验用户授权(scope.userInfo)
      * @return {Object}           返回Promise对象,resolve方法执行验证登录成功后且不检验授权(特指scope.userInfo)的回调函数,reject方法是验证登录失败后的回调
      */
      pageOnLoadInit: function(pageObj, needAuth = false) {
          var _this = this;
          return new Promise((resolve, reject) => {
            _this.pageGetLoginInfo(pageObj).then(function(res) {
              // console.log(_this.gData.logined);
              if (res.logined === true) {
                //登录成功、无需授权
                resolve(res);
          
                if (needAuth) {
                  if (res.authsetting['scope.userInfo'] === false || typeof res.authsetting['scope.userInfo'] === 'undefined') {
                    common.navigateTo('/pages/auth/auth');
                  }
                }
          
              } else {
                reject({
                  'errMsg': 'Fail to login.Please feedback to manager.'
                });
              }
            });
          });
      },
      

      现在问题基本解决了,剩下的就是在每个小程序页面中调用,只校验登录的逻辑在 Page.onLoad() 里面执行,下面以代码写在小程序页面 /pages/mine/index/index.js 中为例:

      // /pages/mine/index/index.js
      
      const app = getApp();
      
      /**
      * 生命周期函数--监听页面加载
      */
      onLoad: function(options) {
          var _this = this;
          
          app.pageOnLoadInit(this).then(function(res) {
            //这里写验证登录成功后且无需验证授权 需要执行的逻辑
            //若还需验证授权成功才执行的逻辑需写在onShow方法里面,并且这里pageOnLoadInit()第二个参数要为 true
          
          }, function(error) {
            //登录失败
            wx.showModal({
              title: 'Error',
              content: error.errMsg ? error.errMsg : 'Fail to login.Please feedback to manager.',
            })
            return false;
          });
      },
      

      到此,针对第一点——页面登录已经完成。


      针对第二点用户授权的。先看看在 app.js 中封装的 exeAuth() 方法,该方法就是统一授权与后台接口的交互

        /**
         * [exeAuth 执行用户授权流程]
         * @param  {[string]} loginKey  自定义登录态信息缓存的key
         * @param  {[Object]} data      wx.getUserInfo接口返回的数据结构一致
         * @return {[Promise]}          返回一个Promise对象
         */
        exeAuth: function(loginKey, data) {
          var _this = this;
      
          return new Promise((resolve, reject) => {
            wxapi('request', {
              'method': 'POST',
              'url': _this.gData.api.request + '/api/User/thirdauth',
              'header': {
                'Content-type': 'application/x-www-form-urlencoded',
              },
              'data': {
                'platform': 'miniwechat',
                'token': _this.gData.userinfo.token,
                'encryptedData': data.encryptedData,
                'iv': data.iv,
              }
            }).then(function(res) {
              //当服务器内部错误500(或者其它目前我未知的情况)时,wx.request还是会执行success回调,所以这里还增加一层服务器返回的状态码的判断
              if (res.statusCode === 200 && res.data.code === 1) {
                //更新app.gData中的数据
                _this.gData.authsetting['scope.userInfo'] = true;
                _this.gData.userinfo = res.data.data.userinfo;
      
                //更新自定义登录态的缓存数据,防止再次进入小程序时读取到旧的缓存数据,这里让它异步执行即可,
                //倘若异步执行的结果失败,直接清除自定义登录态缓存,再次进入小程序时系统会自动重新登录生成新的
                wxapi('setStorage', {
                  'key': loginKey,
                  'data': res.data.data.userinfo
                }).catch(function(error) {
                  console.warn(error.errMsg);
                  wxapi('removeStorage', {
                    'key': loginKey
                  });
                });
      
                resolve(res.data.data.userinfo);
              } else {
                return Promise.reject({
                  'errMsg': res.data.msg ? 'ServerApi error:' + res.data.msg : 'Fail to network request!'
                });
              }
            }).catch(function(error) {
              reject(error);
            });
          });
        },
      

      要调用上述授权方法的地方必不可少的就是 /pages/auth/auth 统一授权页面了,对于其它可能用到的地方我们之后也可直接调用,我们来看看 /pages/auth/authbindGetUserinfo() type = getUserInfo 按钮的回调函数:

      /**
      * getUserinfo回调函数
      */
      bindGetUserinfo: function(e) {
          var data = e.detail;
          if (data.errMsg === "getUserInfo:ok") {
            app.exeAuth('loginInfo', data).then(function(res) {
              var pages = getCurrentPages();
              var prevPage = pages[pages.length - 2]; //上一个页面
          
              prevPage.setData({
                'userinfo': res,
                'authsetting.scope\\.userInfo': true  这里请注意反斜杠转义,'scope.userInfo'被看做一个完整的键名
              }, function() {
                wx.navigateBack({
                  delta: 1
                });
              });
          
            }).catch(function(error) {
              console.error(error);
              wx.showModal({
                title: 'Error',
                content: error.errMsg,
              })
            });
          } else {
            wx.showModal({
              title: 'Warning',
              content: 'Please permit to authorize.',
              showCancel: false
            })
            return false;
          }
      },
      

      最后像上面登录一样,在 app.js 里封装一个 pageOnShowInit() 供需要授权的页面调用:

      /**
      * 封装小程序页面的公共方法
      * 在小程序页面onShow里调用
      * @param {Object}  pageObj   小程序页面对象Page
      * @return {Object}           返回Promise对象,resolve方法执行验证授权(特指scope.userInfo)成功后的回调函数,reject方法是验证授权失败后的回调
      */
      pageOnShowInit: function(pageObj) {
          var _this = this;
          return new Promise((resolve, reject) => {
            /**
             * 这里通过pageObj.data.authsetting == (null || undefined)
             * 来区分pageObj.onLoad方法中是否已经执行完成设置页面授权列表(pageObj.data.authsetting)的方法,
             * 
             * 因为如果已经执行完成设置页面授权列表(pageObj.data.authsetting)的方法,并且获取到的授权列表为空的话,会把pageObj.data.authsetting赋值为
             * 空对象 pageObj.data.authsetting = {} ,所以pageObj.data.authsetting倘若要初始化时,请务必初始化为 null ,不能初始化为 {},切记!
             */
            if (pageObj.data.authsetting === null || typeof pageObj.data.authsetting === 'undefined') {
              /**
               * pageObj.onLoad是异步获取用户授权信息的,很大可能会在 pageObj.onShow 之后才返回数据
               * 这里加入authorizedCb回调函数预防,回调方法会在pageObj.onLoad拿到用户授权状态列表后调用,详看app.pageOnLoadInit()
               */
              pageObj.authorizedCb = (res) => {
                if (res.authsetting['scope.userInfo'] === true) {
                  //授权成功执行resolve
                  resolve();
                } else {
                  reject();
                }
              }
            } else {
              if (res.authsetting['scope.userInfo'] === true) {
                //授权成功执行resolve
                resolve();
              } else {
                reject();
              }
            }
          });
      },
      

      由于授权多数情况下是从授权页面跳转回来的,所以这个方法设计在小程序页面的 Page.onShow() 中调用,具体调用这里不贴代码了,类似校验登录一样。

    PHP后端

    由于文章篇幅原因,文中涉及的登录和授权两个后台接口这里不贴源码,有兴趣了解可以到文章开头的项目地址,项目的安装文档也有具体说明。若有疑问,可以联系本人。

    结语

    文章可能缺乏一定的条理性,望谅解~

    纯属原创,转载请注明出处,谢谢~

    相关文章

      网友评论

        本文标题:小程序登录案例

        本文链接:https://www.haomeiwen.com/subject/yhynmqtx.html