JS异步编程

并发和并行区别

并发是宏观概念,现在分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。

并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。

回调函数(Callback)

什么是回调函数(Callback)

其实回调函数并不复杂,明白两个重点即可:

  1. 函数可以作为一个参数传递到另一个函数中。
  2. JS是异步编程语言。

为什么需要回调函数?

JavaScript 按从上到下的顺序运行代码。但是,在有些情况下,必须在某些情况发生之后,代码才能运行(或者说必须运行),这就不是按顺序运行了。这是异步编程。

回调函数确保:函数在某个任务完成之前不运行,在任务完成之后立即运行。它帮助我们编写异步 JavaScript 代码,避免问题和错误。

在 JavaScript 里创建回调函数的方法是将它作为参数传递给另一个函数,然后当某个任务完成之后,立即调用它。

回调函数有个致命的弱点,就是容易写出回调地狱。

ajax(url, () => {
  // 处理逻辑
  ajax(url1, () => {
    // 处理逻辑
    ajax(url2, () => {
        // 处理逻辑
    })
  })
})

回调地狱的根本问题就是:

  • 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
  • 嵌套函数一多,就很难处理错误
  • 不能使用 try catch 捕获错误
  • 不能直接 return

Generator

执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征。

  1. function 关键字与函数名之间有一个 *
  2. 函数体内部使用 yield 表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。

yield

由于 Generator 函数返回的遍历器对象,只有调用 next 方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield 表达式就是暂停标志。

遍历器对象的 next 方法的运行逻辑如下:

  1. 遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值。
  2. 下一次调用 next 方法时,再继续往下执行,直到遇到下一个yield 表达式。
  3. 如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值。
  4. 如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined

next

next 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。

function* foo(x) {
  const y = 2 * (yield (x + 1));
  const z = yield (y / 3);
  return (x + y + z);
}

const a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

代码详解:

  • 第二次运行 next 方法的时候不带参数,导致 y 的值等于2 * undefined(即 NaN ),除以 3 以后还是 NaN ,因此返回对象的 value 值也等于 NaN
  • 第三次运行 next 方法的时候不带参数,所以 z 等于undefined,返回对象的 value 值等于 5 + NaN + undefined,即 NaN
function* foo(x) {
  const y = 2 * (yield (x + 1));
  const z = yield (y / 3);
  return (x + y + z);
}

const b = foo(5);
b.next()   //  { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

代码详解:

  • 当执行第一次 next 时,函数暂停在 yield (x + 1) 处,所以返回 5 + 1 = 6
  • 当执行第二次 next 时,将上一次 yield 表达式的值设为 12 ,此时 const y = 2 * 12,所以第二个 yield 等于 2 * 12 / 3 = 8
  • 当执行第三次 next 时,将上一次 yield 表达式的值设为 13 ,所以 z = 13, x = 5, y = 24,相加等于 42

TIP

由于 next 方法的参数表示上一个 yield 表达式的返回值,所以在第一次使用 next 方法时,传递参数是无效的。V8 引擎直接忽略第一次使用 next 方法时的参数,只有从第二次使用 next 方法开始,参数才是有效的。从语义上讲,第一个 next 方法用来启动遍历器对象,所以不用带有参数。

Generator 解决回调地狱如下:

function *fetch() {
  yield ajax(url, () => {console.log('这里是首次回调函数');});
  yield ajax(url, () => {console.log('这里是第二次回调函数');});
  yield ajax(url, () => {console.log('这里是第三次回调函数');});
}
const it = fetch();
const result1 = it.next();
const result2 = it.next();
const result3 = it.next();

Promise

Promise 是异步编程的一种解决方案。

new Promise((resolve, reject) => {})

Promise对象代表一个异步操作,有三种状态:

  1. pending(进行中)
  2. fulfilled(已成功)
  3. rejected(已失败)

特点:

  • 对象的状态不受外界影响
  • 一旦状态改变就不会再变,任何时候都可得到这个结果

方法:

  • then():分别指定 resolved 状态和 rejected 状态的回调函数

    • 第一参数:状态变为 resolved 时调用
    • 第二参数:状态变为 rejected 时调用(可选)
  • catch():指定发生错误时的回调函数

  • finally():用于指定不管 Promise 对象最后状态如何,都会执行的操作。

  • Promise.all():将多个实例包装成一个新实例,返回全部实例状态变更后的结果数组(齐变更再返回)

    • 入参:具有 Iterator 接口的数据结构

    • 成功:只有全部实例状态变成 fulfilled ,最终状态才会变成fulfilled

    • 失败:其中一个实例状态变成 rejected ,最终状态就会变成rejected

      var p1 = Promise.resolve(1);
      var p2 = new Promise((resolve) => {
        setTimeout(() => {
          resolve(2);
        }, 100);
      })
      var p3 = 3;
      Promise.all([p1,p2,p3]).then((res) => {
        console.log(res); // 输出[1,2,3]
      })
      
  • Promise.race():将多个实例包装成一个新实例,返回全部实例状态优先变更后的结果(先变更先返回)

    • 入参:具有 Iterator 接口的数据结构
    • 成功失败:哪个实例率先改变状态就返回哪个实例的状态
  • Promise.resolve():将对象转为Promise对象(等价于 new Promise(resolve => resolve()))

  • Promise.reject():将对象转为状态为 rejected 的Promise对象(等价于 new Promise((resolve, reject) => reject()))

Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))

当我们在构造 Promise 的时候,构造函数内部的代码是立即执行的

new Promise((resolve, reject) => {
  console.log('new Promise')
  resolve('success')
  console.log('end')
})
console.log('finish')

// 打印顺序
// new Promise => end => finish

Promise 实现了链式调用,也就是说每次调用 then 之后返回的都是一个 Promise,并且是一个全新的 Promise,原因也是因为状态不可变。如果你在 then 中 使用了 return,那么 return 的值会被 Promise.resolve() 包装

Promise.resolve(1)
  .then(res => {
    console.log(res) // => 1
    return 2 // 包装成 Promise.resolve(2)
  })
  .then(res => {
    console.log(res) // => 2
  })

Promise 解决了回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:

ajax(url)
  .then(res => {
      console.log(res)
      return ajax(url1)
  }).then(res => {
      console.log(res)
      return ajax(url2)
  }).then(res => console.log(res))

总结

  • 只有异步操作的结果可决定当前状态是哪一种,其他操作都无法改变这个状态???
  • 状态改变只有两种可能:从pending变为resolved、从pending变为rejected
  • 一旦新建Promise对象就会立即执行,无法中途取消
  • 建议使用catch()捕获错误,不要使用then()第二个参数捕获
  • then()返回新实例,其后可再调用另一个then()
  • then()运行中抛出错误会被catch()捕获
  • resolve()reject()的执行总是晚于本轮循环的同步任务
  • 实例状态的错误具有冒泡性质,会一直向后传递直到被捕获为止,错误总是会被下一个catch()捕获
  • Promise 中的错误是不会影响外层的运行,window.onerror 也是无法检测到的

async及await

一个函数如果加上 async ,那么该函数就会返回一个 Promise

async function test() {
  return '1'
}
console.log(test())

打印结果如下: alt

async就是将函数的返回值使用 Promise.resolve() 包裹了一下,和 then 中处理返回值一样,并且 await 只能配套 async使用

async function test() {
  const value = await 3 // await 后面跟的不是 Promise 的话,就会包装成 Promise.resolve(返回值)
}

asyncawait 可以说是异步终极解决方案了,相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码,毕竟写一大堆 then 也很恶心,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。

async function test() {
  // 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
  // 如果有依赖性的话,其实就是解决回调地狱的例子了
  await fetch(url)
  await fetch(url1)
  await fetch(url2)
}

常用定时器函数

常见的定时器函数有 setTimeout、setInterval、requestAnimationFrame。

setTimeout

setTimeout延时执行某一段代码,但setTimeout由于EventLoop的存在,并不百分百是准时的,一个setTimeout可能会表示如下的形式:

// 延时1s之后,打印hello,world
setTimeout(() => {
  console.log('hello,world');
}, 1000)

setInterval

setInterval在指定的时间内,重复执行一段代码,与setTimeout类似,它也不是准时的,并且有时候及其不推荐使用setInterval定时器,因为它与某些耗时的代码配合使用的话,会存在执行积累的问题,它会等耗时操作结束后,一起一个或者多个执行定时器,存在性能问题。一个setInterval可能会表示如下的形式:

setInterval(() => {
  console.log('hello,world');
}, 1000)

requestAnimationFrame

翻译过来就是请求动画帧,它是html5专门用来设计请求动画的API,它与setTimeout相比有如下优势:

  • 根据不同屏幕的刷新频率,自动调整执行回调函数的时机。
  • 当窗口处于未激活状态时,requestAnimationFrame会停止执行,而setTimeout不会
  • 自带函数节流功能
let progress = 0;
let timer = null;
function render() {
  progress += 1;
  if (progress <= 100) {
    console.log(progress);
    timer = window.requestAnimationFrame(render);
  } else {
    cancelAnimationFrame(timer);
  }
}

//第一帧渲染
window.requestAnimationFrame(render);