【JavaScript】这一次,彻底搞懂 JS 异步及其演进历程 ~

您所在的位置:网站首页 异步通信的作用 【JavaScript】这一次,彻底搞懂 JS 异步及其演进历程 ~

【JavaScript】这一次,彻底搞懂 JS 异步及其演进历程 ~

2023-05-04 13:00| 来源: 网络整理| 查看: 265

JavaScript 是一种单线程语言,这意味着在任何给定的实例中,JavaScript 的引擎(在托管环境中运行,如标准 Web 浏览器)一次只能执行一条语句或一行代码。在浏览器中加载 JavaScript 文件时,JavaScript 引擎会从上到下处理文件中的每一行。

虽然单线程语言简化了代码编写,因为你不必担心并发问题,但这也意味着你无法在不阻塞主线程的情况下执行网络访问等耗时操作。

想象一下从 API 请求一些数据。根据情况,服务器可能需要一些时间来处理请求,同时阻塞主线程使网页无响应。而使用异步 JavaScript(例如回调、promises 和 async/await),你可以在不阻塞主线程的情况下执行耗时的网络请求。

当该线程在执行某些耗时操作时(比如网络请求、文件读取等),如果这个操作是同步的,那么主线程会被完全阻塞,无法响应任何其他的用户交互事件,导致整个页面失去响应。这就是所谓的“UI 阻塞”。

为了避免这种情况的发生,JavaScript 引入了异步编程的概念。异步编程的本质是在主线程发起一个耗时操作时,不必等待其完成,而是立即返回给主线程并继续执行其他代码。当操作完成后,又将相应的结果通知到主线程。

JavaScript 的引擎在浏览器上运行,但它并不是孤立运行的。Web 浏览器结合了 JavaScript 引擎和其他附加功能(Web API),在 JavaScript 中对其进行了标记。这些 Web API(setTimeout、setInterval 等)允许 JavaScript 异步运行,允许正常的同步函数在异步任务完成时继续运行。

具体来说,异步编程使用回调函数、Promise、Generator、async/await 等方式来实现,在执行异步操作时,主线程将异步任务委托给浏览器的底层引擎,通过 Event Loop 机制实现对异步操作的检测和处理,使得主线程能够及时响应用户的交互事件,提高了 JavaScript 的性能和用户体验。

因此,JavaScript 之所以需要异步编程,是为了提供更好的用户体验及改善性能。接下来我们就去回顾一下异步 JavaScript 的演进历程。

一、什么是异步? 1、调用堆栈(Call Stack)

调用堆栈是JavaScript中的一个结构,用来暂时保存函数调用的列表。一旦你的JavaScript应用程序开始执行,就会在调用栈的底部创建并添加一个全局执行环境。因为JavaScript是单线程的,这个页面上的所有东西都是由一个单线程执行的。这个执行线程开始一行一行地遍历你的代码。

因为JavaScript一次只能执行一件事,所以它使用调用栈来跟踪已经被调用的函数、已经被调用但还没有完成的函数等的执行环境。当一个函数被调用时,一个新的执行上下文被创建,对该函数的引用被添加到调用栈的顶部,在那里开始执行。当函数的执行完成后,就会从堆栈中移除,JavaScript的引擎就会开始执行顶部的下一个函数。

就像这样:

image.png

它被称为调用堆栈,因为它使用了数据结构中堆栈的概念,遵循后进先出(LIFO)原则。它总是会先处理堆栈顶部的调用。例如,如果你在桌子上有一堆盘子,想添加更多的盘子,你就把它们添加到顶部,如果你要从这堆盘子中拿一个或多个盘子,你就从顶部拿,而不是从底部。最后添加到这堆盘子里的盘子将是第一个被拿走的。

这与调用栈的情况相同。栈顶的函数总是首先被执行。在执行函数并将其添加到调用栈时,如果JavaScript遇到一个使用Web API方法的异步函数,它不会将其添加到调用栈中,而是将其发送到Web API容器中。

2、事件队列(Event Queue)

如果JavaScript遇到一个使用Web API方法的异步函数,它会将其发送到Web API容器中。随后,每当所需的事件发生时,该方法就会从 Web API容器 转移到事件队列中。例如,setTimeout中的回调在指定时间过后被添加到队列中。

