目录

5.第五日用好异步

第五天

第一个故事:多个异步间的状态同步

第三天中,我们了解了JS中的三种异步方式:定时器、Promise和async/wait。

在不过多考虑代码的复用性和扩展性的时候,其实这三种实现方式差别不大。第一种方式因为有回调嵌套,结构上稍稍不那么美观,而第二、三种方式结构实际差不多,第三种方式之所以好,是因为它更接近于自然语言,以及人思考问题的模式。但本质上,这三种异步表达方式并没有太大的区别。不过,这是因为我们的问题比较简单。

如果我们考虑更复杂的问题,比如一个小游戏中需要几个异步操作,而这几个异步操作又同时需要维护了一个状态,(比如:游戏结束的状态)。这时候,我们需要一种机制来维护异步操作间的状态控制。一个最简单粗暴的做法就是维护一个全局变量,让每个异步操作监控这个变量。只要其中一个异步操作结束时,这个变量就被置为结束状态,然后其他异步操作在监控到这个状态的时候,也中止自身的异步进程。但是使用这样的局部变量不仅增加了代码的复杂度,也使得模块间的耦合更高。所以,我们应该尽量避免使用这样的全局变量。

下面,我们通过一个简单的打字游戏来看看有没有其他方式能够实现各异步过程之间的状态同步。

这个游戏是这样的:界面上会随机出现一段文本,用户如果在规定时间内打完这段文本,那么用户胜出,否则失败。

这个游戏的HTML和CSS结构如下:

<div id="main">
  <div id="panel"></div> <!-- 显示系统生成的文本 -->
  <div id="typed"></div> <!-- 显示用户实际打印的文本 -->
  <div id="starting"></div> <!-- 显示开场倒计动画 -->
  <div id="countdown">00:00</div>
</div>
html, body {
  width: 100%;
  height: 100%;
  padding: 6px;
  margin: 0;
  overflow: hidden;
}

#main {
  position: relative;
  display: inline-block;
}

#panel, #typed {
  border: solid 1px #000;
  line-height: 1.5;
  white-space: pre-wrap;
  margin: 0;
  padding: 18px 6px 6px 6px;
  color: #0006;
}

#panel {
  width: 600px;
  min-height: 400px;
}
#panel:empty {
  cursor: pointer;
}
#panel:empty::after {
  content: '鼠标点击后开始';
}

#typed {
  max-width: 600px;
  position: absolute;
  top: 0;
  border-color: transparent;
  color: #008;
  background-color: #eea6;
  background-clip: content-box;
  overflow: hidden;
}
#typed:empty {
  background-color: transparent;
}

#starting {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 3rem;
}

#countdown {
  position: absolute;
  top: 0;
  right: 10px;
  opacity: 0.3;
}

游戏界面分为4个部分,#panel显示要打的文章,#typed显示已经打出的部分,#starting是用来显示开始时倒计时的界面,#countdown是显示正在打字时的倒计时界面。游戏效果如下所示:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/193a7fafbb0f4d65bdc7b68a9eb60dc8~tplv-k3u1fbpfcp-zoom-1.image

这个游戏包含3个异步操作:

  1. 游戏开场动画
  2. 游戏时间倒计
  3. 用户输入操作

首先,我们先来实现游戏开动画的异步过程:

const text = `If you already have experience making drawings with computers, you know that in that process you draw a circle, then a rectangle...
...
...Each pipe is also known as a thread.`

// 将一段文本赋值给panel元素
const panel = document.getElementById('panel');
panel.addEventListener('click', main);

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function starting(el, count = 3) {
  el.innerText = count;
  while(count--) {
    await wait(1000);
    el.innerText = count;
  }
  el.innerText = '';
}

const startingEl = document.getElementById('starting');

// 游戏主体
async function main() {
  panel.innerText = text;
  await starting(startingEl);
}

这段代码中,我们定义一个开始前倒计时的异步函数starting。它有两个参数,一个参数是要更新状态的元素,另一个参数是倒计时执行的秒数,默认是3秒。我们通过执行定时器异步函数await wait(1000)来每隔1秒钟更新一次倒计时。然后,我们在游戏的主体main函数中执行,这样游戏的开场动画(3秒倒计时)就完成了。

接着,我们还要实现两个异步过程,一个是打字中的倒计时,另一个是打字时的键盘输入,我们先实现其中简单的,打字中的倒计时:

async function countDown(el, sec) {
  while(sec--) {
    const minute = Math.floor(sec / 60);
    const second = sec % 60;
    const time = `${minute > 10 ? minute: `0${minute}`}:${second > 10 ? second: `0${second}`}`;
    el.innerText = time;
    await wait(1000);
  }
}

