JavaScript 异步编程指南 — 事件与回调函数 Callback
这是一个系列文章,你可以关注公众号「五月君」订阅话题《JavaScript 异步编程指南》获取最新信息。
JavaScript 异步编程中回调是最常用和最基础的实现模式。回调就是函数,一般我们也会称它为 Callback,相信这对于 JavaScript 开发者不会陌生,而函数在 JavaScript 中属于一等公民,可以将函数传递给方法作为实参调用。
这种编程模式对于习惯同步思维的人来说很难理解,一般我们的大脑对事物的理解是同步的、线性的,在异步编程中它是一种相反的模式,你会看到代码的编写顺序与实际执行顺序并不是我们预期的,因为它们的编写与实际执行顺序也许没有什么直接的关系,特别是在处理一些复杂的业务场景时,掌握不好异步编程,通常也会写出糟糕的代码。
在笔者组建的技术交流群中,有时候大家提问一些问题,当看到一大堆 Callback 嵌套的代码时,感觉就很糟糕,顿时很难让人在有耐心去看它,这种模式它不会给予我们很友好的阅读体验,有时看到了我会说你先把代码书写逻辑整理下,也许问题就出在这里!
谈回调也少不了一个概念 “事件”,在使用 JavaScript 操作 DOM、网络请求或在 Node.js 中更多的是一种事件驱动的模型,由事件触发执行我们的回调。
定时器
例如,我们为 定时器 API 其传入一个函数,让其在将来某个时间之后执行。我们可以通过 setTimeout 或 setInterval 实现,前一个 setTimeout 是仅执行一次,后一个 setInterval 是间隔指定时间后重复执行。
这两个 API 在浏览器、Node.js 环境中使用都是一样的。
function fn() {
// do something...
}
setTimeout(fn, 1000);
setInterval(fn, 1000);
网络事件
发起一个请求从另一端获取数据,这也是异步中很常见的一个操作,在客户端早期我们可以使用 XMLHttpRequest发起 HTTP 请求并异步处理服务器返回的响应。
const httpRequest = new XMLHttpRequest();
httpRequest.open('GET', 'http://openapi.xxx.com/api');
httpRequest.send();
httpRequest.onreadystatechange = function() {
if (httpRequest.readyState === XMLHttpRequest.DONE) {
if (httpRequest.status === 200) {
alert(httpRequest.responseText);
} else {
alert('There was a problem with the request.');
}
}
};
现在浏览器端有了一个新的 API fetch() 取代了复杂且名字容易误导人的 XMLHttpRequest,因为这个虽然名字带了 XML 但和 XML 没关系,fetch() API 完全基于 Promise 可以方便的让你编写代码从网络获取数据,简单看一下:
fetch('http://example.com/movies.json')
.then(function(response) {
return response.json();
})
.then(function(myJson) {
console.log(myJson);
});
Node.js 中也定义了一些网络相关的 API,Node.js 提供的 HTTP/HTTPS 模块可以帮助我们在 Node.js 客户端向服务端请求数据
const http = require('http');
function sendRequest() {
const req = http.request({
method: 'GET',
host: '127.0.0.1',
port: 3010,
path: '/api'
}, res => {
let data = '';
res.on('data', chunk => data += chunk.toString());
res.on('end', () => {
console.log('response body: ', data);
});
});
req.on('error', console.error);
req.end();
}
sendRequest();
这种方式来写还是有点繁琐的,在实际的业务开发中我们使用一些功能完备的 HTTP 请求模块,例如 node-fetch、nodejs/undici、axios 等,这些工具都是可以基于 Promise 的形式。
Node.js 做为一个服务端启动,我们还可以使用 HTTP 模块,如下方式启动一个 Server:
const http = require('http');
http.createServer((req, res) => {
req.on('data', chunk => {
// TODO
});
req.on('end', () => res.end('ok!'))
req.on('error', () => ...)
}).listen(3010);
客户端 DOM 事件与回调
客户端下的 JavaScript 我们可以获取指定的 DOM 元素,为特定类型的事件注册回调函数,当用户移动鼠标或移动触摸板、按下键盘时,浏览器会生成相应的事件并调用我们事先注册的回调函数,这些都是由事件驱动的。
下例,通过 addEventListener() 函数为事件注册回调函数。相对来说 DOM 事件在互相依赖、多级依赖嵌套的场景较少些,但是在 Node.js 里面你可能会遇到很多。
<button id="btn"> 点我哦 </button>
<script>
const btn = document.getElementById('btn');
// 单击时触发
btn.addEventListener('click', event => console.log('click!'));
// 鼠标移入触发
btn.addEventListener('mouseover', event => console.log('mouseover!'));
// 鼠标移出触发
btn.addEventListener('mouseout', event => console.log('mouseout!'));
</script>
Node.js 中的事件与回调
Node.js 作为 JavaScript 的服务端运行时,大部分的 API 都是异步的,大家可能也听过 Node.js 比较擅长 I/O 密集型任务,这与它的单线程、基于事件驱动模型、异步 I/O是有关系的,它无需像多线程程序那样为每一个请求创建额外的线程、省掉了线程创建、销毁、上下文切换等开销。
它通过主循环加事件触发的方式执行程序,事件循环会不停地处理网络/文件 IO 事件,每一次的事件循环就是检查,检查是否有待处理的事件,如果有就取出事件及关联的回调函数,如果有传入 JavaScript 回调函数,传递到业务逻辑层执行,也许回调函数里还会在发起一次新的 I/O 请求,整个程序不断的通过事件循环调度执行。
也许你听过这样一句话:“它的优秀之处并非原创,它的原创之处并不优秀。” 异步 I/O 并非 Node.js 原创,但 Node.js 却是第一个成功的平台,Node.js 2009 年出现之前,JavaScript 在服务端近乎空白。例如,文件 API 在 Node.js 中默认就是异步的,也就是它的标准库 I/O 本身给你提供的就是非阻塞的,它没有任何的历史包袱。
谈到异步 I/O 必然少不了异步编程,早期我们的很多程序中都充斥着 Callback 风格的代码,包括 Node.js 提供的 API 大多数也是,大家都遵循一个默认的规则 “错误优先的回调函数”。
例如,下面 API 第一个参数为 err 如果有错误就是一个 Error 对象,否则就为 null,这也是一种默认的约定。
fs.readFile(filename, (err, file) => {
// TODO
})
现在 Node.js 的一些系统模块已经为我们提供了一些工具可以方便的将 callback 转换为 Promise 的工具,或者文件模块我们可以通过 fs.promises 直接引入基于 Promise 版本的 API,这些编程方法我们会在后续章节 Promise 篇幅里讲。
一个糟糕的回调地狱例子
当我们在 Node.js 中有时需要处理一些复杂的业务场景,有些需要多级依赖,如果以 callback 形式很容易造成函数嵌套过深,例如下面示例很容易写出回调地狱、冗余的代码,这也是早期 Node.js 被人诟病比较多的地方。包括现在前段在群里仍然还有看到有些提问题的,写出类似于下面嵌套的代码,确实要改下了。
fs.readdir('/path/xxxx', (err, files) => {
if (err) {
// TODO...
}
files.forEach((filename, index) => {
fs.lstat(filename, (err, stats) => {
if (err) {
// TODO...
}
if (stats.isFile()) {
fs.readFile(filename, (err, file) => {
// TODO
})
}
})
})
});
异步编程 Callback 的形式一个难点是上面说的容易出现回调地狱的例子,另外一方面是异常的处理很麻烦,在一些同步的代码中我们可以像下面示例这样使用 try/catch 捕获错误。
try {
doSomething(...);
} catch(err) {
// TODO
}
这种方式在一些异步方法面前显得无能为力,上面我们写的回调嵌套的示例,如果我们对 fs.readFile() 做 try/catch 捕获,当我们调用 fs.readFile 并为其注册回调函数这个步骤对应异步 I/O 中是提交请求,而 callback 函数会被存放起来,等到下一个事件循环到来 callback 才会被取出执行,这个时间是将来的某个时间点,而 try/catch 是同步的,捕获不到这个错误的。
下面因为我对一个 null 对象做了非法操作,这时程序会给我们报一个 TypeError: Cannot read property 'a' of null
错误,在 Java 中可以称它为空指针异常。
类似于这样的一个错误如果没有被捕获到,在单进程的应用程序中必然会导致进程退出,无关语言。
try {
fs.readFile(filename, (err, file) => {
const obj = null
obj.a;
// TODO
})
} catch () {
// TODO
}
有时候也会听大家说为什么我的 Node.js 程序老是崩溃?也有人说 Node.js 弱爆了(这个我曾经听过一个架构师这样说过...)如果程序这样写,就算你用的 Java 照样崩溃。
在延伸一点,Node.js 的 Process 对象为我们提供了两个事件可以用来捕获程序中出现的未捕获异常,方便程序优雅退出,这是笔者之前写的一篇文章,可以看看如何处理 Node.js 中出现的未捕获异常?
process.on('uncaughtException', fn);
process.on('unhandledRejection', fn);
总结
异步编程中 Callback 是比较早的模式,也是异步编程的基础,但是随着业务的发展、复杂度的上升,基于 Callback 的模式已经不能满足我们的需求了,就像我们的大脑对事物的思考,需要一种同步的、顺序的方式表达异步编程思想。
“办法总比困难多”,解决问题的方案还是很多的,目前的 JavaScript 中已有一些更高级、强大的异步编程模式,在本系列中会逐步的讲解。
·END·
汇聚精彩的免费实战教程
喜欢本文,点个“在看”告诉我