Promise 实战篇:处理一个页面中多个回车事件的解决方案

725 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情

1. 问题背景

有这么一个需求:在同一个页面中,可以点击 提交按钮,进入下一个 表单,点击 表单 中的 提交按钮,又会出现一个 表单,点击这个 表单 中的 提交按钮 又会出现一个 表单 。。。现在为了用户体验,要求 按下回车 也可以触发按钮

如果我们对全局进行多个 回车事件 的绑定,这时候就会带来一个问题,如果用户按下回车,会 触发多个回车事件,这时候我们要怎么区分呢?

2. 问题模型

为了简化这个过程我们将这个问题转化成如下模型

const fn1 = () => {
  console.log(1);
};

const fn2 = () => {
  console.log(2);
};

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    fn1();
  }
});

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    fn2();
  }
});

这里在 document 绑定了两个回车事件,当按下回车的时候,依次输入 1 2,但我们希望第一次按下回车输出 1,第二次回车输出 2

3. 第一种解决方案 - 异步等待

思路:我们可以设置一个变量,这个变量专门 记录回车事件的次数,这样就可以 区分 两个回车事件

let count = 0;

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13 && !(count % 2)) {
    fn1();
    count++;
  }
});

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13 && count % 2) {
    fn2();
    count++;
  }
});

当我们按下回车,依然会同属输出 1 2,这是为什么呢?

原因:当我们按下回车的时候,判断是否是偶数,如果是的话,执行 fn1,然后增加计数器,这时候立刻进行第二次判断,是奇数,执行 fn2

解决办法:在很多的实际的开发场景中,按下回车,往往会引起视图的变化,这个过程是异步的,所以我们将这种 异步 的思想用来解决这个问题了,当输入回车的时候,对计数器进行判断,然后执行对应的处理函数,异步地增加 计数器,也就是把计数器增加的过程放置在进行其余分支判断之后,这是一种简单 异步等待 的思想

let count = 0;

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13 && !(count % 2)) {
    fn1();
    setTimeout(() => {
      count++;
    });
  }
});

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13 && count % 2) {
    fn2();
    setTimeout(() => {
      count++;
    });
  }
});

我们只要在对 增加计数器 这个过程包裹一个异步任务就可以实现这个需求了,每当执行完处理函数,等到执行完所有其余的同步代码异步等待 计数器增加

4. 第二种解决方案 - 策略模式

如果我们想要还增加一个回车事件呢?

我们可以对判断计数器的任务多增加一个 判断分支

document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    if (count % 3 === 0) {
      fn1();
      setTimeout(() => {
        count++;
      });
    } else if (count % 3 === 1) {
      fn2();
      setTimeout(() => {
        count++;
      });
    } else if (count % 3 === 2) {
      fn3();
      setTimeout(() => {
        count++;
      });
    }
  }
});

在这里我们将多个回车事件合并在一个事件处理函数中,每当我们多增加一个回车事件,我们可以多增加一个分支,既然如此,不妨我们把这种方式 封装 起来

const enterHandlers: Function[] = [];

const addEnterListener = (fn: Function) => {
  enterHandlers.push(fn);
};

let count = 0;
document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    const len = enterHandlers.length;

    enterHandlers[count++ % len]();
  }
});

// 绑定回车事件
addEnterListener(() => {
  console.log(1);
});
addEnterListener(() => {
  console.log(2);
});

addEnterListener(() => {
  console.log(3);
});

在这里我们首先定义了一个专门 收集回车事件的处理函数数组,当触发回车事件的时候会自动分发到对应的处理函数,如果我们想要增加一个全新的回车事件,只需要往这个数组中追加 新的事件处理函数

这个时候不设置定时器函数,依然可以区分多个回车事件,这样的做法 摆脱异步

在很多实际开发的时候,每当我们执行 回车事件 触发按钮,进入下一个表单的时候,就应该立即删除之前的回车事件,上面一种做法很难做到 删除事件

const docKeydownHandler = (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    const len = enterHandlers.length;

    enterHandlers[count++ % len]();

    if (count === len) {
      document.removeEventListener('keydown', docKeydownClick);
    }
  }
};

document.addEventListener('keydown', docKeydownHandler);

即使这样,也执行 最后一次 的回车处理函数 一起 删除 整个回车事件

5. 第三种解决方案 - Promise 与 发布订阅

我们的目的是:每执行完一个事件处理函数立即删除当前处理函数,如果我们用一个变量记录当前的回车事件,执行完处理函数,更新这个变量

let cb: Function;

const docKeydownHandler = (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    cb();
  }
};

document.addEventListener('keydown', docKeydownHandler);

const addEnterLister = (fn: Function) => {
  cb = fn;
};

addEnterLister(() => {
  console.log(1);
  addEnterLister(() => {
    console.log(2);
    addEnterLister(() => {
      console.log(3);
      document.removeEventListener('keydown', docKeydownHandler);
    });
  });
});

这样整个代码就陷入 回调地狱,我们可以使用 Promise 优化这个过程

