【译】理解 JavaScript 异步编程

·  阅读 615

作者:Sukhjinder Arora

原文:blog.bitsrc.io/understandi…
翻译:零和幺

JavaScript 是一个单线程的编程语言,这意味着同一时间只能发生一件事情。也就是说,JavaScript 引擎在同一时间只能处理一条语句。

用单线程语言编码十分简单,因为你无需担心并发问题。当然你也不能期望像网络请求这样的耗时操作不会阻塞你的主线程。

想象一下当你请求某个接口时,服务器将会花费一些时间来处理你的请求的场景吧!主线程阻塞,页面变成无响应状态。为了解决这一问题,JavaScript 异步编程出现了。使用 callbacks,promises,async/await 这样的 JavaScript 异步编程技巧,可以在不阻塞主线程的情况下,解决像网络请求这样耗时较长的操作。

同步 JavaScript 是如何工作的

在深入理解异步 JavaScript 之前,让我们先弄清楚同步 JavaScript 代码在 JavaScript 引擎中是如何执行的。例如:

const second = () => {
	console.log('Hello there!')
}

const first = () => {
	console.log('Hi there!')
  	second()
  	console.log('The End')
}

first()
复制代码

为了更好的理解上面的代码是如何执行的,我们必须首先理解两个概念:执行上下文调用栈

执行上下文

只要代码运行在 JavaScript 引擎中,它们也必然运行在执行上下文中。

函数代码执行时,会有一个函数执行上下文中;而全局代码执行时,会有一个全局执行上下文。每一个函数都有它们独自的执行上下文环境。

调用栈

调用栈从名字就可以看出它是一个典型的栈结构,具有先进后出的特点。当代码执行的时候,会创建很多的执行上下文环境,而调用栈就用来存储这些执行上下文。

因为 JavaScript 是一个单线程语言,所以它只有一个调用栈。调用栈具有先进后出的特点也意味着栈中所有的 item 只能从栈顶添加或移除。

现在,我们回到上面的代码片段,尝试理解 JavaScript 代码是如何在 JavaScript 引擎中被执行的。

const second = () => {
  	console.log('Hello there!');
}

const first = () => {
  	console.log('Hi there!');
  	second();
  	console.log('The End');
}

first();
复制代码

这里发生了什么

当代码开始执行,一个全局的执行上下文环境被创建,同时它将被压入调用栈中(这里以 main() 代替)。当 first() 函数被调用时,它也被压入栈中。

紧接着,console.log('Hi there!') 被压入栈中,而当它执行结束,又从栈中弹出。接下来,second() 函数被调用,所以 second() 函数被压入栈中。

console.log('Hello there!') 被压入栈中,执行结束又被弹出栈。second() 函数执行完毕,也从栈中弹出。

console.log('The End') 被压入栈中,执行完毕,弹出。此时,first() 也执行完毕,所以它也被弹出。

最后,整个程序执行完毕,全局执行上下文(main()) 也从栈中被弹出。

异步 JavaScript 如何工作?

现在我们理解了执行上下文和调用栈,并且知道了同步 JavaScript 代码是如何执行的。让我们回到异步 JavaScript 代码中。

什么是阻塞?

让我们假设一下用同步的方式处理图片或者网络请求:

const processImage = (image) => {
    /**
    * doing some operations on image
    **/
    console.log('Image processed');
}


const networkRequest = (url) => {
    /**
    * requesting network resource
    **/
    return someData;
}


const greeting = () => {
  	console.log('Hello World');
}


processImage(logo.jpg);
networkRequest('www.somerandomurl.com');
greeting();
复制代码

图片处理和网络请求需要花费时间。所以当 processImage() 函数被调用时,所花费的时间取决于图片的大小。

processImage() 函数处理完成,它将会从栈中被弹出。紧接着 networkRequest() 函数被调用,然后被压入栈中,同样它也需要花费一定的时间来完成它的执行。

最后,当 networkRequest() 函数完成并从栈中弹出后,greeting() 函数才会被调用,然而这个函数中仅仅包含一条 console.log 语句,console.log 语句通常执行的很快,所以 greeting() 函数会立即执行并且被返回。

你可以看到,我们必须等待像 processImage()networkRequest() 这样耗时较长的函数结束,才可以执行后面的代码。这也意味着这些函数会阻塞调用栈或者主线程,这也会导致我们无法执行其他的操作。

解决方案?

最简单的解决方案就是使用异步回调。我们使用异步回调,可以使我们的代码变成非阻塞式的:

const networkRequest = () => {
    setTimeout(() => {
      	console.log('Async Code');
    }, 2000);
};

console.log('Hello World');

networkRequest();
复制代码

这里我们使用 setTimeout() 方法去模拟网络请求。请注意setTimeout() 方法并不是 JavaScript 引擎的一部分,它是 web APIs(浏览器中) 和 C/C++ APIs(Node.js 中) 的一部分。

为了理解上面的代码是如何执行的,我们还需要理解两个概念:事件循环和回调队列(也被称为任务队列或消息队列)。

事件循环web APIs消息队列/任务队列 并不是 JavaScript 引擎的一部分。它是浏览器 JavaScript 运行时环境或者 Node.js JavaScript 运行时环境的一部分。在 Node.js 中,web APIs 被替换为 C/C++ APIs。

