进阶篇:JavaScript 异步编程
异步(Asynchronous,async)是与同步(Synchronous,sync)相对的概念。
在单线程里,同步指步骤在一个控制流里按顺序执行,当前任务没做完就不会去做后面的。异步指发起一项工作后不必干等到它完成,完成时再通过回调、Promise 等接到结果。同步会阻塞后续代码;异步发起后不阻塞后续代码(回调仍在主线程排队执行)。
// 异步编程:定时器回调函数
setTimeout(() => {
console.log(1)
}, 100)
console.log(2)
// 2
// 1
异步编程
浏览器和操作系统里,磁盘、网络可以并行工作。JavaScript 主线程仍是单线程:一次只跑一段脚本。耗时 I/O 由宿主(浏览器、Node)在后台完成,结果通过事件循环把回调排回主线程。这不是「从主线程再发射一个 JS 子线程」。
异步编程让程序在等长期任务时还能响应其他事件。常见形式是:事件发生时再调用你提供的函数(回调),而不是立即调用。
通常,C、Java、C#、PHP、Go、Ruby、Swift、Python 默认按同步控制流写;它们往往用线程或语言/库里的异步设施来重叠 I/O。
JavaScript 主线程一次只能做一件事。不能像 Java 那样随手 new Thread()。后台计算可用 Web Worker,但 Worker 与主线程默认不共享内存(SharedArrayBuffer 除外),通信靠消息。
而浏览器提供的许多功能可能需要很长时间,因此做成异步 API:
- 定时器:
setTimeout、setInterval。 - 网络请求:异步 XHR 或
fetch。 - DOM 事件:
click、mouseover、change、submit等监听函数。 - 使用
getUserMedia()访问摄像头和麦克风。 - 使用
showOpenFilePicker()请求用户选择文件。
为了衔接这些 API,JavaScript 提供了回调函数、Promise、Generator / yield、async / await 等写法。
回调函数
回调函数是 JavaScript 异步编程的最基本、最原始的方式,例如事件回调、setTimeout / setInterval、Ajax 等,但是使用回调函数存在一个非常棘手的问题,那就是回调地狱,一开始写没什么,等过一段时间后,不管自己看还是别人看,都会觉得这代码写的真恶心。
回调函数其实是一个简单的函数,会作为值被传给另一个函数,并且仅在事件发生时才被执行。 之所以这样做,是因为 JavaScript 具有顶级的函数,这些函数可以被分配给变量并传给其他函数(称为高阶函数)。
从历史发展的脉络来看,早些年为了实现 JavaScript 的异步编程,一般都采用回调函数的方式。
// window 对象的 load 事件监听器
window.addEventListener('load', () => {
console.log('页面准备就绪时才会运行')
})
// 定时器的回调函数
setTimeout(() => {
console.log('1秒之后运行')
}, 1000)
// XHR请求的回调函数
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
console.log(xhr.responseText)
return
}
console.error('出错')
}
}
xhr.open('GET', 'http://example.com')
xhr.send()
使用回调函数存在一个很常见的问题:当嵌套层级过多时,会形成 “回调地狱” 或 “厄运金字塔”,代码会变得非常复杂,难以阅读和调试:
const fs = require('fs')
fs.readFile('./1.txt', 'utf-8', (err, r1) => {
console.log(r1)
fs.readFile(r1, 'utf-8', (err, r2) => {
console.log(r2)
fs.readFile(r2, 'utf-8', function(err, r3) {
console.log(r3)
fs.readFile('./1.txt', 'utf-8', function(err, r3) {
console.log('全部完成!')
});
});
});
});
Promise
Promise 是异步编程的一种解决方案,比传统的解决方案 “回调函数和事件” 更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了 Promise 对象,在一定程度上解决了回调地狱的问题。但是存在的问题也很明显,过多使用 then 链式调用,其实并没有从根本上解决回调地狱的问题,只是换了一种写法,可读性虽然有所提升,但是依旧很难维护。
Promise 本意是承诺,在程序中的意思就是承诺过一段时间后会给一个结果。 什么时候会用到过一段时间?答案是异步操作,异步是指可能比较长时间才有结果的操作,例如网络请求、读取本地文件等。
从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。
当 Promise 被调用后,它会以处理中状态开始。 这意味着调用的函数会继续执行其他任务,而 Promise 仍处于处理中直到解决为止,从而为调用的函数提供所请求的任何数据。被创建的 Promise 最终会以被解决状态或被拒绝状态结束,并在完成时调用相应的回调函数(then 或 catch)。
Promise 的三种状态:
- pending:待定,创建时的初始状态。
- fulfilled(口语里也叫 resolved):已兑现,操作成功。规范里
resolved还表示已经锁定到另一个 thenable。 - rejected:已拒绝,操作失败。
const fs = require('fs')
function read(file) {
return new Promise((resolve, reject) => {
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
reject(err)
return
}
console.log(data)
resolve(data)
})
})
}
read('./1.txt').then(r1 => {
return read(r1);
}).then(r2 => {
return read(r2);
}).then(r3 => {
return read(r3);
}).catch(err => {
console.log(err);
});
// 或者
read('./1.txt')
.then(read)
.then(read)
.then(read)
.catch(err => {
console.log(err);
});
Promise 对 “回调地狱” 进行了改进,可读性的确有一定的提升,可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数,但是 Promise 也存在一些问题,即便是使用 Promise 的链式调用,如果操作过多,其实并没有从根本上解决回调地狱的问题,只是换了一种写法,可读性虽然有所提升,但是依旧很难维护。
另外,Promise 还提供了一个 all 方法,用于并行执行多个 Promise:
// all 方法:只有所有promise的状态都成功,all的状态才会变成成功。
Promise.all([read('./1.txt'), read('./2.txt'), read('./3.txt')]).then(data => {
console.log(data);
}).catch(err => {
console.log(err)
});
Generators/yield
Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同,Generator 最大的特点就是可以控制函数的执行。Generator 函数可以看作是异步任务的容器,需要暂停的地方,都用 yield 语法来标注:Generator 函数返回的是一个迭代器对象,函数内遇到 yield 关键字会暂停,然后,通过调用迭代器的 next() 方法可重新启动,每次返回的是 yield 后的表达式结果。
yield 表达式本身没有返回值,或者说总是返回 undefined。next() 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。
function* generatorFunc(x) {
const y = 2 * (yield (x + 1))
const z = yield (y / 3)
return (x + y + z)
}
const it = generatorFunc(5)
console.log(it.next()) // {value: 6, done: false}
console.log(it.next(12)) // {value: 8, done: false}
console.log(it.next(13)) // {value: 42, done: true}
代码执行分析:
- Generator 函数调用和普通函数不同,它会返回一个迭代器;
- 当执行第一次 next 时,传参会被忽略,并且函数暂停在
yield (x + 1)处,所以返回5 + 1 = 6; - 当执行第二次 next 时,传入的参数 12 就会被当作上一个 yield 表达式的返回值,此时
let y = 2 * 12,所以第二个 yield 等于2 * 12 / 3 = 8。注意: 如果不传参,yield 永远返回undefined。 - 当执行第三次 next 时,传入的参数 13 就会被当作上一个 yield 表达式的返回值,所以
z = 13, x = 5, y = 24,相加等于 42。
注意: yield 后面的表达式是同步的,也就是说,如果后面的表达式是异步操作,next() 方法启动时,不会等待当前停留的 yield 的异步操作执行结束。
const fs = require('fs')
function read(file) {
return new Promise((resolve, reject) => {
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
reject(err)
return
}
console.log(data)
resolve(data)
})
})
}
function * generatorRead() {
const r1 = yield read('./1.txt')
const r2 = yield read(r1)
const r3 = yield read(r2)
}
const it = generatorRead();
it.next().value.then((r1) => {
it.next(r1).value.then((r2) => {
it.next(r2).value.then(() => {
console.log('全部完成!')
})
})
})
以上是 Generator 函数异步编程的代码,跟回调函数的 “回调地狱” 很像。在实际开发中,历史上常配合 co 使用。co 已停止维护,新代码用 async / await。
co 是一个为 Node.js 和浏览器打造的基于生成器的流程控制工具,借助于 Promise,可以使用更加优雅的方式编写非阻塞代码。
const co = require('co')
co(generatorRead()).then(() => {
console.log('全部完成!')
})
Async/await
async/await 是 ES2017 提出的异步写法,相当于 Generator + 执行器的语法糖。现行项目里这是最常用的方案,真正做到异步代码按同步顺序来读。
其最大的优点是代码清晰,让异步逻辑的代码看起来像同步一样。
const fs = require('fs')
function read(file) {
return new Promise((resolve, reject) => {
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
reject(err)
return
}
console.log(data)
resolve(data)
})
})
}
async function promiseRead() {
const r1 = await read('./1.txt')
const r2 = await read(r1)
const r3 = await read(r2)
}
promiseRead();
其他方法
事件监听
通过事件机制,实现代码的解耦。JavaScript 处理 DOM 交互就是采用的事件机制。
发布-订阅
发布订阅基于事件监听,发布者和订阅者通过一个事件中心进行通信,并且实现了多个事件解耦。