由项目需求中引出的思考,Promise链式调用如何防抖

3,513 阅读5分钟

03/18 - 20:36 更新了一些的更具体的问题描述

想和大家探讨一下最近项目上遇到的一个防抖问题。

问题概述

大致需求是:有一个表格,点击其中任意一行会加载一些与之相关的详细内容(与表格在同一页面)。加载这个步骤是一个Promise链,会依次从2个不同的服务器端获取相关信息(存在依赖关系无法同时发送请求)。

在短时间内多次点击时,由于加载的时间每次不一样,可能会造成最终显示的不是最后一次点击的内容,且每一次点击都会有DOM操作从而造成浏览器性能的损失。

我们认为最合理的当然是加载过程中阻止用户继续点击,然而此方案被客户否决了:用户不应该被限制自由,假如用户点错了,还要等加载完才能改吗等等o(一︿一+)o

到这里,我们很自然的想到了利用防抖来进行延迟执行。但问题来了,加载的时间是个很大的区间(几百毫秒到几秒都有可能),传统的防抖在这个情况下并不适用。

举个例子,我们延迟500毫秒执行,第一次点击加载花了2秒,1秒后我们又点了一次加载,这次只花了500毫秒,结果就是最终先显示后一次结果,然后被前一次结果覆盖。如果我们设置一个过大的延迟值,那将会极大的降低用户体验。

由此引出今天讨论的话题,如何实现当Promise链未获取最终结果前,只有最后一次点击能够操作DOM改变页面。
P.S.由于实际工程比较复杂,http请求被封装在其他的模块中,所以在这里不考虑通过abort来终止请求以达到更好的优化。

以下为实际问题简化版:p1、p2、p3形成Promise链,可以看到,每次点击都会执行改变页面。(固定了Promise执行时间,且多加了一个Promise来更好的扩展假设有n个Promise的情况)

const p1 = (data) => {
  return new Promise(resolve => {
    setTimeout(() => resolve(data + 1), 200);
  });
};
const p2 = (data) => {
  return new Promise(resolve => {
    setTimeout(() => resolve(data + 2), 300);
  });
};
const p3 = (data) => {
  return new Promise(resolve => {
    setTimeout(() => resolve(data + 3), 500);
  });
};
const onClick = (data) => {
  p1(data)
    .then(data => p2(data))
    .then(data => p3(data))
    .then(result => {
      // 实际情况为操作返回值改变页面
      console.log(result);
    })
    .catch(err => {
      // 处理错误
    });
};
// 模拟点击
onClick(1);
setTimeout(() => onClick(2), 400);
setTimeout(() => onClick(3), 2000);
// 7
// 8
// 9

方案一

我们可以在onClick上设置一个counter,每次点击加1,只有当前值匹配counter时才改变页面。

// 省略p1,p2,p3申明
let counter = 0;
const onClick = (data) => {
  const current = ++counter;
  p1(data)
    .then(data => p2(data))
    .then(data => p3(data))
    .then(result => {
      if (current === counter) {
        // 实际情况为操作返回值改变页面
        console.log(result);
      }
    })
    .catch(err => {
      if (current === counter) {
        // 处理错误
      }
    });
};
onClick(1);
setTimeout(() => onClick(2), 400);
setTimeout(() => onClick(3), 2000);
// 第一个onClick不会刷新页面
// 8
// 9 第三个点击时第二个已经刷新,所以第三个继续刷新页面

这个方案解基本解决了问题,但是仔细想想,实际上在每次点击时,所有的Promise链还是完全都执行了。 比如在第二个onClick时,第一个的Promise链才执行到p2,那么能不能不执行p3来达到更好的优化呢?

方案二:在方案一的基础上进一步优化

通过在每个Promise上嵌套一个函数来实现进一步优化,如果不匹配counter,直接reject中断Promise链。

// 省略p1,p2,p3申明
let counter = 0;
const onClick = (data) => {
  const current = ++counter;
  p1(data)
    .then(wrapWithCancel(p2))
    .then(wrapWithCancel(p3))
    .then(result => {
      if (current === counter) {
        // 实际情况为操作返回值刷新页面
        console.log(result);
      }
    })
    .catch(err => {
      if (current === counter && err !== 'cancelled') {
        // 处理除了cancelled以外的错误
      }
    });

  function wrapWithCancel(fn) {
    return (data) => {
      if (current === counter) {
        return fn(data);
      } else {
        return Promise.reject('cancelled');
      }
    }
  }
};
onClick(1);
setTimeout(() => onClick(2), 100);
setTimeout(() => onClick(3), 400);
// 第一个onClick的p2和p3都不会执行
// 第二个onClick的p3不会执行
// 9

方案三:加上常规的防抖延迟执行

我们同样可以在这基础上加上常规的防抖延迟执行,进一步优化:

// 省略p1,p2,p3申明
let counter = 0;
const onClick = (data) => {
  const current = ++counter;
  p1(data)
    .then(wrapWithCancel(p2))
    .then(wrapWithCancel(p3))
    .then(result => {
      if (current === counter) {
        // 实际情况为操作返回值刷新页面
        console.log(result);
      }
    })
    .catch(err => {
      if (current === counter && err !== 'cancelled') {
        // 处理除了cancelled以外的错误
      }
    });

  function wrapWithCancel(fn) {
    return (data) => {
      if (current === counter) {
        return fn(data);
      } else {
        return Promise.reject('cancelled');
      }
    }
  }
};
const debounce = function (fn, wait) {
  var timer = null;
  return function () {
    const context = this;
    const args = arguments;
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(context, args);
    }, wait);
  }
};
const debounced = debounce(onClick, 200);
debounced(1);
setTimeout(() => debounced(2), 100);
setTimeout(() => debounced(3), 200);
setTimeout(() => debounced(4), 600);
// 前两个onClick的p1,p2和p3都不会执行
// 第三个onClick的p3不会执行
// 10 

第一次发文,不足之处还请轻喷,欢迎指出错误,如果你有更好的方法,也希望大家一起共同探讨共同进步~