打字中的倒计时和开始前的倒计时原理差不多,我们仍然是使用await wait(1000)这个异步方法来每秒更新一次对应的元素的内容,不过我们要将时间给格式化成分:秒的形式。

然后,我们实现打字中的键盘输入:

async function typings(el, text) {
  for(let i = 0; i < text.length; i++) {
    const char = text[i];
    el.innerText = '_';
    const key = await new Promise((resolve) => {
      document.addEventListener('keydown', function f({key}) {
        if(key ===  char) {
          document.removeEventListener('keydown', f);
          resolve(key);
        }
      });
    });
    el.innerText = el.innerText.slice(0, -1) + key + '_';
  }
}

如上代码所示,异步函数typings等待键盘输入。如果用户输入的键值与文本内容中当前应输入的字符相同,那么我们更新#typed元素(这里的el参数)中的innerText并等待下一个字符的输入。

最后,我们在main函数中组合这三个异步内容:

// ...省略其他代码...

async function main() {
  panel.innerText = text;
  await starting(startingEl); //开场动画
  const countDownPromise = countDown(countdownEl, 10); // 游戏倒计时
  const typingPromise = typings(typedEl, text); // 用户输入操作
  await Promise.race([countDownPromise, typingPromise]);
  console.log('结束');
}

main函数中,我们用Promise.race来执行倒计时和打字输入两个异步函数。Promise.race表示当其中一个异步函数resove时,它就会resolve,所以,不论是倒计时还是打字输入,哪一个先结束,游戏就会结束。

可能你认为到这里我们的打字游戏就完成了。但是,上述的代码却存在这样一个问题:虽然游戏结束了,但是游戏倒计时和用户输入的这两个异步操作的状态却没有同步。

当用户并未在游戏时间内完成打字任务时,虽然countDown的异步已经结束,但是typingPromise却还在不断地监听keydown事件。所以,即使游戏结束了,用户依然能继续打字。这显然不符合游戏的逻辑。

要解决这个问题,就像前面叙述的,我们可以采取简单粗暴的方式,通过外部状态告诉typingPromise不要继续监听输入事件。或者,我们发送游戏结束的消息给typingPromise。不过这两种方法无论哪一种,都增加额外的代码复杂度。

一个更加优雅的做法是,通过JavaScript的生成器函数来避免这种状态耦合。

function * typings(text) {
  for(let i = 0; i < text.length; i++) {
    const char = text[i];
    yield new Promise((resolve) => {
      document.addEventListener('keydown', function f({key}) {
        if(key ===  char) {
          document.removeEventListener('keydown', f);
          resolve(key);
        }
      });
    });
  }
}

上面的代码中,我们去掉了typings直接操作#typed元素的代码,并将async函数修改为生成器函数,它的yield操作每次返回一个Promise对象。

然后我们修改main函数:

async function main() {
  panel.innerText = text;
  await starting(startingEl);
  const countDownPromise = countDown(countdownEl, 10);
  typedEl.innerText = '_';
  for(const typing of typings(text)) {
    const key = await Promise.race([countDownPromise, typing]);
    if(key) {
      typedEl.innerText = `${typedEl.innerText.slice(0, -1)}${key}_`;
    } else {
      break;
    }
  }
  console.log('结束');
}

我们通过for...of来迭代生成器,每次循环拿到一次输入的Promise,将它和countDownPromisePromise.race操作。如果是输入的Promise先返回,那么我们可以拿到一个key值,用这个key值去更新typedEl的内容。否则,就是倒计时结束,那么我们用break来跳出循环结束游戏。这样就能保证当游戏结束时,不论是倒计时或者用户输入的操作都中止了。

完整的JS代码如下:

在线演示

const text = `If you already have experience making drawings with computers, you know that in that process you draw a circle, then a rectangle, a line, some triangles until you compose the image you want. That process is very similar to writing a letter or a book by hand - it is a set of instructions that do one task after another.

Shaders are also a set of instructions, but the instructions are executed all at once for every single pixel on the screen. That means the code you write has to behave differently depending on the position of the pixel on the screen. Like a type press, your program will work as a function that receives a position and returns a color, and when it's compiled it will run extraordinarily fast. 

Why are shaders fast? To answer this, I present the wonders of parallel processing.

Imagine the CPU of your computer as a big industrial pipe, and every task as something that passes through it - like a factory line. Some tasks are bigger than others, which means they require more time and energy to deal with. We say they require more processing power. Because of the architecture of computers the jobs are forced to run in a series; each job has to be finished one at a time. Modern computers usually have groups of four processors that work like these pipes, completing tasks one after another to keeping things running smoothly. Each pipe is also known as a thread.`;

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function starting(el, count = 3) {
  el.innerText = count;
  while(count--) {
    await wait(1000);
    el.innerText = count;
  }
  el.innerText = '';
}

