异步编程

JavaScript 默认是同步的,并且是单线程的。这意味着代码不能创建新线程并并行运行。

一行一行的代码依次执行,例如:

1
2
3
4
5
6

const a = 1
const b = 2
const c = a * b
console.log(c)
doSomething()

一、回调

例如,无法知道用户何时会单击按钮。因此,为 click 事件定义了一个事件处理程序。此事件处理程序接受一个函数,该函数将在事件触发时调用:

1
2
3
4

document.getElementById('button').addEventListener('click', () => {
  //item clicked
})

这就是所谓的回调。

1. 处理回调中的错误

任何回调函数中的第一个参数是错误对象:错误优先回调。这是 Node.js 采用的策略。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

fs.readFile('/file.json', (err, data) => {
  if (err) {
    //handle error
    console.log(err)
    return
  }

  //no errors, process data
  console.log(data)
})

2. 回调的问题

回调非常适合简单的情况!

然而,每个回调都会增加一层嵌套,当你有很多回调时,代码很快就会变得复杂:(回调地狱)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

window.addEventListener('load', () => {
  document.getElementById('button').addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        //your code here
      })
    }, 2000)
  })
})

二、Promises (ES6)

Promise 是处理异步代码的一种方式,不会陷入回调地狱。

1. 创建 Promise

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

let done = true

const isItDoneYet = new Promise((resolve, reject) => {
  if (done) {
    const workDone = '这是我构建的东西'
    resolve(workDone)
  } else {
    const why = '还在做别的事情'
    reject(why)
  }
})

2. 消费 Promise

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

const isItDoneYet = new Promise(/* ... as above ... */)
//...

const checkIfItsDone = () => {
  isItDoneYet
    .then(ok => {
      console.log(ok)
    })
    .catch(err => {
      console.error(err)
    })
}
  • 运行checkIfItsDone()指定在isItDoneYet承诺解决(在then调用中)拒绝(在catch调用中)时要执行的函数。

3. 链式 Promise

一个承诺可以返回到另一个承诺,创建一个承诺链。

链式 promise 的一个很好的例子是 Fetch API,可以使用它来获取资源并在获取资源时将一系列 promise 排队执行。

Fetch API 是一种基于 promise 的机制,调用fetch()相当于使用new Promise()。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

const status = response => {
  if (response.status >= 200 && response.status < 300) {
    return Promise.resolve(response)
  }
  return Promise.reject(new Error(response.statusText))
}

const json = response => response.json()

fetch('/todos.json')
  .then(status)    // 请注意,status 函数实际上在这里被调用,并且它返回一个承诺
  .then(json)      // 同样,这里唯一的区别是这里的 json 函数返回一个用 data 解析的 promise
  .then(data => {  // ...这就是为什么 data 在这里显示为匿名函数的第一个参数
    console.log('Request succeeded with JSON response', data)
  })
  .catch(error => {
    console.log('Request failed', error)
  })

4. 处理错误

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

new Promise((resolve, reject) => {
  throw new Error('Error')
}).catch(err => {
  console.error(err)
})

// or

new Promise((resolve, reject) => {
  reject('Error')
}).catch(err => {
  console.error(err)
})

5. 级联错误

如果在 catch() 内部引发错误,则可以附加第二个 catch() 来处理它,依此类推。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch(err => {
    throw new Error('Error')
  })
  .catch(err => {
    console.error(err)
  })

6. 协调 Promise

Promise.all()

如果需要同步不同的 promise,Promise.all() 帮助定义一个 promise 列表,并在它们全部解决后执行一些事情。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

const f1 = fetch('/something.json')
const f2 = fetch('/something2.json')

Promise.all([f1, f2])
  .then(res => {
    console.log('Array of results', res)
  })
  .catch(err => {
    console.error(err)
  })

// or

Promise.all([f1, f2]).then(([res1, res2]) => {
  console.log('Results', res1, res2)
})

Promise.race()

Promise.race() 在您传递给它的第一个承诺解决(解决或拒绝)时运行,并且它只运行一次附加的回调,第一个承诺的结果得到解决。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

const first = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'first')
})
const second = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'second')
})

Promise.race([first, second]).then(result => {
  console.log(result) // second
})

Promise.any()

Promise.any() 在您传递给它的任何承诺实现或所有承诺被拒绝时解决。
它返回一个单一的承诺,该承诺以第一个已履行的承诺中的值进行解析。
如果所有 Promise 都被拒绝,则返回的 Promise 将被拒绝并带有 AggregateError

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

const first = new Promise((resolve, reject) => {
  setTimeout(reject, 500, 'first')
})
const second = new Promise((resolve, reject) => {
  setTimeout(reject, 100, 'second')
})

Promise.any([first, second]).catch(error => {
  console.log(error) // AggregateError
})

7. 常见错误

未捕获的类型错误

Uncaught TypeError: undefined is not a promise

如果在控制台中收到 Uncaught TypeError: undefined is not a promise 错误,请确保使用 new Promise() 而不是 Promise()。

未处理的承诺拒绝警告

UnhandledPromiseRejectionWarning

这意味着调用的承诺被拒绝,但没有用于处理错误的捕获。 添加一个 catch 捕获 然后正确处理它。


三、async/await (ES2017)

Promise 是为了解决著名的回调地狱问题而引入的,但它们自身也引入了复杂性和语法复杂性。

