扫码登录实战系列 3: 前端 SDK 封装

3,182 阅读3分钟

本文是扫码登录实战系列的第三篇,完整目录:

有了后端的接口,现在就具备了实现扫码登录的所需的一切了,只需要 web 端和 app 端调用即可。为了 web 端能够更方便地调用,我们接下来一步一步封装成 sdk。这也是我们文章最开头所展示的最终成果:

startAppAuthScanning({
  onSuccess(userInfo) {
    localStorage.setItem('token', userInfo.token);
  }
})

我们一共要封装 4 个 API:

  • geneQRCode 生成二维码
  • checkQRCodeStatus: 查询二维码状态
  • startPollingQRCodeStatus: 开始轮询
  • startAppAuthScanning: 对上述所有方法的封装,一键生成扫码登录组件

geneQRCode

const host = "http://localhost:8888"
function geneQRCode() {
  let url = `${host}/qrcode/gene`
  return axios.get(url).then(res => {
    return res.data
  }).catch(err => {
    return err
  });
}

checkQRCodeStatus

// 检验微信二维码扫描状态
function checkQRCodeStatus(qrcodeId) {
  let url = `${host}/qrcode/check?qrcodeId=${qrcodeId}`
  return axios.get(url).then(res => {
    return res.data
  }).catch(err => {
    return err
  });
}

startPollingQRCodeStatus

先设计接口:采用注册回调函数的方式,一共有以下可用注册函数:

  • onPollingStart,轮询开始时回调,回调参数 intervalNum 是 startInterval 返回的 interval id,可以调用 clearInterval 手动关闭轮询。
  • onResult:每次获取到最新二维码状态时都会回调。
  • onScanned:用户扫码时回调,只回调一次。
  • onSuccess:用户同意授权时回调,只回调一次,之后轮询结束。
  • onCancel:用户取消授权时回调,只回调一次,之后轮询结束。
  • onExpired:二维码过期时回调,只回调一次,之后轮询结束。
  • onError:每次获取二维码信息失败时回调,失败原因可能有二维码不存在等。
authing.startPollingQRCodeStatus({
  qrcodeId,
  interval: 1000,
  onPollingStart: (intervalNum) => { },
  onResult: (res) => { },
  onScanned: (userInfo) => { },
  onSuccess: (userInfo) => { },
  onCancel: () => { },
  onExpired: () => { },
  onError: (data) => { }
})

以下是具体实现:每当发生某事件时,判断一下是否有相应回调函数注册,如果有就调用回调函数。

由于篇幅限制,这里就不对参数进行检验了。

function startPollingQRCodeStatus(options) {
  let { qrcodeId, interval = 800, onPollingStart, onResult, onScanned, onExpired, onSuccess, onCancel, onError } = options

  let intervalNum = 0
  let calledOnScanned = false
  let calledOnPoolingStart = false

  intervalNum = setInterval(() => {
    // 开始轮询时回调 onPollingStart
    if (onPollingStart && ! calledOnPoolingStart) {
      onPollingStart(intervalNum)
      calledOnPoolingStart = true
    }

    checkQRCodeStatus(qrcodeId).then(res => {

      // 业务状态码 code = 200 表示成功查询到二维码状态
      if (res.code === 200) {

        // 每次获取到数据都回调 onResult 函数
        if (onResult) {
          onResult(res)
        }

        const data = res.data;
        // 第一次查询到已扫码状态,回调 onScanned
        if (data.scanned) {
          if (onScanned && !calledOnScanned) {
            const { userInfo } = data
            onScanned(userInfo)
            calledOnScanned = true
          }
        }

        // 二维码过期,清除定时器
        if (data.expired) {
          clearInterval(intervalNum)
          if (onExpired) {
            onExpired()
          }
        }

        // 成功登录
        if (data.success) {
          clearInterval(intervalNum)
          const { ticket, userInfo } = data
          if (onSuccess) {
            onSuccess({
              ticket,
              userInfo
            })
          }
        }

        // 用户取消
        if (data.canceled) {
          clearInterval(intervalNum)
          if (onCancel) {
            onCancel()
          }
        }

      } else {
        // 业务状态码不为 200,可能是由于二维码不存在等原因
        if (onError) {
          onError(res)
        }
      }
    })
  }, interval)

  return intervalNum
}

startAppAuthScanning

先设计 API:

  • mount:可选,二维码挂载点,如不写则默认漂浮在文档中间
  • interval:轮询间隔时间。
  • onPollingStart 到 onError 和 startPollingQRCodeStatus 意义一致,事实上这部分就是直接调用的 startPollingQRCodeStatus。
  • onQRCodeLoad:二维码首次成功加载时回调。参数 qrcode 是一个字典,包含两个字段:qrcodeId、qrcodeUrl。
  • onQRCodeShow:二维码首次出现在页面上时回调。参数 qrcode 同上。
  • onQRCodeLoadFaild:二维码加载失败时会被回调。
  • 其余的是一些自定义文字展示