与调用堆栈不同,事件队列遵循 FIFO(先进先出)原则,这意味着调用是按照它们被添加到队列的顺序处理的。

3、事件循环(Event Loop)

事件循环是一个无限期运行的循环,作为调用栈和事件队列之间的连接。事件循环反复检查调用栈,并将子任务从事件队列转移到调用栈。在事件循环开始从队列中转移子任务之前,调用堆栈必须是空的,这表明所有程序的常规(同步)功能已经被执行。

如果调用堆栈是空的,添加到事件队列中的第一个子任务(最老的那个)将从队列中移除,其相关的函数将被添加到调用堆栈中,并以该子任务为参数执行。

就像这样:

image.png

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。

4、Generator / yield

JavaScript Generator 是一个相对较新的概念,它们在ES6(也被称为ES2015)中被引入。它是一种特殊类型的函数,它可以被暂停和恢复运行,并且可以通过 yield 语句向调用方返回数据。结合 Promise 使用,可以实现异步编程。

先来看一个简单的例子:

function* foo () { var index = 0; while (index < 2) { yield index++; } } var bar = foo(); console.log(bar.next()); // { value: 0, done: false } console.log(bar.next()); // { value: 1, done: false } console.log(bar.next()); // { value: undefined, done: true } 复制代码

当 Generator 函数执行时,会返回一个迭代器对象,该迭代器对象可以通过 next() 方法向下执行生成器函数。在 Generator 函数中,通过 yield 关键字可以将代码的执行权交回给调用者,并将 yield 后的表达式作为参数传递给调用者。这样就实现了协作式多任务处理。

可以看到,generator函数有一个最大的特点,可以在内部执行的过程中交出程序的控制权,yield相当于起到了一个暂停的作用;而当一定情况下,外部又将控制权再移交回来。

想象一下,我们用generator来封装代码,在异步任务处使用yield关键词,此时generator会将程序执行权交给其他代码,而在异步任务完成后,调用next方法来恢复yield下方代码的执行。以readFile为例,大致流程如下:

// 我们的主任务——显示关键字 // 使用yield暂时中断下方代码执行 // yield后面为promise对象 const showKeyword = function* (filepath) { console.log('开始读取'); let keyword = yield readFile(filepath); console.log(`关键字为${filepath}`); } // generator的流程控制 let gen = showKeyword(); let res = gen.next(); res.value.then(res => gen.next(res)); 复制代码

在主任务部分,原本readFile异步的部分变成了类似同步的写法,代码变得非常清晰。而在下半部分,则是对于什么时候需要移交回控制权给generator的流程控制。

然而,我们需要手动控制generator的流程,如果能够自动执行generator——在需要的时候自动移交控制权,那么会更加具有实用性。

为此,我们可以使用 co 这个库。它可以是省去我们对于generator流程控制的代码

const co = reuqire('co'); // 我们的主任务——显示关键字 // 使用yield暂时中断下方代码执行 // yield后面为promise对象 const showKeyword = function* (filepath) { console.log('开始读取'); let keyword = yield readFile(filepath); console.log(`关键字为${filepath}`); } // 使用co co(showKeyword); 复制代码

其中,yeild关键字后面需要是functio, promise, generator, array或object。可以改写文章一开始的例子:

const co = reuqire('co'); const task = function* (filepath) { let keyword = yield readFile(filepath); let count = yield queryDB(keyword); let data = yield getData(res.length); console.log(data); }); co(task, './sample.txt'); 复制代码 Generator + Promise

Generator 函数与 Promise 结合使用时,可以通过 yield 关键字桥接两者之间的通信。在 Generator 函数中通过 yield 语句返回一个 Promise 对象,在 Promise 对象成功或失败后,再次通过 Generator 函数的 next() 方法恢复生成器的执行。在执行过程中,可以通过 try...catch 捕获错误并进行相应的处理。

通过以上方式,利用 Generator 和 Promise 的协作,可以有效解决 JavaScript 中异步编程的问题,提高代码可读性和可维护性。

我们以一个简单的示例来说明 Generator 如何实现异步编程。假设我们有一个包含异步操作的函数 getData(),它返回一个 Promise 对象。

function getData() { return new Promise((resolve, reject) => { setTimeout(() => { resolve('Data received from server'); }, 2000); }); } 复制代码