async/await 是建立在 promises 之上的,用于解决这种复杂性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

// Promise
const aFunction = () => {
  return Promise.resolve('test')
}

aFunction().then(alert) // This will alert 'test'

// async
const aFunction = async () => {
  return 'test'
}

aFunction().then(alert) // This will alert 'test'

更复杂的例子(多个异步函数串联):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

const promiseToDoSomething = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 10000)
  })
}

const watchOverSomeoneDoingSomething = async () => {
  const something = await promiseToDoSomething()
  return something + '\nand I watched'
}

const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
  const something = await watchOverSomeoneDoingSomething()
  return something + '\nand I watched as well'
}

watchOverSomeoneWatchingSomeoneDoingSomething().then(res => {
  console.log(res)
})

// I did something
// and I watched
// and I watched as well

四、事件循环

1. 重要性

事件循环是了解 Node.js 的最重要方面之一。

它解释了 Node.js 如何可以是异步的并且具有非阻塞 I/O,所以它基本上解释了 Node.js 作为 “杀手级应用程序” 为何如此成功。

Node.js JavaScript 代码在单线程上运行。一次只发生一件事。

这是一个实际上非常有用的限制,因为它大大简化了编程方式,而无需担心并发问题。

只需要注意如何编写代码并避免任何可能阻塞线程的事情,例如同步网络调用或无限循环。

2. 案例

举例来说,在大多数浏览器中,每个浏览器选项卡都有一个事件循环,使每个进程隔离,避免网页无限循环或繁重的处理来阻塞整个浏览器。

该环境管理多个并发事件循环,例如处理 API 调用。 Web Workers 也运行在他们自己的事件循环中。

主要需要担心代码将在单个事件循环上运行,并在编写代码时考虑到这一点,以避免阻塞它。

3. 阻塞事件循环

任何需要太长时间将控制权返回给事件循环的 JavaScript 代码都会阻塞页面中任何 JavaScript 代码的执行,甚至阻塞 UI 线程,并且用户无法四处点击、滚动页面等。

JavaScript 中几乎所有的 I/O 原语都是非阻塞的。

网络请求、文件系统操作等 阻塞是一个例外,这就是为什么 JavaScript 如此多地基于回调,最近又有了 promise 和 async/await。

4. 调用栈

调用堆栈是一个 LIFO(后进先出)堆栈。

事件循环不断检查调用堆栈以查看是否有任何函数需要运行。

这样做时,它会找到的任何函数调用添加到调用堆栈中,并按顺序执行每个调用。

您知道调试器或浏览器控制台中您可能熟悉的错误堆栈跟踪吗? 浏览器在调用堆栈中查找函数名称以通知您哪个函数发起当前调用:

5. 事件循环解释

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()

当此代码运行时,首先调用 foo()。 在 foo() 中,首先调用 bar(),然后调用 baz()。

每次迭代的事件循环都会查看调用堆栈中是否有内容,并执行它:

直到调用栈为空。

1). 排队功能执行

上面的例子看起来很正常,没有什么特别之处:JavaScript 找到要执行的东西,按顺序运行它们。

让我们看看如何将函数推迟到堆栈清除为止。

setTimeout(() => {}, 0) 的用例是调用一个函数,但是代码中的每个函数都执行一次。

例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}

foo()

// foo
// baz
// bar

当此代码运行时,首先调用 foo()。 在 foo() 中,首先调用 setTimeout,将 bar 作为参数传递,然后指示它尽可能快地立即运行,将 0 作为计时器传递。 然后调用 baz()。

此时调用堆栈如下所示:

这是程序中所有函数的执行顺序:

为什么会这样?

2). 消息队列

当 setTimeout() 被调用时,浏览器或 Node.js 启动计时器。 一旦计时器到期,回调函数被放入消息队列中。

消息队列也是用户发起的事件(如单击或键盘事件)或获取响应在您的代码有机会对其做出反应之前排队的地方。 或者还有像 onload 这样的 DOM 事件。

循环优先处理调用栈,它首先处理在调用栈中找到的所有东西,一旦里面没有任何东西,它就会去消息队列中取东西。

不必等待 setTimeout、fetch 或其他函数来做自己的工作,因为它们是由浏览器提供的,并且它们存在于自己的线程中。
例如,如果将 setTimeout 超时设置为 2 秒,则不必等待 2 秒 - 等待发生在其他地方。

6. ES6 作业队列

ECMAScript 2015 引入了 Job Queue 的概念,它被 Promises 使用(也在 ES6/ES2015 中引入)。
这是一种尽快执行异步函数结果的方法,而不是放在调用堆栈的末尾。

在当前函数结束之前解析的 Promise 将在当前函数之后立即执行。

例如,在游乐园坐过山车:

消息队列把你放在队列的后面,在所有其他人的后面,你必须在那里等待轮到你,
作业队列快速通行证 这样就可以在完成上一次骑行后立即乘坐另一次骑行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('应该在 baz 之后,在 bar 之前')
  ).then(resolve => console.log(resolve))
  baz()
}

foo()

// foo
// baz
// 应该在 baz 之后,在 bar 之前
// bar

这是 Promises(和 Async/await,它建立在 Promise 之上)和通过 setTimeout() 或其他平台 API 实现的普通异步函数之间的巨大差异。

最后,这是上面示例的调用堆栈的样子: