小程序静默登录, 自定义Promise.all实现业务逻辑的封装

前言

最近刚做了一个小程序, 里面涉及到用户授权以及登录的情况, 初次登录需要获取用户信息, 然后再走登录流程, 后续就不需要用户授权了, 就可以直接走登录流程了

同时有的数据需要登录之后才能获取, 如果未登录则返回401, 此时我需要先登录, 然后再去获取数据

也就是说我需要做静默登录的操作: 请求数据, 登录了则正常获取数据, 而如果未登录则需要先登录然后才能获取数据

当页面只有一个数据来源的时候, 我只需要正常发起请求, 遇到401就去登录, 然后再执行这个获取数据的函数即可, 但当页面上有多个, 比如两个数据来源的时候, 遇到401然后重新请求数据, 此时就可能发生这么一个情况: 没登录, 两个接口都返回401然后都去登录, 就会登录两遍, 虽然登录两遍也只是除了第一次之外又刷新了一次登录的有效期, 但作为一个有(想)追(装)求(X)的程序员, 这样的事我无(很)法(想)容(装)忍(X), 于是就有了这篇文章, 那么接下来, 我们就一起来看看这个问题怎么处理吧

模拟获取数据的函数

不得不说, 后端返回的数据的格式各家公司有各家的规范, 有的公司在处理异常的时候会返回相应的status code, 而有的则是全部按200处理然后通过响应体中的code字段来标识, code0表示成功, 为其他数字表示异常, 同时这些code和除了200之外的其他status code是一一对应的, 比如401表示未授权这样的

我这边遇到的就是后者

首先我们需要创建一个函数, 模拟请求发送, 登录了返回相应的数据, 未登录则返回401和失败原因这些信息, 同时, 由于我们需要控制请求的失败与否来测试我们的代码, 因此还需要在这个函数的外部定义一个变量, 让我们的这个函数可以根据这个外部变量来修改自己的返回结果, 为了便于测试, 这样的函数我们写两个:

let isFooSuccess = false;
let isFoo2Success = true;

//请求foo数据的函数
const foo = ({ delay }) => (
  new Promise(
    (resolve, reject) => {
      console.log('foo被调用, isFooSuccess', isFooSuccess);

      const res = {
        code: isFooSuccess ? 0 : 401,
        msg: isFooSuccess ? 'foo成功' : 'foo失败, 需要登录',
        data: isFooSuccess
        ? [
          {
            a: 1
          }
        ]
        : []
      }

      setTimeout(
        () => {
          isFooSuccess ? resolve(res) : reject(res);
        },
        delay
      )
    }
  )
)

//请求foo2数据的函数
const foo2 = ({ delay }) => (
  new Promise(
    (resolve, reject) => {
      console.log('foo2被调用, isFoo2Success', isFoo2Success);

      const res = {
        code: isFoo2Success ? 0 : 401,
        msg: isFoo2Success ? 'foo2成功' : 'foo2失败, 需要登录',
        data: isFooSuccess
        ? [
          {
            a: 1
          }
        ]
        : []
      }

      setTimeout(
        () => {
          isFoo2Success ? resolve(res) : reject(res);
        },
        delay
      )
    }
  )
)
复制代码

两个函数都需要返回一个promise, 参数的话是考虑到实际中会有多个参数的情况, 所以传递的是一个object, 为了方便调试也添加了输出语句, 两个函数返回的结果的结构是一样的, 其实也就是一个封装过的request, 它请求后端接口, 然后返回一个promise

模拟登录的函数

接下来就是模拟登录的函数了:

//登录
const login = (isLoginSuccess, delay) => (
  new Promise(
    (resolve, reject) => {
      console.log('login被调用, isLoginSuccess', isLoginSuccess);

      const res = {
        msg: isLoginSuccess ? 'success' : 'fail',
        code: isLoginSuccess ? 0 : 123,
        isLoginSuccess
      };

      isFooSuccess = isLoginSuccess;
      isFoo2Success = isLoginSuccess;

      setTimeout(
        () => {
          isLoginSuccess ? resolve(res) : reject(res);
        },
        delay
      );
    }
  )
)
复制代码

登录之后需要将上面我们提到的外部变量做一个修改, 从而当我们再次请求的时候, '后端接口'才知道我们的登录情况

处理接口401的函数