接下来,我们创建一个 Generator 函数 run(),在该函数中实现异步操作的流程控制。程序执行到 yield 语句时,run() 函数会暂停执行并将控制权交还给调用者。当 Promise 对象成功返回数据后,再次通过 next() 方法恢复 Generator 的执行。在中间过程中,可以通过 try...catch 捕获错误并进行相应的处理。

function* run() { try { const data = yield getData(); console.log(data); } catch (error) { console.error(error); } } const iterator = run(); const promise = iterator.next().value; promise.then(data => { iterator.next(data); }).catch(error => { iterator.throw(error); }); 复制代码

首先,我们需要创建一个 Generator 实例 iterator,并通过 iterator.next() 方法启动迭代器,开始执行 run() 函数。此时程序执行到 yield 语句,run() 函数暂停执行并将控制权交还给调用者。

之后,我们获取到 promise 对象,通过 promise 对象的 then() 方法注册回调函数,当数据成功返回时则触发回调函数,恢复 Generator 的执行,并将返回值作为参数传递给 yield 表达式。这样,我们就可以拿到异步操作得到的数据,并进行下一步处理。

当然,在中间过程中如果发生任何错误,我们可以通过 promise 对象的 catch() 方法或 iterator.throw() 方法捕获异常并进行相应的处理。

总之,将 Generator 与 Promise 结合使用,利用 yield 语句返回 Promise 对象,再通过 next()、then()、catch()、throw() 等方法实现异步编程的流程控制。

5、async/await

上面的方法虽然都在一定程度上解决了异步编程中回调带来的问题。然而:

事件发布/监听方式模糊了异步方法之间的流程关系; Promise虽然使得多个嵌套的异步调用能够通过链式的API进行操作,但是过多的then也增加了代码的冗余,也对阅读代码中各阶段的异步任务产生了一定干扰; 通过generator虽然能提供较好的语法结构,但是毕竟generator与yield的语境用在这里多少还有些不太贴切。

Async/await 语法是一种在 JavaScript 中处理异步操作的更现代的方式,它提供了一种更简洁的编写异步代码的方式。这是一个例子:

async function fetchData(url) { let response = await fetch(url); if (!response.ok) { throw new Error('Network response was not ok'); } let data = await response.json(); console.log(data); } fetchData('https://jsonplaceholder.typicode.com/todos/1') .catch(error => { console.error(error); }); 复制代码

在这个例子中,我们定义了一个名为fetchData()的异步函数,使用 await 关键字来等待 fetch() 调用的响应。如果响应是不确定的,我们就抛出一个错误。否则,我们将响应解析为JSON,并将其记录到控制台。

然后我们用一个URL调用 fetchData() 函数,并捕捉任何可能被抛出的错误。

与回调和承诺相比,Async/await 语法为处理异步操作提供了一种更易读、更少出错的方式,并且可以与现代功能如结构化和对象速记符号一起使用。

三、使用 RxJS 进行响应式编程

RxJS 是 Reactive Extensions for JavaScript 的缩写,是一种基于可观察对象的异步编程实现方式。RxJS 提供了丰富的操作符和函数,让开发者可以方便地处理异步事件、流数据等场景。

来看一个例子:

const { from } = rxjs; const { filter, map } = rxjs.operators; from(fetch('https://jsonplaceholder.typicode.com/todos/1')) .pipe( filter(response => response.ok), map(response => response.json()) ) .subscribe(data => { console.log(data); }, error => { console.error(error); }); 复制代码

在这个例子中,我们使用 RxJS 的 from() 方法,从 fetch() 调用中创建一个可观察变量。然后我们使用 filter() 和 map() 操作符来转换该观察变量,过滤掉非OK响应,并将响应解析为JSON。

然后我们订阅 observable,将数据记录到控制台或捕捉任何可能被抛出的错误。

使用 RxJS 的响应式编程为处理异步数据流提供了一种强大的方式,既可读又可维护,特别是在处理大型复杂数据集时。

参考资料 The Evolution of Asynchronous JavaScript Master Asynchronous JavaScript in 2023: A Comprehensive Guide Understanding Asynchronous JavaScript Asynchronous Programming in JavaScript – Guide for Beginners The Evolution of Asynchronous Data Fetching in JavaScript

end~



【本文地址】


今日新闻


推荐新闻


    CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3