function* typings(text) {
  for(let i = 0; i < text.length; i++) {
    const char = text[i];
    yield new Promise((resolve) => {
      document.addEventListener('keydown', function f({key}) {
        if(key === char) {
          document.removeEventListener('keydown', f);
          resolve(key);
        }
      });
    });
  }
}

async function countDown(el, sec) {
  while(sec--) {
    const minute = Math.floor(sec / 60);
    const second = sec % 60;
    const time = `${minute > 10 ? minute : `0${minute}`}:${second > 10 ? second : `0${second}`}`;
    el.innerText = time;
    await wait(1000);
  }
}

const typedEl = document.getElementById('typed');
const startingEl = document.getElementById('starting');
const countdownEl = document.getElementById('countdown');

const panel = document.getElementById('panel');
panel.addEventListener('click', start);

async function start() {
  panel.innerText = text;
  await starting(startingEl);
  const countDownPromise = countDown(countdownEl, 10);
  typedEl.innerText = '_';
  for(const typing of typings(text)) {
    const key = await Promise.race([countDownPromise, typing]);
    if(key) {
      typedEl.innerText = `${typedEl.innerText.slice(0, -1)}${key}_`;
    } else {
      break;
    }
  }
  console.log('结束');
}

我们可以看到,虽然使用异步Promiseasync/await可以以非常简单的方式处理这种需要持续与用户交互的场景,但是也需要注意多个异步间的状态同步,不要让我们代码存在潜在的逻辑错误或者隐患。

第二个故事:异步信号

这个故事,我们想通过一个简单的例子,让你了解Promise的另一个应用场景:异步信号。

这个例子很简单:有若干个用户参与,每个用户从1到10中选择一个数字作为幸运数字,而系统一秒钟随机产生一个1到10的数字,若这个数字和用户的幸运数字相同,则该用户胜出。

这个任务很简单,我们可以不使用Promise,直接将每一秒钟生成的数字与用户的数字逐一比较,选出胜出的用户。但是如果这样做,我们需要在定时器模块维护一个用户列表信息,这增加了代码的耦合。如果使用异步信号,则可以避免这样的耦合。

下面我们就来看看这个异步信号是如何实现的。

我们知道,一般的 Promise 对象,其状态是在作用域内部控制的:

const promise = new Promise((resolve, reject) => {
  // 在这里调用 resolve、 reject 来改变状态
});

这么设计能够避免 Promise 状态的泄漏导致滥用。

但是现在,我们需要利用它作用为异步信号,那么我们就必须在外部控制这个 promise 状态。

function defer() {
  const deferred = {};
  deferred.promise = new Promise((resolve, reject) => {
    deferred.resolve = resolve;
    deferred.reject = reject;
  });
  return deferred;
}

const deferred = defer();
deferred.resolve(); // 在外部控制 promise 状态

如上代码所示,defer()函数返回一个deferred对象。它包含 {promise, resolve, reject} 三个属性。然后,我们可以通过deferred.resolve()在外部控制deferred中的promise的状态了(即deferred.promise)。

有了这个deferred对象后,我们就可以用它来实现异步信号类 Singal:

function defer() {
  const deferred = {};
  deferred.promise = new Promise((resolve, reject) => {
    deferred.resolve = resolve;
    deferred.reject = reject;
  });
  return deferred;
}

const _state = Symbol('state');
const _checkers = Symbol('checker');

export class Signal {
  constructor(initState) {
    this[_state] = initState;
    this[_checkers] = new Map();
  }

  get state() {
    return this[_state];
  }

  set state(value) {
    // 每次状态变化时,检查未结束的 defer 对象
    [...this[_checkers]].forEach(([promise, {type, deferred, state}]) => {
      if(type === 'while' && value !== state // 当信号状态改变时,while 信号结束
        || type === 'until' && value === state // 当信号状态改变为对应的 state 时,until 信号结束
      ) {
        deferred.resolve(value);
        this[_checkers].delete(promise);
      }
    });
    this[_state] = value;
  }

  while(state) {
    const deferred = defer();
    if(state !== this[_state]) {
      // 如果当前状态不是 while 状态, while 的 deferred 结束
      deferred.resolve(this[_state]);
    } else {
      // 否则将它添加到 checkers 列表中等待后续检查
      this[_checkers].set(deferred.promise, {type: 'while', deferred, state});
    }
    return deferred.promise;
  }