startAppAuthScanning({
  mount: '', // 可选,二维码挂载点,如不写则默认漂浮在文档中间
  interval: 1000, // 可选,轮询间隔时间,默认为 800 ms 
  onPollingStart: (intervalNum) => {},
  onResult: (res) => {},
  onScanned: (userInfo) => {},
  onSuccess: (data) => {
      const { ticket, userInfo } = data;
  },
  onCancel: () => {},
  onExpired: () => {},
  onError: (data) => {},

  onQRCodeShow: (qrcode) => {},
  onQRCodeLoad: (qrcode) => {},
  onQRCodeLoadFaild: (error) => {},
  tips: '使用 <strong> APP </strong> 扫码登录',
  scannedTips: '用户已扫码,等待确认',
  canceledTips: '用户取消授权',
  expiredTips: '二维码已过期',
  successTips: '扫码成功',
  retryTips: '重试',
  failedTips: '网络出错,请重试'
})

这个方法是以上所有方法的封装,主要逻辑如下:

  • 调用 geneQRCode 生成二维码
  • 在浏览器文档中展示二维码
  • 调用 startPollingQRCodeStatus 开始轮询
  • 发生相应事件的时候调用注册的回调函数

具体实现如下:

function startAppAuthScanning(options) {

  let { mount = "authing__app-scan-qrcode-root-node", interval = 800,
    onPollingStart, onResult, onScanned, onExpired, onSuccess, onCancel, onError, onQRCodeShow, onQRCodeLoad,
    onQRCodeLoadFaild,
    tips = "使用 <strong> APP </strong> 扫码登录",
    scannedTips = "用户已扫码,等待确认",
    canceledTips = "用户取消授权",
    expiredTips = "二维码已过期",
    successTips = "扫码成功",
    retryTips = "重试",
    failedTips = "网络出错,请重试",
    qrcodeSize = {
      height: 240,
      width: 240
    },
    containerSize = {
      height: 300,
      width: 300
    },
  } = options


  // mount 二维码
  const mountNode = mount
  let qrcodeNode = document.getElementById(mountNode);
  let qrcodeWrapper;
  let needGenerate = false;
  if (!qrcodeNode) {
    qrcodeNode = document.createElement('div');
    qrcodeNode.id = mountNode;
    qrcodeNode.style = `z-index: 65535;position: fixed;background: #fff;width: ${containerSize.width}px;height: ${containerSize.height}px;left: 50%;margin-left: -${containerSize.width / 2}px;display: flex;justify-content: center;align-items: center;top: 50%;margin-top: -${containerSize.height / 2}px;border: 1px solid #ccc;`;
    document.getElementsByTagName('body')[0].appendChild(qrcodeNode);
    needGenerate = true;
  } else {
    qrcodeNode.style = 'position:relative';
  }

  // 创建 <style> 
  const styleNode = document.createElement('style'); const
    style = '#authing__retry a:hover{outline:0px;text-decoration:none;}#authing__spinner{position:absolute;left:50%;margin-left:-6px;}.spinner{margin:100px auto;width:20px;height:20px;position:relative}.container1>div,.container2>div,.container3>div{width:6px;height:6px;background-color:#00a1ea;border-radius:100%;position:absolute;-webkit-animation:bouncedelay 1.2s infinite ease-in-out;animation:bouncedelay 1.2s infinite ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}.spinner .spinner-container{position:absolute;width:100%;height:100%}.container2{-webkit-transform:rotateZ(45deg);transform:rotateZ(45deg)}.container3{-webkit-transform:rotateZ(90deg);transform:rotateZ(90deg)}.circle1{top:0;left:0}.circle2{top:0;right:0}.circle3{right:0;bottom:0}.circle4{left:0;bottom:0}.container2 .circle1{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.container3 .circle1{-webkit-animation-delay:-1.0s;animation-delay:-1.0s}.container1 .circle2{-webkit-animation-delay:-0.9s;animation-delay:-0.9s}.container2 .circle2{-webkit-animation-delay:-0.8s;animation-delay:-0.8s}.container3 .circle2{-webkit-animation-delay:-0.7s;animation-delay:-0.7s}.container1 .circle3{-webkit-animation-delay:-0.6s;animation-delay:-0.6s}.container2 .circle3{-webkit-animation-delay:-0.5s;animation-delay:-0.5s}.container3 .circle3{-webkit-animation-delay:-0.4s;animation-delay:-0.4s}.container1 .circle4{-webkit-animation-delay:-0.3s;animation-delay:-0.3s}.container2 .circle4{-webkit-animation-delay:-0.2s;animation-delay:-0.2s}.container3 .circle4{-webkit-animation-delay:-0.1s;animation-delay:-0.1s}@-webkit-keyframes bouncedelay{0%,80%,100%{-webkit-transform:scale(0.0)}40%{-webkit-transform:scale(1.0)}}@keyframes bouncedelay{0%,80%,100%{transform:scale(0.0);-webkit-transform:scale(0.0)}40%{transform:scale(1.0);-webkit-transform:scale(1.0)}}';
  styleNode.type = 'text/css';
  if (styleNode.styleSheet) {
    styleNode.styleSheet.cssText = style;
  } else {
    styleNode.innerHTML = style;
  }
  document.getElementsByTagName('head')[0].appendChild(styleNode);


  // 一些显示事件
  const loading = () => {
    qrcodeNode.innerHTML = '<div id="authing__spinner" class="spinner"><div class="spinner-container container1"><div class="circle1"></div><div class="circle2"></div><div class="circle3"></div><div class="circle4"></div></div><div class="spinner-container container2"><div class="circle1"></div><div class="circle2"></div><div class="circle3"></div><div class="circle4"></div></div><div class="spinner-container container3"><div class="circle1"></div><div class="circle2"></div><div class="circle3"></div><div class="circle4"></div></div></div>';
  };

  const unloading = () => {
    const child = document.getElementById('authing__spinner');
    qrcodeNode.removeChild(child);
  };

  const genTip = (text) => {
    const tip = document.createElement('span');
    tip.class = 'authing__heading-subtitle';
    if (!needGenerate) {
      tip.style = 'display: block;font-weight: 400;font-size: 15px;color: #888;ine-height: 48px;';
    } else {
      tip.style = 'display: block;font-weight: 400;font-size: 12px;color: #888;';
    }
    tip.innerHTML = text;
    return tip;
  };

  const genImage = (src) => {
    const qrcodeImage = document.createElement('img');
    qrcodeImage.class = 'authing__qrcode';
    qrcodeImage.src = src;
    qrcodeImage.width = qrcodeSize.width;
    qrcodeImage.height = qrcodeSize.height;
    return qrcodeImage;
  };

  const genShadow = (text, aOnClick, shadowAId) => {

    let shadowId = 'authing__retry'

    if (document.getElementById(shadowId)) {
      document.getElementById(shadowId).remove()
    }

    const shadow = document.createElement('div');
    shadow.id = shadowId;
    shadow.style = `text-align:center;width: ${qrcodeSize.width}px;height: ${qrcodeSize.height}px;position: absolute;left: 50%;top: 0px;margin-left: -${qrcodeSize.width / 2}px;background-color: rgba(0,0,0, 0.5);line-height:${qrcodeSize.height}px;color:#fff;font-weight:600;`;

    const shadowA = document.createElement('a');
    shadowA.innerHTML = text;
    shadowA.style = 'color:#fff;border-bottom: 1px solid #fff;cursor: pointer;';
    shadowA.onclick = aOnClick;
    shadowA.id = shadowAId;
    shadow.appendChild(shadowA);
    return shadow;
  };

  const displayScannedUser = (nickname, photo) => {

    let shadowId = 'authing__retry'

    if (document.getElementById(shadowId)) {
      document.getElementById(shadowId).remove()
    }
    const shadow = document.createElement('div');
    shadow.style = `text-align:center;width: ${qrcodeSize.width}px;height: ${qrcodeSize.height}px;position: absolute;left: 50%;top: 0px;margin-left: -${qrcodeSize.width / 2}px;line-height:${qrcodeSize.height}px;color:#fff;font-weight:600;display: flex;
    align-items: center; /*垂直居中*/
    justify-content: center; /*水平居中*/`;
    shadow.id = shadowId;

    const img = document.createElement('img');
    img.id = 'authing__scanned-user';
    img.src = photo
    img.style = "width: 100px; height: 100px"
    shadow.appendChild(img);
    return shadow;
  };

  function genRetry(qrcodeElm, tipText, retryId) {
    const tip = genTip(tipText);

    qrcodeWrapper = document.createElement('div');
    qrcodeWrapper.id = 'authing__qrcode-wrapper';
    qrcodeWrapper.style = 'text-align: center;position: relative;';

    // TODO: 这里换一个二维码
    const qrcodeImage = genImage('https://usercontents.authing.cn/0ab3a1bf19c0d7106673e494d532f91a.png');

    if (!needGenerate) {
      qrcodeImage.style = 'margin-top: 12px;';
    } else {
      qrcodeImage.style = 'margin-top: 16px;';
    }

    qrcodeImage.onload = () => {
      unloading();
    };

    const shadow = genShadow(retryTips, () => {
      start();
    }, retryId || '__authing_retry_btn');

    qrcodeWrapper.appendChild(qrcodeImage);
    qrcodeWrapper.appendChild(shadow);
    qrcodeWrapper.appendChild(tip);
    qrcodeElm.appendChild(qrcodeWrapper);
  }


  let start = () => {
    loading();
    geneQRCode().then((res) => {
      if (res.code !== 200) {
        genRetry(qrcodeNode, res.message);
        if (onQRCodeLoadFaild) {
          onQRCodeLoadFaild(res);
        }
      } else {
        const { qrcodeId, qrcodeUrl } = res.data
        if (onQRCodeLoad) {
          onQRCodeLoad(qrcode);
        }

        if (qrcodeNode) {
          qrcodeWrapper = document.createElement('div');
          qrcodeWrapper.id = 'authing__qrcode-wrapper';
          qrcodeWrapper.style = 'text-align: center;position: relative;';

          const qrcodeImage = genImage(qrcodeUrl);
          qrcodeImage.onload = () => {
            unloading();
            if (onQRCodeShow) {
              onQRCodeShow({
                qrcodeId,
                qrcodeUrl
              });
            }

            // 需要对用户的 onSuccess, onScanned, onExpired, onCancel 进行加工从而在页面上展示相关提示
            let decoratedOnSuccess = function (data) {
              const shadow = genShadow(successTips, null, '__authing_success_tip');
              qrcodeWrapper.appendChild(shadow);
              if (onSuccess) {
                onSuccess(data)
              }
            }

            let decoratedOnScanned = function (userInfo) {
              const shadow = displayScannedUser(
                userInfo.nickname,
                userInfo.photo
              )
              qrcodeWrapper.appendChild(shadow);
              if (onScanned) {
                onScanned(userInfo)
              }
            }

            let decoratedOnCancel = function () {
              const shadow = genShadow(canceledTips, null, '__authing_success_tip');
              qrcodeWrapper.appendChild(shadow);
              if (onCancel) {
                onCancel()
              }
            }

            let decoratedOnExpired = function () {
              const shadow = genShadow(expiredTips, null, '__authing_success_tip');
              qrcodeWrapper.appendChild(shadow);
              if (onExpired) {
                onExpired()
              }
            }

            let decoratedOnError = function (data) {
              if (onError) {
                onError(data)
              }
            }

            // 开始轮询
            startPollingQRCodeStatus({
              qrcodeId,
              interval,
              onPollingStart,
              onResult,
              onScanned: decoratedOnScanned,
              onExpired: decoratedOnExpired,
              onSuccess: decoratedOnSuccess,
              onCancel: decoratedOnCancel,
              onError: decoratedOnError
            })
          };

          const tip = genTip(tips);
          qrcodeWrapper.appendChild(qrcodeImage);
          qrcodeWrapper.appendChild(tip);
          qrcodeNode.appendChild(qrcodeWrapper);
        }
      }
    }).catch((error) => {
      genRetry(qrcodeNode, failedTips);
      if (onQRCodeLoadFaild) {
        onQRCodeLoadFaild(error);
      }
    });
  };

  start();
}

