likes
comments
collection

扩展 Promise 实现可取消、进度通知

作者站长头像
站长
· 阅读数 14

最近对 Promise 的用法有了全新的认识。想和你分享一下让我豁然开朗的点,大概会更新三篇 Promise 的文章,这是第一篇。

Promise 的返回值是什么

首先需要知道的是 Promise 有三个状态:pending、fulfilled、rejected。fulfilled 和 rejected 也被叫做 settled 。Promise 进入了 settled 就不能再改变了。

接下来,让你看一个示例,请问你觉得会输出什么呢?

const p1 = Promise.resolve(1);
const p2 = Promise.resolve(p1);

console.log(p1 === p2)

答案是:true。

为什么有这样的表现呢?是因为如果 Promise.resolve 的入参是一个 Promise,它会直接返回那个入参;如果不是 Promise,返回的结果就是将返回值包裹了一层 Promise.resolve ,返回的结果是一个 Promise 对象。

另外,值得注意的是,就算是一个拒绝状态的 Promise,根据我们的规则,也是原样返回,还是拒绝状态:

Promise.resolve(Promise.reject(1))
// Promise {<rejected>: 1}

基于此,再看一个例子:

// 例子 A
Promise.resolve().then(function onResolved() {
  return 'hello world';
}).then(res => {
  console.log(res);
})

这一段比较好理解,会输出 'hello world'。

如果这里 onResolved 的返回值是一个 Promise 呢,会有什么样的表现?

// 例子 B
function sleepOneSecondAndLog() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello world')
    }, 1000)
  })
}

Promise.resolve().then(function onResolved() {
  return sleepOneSecondAndLog();
}).then(res => {
  console.log(res);
})

结果是过了一秒,我们 console.log(res) 那一行打印出了 'hello world' 。

解释这个现象的话,就是延续我们举的第一个例子的思路,在上面的例子中,下一个 Promise.then 是根据前一个 onResolved 函数的返回值来确定的,如果返回值不是 Promise 对象,那就包裹一层 Promise.resolve ,作为下一个 Promise 对象。也就是说,我们例子 A 相当于

Promise.resolve('hello world').then...

这也解释了为什么有时候我们上一级走 onRejected 的处理方法,下一个 then 却是进入了 onResolved,因为与它进入的处理函数无关,只和它的返回值有关,最后的结果也是被包裹了一层 Promise.resolve:

Promise.reject(new Error).then(undefined, () => {}).then(() => {
  console.log('resolved')
}, () => {
  console.log('rejected')
})

// resolved

如果 onResolved 返回值是 Promise 对象呢?那它就原样返回,then 方法的触发时机也就是等返回的这个 Promise 对象处于 settled 状态后再触发。所以我们的例子 B 过了一秒,才显示了打印结果。

总之,搞懂这个现象的根本就是知道返回值被悄悄的处理了一下。反正之前一直没有注意到这个细节。

扩展 Promise 可取消

之前有一个关于 Promise 可取消的提案,不过那个提案已经被驳回了。这里也讨论了为什么这个提案被驳回的一些原因。总结一下的话,被驳回是因为在 TC39 委员会,有一群人认为增加了可取消会让 Promise 变得太复杂了。然后反对该提案的主力是谷歌的,谷歌在 TC39 里面占了很大一部分,想推进实施也没办法,会遭到大多数人的反对。 在整个辩论过程中,作者身心俱疲,到最后,便不想在争论了,也不想再参与此提案了。 这件事情不禁让我同情起他。

于是乎,我们只能自己扩展原生的 Promise 来实现。首先来看一个最基础的扩展:

function makeCancelable<T>(promise: Promise<T>) {
  let hasCanceled = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then((val) => {
      hasCanceled ? reject({hasCanceled: true}) : resolve(val)
    })

    promise.catch((err) => {
      hasCanceled ? reject({hasCanceled: true}) : resolve(err)
    })
  }) 

  function cancel() {
    hasCanceled = true;
  }

  return {
    promise: wrappedPromise,
    cancel
  }
}

const {promise, cancel} = makeCancelable(fetch("https://jsonplaceholder.typicode.com/posts/1"));
cancel()