Promise.resolve()
  .then(() => {
    return new Promise(resolve => {
      addEnterLister(() => {
        console.log(1);
        resolve(0);
      });
    });
  })
  .then(() => {
    return new Promise(resolve => {
      addEnterLister(() => {
        console.log(2);
        resolve(0);
      });
    });
  })
  .then(() => {
    return new Promise(resolve => {
      addEnterLister(() => {
        console.log(3);
        resolve(0);
        document.removeEventListener('keydown', docKeydownHandler);
      });
    });
  });

我们依然对这种 Promise 方法处理进行封装

let cb: Function;

const docKeydownHandler = (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    cb();
  }
};

document.addEventListener('keydown', docKeydownHandler);

// 订阅函数
const addEnterLister = (fn: Function) => {
  thenCbs.push(() => {
    return new Promise(resolve => {
      cb = () => {
        fn();
        resolve(0);
      };
    });
  });
};

const thenCbs: (() => Promise<0>)[] = [];

// 订阅
addEnterLister(() => {
  console.log(1);
});

addEnterLister(() => {
  console.log(2);
});

addEnterLister(() => {
  console.log(3);
  document.removeEventListener('keydown', docKeydownHandler);
});

// 发布
let res: Promise<0> = Promise.resolve(0);
for (let i = 0; i < thenCbs.length; i++) {
  res = res.then(thenCbs[i]);
}

在这个过程中,我们定义了 thenCbs 专门用于收集 Promise 创建器(函数返回值为 Promise) 的函数,在 发布 的时候构造出 Promise 链条,当执行每一次 回车事件 的时候,回车事件处理函数执行完,立即修改 cb,然后传递 Promise

6. 第四种解决方案 - 立即删除事件

除了上述三种方法,还有一种笨方法:每当执行完 回车事件,立即删除回车事件

const fn1 = () => {
  console.log(1);
};

const fn2 = () => {
  console.log(2);
};

const cb1 = (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    fn1();
    document.removeEventListener('keydown', cb1);
    document.addEventListener('keydown', cb2);
  }
};
const cb2 = (e: KeyboardEvent) => {
  if (e.keyCode === 13) {
    fn2();
    document.removeEventListener('keydown', cb2);
    document.addEventListener('keydown', cb1);
  }
};

document.addEventListener('keydown', cb1);

优点

  • 直观易懂

缺点

  • 极其难扩展
  • 程序耦合性强
  • 不容易观察执行顺序

7. 总结

对于解决页面中 多个回车事件,我们提供了四种解决方案,对于这个需求,其实可以细分为 三种 更具体的情况:

  • 循环多个回车事件,输出 1 2 3 4 1 2 3 4 ... 的效果,也就是不 removeEventListener 回车事件
  • 只循环一次回车事件,输出 1 2 3 4 4 4 ... ,不 removeEventListener 回车事件
  • 只循环一次回车事件,输出 1 2 3 4 之后立即 removeEventListener 回车事件

如果是第一种情况,完全可以使用 策略模式
如果是第二种和第三种情况,使用 Promise 结合发布订阅 具有更好的扩展性

有这样一个情景,实现注册页面的时候,在这个页面有多个表单,填写完一个表单,点击提交按钮之后出现一个新的表单。。。

就像 JD 注册的过程 image.png

为了更好的适配性,我们将 Promise 封装成面向对象的形式

class EnterHandlers {
  thenCbs: (() => Promise<0>)[] = [];
  res = Promise.resolve(0);
  cb: Function = () => {};
  docKeydownHandler: (e: KeyboardEvent) => void;

  constructor() {
    this.docKeydownHandler = (e: KeyboardEvent) => {
      if (e.keyCode === 13) {
        this.cb();
      }
    };
    document.addEventListener('keydown', this.docKeydownHandler);
  }

  addEnterListener(fn: Function) { // 绑定回车事件
    this.thenCbs.push(() => {
      return new Promise(resolve => {
        this.cb = () => {
          fn();
          resolve(0);
        };
      });
    });

    return this;
  }

  addLastEnterListener(fn: Function) { // 执行完立即删除整个回车事件
    this.addEnterListener(() => {
      fn();
      document.removeEventListener('keydown', this.docKeydownHandler);
    });

    return this;
  }

  notify() { // 发布
    for (let i = 0; i < this.thenCbs.length; i++) {
      this.res = this.res.then(this.thenCbs[i]);
    }

    return this;
  }
}

使用:

new EnterHandlers()
  .addEnterListener(() => {
    console.log(1);
  })
  .addEnterListener(() => {
    console.log(2);
  })
  .addEnterListener(() => {
    console.log(3);
  })
  .addLastEnterListener(() => {
    console.log(4);
  })
  .notify();

结语

如果觉得本文不错的话,可以给作者点一个小小的赞,你的鼓励将是我前进的动力
如果本文对你有帮助或者你有不同的解决方案,欢迎在评论区留下你的足迹

Promise 相关文章推荐