同时我们还需要一个处理401的函数, 同时也是这一整个需求的关键代码: 当响应体中的code的值为401的时候就做错误处理, 然后登录, 接着再再次请求数据, 以及如果遇到其他异常, 那么我们也要用reject抛出

也就是: 遇到请求返回的结果中code401, 则返回一个rejectpromise, 否则resolve:

//遇到请求返回的结果中code为401, 则返回一个reject的promise, 否则resolve
const handlePromise401Reject = (promiseReq, promiseReqParams) => {
  const finalPromise = promiseReqParams ? promiseReq(promiseReqParams) : promiseReq();

  return new Promise(
    (resolve, reject) => {
      finalPromise
        .then(
          res => {
            const { code } = res;

            code === 401 ? reject(res) : resolve(res);
          }
        )
        .catch(error => {
          reject(error);
        });
    }
  )
}
复制代码

封装一个处理401的函数, 这个函数返回一个promise, 同时它也接收一个返回promise的请求函数, 以及这个请求函数所需要的参数, 当然也要考虑没有参数的情况

有了请求函数, 也有了我们封装的处理401的函数, 现在我们使用一下:

handlePromise401Reject(foo, { delay: 1000 })
.then(res => {
  console.log('成功', res);
})
.catch(error => {
  console.log('失败', error);
})
复制代码

我们可以看到, 接口返回401, 此时promise的状态为reject, 最终结果进到了catch回调中, 符合预期

结合起来

接着, 我们需要把上面提到的部分都结合起来, 也就是: 获取数据, 登录未过期则返回数据, 登录过期就先登录然后再去获取数据:

let isFooSuccess = false;
let isFoo2Success = true;

//请求foo数据的函数
const foo = ({ delay }) => (
  new Promise(
    (resolve, reject) => {
      console.log('foo被调用, isFooSuccess', isFooSuccess);

      const res = {
        code: isFooSuccess ? 0 : 401,
        msg: isFooSuccess ? 'foo成功' : 'foo失败, 需要登录',
        data: isFooSuccess
        ? [
          {
            a: 1
          }
        ]
        : []
      }

      setTimeout(
        () => {
          isFooSuccess ? resolve(res) : reject(res);
        },
        delay
      )
    }
  )
)

//请求foo2数据的函数
const foo2 = ({ delay }) => (
  new Promise(
    (resolve, reject) => {
      console.log('foo2被调用, isFoo2Success', isFoo2Success);

      const res = {
        code: isFoo2Success ? 0 : 401,
        msg: isFoo2Success ? 'foo2成功' : 'foo2失败, 需要登录',
        data: isFooSuccess
        ? [
          {
            a: 1
          }
        ]
        : []
      }

      setTimeout(
        () => {
          isFoo2Success ? resolve(res) : reject(res);
        },
        delay
      )
    }
  )
)

//登录
const login = (isLoginSuccess, delay) => (
  new Promise(
    (resolve, reject) => {
      console.log('login被调用, isLoginSuccess', isLoginSuccess);

      const res = {
        msg: isLoginSuccess ? 'success' : 'fail',
        code: isLoginSuccess ? 0 : 123,
        isLoginSuccess
      };

      isFooSuccess = isLoginSuccess;
      isFoo2Success = isLoginSuccess;

      setTimeout(
        () => {
          isLoginSuccess ? resolve(res) : reject(res);
        },
        delay
      );
    }
  )
)

//遇到请求返回的结果中code为401, 则返回一个reject的promise, 否则resolve
const handlePromise401Reject = (promiseReq, promiseReqParams) => {
  const finalPromise = promiseReqParams ? promiseReq(promiseReqParams) : promiseReq();

  return new Promise(
    (resolve, reject) => {
      finalPromise
        .then(
          res => {
            const { code } = res;

            code === 401 ? reject(res) : resolve(res);
          }
        )
        .catch(error => {
          reject(error);
        });
    }
  )
}

//请求数据的函数
const getData = () => {
  handlePromise401Reject(foo, { delay: 1000 })
    .then(res => {
      console.log('成功', res);
    })
    .catch(error => {
      console.log('失败', error);

      login(true, 1000)
      .then(res => {
        console.log('登录成功', res);

        getData();
      })
      .catch(error => {
        console.log('登录失败', error);
      });
    });
};

getData();
复制代码

请求数据, 首先请求foo数据, 返回401, 此时我们登录, 登录成功之后再次请求, 请求成功, 没问题

