JavaScript 异步编程

314 阅读9分钟

在前面的一篇文章事件循环(Event lop)中,我给大家讲了浏览器的事件循环机制,其中有这样的一段话:JavaScript 是单线程的,所谓单线程意味着一次只能运行一个任务。

那么为什么 JS 要以单线程的形式运行呢?今天我们来聊一聊。

JS 设计初衷

早期的 JS 设计出来作为浏览器的脚本语言执行,主要目的就是用来操作 DOM 实现页面交互。因此,设想一下,如果 JS 不是单线程的,那么当有多个线程同时操作一个 DOM 的时候,这一边想要修改,那一边想要删除,浏览器该以哪一个操作命令为准呢?而为了避免这样的线程同步问题,JS 的运行就采用了单线程的模式,这也就成为了 JS 的一个核心机制之一。

所谓的单线程指的是:JS 运行环境中负责执行代码的线程只有一个(一个函数调用栈)。

单线程的好处当然是避免了线程同步的问题,可是也就导致了当同时有多个任务发生时就需要产生排队,而一旦其中的某些任务耗时过长就会产生卡顿。

那么为了在执行任务的时候不阻塞程序整体的运行,就需要掌握如何进行异步编程。

同步模式和异步模式

在进行异步编程的案例之前,先来介绍一下什么是异步编程。在 JS 中有两种编程模式:同步模式和异步模式。

在单线程的模式下,大部分代码都会以同步模式执行。同步模式并不是说所有的代码同时执行,而是说排队依次执行。来看一个例子,如下图代码的运行过程:
未命名.gif

代码在函数调用栈中执行,首先进栈一个匿名函数 anonymous,这个函数的作用域是全局环境,在遇到函数调用时进栈,执行完毕得到结果后出栈,一直运行到最底部执行完毕。一段代码执行完毕后向下继续执行剩余的代码,这就是同步模式下的代码运行了。

而异步模式下的代码运行就不同了,异步模式下的代码在开启后不会等待执行完毕,而是直接执行下一段代码,而异步代码的后续操作都放到回调函数中去执行。

异步函数的回调是:我需要做一个什么样的事情,但是做这件事之前需要满足一定的条件,只要条件达到了就按照我预先给定的方案执行。如下图中 setTimeout 函数中的第一个 function 参数:
image.png

需要强调的是: JS 是单线程的,而浏览器是多线程的。也正是因为浏览器的多线程机制,才能够实现异步编程。

接下来讲讲怎么实现异步编程。

异步编程回调函数

在早期,如果我们提到异步编程,一般都会想到 Ajax 和 setTimeout。

调用 setTimeout 时,需要传递的第一个参数是一个可执行的函数,第二个参数是延迟时间。当达到延迟时间后,如果调用栈中的函数都执行完了,开始执行传入的第一个参数,这个传入的函数参数我们就叫它回调函数。

熟悉 Ajax 的都知道,需要给 onreadystatechange 传递一个可执行的函数参数,当请求完成后会调用这个函数,这个函数同样也是回调函数。

当使用过多的回调函数时,会引起什么问题呢?来看这个经典的问题:回调地狱。

回调地狱 Callback Hell

有一道面试题:你知道什么是回调地狱吗?(这里有篇文章可以看看)

对于这个问题,回答可以从以下几个方面阐述:

1. 回调函数是什么?
2. 为什么会有回调地狱?
3. 我如何解决?
4. 扩展解决办法。


这里给出我的解答:

1. 由于 JavaScript 的运行是单线程的,有的时候我们需要等待一个函数A的执行结果,在得到该结果之后执行我们的“下一步操作B”,我们不希望在等待这个执行过程的时候影响我们程序的其他运行步骤C,因此会将刚刚提到的“下一步操作B”丢给等待执行完毕的函数A,让函数A在执行完后调用,这个提供出去的“下一步操作B”就是回调函数。
   
2. 业务很复杂的时候,难免会产生多层嵌套,当嵌套包裹的层次过多后,代码的可读性和可维护性降低,并且一旦其中的某一步产生错误,会导致整个链条的错误,我们还不能很好的排除bug,就产生了回调地狱。

3. 常见的回调函数一般都是匿名函数直接写在函数的调用参数位置,因此最简单的解决办法是给函数起名字并且将函数提取出去,下面会给出例子

4. 扩展解决办法:异步编程--Promise,async/await,Generator


一个可能的callback hell 案例:


ajax1(url, () => {
  //doSomething
    ajax2(url, () => {
       //doSomething
        ajax3(url, () => {
          //doSomething()
        })
    })
})

可能比较简单的解决方案:

function func1(){
  // doSomething
  ajax2(url,func2);
}
function func2(){
  // doSomething
  ajax3(url, func3);
}
function func3(){
  // doSomething()
}

ajax1(url, func1);

异步编程案例


上面的解决方案其实并没有很好的解决 callback hell 问题,但是通过抽取出独立模块的方式已经大大的降低了阅读难度,提高了代码的可维护性,随着技术的发展有了更多更好的方式解决这个问题。

用一个红绿灯的案例来讲异步编程这件事。

题:一个路口的红绿灯会按照绿灯亮 10 秒,黄灯亮 2 秒,红灯亮 5 秒的顺序无限循环,请编写 JS 代码来控制这个红绿灯。


HTML 结构和 CSS 样式如下:

<style>
  div {
    background-color: gray;
    height: 100px;
    width: 100px;
    border-radius: 50%;
  }
  .green.light {
    background-color: green;
  }
  .yellow.light {
    background-color: yellow;
  }
  .red.light {
    background-color: red;
  }
</style>

<div id="traffic-light">
  <div class="green"></div>
  <div class="yellow"></div>
  <div class="red"></div>
</div>

在开始主要的逻辑代码之前,先将“亮灯”的模块代码抽离出来:

// dom容器
let container = document.querySelector("#traffic-light");
// “关灯”函数
function disableLight() {
  for (let i = 0, len = container.children.length; i < len; i++) {
    container.children[i].classList.remove("light");
  }
}
// 亮绿灯
function green() {
  disableLight();
  document.querySelector("#traffic-light .green").classList.add("light");
}
// 亮黄灯
function yellow() {
  disableLight();
  document.querySelector("#traffic-light .yellow").classList.add("light");
}
// 亮红灯
function red() {
  disableLight();
  document.querySelector("#traffic-light .red").classList.add("light");
}


看到题目里延时多少秒后,自然的想到了使用 setTimeout

 // 时间叠加
function go1() {
  green();
  setTimeout(function () {
    yellow();
  }, 10000);
  setTimeout(function () {
    red();
  }, 12000);
  setTimeout(function () {
    go1();
  }, 17000);
}

使用上面的方式,每一次 setTimeout 都是独立的,想要后面的后执行,需要计算延迟时间,如果不想计算,可以使用如下方式2:

function go2() {
  green();
  setTimeout(function () {
    yellow();
    setTimeout(function () {
      red();
      setTimeout(function () {
        go2();
      }, 5000);
    }, 2000);
  }, 10000);
}

通过方式2可以看到,函数的执行过程是嵌套的,每一个回调函数是独立的,只关心自己需要延迟多少时间,但是可能会形成 callback hell,代码不够美观,有没有更好的办法呢?

Promise

为什么要设计 Promise ?

在 Promise 出现之前,JavaScript 本身不具备异步的能力,而只能通过宿主环境提供的 API 比如浏览器的setTimeout 来实现异步,而为了语言的完备性,必须设计一个内部的异步支持,否则 JavaScript 将无法脱离浏览器工作。

对于上面提到的解决方式,分析代码我们可以发现,对于延时几秒我们可以抽取一个公共的模块出来,生成一个通用工具函数--延时函数:

// 延时函数,单位秒s
function sleep(second) {
  return new Promise((resolve) => {
    setTimeout(resolve, second * 1000);
  });
}

因此我们可以使用如下代码解决红绿灯问题:

function go3() {
  green();
  sleep(10)
    .then(() => {
      yellow();
      return sleep(2);
    })
    .then(() => {
      red();
      return sleep(5);
    })
    .then(() => {
      go3();
    });
}

调用 sleep 函数会生成一个 Promise 对象实例,延时函数在给定的延时时间后,执行 resolve 方法,then 中传递的参数即是我们需要执行的 resolve 函数,在 then 中“return sleep(2);”再返回一个Promise 对象实例,即可产生链式调用。

使用 Promise 后代码虽然看起来有变好一些,但仍然不够优雅。通过链式方式组织我们的代码,逻辑变的清晰起来但是仍然有一定的阅读难度。

仅仅使用 Promise 的好处是这部分代码不会阻塞住整体进程,其他在 Promise 后的代码仍可以运行,then 部分的执行是在其他的异步微任务中。

如果我们的代码中不需要考虑其他部分,那么可以使用 async/await 进一步优化代码(关于 Promise 的学习可以查看MDN,本处不做展开)。

async/await


await  操作符用于等待一个Promise 对象。它只能在异步函数 async function 中使用。

使用此操作符,我们上面的代码可以进一步优化如下:

async function go4() {
  green();
  await sleep(10);
  yellow();
  await sleep(2);
  red();
  await sleep(5);
  go4();
}

由于 await 会阻塞之后的代码运行,直到 “等待”的函数执行完毕,如上述 sleep(10),即10秒之后往下执行到 yellow(),因此我们可以修改一下代码:

async function go4() {
  while(true){
    green();
    await sleep(10);
    yellow();
    await sleep(2);
    red();
    await sleep(5);
  }
}

使用 await 后 写在 await 行的下一行的代码都可以认为是以前的 then 中的内容,比如4行和5行。

在这里使用 while(true) 产生一个无限循环,即使无限了也不会报错,递归可能会产生的调用栈溢出这里也不会产生,因为while(true) 只是一个循环,不会分配其他调用内存。

Generator


除了上述的两种实现方式外,还有一种方式,我们可以通过 Generator 生成器组织我们的异步代码。

Generator 本身其实跟异步无关,但是因为 Generator 可以中断函数执行,我们可以通过列表调用的方式组织代码实现异步编程:

function* go5() {
  while (true) {
    green();
    yield sleep(10);
    yellow();
    yield sleep(2);
    red();
    yield sleep(5);
  }
}
// 调用 generator 赋值给 iterator
function run(iterator) {
  let { value, done } = iterator.next();// 最后一个yield执行后,while循环
  if (done) {// 执行完结束
    return;
  }
  if (value instanceof Promise) {
    value.then(() => {
      run(iterator);
    });
  }
}

function co(generator) {
  return function () {
    run(generator()); // 调用generator
  };
}

let g = co(go5);
g();
  1. Generator 函数调用后会生成一个可迭代的对象,传递给 iterator
  2. iterator 调用 next(),拿到 yield,对yield 解构获取其 value 和 done
  3. 由于此处 value 是个 Promise,我们可以执行 then 然后再次运行当前函数
  4. 当所有的 yield 取完后,一轮执行结束,while(true)循环,重新开始


(关于 Generator 的学习可以查看MDN,本处不做展开)