JavaScript 异步编程指南 — 事件与回调函数 Callback

共 7387字,需浏览 15分钟

 ·

2021-05-30 13:34

这是一个系列文章,你可以关注公众号「五月君」订阅话题《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',
    port3010,
    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 中已有一些更高级、强大的异步编程模式,在本系列中会逐步的讲解。



● 字节跳动最爱考的前端面试题:CSS 基础

● 字节跳动最爱考的前端面试题:JavaScript 基础

● 字节跳动最爱考的前端面试题:计算机网络基础



·END·

图雀社区

汇聚精彩的免费实战教程



关注公众号回复 z 拉学习交流群


喜欢本文,点个“在看”告诉我

浏览 77
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报