多个请求

但实际情况是我们经常会需要处理多个请求的情形, 此时怎么办呢?

比如此时同时请求foo数据和foo2数据, 遇到任意一个返回401, 那么就去登录, 登录成功再次做请求, 也就是: 多个promise, 只要其中一个reject了, 那么最终的结果就是reject, 这个场景是不是似曾相识? 对的, 这就是Promise.all的处理逻辑, 所以刚才提到的这个情形, 我们可以用Promise.all来处理

其余代码不变, 修改我们请求数据的函数getData如下:

//请求数据的函数
const getData = () => {
  Promise.all([
    handlePromise401Reject(foo, { delay: 1000 }),
    handlePromise401Reject(foo2, { delay: 1000 })
  ])
    .then(res => {
      console.log('成功', res);
    })
    .catch(error => {
      console.log('失败', error);

      login(true, 1000)
      .then(res => {
        console.log('登录成功', res);

        getData();
      })
      .catch(error => {
        console.log('登录失败', error);
      });
    });
};

getData();
复制代码

这样我们就解决了这个需求, 但仔细一看还是不够完美, 为什么呢? 比如我们会将handlePromise401Reject用作一个公共的utils, 然后使用的时候import, 但这里还需要登录, 们还要导入login, 毕竟401了需要做登录的操作, 也就是说我们在使用的时候需要导入handlePromise401Rejectlogin, 这里我们能不能封装一个函数, 直接将login放进去, 我们用这个函数去请求数据, 401了登录, 登录完毕之后将请求到的数据给我们resolve出来, 大概像这样:

const getData = () => {
  my401PromiseAll([
    handlePromise401Reject(foo, { delay: 1000 }),
    handlePromise401Reject(foo2, { delay: 1000 })
  ])
  .then(res => {
    console.log('数据获取成功:', res);
  })
  .catch(error => {
    console.log('数据获取失败:', error);
  })
}

getData();
复制代码

说干就干

更完美的方案

这里还是我们熟悉的Pormise.all, 只要有一个reject了那么它的结果就reject, 符合我的需求, 此时我去登录就好了, 然后就resolve了, 这里我们写一个这样的函数:

const my401PromiseAll = promiseReqList => {

  return new Promise(
    (resolve, reject) => {
      console.log('my401PromiseAll被调用');

      Promise.all(promiseReqList)
      .then(
        res => {
          console.log('my401PromiseAll resolve', res);

          resolve(res);
        }
      )
      .catch(error => {
        console.log('my401PromiseAll reject', error);

        login(true, 1000)
        .then(res => {
          console.log('登录成功', res);

          my401PromiseAll(promiseReqList);
        })
        .catch(error => {
          console.log('登录失败', error);
        });

        reject(error);
      })
    }
  )
}

const getData = () => {
  my401PromiseAll([
    handlePromise401Reject(foo, { delay: 1000 }),
    handlePromise401Reject(foo2, { delay: 1000 })
  ])
  .then(res => {
    console.log('数据获取成功:', res);
  })
  .catch(error => {
    console.log('数据获取失败:', error);
  })
}

getData();
复制代码

到这里可能有的朋友已经发现问题了: 死循环

是的, 是这样的, 输出真的是最好的学习函数, 我一直以为promise我会了, 直到自己开始做输出, 写技术文章的时候才发现, 是会了, 但没全会

当我们调用getData函数之后, 这段代码的执行是这样:

  1. foo函数被调用
  2. foo2函数被调用
  3. my401PromiseAll函数被调用
  4. foo reject, my401PromiseAllreject
  5. 进登录流程中, login被调用
  6. 同时外部getData进到了catch中, 因为my401PromiseAll reject
  7. 登录成功, 递归调用my401PromiseAll函数

此时就进入死循环了, 因为my401PromiseAll返回的promise的状态已经改变了, my401PromiseAll内部再次修改它的promise状态将不起作用, foo reject导致my401PromiseAllreject, 此时内部尝试修改promise的状态为resolve(通过登录)则是徒劳:

image.png

图中有个Uncaught (in promise) {code: 401, msg: 'foo失败, 需要登录', data: Array(0)}, 推测是因为外部promise的状态已经改变了, 而我在内部尝试再次修改外部promise状态导致的, 毕竟getData中是写了catch回调的, 不然这句也不会输出:

数据获取失败: {code: 401, msg: 'foo失败, 需要登录', data: Array(0)}
复制代码

这里没有考虑到外部promise状态改变之后, 内部无法再次变更外部promise的状态, 那怎么办呢?

我们希望foofoo2中任意一个或者两个都401就去登录, 登录成功再次执行foofoo2然后再把结果返回给我们, 这个能力Promise.all能提供, 但是不够完美, 因此解决方案就是我们手动实现一个符合我们自己需求的Promise.all即可

最终方案: 自定义Promise.all

这里我们自定义的Promise.all和原生的Promise.all有一些不同, 原生的它状态改变之后无法再次变更, 这里我们需要使得它的状态变更一次之后再次发生变更, 那么就是说在第一次变更之前, 我们需要保存一下请求操作, 流程大概如下:

  1. 保存请求
  2. 发送请求, 401, 此时内部状态第一次变更, 但不改变外部状态
  3. 登录, 登录成功之后再次发送请求, 此时需要发送保存的请求, 因为第一次的状态已经变更了, 我们无法再次变更, 只能产生新的状态
  4. 第二次发送请求, 请求成功, 此时再改变外部状态

这里的关键在于等待第二次发送的请求, 只有在第二次请求结束之后我们才能改变外部的状态, 而第一次请求发送完成的时候是不能, 相当于让一个任务挂起, 完成之后再继续执行后面的代码

而让任务挂起的操作, 我想到了await, 在await语句没有执行完成的时候后面的代码是不会执行的, 要用await自然少不了async, async函数返回一个promise, 我们只需要在这个async函数里面做请求, 当第二次请求结束之后将结果return出去即可

使用async/await随之而来就是遇到error的时候没法catch了, 此时我们需要用到try catch语句, 从而能让我们可以catch到抛出的error, 具体代码如下:

const my401PromiseAll = async promiseReqList => {
  const reservedPromiseReqList = promiseReqList;

  let res = null;

  console.log('my401PromiseAll被调用');

  try{
    res = await Promise.all(promiseReqList.map(promiseReq => promiseReq()));
    console.log('my401PromiseAll resolve', res);
  }catch(error) {
    console.log('my401PromiseAll reject', error);

    try{
      const loginRes = await login(true, 1000);
      console.log('登录成功', loginRes);
      res = await Promise.all(reservedPromiseReqList.map(promiseReq => promiseReq()));
    }catch(error2) {
      console.log('登录失败', error2);
      res = error2;
    }
    
  }

  return res;
}
复制代码

同时使用的时候需要这样:

const getData = () => {
  my401PromiseAll([
    () => handlePromise401Reject(foo, { delay: 1000 }),
    () => handlePromise401Reject(foo2, { delay: 1000 })
  ])
  .then(res => {
    console.log('数据获取成功:', res);
  })
  .catch(error => {
    console.log('数据获取失败:', error);
  })
}

getData();
复制代码

由于需要保存请求, 那么就不能像原生的Promise.all那样传递promise实例, 而是传递函数, 这样我们才可以保存之后再次进行调用

同时需要注意的是try catch语句中try代码块部分, 当遇到第一个抛出错误的代码之后, try中抛出错误的代码之后的代码就不会运行了, 会进到catch代码块中, 比如:

let res = null;

try{
  res = await foo({ delay: 1000 });
  res = await foo2({ delay: 1000 });
  console.log('try成功', res);
}catch(error) {
  console.log('try没成功', error);
}
复制代码

foo抛出错误了, 那么它后面的foo2就不会执行了, foo2后面的输出语句也不会执行, 会进到catch代码块中执行catch代码块中的代码

至此, 便完成了我们小程序静默登录的需求

结语

到这, 可能有的小伙伴会觉得第一种方式, 就是在外部使用login的方式也可以, 确实是这样的, 但我想直接把login也一起封装进去, 毕竟小程序静默登录, 遇到401了肯定要重新登录之后再做接下来的操作, 我觉得它们业务逻辑联系比较紧密, 因此就放到一起了, 再一个我也比较好奇, 好奇如果将login放进去了, 那么我要如何处理, 于是便有了后面的最终方案, 也可以理解为是一种不满足, 一种探索吧

以上就是这篇文章的全部内容了, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需

参考文献:

  1. Promise 对象 - ECMAScript 6入门

猜你喜欢

转载自juejin.im/post/7110880043965874206