有几点说明一下:

  • 调用 startPollingQRCodeStatus 的时候,注册的 onScanned 回调函数用的是 decoratedOnScanned
startPollingQRCodeStatus({
  qrcodeId,
  interval,
  onPollingStart,
  onResult,
  onScanned: decoratedOnScanned,
  onExpired: decoratedOnExpired,
  onSuccess: decoratedOnSuccess,
  onCancel: decoratedOnCancel,
  onError: decoratedOnError
})

decoratedOnScanned 函数的作用是:先在扫码登录组件中展示用户头像,然后再回调用户注册的 onScanned。这样就即实现了 UI 展示,又没有影响正常的回调业务逻辑。

let decoratedOnScanned = function (userInfo) {
  const shadow = displayScannedUser(
    userInfo.nickname,
    userInfo.photo
  )
  qrcodeWrapper.appendChild(shadow);
  if (onScanned) {
    onScanned(userInfo)
  }
}

现在我们所有的函数就封装好了,创建一个 html 文件看下效果:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>扫码登录 Demo</title>
</head>

<body>

</body>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="./sdk/index.js"></script>
<script>
  startAppAuthScanning({
    onPollingStart: function (intervalNum) {
      console.log("Start polling for qrcode status: ", intervalNum)
      // clearInterval(intervalNum)
    },
    onResult: function (res) {
      console.log("Got qrcode latest result: ", res)
    },
    onScanned: function (userInfo) {
      console.log("User scanned qrcode: ", userInfo)
    },
    onSuccess: function (userInfo) {
      console.log(userInfo)
    },
    onCancel: function () {
      console.log("User canceled authorization")
    },
    onExpired: function () {
      console.log("QRCode has expired.")
    },
    onError: function (data) {
      console.log("Chcek qrcode status failed: ", data)
    }
  })
</script>

</html>

本文的所有代码都在这里了:github.com/liaochangji…