上面的取消只是表面上的取消,在最后的调用演示中,接口还是被调用了,只不过,在使用方拿不到结果。由于 Promise 的结果返回了,所以也不会阻塞回调里后续的操作。但如果我们想在接口层面取消呢?

如果我们就是想中断一个接口的调用呢?我们以上面的 fetch API 为例。浏览器的 fetch API 本身并不支持取消,需要借助 AbortController

基本使用如下:

const controller = new AbortController();
const signal = controller.signal;

const downloadBtn = document.querySelector('.download');
const abortBtn = document.querySelector('.abort');

downloadBtn.addEventListener('click', fetchVideo);

abortBtn.addEventListener('click', function() {
  controller.abort();
  console.log('Download aborted');
});

function fetchVideo() {
  ...
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
   reports.textContent = 'Download error: ' + e.message;
  })
}

上面的关键点,也就是在 fetch 调用的时候多传一个 signal,然后后续如果想取消,就调用 controller.abort ,fetch 可以检测到 signal 的变化,从而中断请求。

根据这个特性,我们扩展一个可取消的 fetch API。

function makeCancelableFetch(url: string) {
  const controller = new AbortController();
  const signal = controller.signal;

  const promise = fetch(url, {signal})
  const cancel = () => {
    controller.abort();
  }

  return {
    promise,
    cancel
  }
}

const {promise, cancel} = makeCancelableFetch('https://jsonplaceholder.typicode.com/posts/1')
cancel()
promise.then(res => {
  console.log(res)
}, (err) => {console.log(err)}) // DOMException: The user aborted a request.

扩展 Promise 实现可取消、进度通知

支持进度通知

假设有一个列表页,在刚进入的时候需要同步,同步耗时会比较久,此时就需要后端提供一个查询进度的接口,我们前端这边不断的轮询查询接口,直到进度到达 100 %,便进行下一步的列表展示逻辑。

最基础的,我们可以这么写:

let timerId: undefined | number = undefined;
let progress = 0;

async function getList() {
  console.log("开始查询列表");
}
function notify(progress: number) {
  console.log("当前进度%d%", progress);
}
async function readProgress() {
  progress += 20;
  return progress;
}

async function run() {
  let currentProgress = await readProgress();
  console.log(currentProgress);
  if (currentProgress >= 100) {
    clearTimeout(timerId);
    getList();
    return;
  }

  notify(currentProgress);

  timerId = setTimeout(() => {
    run();
  }, 100);
}

run();

如果想通过扩展 Promise 对象实现,结果如下:

let progress = 0;
async function getList() { console.log('开始查询列表') }
function notify(progress: number) { console.log('当前进度%d\%', progress); }
async function readProgress() {
  progress += 20;
  return progress;
}

type AnyFunction = (...arg: any) => void;
type Executor<T> = (
  resolve: (value: T | PromiseLike<T>) => void,
  reject: (reason?: any) => void,
  notifyFn: (status: any) => void,
) => void

class TrackablePromise<T> extends Promise<T> {
  subscribes: AnyFunction[]

  constructor(executor: Executor<T>) {
    const subscribes: AnyFunction[] = []
    
    super((resolve, reject) => {
      return executor(resolve, reject, (status: any) => {
        subscribes.forEach((subscribe) => subscribe(status))
      })
    })

    this.subscribes = subscribes; 
  }

  addSubscribe(subscribe: AnyFunction) {
    this.subscribes.push(subscribe)
    return this
  }
}

const p = new TrackablePromise((resolve, reject, notify) => {
  async function run() {
    let currentProgress = await readProgress();
    if (currentProgress >= 100) {
      resolve({success: true});
    } else {
      notify(currentProgress);
      setTimeout(() => {
        run();
      }, 1000)
    }
  }

  run();
} )

// 如果想添加多个订阅者,可以继续链式调用
p.addSubscribe((x: any) => {
  console.log(x);
}).addSubscribe(...)

p.then(() => { getList()})

使用这种方式,我们可以让获取列表的接口和读取进度的接口分离开来了,也提高了这个读取进度接口在其他地方复用的可能性。不过更重要的是让我们知道了在基础上扩展 Promise 对象。

以上就是本文的全部内容了,谢谢阅读 ~