现在我们回到上面的代码,看看它是如何通过异步的方式执行的。

const networkRequest = () => {
    setTimeout(() => {
      	console.log('Async Code');
    }, 2000);
};

console.log('Hello World');

networkRequest();

console.log('The End');
复制代码

当上面的代码运行在浏览器中,console.log('Hello World') 被压入调用栈,执行完毕后从栈中弹出。紧接着,networkRequest() 函数被执行,所以它被压入栈顶。

接下来,setTimeout() 函数被调用,所以它也被压入栈中。此时,setTimeout() 函数还有两个参数:1) 回调函数,2) 毫秒数。

setTimout() 方法在 web APIs 环境开启了一个 2s 的定时器。此时,setTimeout() 被弹出栈。最后,console.log('The End') 被压入栈、执行并且弹出。

而当 2s 的定时器到期后,回调函数被压入消息队列(message queue)。但是,回调函数并不是立即执行的,它需要等待事件循环(event loop)将它推入调用栈。

事件循环

事件循环的任务是:时刻查看调用栈,并判断调用栈是否为空。如果当前调用栈为空,它会去查看消息队列中是否有正在等待被执行的回调函数。

在上面的例子中,消息队列中只包含一个回调函数,并且此时调用栈为空。所以,事件循环将回调函数压入调用栈中。

console.log('Async Code') 被推入栈中,执行并从栈中弹出后,回调函数也从栈中弹出,整个程序执行完毕。

DOM 事件

消息队列也包括从 DOM 事件传递过来的回调函数,如点击事件、键盘事件等等:

document.querySelector('.btn').addEventListener('click',(event) => {
  	console.log('Button Clicked');
});
复制代码

在这个例子中,事件监听器在 web APIs 环境中等待真正的事件(此处为点击事件)发生,当事件发生时,回调函数将被推进消息队列并等待执行。

事件循环会检查调用栈是否为空,如果为空,则将回调函数压入调用栈并执行。

到现在,我们已经了解了异步回调和 DOM 事件是如何被执行的 —— 利用消息队列存储所有的回调函数,然后回调函数等待被执行。

ES6 工作队列/微任务队列

ES6 提出了工作队列/微任务队列的概念,它们被用于 Promise。消息队列与工作队列的区别是:工作队列相比于消息队列有更高的优先级,这意味着在工作队列/微任务队列中的 promise 任务总是优先执行于在消息队列中的回调函数。

举个例子:

console.log('Script start');

setTimeout(() => {
  	console.log('setTimeout');
}, 0);

new Promise((resolve, reject) => {
    resolve('Promise resolved');
})
.then(res => console.log(res))
.catch(err => console.log(err));
    
console.log('Script End');
复制代码

输出结果:

Script start
Script End
Promise resolved
setTimeout
复制代码

我们可以看到 promise 优先于 setTimeout 被执行。这是因为 promise 的响应被存储在微任务队列中,而微任务队列比消息队列具有更高的优先级。

我们来看另外一个例子,这一次,我们使用两个 promise 和 两个 setTimeout:

console.log('Script start');

setTimeout(() => {
  	console.log('setTimeout 1');
}, 0);

setTimeout(() => {
  	console.log('setTimeout 2');
}, 0);

new Promise((resolve, reject) => {
	resolve('Promise 1 resolved');
})
.then(res => console.log(res))
.catch(err => console.log(err));

new Promise((resolve, reject) => {
    resolve('Promise 2 resolved');
})
.then(res => console.log(res))
.catch(err => console.log(err));

console.log('Script End');
复制代码

打印结果为:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2
复制代码

我们可以看到两个 promise 都优先执行于 setTimeout 的回调函数,还是因为事件循环优先选择微任务队列中的任务。

另一方面,当事件循环正在执行微任务队列中的任务时,又有另一个 promise 被 resolve 了,它将会被添加到同一个微任务队列的队尾,同样也会被优先执行于消息队列中的回调函数,无论这个回调函数等待了多久。

console.log('Script start');

setTimeout(() => {
  	console.log('setTimeout');
}, 0);

new Promise((resolve, reject) => {
    resolve('Promise 1 resolved');
})
.then(res => console.log(res));
  
new Promise((resolve, reject) => {
  	resolve('Promise 2 resolved');
  	}).then(res => {
       	console.log(res);
       	return new Promise((resolve, reject) => {
        	resolve('Promise 3 resolved');
       	})
    }).then(res => console.log(res));
     
console.log('Script End');
复制代码

打印结果为:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout
复制代码

所以,所有在微任务队列中的任务将会优先执行于消息队列中的任务。也就是说,事件循环将在执行任何消息队列中的回调函数之前,首先清空微任务队列中的任务。

总结

我们学会了异步 JavaScript 的工作方式以及一些其他的概念。如调用栈,事件循环,消息队列/任务队列,以及工作队列/微任务队列,这些概念共同组成了 JavaScript 运行时环境。理解这些概念并不是你成为优秀的 JavaScript 开发者的必要条件,但一定会对你有帮助。

最后的话

你的点赞会给我一天好心情,如果能顺手 来个 star 就更完美了。

分类:
前端
标签: