4、同步与异步(Synchronous vs. Asynchronous)
当涉及到需要一些时间才能完成的代码时(比如经常向服务器发出请求),同步运行你的代码并不是最好的选择,因为可能需要一些时间来取回你的数据,而且你可能不希望你的程序在发出请求时等待,相反,你希望它继续做其他事情。
为了实现这一点,你需要使用一个异步函数来请求外部数据,并将一个回调函数作为参数传递给它。这样,该函数现在就可以开始,一旦请求完成并收到数据,回调函数就可以稍后运行并完成。
在JavaScript中,如果任务是一个接一个地执行,那么一个操作就是同步的或阻塞的。每一步都必须在进行下一步之前完成,而且程序是按照语句的确切顺序进行评估的。这意味着,无论完成当前任务需要多长时间,下一个任务的执行都会被阻断,直到当前任务完成。
举一个例子:
function firstTask() { console.log("Task 1"); } function secondTask() { console.log("Task 2"); } function thirdTask() { console.log("Task 3"); } firstTask(); secondTask(); thirdTask();
如果我们在一个JavaScript文件里有这三个函数,它们将被逐一执行,在控制台中打印如下:
Task 1 Task 2 Task 3
任务2在任务1完成之前不能执行,任务3在任务2完成之前也不能执行。
与同步编程相反,如果下一个任务可以开始其执行过程,而不需要等待当前任务的完成,那么就可以说一个操作是异步的或非阻塞的。作为异步编程的结果,你可以同时执行许多请求(提出API请求和接收响应,滚动页面,重新绘制和更新位置),从而在更短的时间内完成任务。
让我们修改我们的代码,将任务2延迟5秒。
function firstTask() { console.log("Task 1"); } function secondTask() { setTimeout(function() { console.log("Task 2") },5000); } function thirdTask() { console.log("Task 3"); } firstTask(); secondTask(); thirdTask();
为了演示异步操作,我们使用 setTimeout
将任务2
延迟5000毫秒。 setTimeout
接受两个参数:第一个输入是要执行的函数,第二个输入是你想在执行该函数前等待的毫秒数。
以下内容将被记录到控制台。
Task 1 Task 3 Task 2
setTimeout
通过继续操作而不是等待任务2
的完成,使操作成为异步的。它继续运行任务3
,然后在5秒后执行传入setTimeout
的回调函数,之后将任务2
记录在控制台。
二、异步演进历程
1、回调
“回调函数是作为参数传递给另一个函数的函数,然后在外部函数内部调用该函数以完成某种例程或操作。” (MDN)
回调是处理 JavaScript 中异步操作的最古老和最基本的方法。回调只是一个作为参数传递给另一个函数的函数功能, 并在操作完成时执行。比如:
function fetchData(url, callback) { let xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { callback(xhr.responseText); } }; xhr.open('GET', url); xhr.send(); } fetchData('https://jsonplaceholder.typicode.com/todos/1', function(data) { console.log(data); });
在这个例子中,我们定义了一个fetchData()
函数,它接受一个 URL 和一个回调函数作为参数。我们创建一个XMLHttpRequest
对象并将其onreadystatechange
属性设置为一个函数,该函数检查响应是否准备就绪并成功,如果是,则使用响应文本调用回调函数。
然后,我们fetchData()
使用一个 URL 和一个将数据记录到控制台的匿名回调函数调用该函数。
当嵌套很深时,回调很快就会变得混乱且难以阅读,从而导致通常所说的“回调地狱”。为避免这种情况,最好使用命名函数并尽可能模块化您的代码。
回调很好地实现了异步,但如果你过度使用回调这种异步方式,有可能会造成 “回调地狱”。特别是当你将回调嵌套在多个深度的回调中时,就会发生这种情况。
回调地狱的形状像金字塔,也被称为“厄运金字塔”。这使得代码很难维护和理解。比如:
getData(function(a) { getMoreData(a, function(b) { getEvenMoreData(b, function(c) { getEvenEvenMoreData(c, function(d) { getFinalData(d, function(finalData) { console.log(finalData); }); }); }); }); });
这种回调的嵌套会使代码难以维护,而缩进则使人更难看到代码的整体结构。
为了避免回调地狱,你可以使用一种更现代的处理异步操作的方式,即 Promises
。与回调函数相比,Promise 提供了一种更优雅的方式来处理程序的异步流程。
2、事件发布/监听模式
在JavaScript中,事件发布/监听模式(Event Emitter/Listener Pattern)
也是一种常用的异步编程实现方式。它通常由一个事件触发器(Event Emitter)
和多个事件监听器(Event Listener)
组成,通过将异步操作的结果作为事件触发后通知所有相关的监听器来完成异步编程。
如果在浏览器中写过事件监听addEventListener
,那么你对这种事件发布/监听的模式一定不陌生。
借鉴这种思想,一方面,我们可以监听某一事件,当事件发生时,进行相应回调操作;另一方面,当某些操作完成后,通过发布事件触发回调。这样就可以将原本捆绑在一起的代码解耦。
const events = require('events'); const eventEmitter = new events.EventEmitter(); eventEmitter.on('db', (err, kw) => { db.find(`select * from sample where kw = ${kw}`, (err, res) => { eventEmitter('get', res.length); }); }); eventEmitter.on('get', (err, count) => { get(`/sampleget?count=${count}`, data => { console.log(data); }); }); fs.readFile('./sample.txt', 'utf-8', (err, content) => { let keyword = content.substring(0, 5); eventEmitter. emit('db', keyword); });
使用这种模式的实现需要一个事件发布/监听的库。上面代码中使用node原生的events
模块,当然你可以使用任何你喜欢的库。
还可以自己封装一个事件发布/监听类来实现异步控制,下面让我们通过一个简单的示例来说明事件发布/监听模式如何实现异步编程。
class AsyncOperation { constructor() { this.subscribers = []; } subscribe(callback) { this.subscribers.push(callback); } async execute() { const data = await fetchData(); this.subscribers.forEach(callback => callback(data)); } } function fetchData() { return new Promise(resolve => { setTimeout(() => { const data = 'Async Data'; resolve(data); }, 1000); }); } console.log('Start'); const operation = new AsyncOperation(); operation.subscribe(data => console.log(data)); operation.execute(); console.log('End');
在上述代码中,定义了一个名为 AsyncOperation
的类,该类包含了两个方法:subscribe
和 execute
。其中,subscribe
方法用于向该对象添加事件监听器,execute
方法用于执行异步操作,并在操作结束后通知所有相关的监听器。
在该类内部,我们使用了一个数组 subscribers
来保存所有的事件监听器。在 execute
方法中,我们先执行异步操作 fetchData
并等待其返回。当 fetchData
返回结果后,我们遍历 subscribers
数组并依次调用每个监听器函数,将异步结果作为参数传入其中。最后,所有监听器都得到了异步结果并处理完成。
在主线程中,我们依次输出了 Start、End 和订阅的回调函数的结果。由于异步操作是异步执行的,因此所有的输出都是异步完成的。
事件发布/监听模式也是一种常用的实现异步编程的方式,在JavaScript中尤其常见。它可以使异步代码更加清晰易懂,并具有良好的可扩展性和灵活性。
3、Promise
Promises
提供了一种更结构化的方式来处理 JavaScript 中的异步操作。Promise
是表示异步操作最终完成(或失败)的对象。可以使用 then()
和 catch()
方法链接和处理 Promise
。比如:
function fetchData(url) { return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if (xhr.status === 200) { resolve(xhr.responseText); } else { reject(new Error('Network response was not ok')); } } }; xhr.open('GET', url); xhr.send(); }); } fetchData('https://jsonplaceholder.typicode.com/todos/1') .then(data => { console.log(data); }) .catch(error => { console.error(error); });
在此示例中,定义了一个fetchData()
返回新 Promise
的函数。创建一个XMLHttpRequest
对象并将其onreadystatechange
属性设置为一个函数,如果请求成功,该函数将使用响应文本解析承诺,否则将返回错误。
然后调用 fetchData()
函数,用链式的 then()
和 catch()
方法来处理这个 Promise
。如果 Promise
得到解决,就将数据记录到控制台。如果 Promise
被拒绝,就将错误记录到控制台。Promises
提供了一种比回调更结构化和可读性更强的处理异步操作的方式,并且可以与现代语法一起使用,例如async/await
。