如何使用setInterval和setTimeout去代替webSocket进行轮询调用接口查询

最近leader扔给我一个扫码登录的需求,我一看有点来劲了。一来做了多年前端,类似的需求还没有接触过,平时做的多的页面需求和改改bug对自身能力显然是无法提升的。二来扫码登录的功能很多应用都有做过,常见的微信扫码登录,也挺好奇具体如何实现。我大概看了一遍需求文档,写的挺详细的,流程图也标明了各端的交互流程。由于内网开发,产品流程图也忘记截图了,此处在网上找到的一个大概的流程图:

主要涉及到的是pc端、手机端和后台服务端。由于听产品同事说手机端由原生端(安卓和IOS)来实现,因此我这边只需要开发pc端就行,工作量直接减半有没有。做过该功能的小伙伴肯定了解,pc端的实现还是比较简单的,主要就是开启轮询查询后台扫码状态,然后做对应的提示或登录成功后跳转首页。

扫码登录的需求在前端主要难点在轮询上

0. 什么叫轮询?

所谓的轮询就是,由后端维护某个状态,或是一种连续多篇的数据(如分页、分段),由前端决定按序访问的方式将所有片段依次查询,直到后端给出终止状态的响应(结束状态、分页的最后一页等)。

1. 轮询的方案?

一般有两种解决方案:一种是使用websocket,可以让后端主动推送数据到前端;还有一种是前端主动轮询(上网查了下细分为长轮询和短轮询),通过大家熟悉的定时器(setIntervalsetTimeout)实现。

由于项目暂未用到websocket,且长轮询需要后台配合,所以直接采用短轮询(定时器)开撸了。

遇到的问题:

1、由于看需求文档上交互流程比较清晰,最开始没去网上查找实现方案,自己直接整了一版setInterval的轮询实现。在跟后台联调的过程中发现定时器每1s请求一次接口,发现很多接口没等响应就开启下一次的请求,很多请求都还在pending中,这样是不对的,对性能是很大消耗。于是想了下,可以通过setTimeout来优化,具体就是用setTimeout递归调用方式模拟setInterval的效果,达到只有上一次请求成功后才开启下一次的请求。

复制代码
// 开启轮询
    async beginPolling() {
      if (this.isStop) return;
      try {
        const status = await this.getQrCodeStatus();
        if (!status) return;
        this.codeStatus = status;
        switch(this.codeStatus) {
          case '2':
            this.stopPolling();
            // 确认登录后,需前端修改状态
            this.codeStatus = '5';
            this.loading = true;
            // 走登录逻辑
            this.$emit('login', {
              qrcId: this.qrcId,
              encryptCSIIStr: this.macAddr
            })
            break;
          case '3':
            // 取消登录
            this.stopPolling();
            await this.getQrCode();
            break;
          case '4':
            // 二维码失效
            this.stopPolling();
            break;
          default:
            break;
        }
        this.timer = setTimeout(this.beginPolling);
      } catch(err) {
        console.log(err);
        this.stopPolling();
      }
    },

2、在自测了过程中又发现了另外一个问题,stopPolling方法中clearTimeout似乎无法阻止setTimeout的执行,二维码失效后请求仍在不停发出,这就很奇怪了。上网搜索了一番,发现一篇文章(很遗憾,已经找不到是哪篇文章了!)记录了这个问题:大概意思是虽然clearTimeout已经清除了定时器,但此时有请求已经在进行中,导致再次进入了循环体,重新开启了定时器。解决办法就是,需要手动声明一个标识位isStop来阻止循环体的执行。

复制代码
    stopPolling() {
      if (this.timer) {
        clearTimeout(this.timer);
        this.timer = null;
        // 标记终止轮询(仅clearTimeout无法阻止)
        this.isStop = true;
      }
    },

试了下确实达到效果了,但其实这个问题产生的具体原因我还是有些模糊的,希望遇到过相关问题的大佬能指点一下,感激不尽!