  until(state) {
    const deferred = defer();
    if(state === this[_state]) {
      // 如果当前状态就是 until 状态, until 的 deferred 结束
      deferred.resolve(this[_state]);
    } else {
      // 否则将它添加到 checkers 列表中等待后续检查
      this[_checkers].set(deferred.promise, {type: 'until', deferred, state});
    }
    return deferred.promise;
  }

  delete(promise) {
    this[_checkers].delete(promise);
  }

  deleteAll() {
    this[_checkers].clear();
  }
}

这个类很长,我们一步步来分析它。

首先是它的构造函数:

const _state = Symbol('state');
const _checkers = Symbol('checker');

class Signal {
  constructor(initState) {
    this[_state] = initState;
    this[_checkers] = new Map();
  }
  ...
}

这个类有两个私有属性——_state_checkers。就我们这个例子来说,前者用来存储当前定时器发出的幸运数字。后者用来保存用户给出数字(这个数字信息被保持在deferred对象中)。

然后,Signal提供了两种信号“原语”:whileuntil:

while(state) {
  const deferred = defer();
  if(state !== this[_state]) {
    // 如果当前状态不是 while 状态, while 的 deferred 结束
    deferred.resolve(this[_state]);
  }
  else {
    // 否则将它添加到 checkers 列表中等待后续检查
    this[_checkers].set(deferred.promise, {type: 'while', deferred, state});
  }
  return deferred.promise;
}

until(state) {
  const deferred = defer();
  if(state === this[_state]) {
    // 如果当前状态就是 until 状态, until 的 deferred 结束
    deferred.resolve(this[_state]);
  }
  else {
      // 否则将它添加到 checkers 列表中等待后续检查
    this[_checkers].set(deferred.promise, {type: 'until', deferred, state});
  }
  return deferred.promise;
}

这段代码中,while(state)表示当信号的状态保持在state状态时,将deferred对象保存到_checkers集合中,并返回deferred.promise,否则resolve这个deferred对象。

until(state)表示当信号的状态保持直到state状态后,将deferred对象resolve,否则将deferred保存到_checkers集合中,并返回deferred.promise

这两个方法主要是提供给外部使用者使用。使用者可以选择采用while类型信号或者util类型信号来控制自身的状态。

然后,Signal通过statesetter接收其它模块发来的信号(比如:定时器模块):

set state(value) {
  // 每次状态变化时,检查未结束的 defer 对象
  [...this[_checkers]].forEach(([promise, {type, deferred, state}]) => {
    if(type === 'while' && value !== state // 当信号状态改变的时,while 信号结束
      || type === 'until' && value === state // 当信号状态改变为对应的 state 时,until 信号结束
    ) {
      deferred.resolve(value); 
      this[_checkers].delete(promise);
    }
  });
  this[_state] = value;
}

如上代码所示,当Signal收到信号后,先遍历_checkers集合。如果当前是while原语,且新状态不等于while的状态,那么执行resolvepromise状态改变,并将这个deferred对象从Map中移除。同样,如果是until原语,且新状态等于until状态,也执行resolvepromise状态改变,并将这个deferred对象从Map中移除。这样,我们就可以在Signal状态改变时,触发对应的promise状态改变了。

有了这个Signal类,我们就可以非常简单地实现前面的需求。

首先,创建一个Signal对象,这个对象每隔一秒钟接受一次定时器发出的数字:

const lucky = new Signal();

const timerID = setInterval(() => {
  const num = Math.ceil(Math.random() * 10);
  console.log(num);
  lucky.state = num;
}, 1000);

然后我们添加若干个比较数字的“人”:

async function addLuckyBoy(name, num) {
  await lucky.until(num);
  console.log(`${name} is lucky boy!`);
  clearInterval(timerID);
  lucky.deleteAll(); // 删除checkers中的所有promise对象
}

addLuckyBoy('张三', 3);
addLuckyBoy('李四', 5);
addLuckyBoy('王五', 7);

这里我们采用了until的信号模式:每个用户手持自己的幸运数字,直到其中一个用户的数字和系统给出的数字相符的时候,暂停定时器,并将这个用户的deferred对象resolve,同时将其他没有中签的用户的deferred对象从集合中删除。这样我们的幸运者就被选出了。

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/350ad51015124742b8820b8e36fcda83~tplv-k3u1fbpfcp-zoom-1.image

从这个例子我们可以看出,利用异步信号,我们的状态发生器模块(比如:定时器函数)只需要单纯地改变信号的状态,不再需要关心具体细节(比如有多少“人”参与这个游戏)。当状态变化时,可以由信号为媒介,通过deferred对象异步地通知对应的组件作出反应。

当然多组件之间的状态控制可以通过状态机或者第六日中的“中间人”模式实现。但是如果这种状态控制是纯异步的情况下,异步信号还是比较简单且直观的选择。