效果
- 用户体验
- 流畅
登录过程不会阻塞操作,登录前后自然衔接,e.g.点击留言-触发登录-自动继续完成留言行为,不需要刷新页面也不需要再次点击留言按钮。 - 多元
支持不同小程序不同页面不同场景配置不同的登录界面和登录流程,在合适的时候通过合适的引导提高用户登录意愿。 - 精细
支持一进页面就悄悄对用户进行精细区分,对老用户进行个性化定制,同时不需要弹出登录界面打扰新用户;支持精细控制登录时机,可以在新用户进行任何一项要求登录的操作时自动触发登录流程。
- 流畅
- 业务开发
- 省心
只需指明业务功能是否需要登录态即可,各种登录细节不用管,e.g.留言业务,只需指明“留言过程需要登录态”即可,至于:用户点击留言按钮时登录了没、怎么进行登录交互、会不会页面其它地方正登录到一半、会不会后端登录态刚好过期了等各种问题都不需要业务方care。 - 自由
不想关注登录时,各种细节完全不用care;想关注时,各种细节又都可以自由定制,e.g.定制登录界面、跳过登录界面、附加登录步骤、监听登录成功等。
- 省心
- 健壮性
- 后端登录态过期自动重新登录,避免前后端登录态时效差异造成偶现功能异常
- 页面多处同时触发登录自动合并,避免时序混乱互相干扰造成潜在逻辑异常
- 各机制对调用方透明,不依赖调用方自觉性,避免调用失误造成业务异常
- 高效性
- 后端登录态惰性检查,节约每次校验开销
- 公共步骤免并发,节约并发成本
- 前后操作无缝衔接,节约用户重复操作开销
- 复用、扩展、维护
- 支持多小程序多页面多场景复用
- 公共流程统一维护,差异特性各自扩展
- 支持子类继承自定义逻辑扩展
原理
详见ppt:健壮高效的小程序登录方案
基础用法
- fancy-mini setup
- 编写鉴权逻辑
编写鉴权模块,根据用户提供的信息,完成校验过程,并返回对应的登录数据。e.g.:
//FancyWechatAuth.js
import WechatAuth from "fancy-mini/lib/login/auth/WechatAuth";
/**
* 微信登录鉴权模块-自定义实现示例
*/
class FancyWechatAuth extends WechatAuth {
/**
* 微信授权登录
* 根据用户同意授权后从微信处拿到的信息,完成登录过程
* @param {WechatAuth~WxLoginRes} wxLoginRes wx.login执行结果
* @param {Object} authData 登录界面交互结果,格式同wx.getUserInfo返回结果
* @param loginOptions 登录函数调用参数,参见BaseLogin#login
* @param configOptions 登录模块配置参数,参见BaseLogin#config
* @return {BaseAuth~LoginRes}
*/
async loginByWxAuth({wxLoginRes, authData, loginOptions, configOptions}) {
//调用后端接口,根据微信授权信息获取登录结果
let loginRes = await configOptions.requester.request({
url: 'https://xxx/wxAuthLogin', //todo:换成实际后端接口
data: {
code: wxLoginRes.code,
encryptedData: authData.encryptedData,
iv: authData.iv,
//...
},
method: "POST",
});
//后端:
// 1. 调用微信api,根据入参中的code,获取session_key,官方文档:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
// 2. 调用微信api,根据入参中的encryptedData、iv和上一步中获取的session_key,获取微信用户标识和用户信息,官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html
// 2.1 用户标识:openId,用户在小程序中的唯一标识,因小程序而异
// 2.2 用户标识:unionId,用户在同一主体中的唯一标识,同一用户在同一主体下的所有小程序、公众号、网页、APP等应用中unionId一致
// 2.3 用户信息:昵称、头像、性别、地区等
// 3. 根据上一步中获取的openId/unionId,查询用户表;若存在相关记录,则返回对应用户信息,登录成功;若不存在,则自动注册,并存储微信用户信息作为用户初始信息,存储微信用户标识作为关联标识备查,之后返回对应用户信息,登录成功。
//登录失败处理 todo: 根据接口实际格式进行字段调整
if (loginRes.respCode != 0) {
return {
succeeded: false, //是否成功
errMsg: 'login api failed:' + JSON.stringify(loginRes), //详细错误信息,调试用
toastMsg: loginRes.respData && loginRes.respData.errMsg //(若有)错误话术,向用户提示用
};
}
//登录成功处理 todo: 根据接口实际格式进行字段调整
let data = loginRes.respData;
return {
succeeded: true, //是否成功
errMsg: 'ok', //错误信息
userInfo: { //用户信息 todo: 改为实际所需格式
nickName: data.nickName, //昵称
avatarUrl: data.avatarUrl, //头像
gender: data.gender, //性别
uid: data.uid, //后端自己维护的用户标识
sessionKey: data.sessionKey, //后端自己维护的登录凭证
},
expireTime: Date.now() + data.validityPeriod, //有效期至,格式:绝对时间戳,-1表示长期有效
anonymousInfo: { //(若有)匿名信息,登录前分配的临时标识,登录后继续关联
token: data.token,
},
};
}
/**
* 微信静默登录
* 在用户无感知的情况下悄悄完成登录过程
* @param {WechatAuth~WxLoginRes} wxLoginRes wx.login执行结果
* @param loginOptions 登录函数调用参数,参见BaseLogin#login
* @param configOptions 登录模块配置参数,参见BaseLogin#config
* @return {BaseAuth~LoginRes}
*/
async loginByWxSilent({wxLoginRes, loginOptions, configOptions}) {
let loginRes = await configOptions.requester.request({
url: 'https://xxx/wxSilentLogin', //todo:换成实际后端接口
data: {
code: wxLoginRes.code,
//...
},
method: "POST",
});
//后端:
// 1. 调用微信api,根据入参中的code,获取openId,官方文档:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
// 2. 根据openId,查询用户表;若存在相关记录,则返回对应用户信息,登录成功;若不存在相关记录,则登录失败
// 效果:老用户可以在无感知的情况下悄悄静默登录;新用户依然是要等到授权登录时拿到用户信息才进行注册和登录,但静默尝试用户无感知,不会产生打扰
//登录失败处理 todo:根据接口实际格式进行字段调整
if (loginRes.respCode != 0) {
return {
succeeded: false, //是否成功
errMsg: 'fail', //详细错误信息,调试用
anonymousInfo: { //(若有)匿名信息,登录前分配的临时标识,登录后继续关联
token: loginRes.respData.token,
}
};
}
//登录成功处理 todo:根据接口实际格式进行字段调整
let data = loginRes.respData;
return {
succeeded: true, //是否成功
errMsg: 'ok', //错误信息
userInfo: { //用户信息 todo: 改为实际所需格式
nickName: data.nickName, //昵称
avatarUrl: data.avatarUrl, //头像
gender: data.gender, //性别
uid: data.uid, //后端自己维护的用户标识
sessionKey: data.sessionKey, //后端自己维护的登录凭证
},
expireTime: Date.now() + data.validityPeriod, //有效期至,格式:绝对时间戳,-1表示长期有效
anonymousInfo: { //(若有)匿名信息,登录前分配的临时标识,登录后继续关联
token: data.token,
},
}
}
}
export default FancyWechatAuth;
- 编写登录逻辑
编写登录模块,添加自定义参数、自定义流程等。e.g.:
//FancyLogin.js
import BaseLogin from 'fancy-mini/lib/login/BaseLogin';
import {peerAssign} from 'fancy-mini/lib/operationKit';
/**
* 登录模块-自定义实现示例
* 通过继承覆盖的形式,可以在登录模块的各个环节中添加各种自定义逻辑
* e.g.引入cookie逻辑,使得所有接口请求自动携带登录信息
*/
class FancyLogin extends BaseLogin{
cookie = null;
//模块配置
config(configOptions){
//参数校验...
//处理模块自身所需的自定义参数
this.cookie = configOptions.cookie;
//处理需要传递给鉴权模块、钩子函数等其它可配模块的自定义参数
const defaultFancyOpts = {
source: '', //小程序编号,用于区分不同的小程序,由rd指定,后端可以根据source查询到对应小程序的appId、appSecret等信息
};
let fancyOptions = peerAssign({}, defaultFancyOpts, configOptions);
//注册额外参数
this._appendConfig('fancyOptions', fancyOptions); //则鉴权模块、钩子函数等可以通过 configOptions.fancyOptions 拿到这些自定义参数
//处理通用参数
super.config(configOptions);
}
//模块初始化
_init(){
super._init();
//写入cookie
this.cookie.set('uid', this._loginInfo.userInfo.uid || '');
this.cookie.set('sessionKey', this._loginInfo.userInfo.sessionKey || '');
}
//清除登录态
clearLogin(...args){
super.clearLogin(...args);
cookie.set('uid', '');
cookie.set('sessionKey', '');
}
//保存/更新用户信息
_saveInfo(loginInfo){
super._saveInfo(loginInfo);
// 写入cookie
cookie.set('uid', loginInfo.userInfo.uid);
cookie.set('sessionKey', loginInfo.userInfo.sessionKey);
}
}
export default FancyLogin;
- 进行整体配置
小程序引入登录模块,提供相关信息并进行整体配置。e.g.:
//appPlugin.js
import FancyLogin from "../demoExtend/login/FancyLogin";
import FancyWechatAuth from "../demoExtend/login/auth/FancyWechatAuth";
import LoginPlugin from "fancy-mini/lib/request/plugin/LoginPlugin";
import Requester from "fancy-mini/lib/request/Requester";
import Cookie from 'fancy-mini/lib/Cookie';
import CookiePlugin from 'fancy-mini/lib/request/plugin/CookiePlugin';
import {authEvents} from 'fancy-mini/lib/globalEvents';
//实例创建
const loginCenter = new FancyLogin(); //登录中心
const requester = new Requester(); //请求管理器
const cookie = new Cookie(); //cookie管理器
//登录模块配置
loginCenter.config({
requester, //请求管理器,用于发送接口请求
authEngineMap: { //鉴权器映射表,key为登录方式,value为对应的鉴权器
'wechat' : new FancyWechatAuth(), //登录方式:微信,鉴权器:微信登录鉴权
},
defaultAuthType: 'wechat', //默认登录方式:微信
async userAuthHandler(){ //默认登录交互
let userAuthRes = await new Promise(resolve=>{
authEvents.subscribe({ //监听交互结果
eventType: 'userAuthFinish',
handler: resolve, //交互结束时resolve当前promise
persistType: 'once'
});
wx.navigateTo({ //展示登录界面
url: '/pages/login/index'
});
});
//等待用户交互,直到用户交互结束,登录界面信息收集完毕,该Promise才会被resolve,后续代码才会自动继续执行
//返回交互结果
return userAuthRes;
},
//自定义参数
cookie,
source: 1,
});
//请求管理器配置
requester.config({
plugins: [
//登录插件,在请求前后自动加入登录态相关逻辑
new LoginPlugin({
loginCenter,
apiAuthFailChecker(resData, reqOptions){ //根据接口返回内容,判断后端登录态是否已失效
return (
(resData.respMsg && resData.respMsg.includes('请登录')) || //后端登录态失效通用判断条件
(reqOptions.url.includes('/bizA/') && resData.respCode===-1) || //业务线A后端接口登录态失效
(reqOptions.url.includes('/bizB/') && resData.respCode===-2) //业务线B后端接口登录态失效
);
}
}),
//cookie插件,在请求前后自动加入cookie相关逻辑
new CookiePlugin({
cookie,
}),
]
});
export {
requester,
loginCenter,
cookie,
}
- 编写登录界面
小程序提供一个默认的登录界面,负责完成登录交互收集所需信息。e.g.:(示例采用wepy1.x框架语法,其它框架类似)
<template>
<view>
<button open-type="getUserInfo" @getuserinfo="onGetUserInfo">立即登录</button>
</view>
</template>
<script>
//pages/login/index.wpy
import wepy from 'wepy';
import {authEvents} from 'fancy-mini/lib/globalEvents';
export default class extends wepy.page {
config = {
navigationBarTitleText: '登录',
}
data = {
isActiveUnload: false, //是否代码主动触发的页面卸载:true-代码主动退出 | false-被动退出(e.g.用户点击了系统返回按钮)
}
methods = {
onGetUserInfo(ev){
//授权失败,留在当前界面,等待用户再次交互
if (!ev.detail.errMsg.includes('ok'))
return;
//授权成功
//通知登录模块交互结果
authEvents.notify({
eventType: 'userAuthFinish',
data: {
succeeded: true, //是否成功收集到了所需信息
errMsg: 'ok', //(失败时)错误信息
authType: 'wechat', //(成功时)用户选择的登录方式
authData: { //(成功时)该登录方式所需的鉴权信息
...ev.detail
}
}
});
//自动返回
this.isActiveUnload = true;
wx.navigateBack();
}
}
onUnload() {
//被动退出页面时,通知登录模块交互结果
if (!this.isActiveUnload) {
authEvents.notify({
eventType: 'userAuthFinish',
data: {
succeeded: false, //是否成功收集到了所需信息
errMsg: 'cancel', //(失败时)错误信息
authType: '', //(成功时)用户选择的登录方式
authData: { //(成功时)该登录方式所需的鉴权信息
}
}
});
}
}
}
</script>
- 手动触发登录
页面/组件中调用登录相关api,手动触发登录相关功能。e.g.:
//页面/组件
import {loginCenter} from '../../lib/appPlugin';
async onLoad(){
let loginRes = await loginCenter.login(); //登录
console.log('login finished, res:', loginRes);
}
- 自动触发登录
页面/组件中调用接口相关api,自动触发登录功能。e.g.:
//页面/组件
import {requester} from '../../lib/appPlugin';
//留言
async onComment(){
let commentRes = await requester.requestWithLogin({ //调用留言接口时使用requestWithLogin表明“该接口需要登录态”
url: 'https://xxx/comment',
data: {
//...
}
});
//则模块会在请求前后自动加入登录态相关逻辑:
// 1.请求发出前,若未登录,则先触发登录,成功后再发送接口请求,失败则取消接口调用
// 2.请求返回后,若判断后端登录态已失效,则自动重新登录重新发送接口请求,并以重新请求的结果作为本次调用结果返回
//业务方无需关注是否已登录、如何登录、登录并发、登录态过期等各种问题,直接正常进行业务逻辑后续处理即可
console.log('comment finished, res:', commentRes); //处理留言接口返回的内容
}
注册到this上使用
可以将相关方法注册到组件/页面的this对象上,便于组件/页面直接使用。
- 将相关功能注册到this上
//appPlugin.js
import {registerToThis} from 'fancy-mini/lib/wepyKit';
//接上例,登录相关模块引入&配置...
//将登录模块相关功能注册到this上,方便组件/页面直接使用
const propMapThis2Login = { //命名映射,key为this属性名,value为loginCenter属性名, '*this'表示loginCenter自身
'$loginCenter': '*this', // this.$loginCenter 对应 loginCenter
'$login': 'login', // this.$login() 对应 loginCenter.login()
'$logout': 'logout',
'$reLogin': 'reLogin',
'$checkLogin': 'checkLogin',
};
for (let [thisProp, loginProp] of Object.entries(propMapThis2Login)) {
let loginTarget = loginProp === '*this' ? loginCenter : loginCenter.makeAssignableMethod(loginProp);
registerToThis(thisProp, loginTarget);
}
//将请求模块相关功能注册到this上,方便组件/页面直接使用
const propMapThis2Requester = { //命名映射,key为this属性名,value为requester属性名, '*this'表示requester自身
'$requester': '*this', // this.$requester 对应 requester
'$http': 'request', // this.$http() 对应 requester.request()
'$httpWithLogin':'requestWithLogin', //this.$httpWithLogin() 对应 requester.requestWithLogin()
};
for (let [thisProp, requesterProp] of Object.entries(propMapThis2Requester)) {
let requesterTarget = requesterProp === '*this' ? requester : requester.makeAssignableMethod(requesterProp);
registerToThis(thisProp, requesterTarget);
}
//...
- 使注册生效
//项目入口文件(app.js/app.wpy/app.vue/main.js/……,因框架而异)
import './lib/appPlugin'; //负责各种小程序级公共模块的引入和配置
- 页面/组件直接使用相关功能
import wepy from 'wepy';
export default class extends wepy.page {
async onLoad(){
let loginRes = await this.$login(); //可以直接通过this调用相关方法,而不用每次引入相关模块
console.log('login finished, res:', loginRes);
let commentRes = await this.$httpWithLogin({ //可以直接通过this调用相关方法,而不用每次引入相关模块
url: 'https://xxx/comment',
data: {
//...
}
});
console.log('comment finished, res:', commentRes);
}
}
模块级功能定制
对登录模块有特殊需求时,支持进行各种自定义逻辑扩展。
如上文中编写登录逻辑小节所示,可以通过继承&重写的方式编写自己的登录模块,从而实现各种模块级功能定制。
可重写节点详见:API文档-登录模块、源码-登录模块。
小程序级功能定制
同时维护多个小程序时,支持所有小程序共用一个登录模块的同时,各自指定不同的功能逻辑。
如上文中进行整体配置小节所示,小程序可以通过传入各种配置项改写登录模块的默认行为,从而实现各种小程序级功能定制。
可配项详见:BaseLogin#config
页面级功能定制
同一小程序中,支持不同页面指定不同的登录逻辑。
- 小程序中配置页面级参数获取方式
//appPlugin.js
import {getCurWepyPage} from 'fancy-mini/lib/wepyKit';
//...
//登录模块配置
loginCenter.config({
//...
pageConfigHandler(){ //获取页面级登录配置
let curPage = getCurWepyPage() || {}; //获取当前页面实例
return curPage.$loginOpts; //返回当前页面的自定义配置参数
},
});
//...
- 页面中进行功能定制
import wepy from 'wepy';
export default class extends wepy.page {
//对登录行为进行页面级定制
$loginOpts = {
userAuthHandler: async()=>{ //默认登录界面
//展示更符合本页面风格和本页面价值点的登录弹窗,而不使用小程序默认登录交互,提高用户登录意愿
//...
},
onNewlyLogin: ()=>{ //钩子函数,未登录=>已登录 时触发
//当页面中任何位置成功发生登录行为时
//更新页面中所有的用户相关数据...
}
}
}
可配项详见:BaseLogin#_mergeConfigOptions
调用级功能定制
同一页面中,支持不同函数指定不同的登录逻辑。
let loginRes = await this.$login({
userAuthHandler: async()=>{ //指定登录界面
//展示更符合本次操作价值点的登录弹窗,而不使用小程序级/页面级默认登录交互,提高用户登录意愿
//...
},
});
let commentRes = await this.$httpWithLogin({
url: 'https://xxx/comment',
data: {
//...
},
loginOpts: {
userAuthHandler: async()=>{ //指定登录界面
//展示更符合本次操作价值点的登录弹窗,而不使用小程序级/页面级默认登录交互,提高用户登录意愿
//...
}
}
});
可配项详见:BaseLogin#login、LoginPlugin#requestWithLogin
实现其它登录方式
除了微信登录,也支持实现其它登录方式,如:账号密码登录、手机号验证码登录等。
- 编写登录方式对应的鉴权模块
继承BaseAuth类,编写对应的鉴权逻辑。 - 小程序中配置对应鉴权映射
在appPlugin.js中loginCenter.config时,authEngineMap选项中添加相应的键值对。 - 登录界面中收集相应信息
登录界面中返回相应的authType和authData。
登录界面可以同时展示多种登录方式,然后根据用户交互返回用户实际所选登录方式对应的authType和authData。