3、解决了上面提到的问题,就在以为万事大吉,只待提测的时候。后台同事发现了一个问题(点赞后台同事的尽责之心):他在反复切换登录方式(扫码登录<->账号密码登录)的过程中,发现后台日志有一段时间打印的qrcId不是最新的。然后我这边试了下,确实在切换频率过高时,此时有未完成的请求仍在进行中,导致qrcId被重新赋值了。虽然已经在beforeDestroy里调用了stopPolling清除定时器,但此时请求是未停止的。聪明的小伙伴们肯定想到axioscancelToken可以取消未完成的请求,但我实际也并没有用过,而且项目里也没有可以表演Ctrl+CCtrl+V的地方。于是百度了一番,找到一篇掘友的文章,为了表示尊敬我原封不动的搬到我的代码里了,哈哈!

复制代码
import axios from "axios";
const CancelToken = axios.CancelToken;

const cancelTokenMixin = {
  data() {
    return {
      cancelToken: null, // cancelToken实例
      cancel: null, // cancel方法
    };
  },
  created() {
    this.newCancelToken();
  },
  beforeDestroy() {
    //离开页面前清空所有请求
    this.cancel("取消请求");
  },
  methods: {
    //创建新CancelToken
    newCancelToken() {
      this.cancelToken = new CancelToken((c) => {
        this.cancel = c;
      });
    },
  },
};
export default cancelTokenMixin;

掘友文章[:](在vue项目中取消axios请求(单个和全局) - 掘金 (juejin.cn))

在组件里引入mixin,另外在请求时传入cancelToken实例,确实达到效果了。此时再次切换登录方式,之前的未完成的请求已被取消,也就无法再篡改qrcId。写到此处,我发现问题2也是未完成的请求导致的,那么是否可以不用isStop标识,直接在stopPolling中调用this.cancel("取消请求");不是更好吗?

完整代码如下:

复制代码
import sunev from 'sunev'; // 全局公共方法库
import cancelTokenMixin from "@/utils/cancelTokenMixin"; // axios取消请求

export default {
  props: {
    loginType: {
      type: String,
      default: 'code'
    }
  },
  mixins: [cancelTokenMixin],
  data() {
    return {
      qrcId: '', // 二维码标识
      qrcBase64: '', // 二维码base64图片
      macAddr: '', // mac地址
      loading: false,
      isStop: false,
      codeStatus: '0',
      qrStatusList: [
        {
          status: '-1',
          icon: 'error',
          color: '#ed7b2f',
          svgClass: 'icon-error-small',
          text: '二维码生成失败\n请刷新重试',
          refresh: true
        },
        { status: '0', icon: '', text: '', refresh: false },
        {
          status: '1',
          icon: 'scan',
          color: '#2986ff',
          svgClass: 'icon-scan-small',
          text: '扫描成功\n请在移动端确认',
          refresh: false
        },
        {
          status: '2',
          icon: 'confirm',
          color: '#2986ff',
          svgClass: 'icon-confirm-small',
          text: '移动端确认登录',
          refresh: false
        },
        {
          status: '3',
          icon: 'cancel',
          text: '移动端已取消',
          refresh: false
        },
        {
          status: '4',
          icon: 'error',
          color: '#ed7b2f',
          svgClass: 'icon-error-small',
          text: '二维码已失效\n请刷新重试',
          refresh: true
        },
        {
          status: '5',
          icon: 'success',
          color: '#2986ff',
          svgClass: 'icon-success-small',
          text: '登录成功',
          refresh: false
        },
        {
          status: '6',
          icon: 'error',
          color: '#ed7b2f',
          svgClass: 'icon-error-small',
          text: '登录失败\n请刷新重试',
          refresh: true
        }
      ],
      errMsg: ''
    }
  },
  async created() {
    try {
      await this.getQrCode();
      this.beginPolling();
    } catch(err) {
      console.log(err);
    }
  },
  computed: {
    // 当前状态
    curQrStatus() {
      const statusObj = this.qrStatusList.find(item => item.status === this.codeStatus);
      if (this.errMsg) {
        statusObj.text = this.errMsg;
      }
      return statusObj;
    }
  },
  methods: {
    // 开启轮询
    async beginPolling() {
      if (this.isStop) return;
      try {
        const status = await this.getQrCodeStatus();
        if (!status) return;
        this.codeStatus = status;
        switch(this.codeStatus) {
          case '2':
            this.stopPolling();
            // 确认登录后,需前端修改状态
            this.codeStatus = '5';
            this.loading = true;
            // 走登录逻辑
            this.$emit('login', {
              qrcId: this.qrcId,
              encryptCSIIStr: this.macAddr
            })
            break;
          case '3':
            // 取消登录
            this.stopPolling();
            await this.getQrCode();
            break;
          case '4':
            // 二维码失效
            this.stopPolling();
            break;
          default:
            break;
        }
        this.timer = setTimeout(this.beginPolling);
      } catch(err) {
        console.log(err);
        this.stopPolling();
      }
    },
    // 暂停轮询
    stopPolling() {
      if (this.timer) {
        clearTimeout(this.timer);
        this.timer = null;
        // 标记终止轮询(仅clearTimeout无法阻止)
        this.isStop = true;
      }
    },
    // 获取二维码base64
    async getQrCode() {
      this.reset();
      this.loading = true;
      try {
        const params = {
          encryptCSIIStr: this.macAddr
        }
        const res = await sunev.$https.post(
          'sunev/LoginQRCGen',
          { isLoading: false, cancelToken: this.cancelToken },
          params
        )
        if (res.qrcId) {
          this.qrcId = res.qrcId;
          this.qrcBase64 = res.qrcBase64;
        } else {
          this.stopPolling();
        }
      } catch(err) {
        this.errMsg = err.message;
        this.stopPolling();
      }
    },
    // 获取二维码状态
    async getQrCodeStatus() {
      try {
        const params = {
          encryptCSIIStr: this.macAddr
        }
        const res = await sunev.$https.post(
          'sunev/LoginQRCQry',
          { isLoading: false, cancelToken: this.cancelToken },
          params
        )
        return res.status;
      } catch(err) {
        this.stopPolling();
      }
    },
    // 刷新二维码
    async refresh() {
      await this.getQrCode();
      this.beginPolling();
    },
    // 切换登录类型
    toggle() {
      this.$emit('toggleLoginType');
    },
    // 重置
    reset() {
      this.isStop = false;
      this.codeStatus = '0';
      this.errMsg = '';
    },
    beforeDestroy() {
      this.stopPolling();
    }
  }
}

ps:

1、由于是老项目了,登录界面逻辑较多,避免臃肿,二维码登录拆分成单独组件实现

2、由于项目组在内网开发,以下代码都是一行行重新手打的,不是很重要的html和css部分就省略了

后记:

由于此需求并不着急上线,暂未提测,所以还不知测试同事会提出怎样的bug。另外掘友们如果发现问题,也欢迎批评指正,感激不尽!

相关推荐
xjt_09013 分钟前
浅析Web存储系统
前端
foxhuli22941 分钟前
禁止ifrmare标签上的文件,实现自动下载功能,并且隐藏工具栏
前端
青皮桔1 小时前
CSS实现百分比水柱图
前端·css
失落的多巴胺1 小时前
使用deepseek制作“喝什么奶茶”随机抽签小网页
javascript·css·css3·html5
DataGear1 小时前
如何在DataGear 5.4.1 中快速制作SQL服务端分页的数据表格看板
javascript·数据库·sql·信息可视化·数据分析·echarts·数据可视化
影子信息1 小时前
vue 前端动态导入文件 import.meta.glob
前端·javascript·vue.js
青阳流月1 小时前
1.vue权衡的艺术
前端·vue.js·开源
样子20181 小时前
Vue3 之dialog弹框简单制作
前端·javascript·vue.js·前端框架·ecmascript
kevin_水滴石穿1 小时前
Vue 中报错 TypeError: crypto$2.getRandomValues is not a function
前端·javascript·vue.js
翻滚吧键盘1 小时前
vue文本插值
javascript·vue.js·ecmascript