深入理解JavaScript异步编程与事件循环机制
本文系统解析JavaScript单线程特性下的异步编程原理。从浏览器多线程模型入手,阐明同步与异步的执行差异,深入剖析事件循环(Event Loop)的工作机制。内容涵盖XMLHttpRequest、定时器、requestAnimationFrame等异步API,并对比回调地狱、Promise、Generator及Async/Await的演进与优劣,最后解析Node.js异步I/O流程,帮助开发者掌握高效处理异步任务的技巧。
JavaScript异步编程概述
JavaScript被广泛认知为单线程语言,在技术面试中常通过考察代码输出顺序来测试开发者对异步编程的理解深度。提及异步,通常首先联想到Ajax、setTimeout等常见API。在实际项目开发中,绝大多数操作均涉及异步处理。JavaScript的异步机制主要依赖回调函数实现,开发者在长期处理回调的过程中,往往不知不觉间陷入了“回调地狱”的困境。
浏览器线程模型解析
浏览器线程模型概述
在深入异步之前,需先建立对浏览器线程架构的基础认知。虽然JavaScript引擎本身是单线程运行的,但这并不意味着整个浏览器环境也是单线程的。
JavaScript引擎中负责解析和执行脚本代码的主线程仅有一个。然而,浏览器还运行着多个辅助线程以支持JavaScript主线程的工作。诸如onclick事件回调、setTimeout定时器、Ajax网络请求等功能,正是由这些辅助线程协同JavaScript主线程共同实现的。
浏览器内部运行着多种线程,主要包括:
1.GUI渲染线程:负责页面渲染,默认处于挂起或冻结状态。 2.JavaScript引擎线程:专门用于解析和执行JavaScript代码。 3.定时器触发线程:负责处理定时计数器,其计数机制独立于JS引擎。 4.浏览器事件线程:负责处理BOM渲染及事件分发等工作。 5.HTTP线程:主要负责网络数据请求。 6.EventLoop轮询处理线程:当事件被触发时,该线程会将事件添加到待处理队列的队尾。 7.其他辅助线程等。
由此可见,浏览器内部机制远比表面复杂。在上述线程中,GUI渲染线程、JavaScript引擎线程以及浏览器事件线程是浏览器的常驻核心线程。
当浏览器开始解析代码时,会根据代码的具体类型,将任务分配给不同的辅助线程进行处理。
进程概念
进程是指在操作系统中正在运行的一个应用程序实例。
线程概念
线程是进程内独立执行任务的基本单元。线程本身几乎不拥有系统资源,仅保留运行所必需的少量资源,如程序计数器、一组寄存器和栈空间。
进程包含线程,一个进程中可以存在多个线程(原文笔误为N个进程,实指多个线程)。我们可以在电脑任务管理器中查看正在运行的进程,可以认为一个进程对应运行一个程序。例如,用浏览器打开一个网页即开启了一个进程;若打开3个浏览器窗口,则开启了3个独立的进程。
同步与异步的基本概念
同步与异步的基本概念
理解同步与异步是掌握异步编程的前提。虽然Ajax是最具代表性的例子,但为了更清晰地展示逻辑,此处暂不使用Ajax进行举例说明。
同步执行
同步代码会逐行执行,并对后续代码造成阻塞。只有当代码接收到预期的结果后,程序才会继续向下执行。
console.log(1);
alert("同步");
console.log(2);
// 结果:
// 1
// 同步
// 2
异步执行
如果函数返回时调用者无法立即得到预期结果,而是通过某种机制(如回调函数)在将来获取结果,这种模式即为异步。
console.log(1);
setTimeout(() => {
alert("异步");
},0);
console.log(2);
// 结果:
// 1
// 2
// 异步
JavaScript采用异步编程的原因
JavaScript采用异步编程的原因
如前所述,JavaScript是一种单线程执行的脚本语言(这或许是出于历史原因或简化设计)。其单线程特性表现为:任何函数必须从头到尾执行完毕后,才能执行下一个函数。界面的更新、鼠标事件的处理、计时器(setTimeout、setInterval等)的执行都需要排队并串行执行。如果有一段JavaScript代码执行时间较长,在执行期间任何UI更新都会被阻塞,界面事件处理也会停止响应。因此,需要采用异步编程模式,目的是将代码运行打散,或让IO调用(如AJAX)在后台运行,从而确保界面更新和事件处理能够及时响应。
JavaScript语言的设计者意识到,此时主线程完全可以忽略IO设备,挂起处于等待状态的任务,先运行排在后面的任务。等到IO设备返回结果后,再回过头继续执行被挂起的任务。
异步运行机制与示例
异步运行机制详解
1. 所有同步任务都在主线程上执行,形成一个执行栈。
2. 主线程之外,还存在一个任务队列。只要异步任务有了运行结果,就在任务队列中放置一个事件。
3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,检查其中有哪些事件。对应的异步任务结束等待状态,进入执行栈开始执行。
4. 主线程不断重复上述第三步,形成循环。
具体示例分析:
<button onclick="updateSync()">同步</button>
<button onclick="updateAsync()">异步</button>
<div id="output"></div>
<script>
function updateSync() {
for (var i = 0; i < 1000000; i++) {
document.getElementById('output').innerHTML = i;
}
}
function updateAsync() {
var i = 0;
function updateLater() {
document.getElementById('output').innerHTML = (i++);
if (i < 1000000) {
setTimeout(updateLater, 0);
}
}
updateLater();
}
</script>
点击同步按钮会调用updateSync同步函数,逻辑简单:循环体内每次更新output节点内容为i。如果在其他多线程语言中,你可能会看到界面快速显示从0到999999后停止。但在JavaScript中,你会感觉按钮按下时卡了一下,然后直接看到最终结果999999,而没有中间过程。这是因为在updateSync函数运行过程中UI更新被阻塞,只有当它结束退出后才会更新UI。反之,点击异步按钮时,可以明显看到DOM逐步更新的过程。
从上述示例可以明显看出,异步编程对于JavaScript而言至关重要。
异步编程的优势与多线程探讨
异步编程的优势
从编程方式来看,同步编程更为简单,但同步存在局限性:一是如果是单线程,一旦遇到阻塞调用,会造成整个线程阻塞,导致CPU无法得到有效利用。浏览器的JavaScript执行和渲染运行在单线程中,一旦遇到阻塞调用,不仅JavaScript执行被阻塞,整个浏览器渲染也被阻塞,导致界面卡死。若是多线程,则不可避免要考虑互斥和同步问题,这会带来巨大的复杂度。实际上,浏览器下因为同时只能执行一段JavaScript代码,这意味着不存在互斥问题,但同步问题仍然不可避免。在以往的回调风格中,异步的流程控制(本质也是同步问题)也比较复杂。浏览器端的编程方式即GUI编程,其本质是事件驱动的(如鼠标点击、Http请求结束等),异步编程更为自然。
有人可能会问,既然异步如此重要,为什么JavaScript没有使用多线程作业?对此进行调研发现,在HTML5推出后提供了多线程支持,但较为局限。在使用多线程时无法使用window对象。若JavaScript使用多线程,在A线程中正在操作DOM,而B线程中已经把该DOM删除了(这只是一个简单的例子,可能还存在其他问题,具体历史原因无从考究)。这会给编程带来很大的负担。我想这也说明了为什么JavaScript没有使用异步编程(注:原文此处表述有误,应为“为什么JavaScript没有使用多线程”)的原因。
回调函数与异步的关系
异步与回调的关系
回调到底属于异步吗?回想刚开始学习JavaScript时,常将这两个概念混淆。要搞清楚这个问题,首先要明白什么是回调函数。
根据百科定义:回调函数是一个作为参数传递给另一个函数,并在父函数完成后执行的函数。回调的特殊之处在于,出现在“父类”之后的函数可以在回调执行之前执行。另一件需要知道的重要事情是如何正确地传递回调,这也是我经常忘记正确语法的地方。
通过上述解释可以得出,回调函数本质上是一种设计模式,例如我们熟悉的jQuery也遵循了这个设计原则。在JavaScript中,回调函数的具体定义为:函数A作为参数(函数引用)传递到另一个函数B中,并且这个函数B执行函数A。我们就说函数A叫做回调函数。如果没有名称(函数表达式),就叫做匿名回调函数。
简单示例:
function test (n,fn){
console.log(n);
fn && fn(n);
}
console.log(1);
test(2);
test(3,function(n){
console.log(n+1)
});
console.log(5)
// 结果
// 1
// 2
// 3
// 4
// 5
通过上述代码输出结果可以得出,回调函数不一定属于异步。一般同步会阻塞后面的代码,通过输出结果也就得出了这个结论。回调函数在同步情境下通常是最后执行的,而在异步情境下有可能不执行,因为事件没有被触发或者条件不满足。
回调函数的应用场景
回调函数的应用场景
1. 资源加载:动态加载js文件后执行回调,加载iframe后执行回调,ajax操作回调,图片加载完成执行回调等。
2. DOM事件及Node.js事件基于回调机制(Node.js回调可能会出现多层回调嵌套的问题)。setTimeout的延迟时间为0,这个hack经常被用到,settimeout调用的函数其实就是一个callback的体现。
3. 链式调用:链式调用的时候,在赋值器(setter)方法中(或者本身没有返回值的方法中)很容易实现链式调用,而取值器(getter)相对来说不好实现链式调用,因为你需要取值器返回你需要的数据而不是this指针。如果要实现链式方法,可以用回调函数来实现。
4. setTimeout、setInterval的函数调用得到其返回值。由于两个函数都是异步的,即:调用时序和程序的主流程是相对独立的,所以没有办法在主体里面等待它们的返回值。它们被打开的时候程序也不会停下来等待,否则也就失去了setTimeout及setInterval的意义了,所以用return已经没有意义,只能使用callback。callback的意义在于将timer执行的结果通知给代理函数进行及时处理。
JavaScript常见异步操作API
JavaScript中的那些异步操作
既然JavaScript有很多辅助线程,不可能所有的工作都是通过主线程去做,既然分配给辅助线程去做事情。
XMLHttpRequest对象
XMLHttpRequest对象应该不是很陌生的,主要用于浏览器的数据请求与数据交互。XMLHttpRequest对象提供两种请求数据的方式,一种是同步,一种是异步。可以通过参数进行配置,默认为异步。
对于XMLHttpRequest这里就不作太多的赘述了。
var xhr = new XMLHttpRequest();
xhr.open("GET", url, false); //同步方式请求
xhr.open("GET", url, true); //异步
xhr.send();
同步Ajax请求流程:
当请求开始发送时,浏览器事件线程通知主线程,让Http线程发送数据请求。主线程收到请求之后,通知Http线程发送请求。Http线程收到主线程通知之后就去请求数据,等待服务器响应。过了N年之后(此处为夸张修辞,指等待时间),收到请求回来的数据,返回给主线程数据已经请求完成。主线程把结果返回给了浏览器事件线程,去完成后续操作。
异步Ajax请求流程:
当请求开始发送时,浏览器事件线程通知,浏览器事件线程通知主线程,让Http线程发送数据请求。主线程收到请求之后,通知Http线程发送请求。Http线程收到主线程通知之后就去请求数据,并通知主线程请求已经发送。主进程通知浏览器事件线程已经去请求数据,则浏览器事件线程只需要等待结果,并不影响其他工作。
setInterval与setTimeout
setInterval与setTimeout同属于异步方法,其异步是通过回调函数方式实现。其两者的区别则setInterval会连续调用回调函数,而setTimeout会延时调用回调函数只会执行一次。
setInterval(() => {
alert(1)
},2000)
// 每隔2s弹出一次1
setTimeout(() => {
alert(2)
},2000)
// 进入页面后2s弹出2,则不会再次弹出
requestAnimationFrame
requestAnimationFrame字面意思就是去请求动画帧。在没有此API之前都是基于setInterval,与setInterval相比,requestAnimationFrame最大的优势是由系统来决定回调函数的执行时机。具体一点讲,如果屏幕刷新率是60Hz,那么回调函数就每16.7ms被执行一次;如果刷新率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms。换句话说就是,requestAnimationFrame的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。
简单示例:
var progress = 0;
//回调函数
function render() {
progress += 1; //修改图像的位置
if (progress < 100) {
//在动画没有结束前,递归渲染
window.requestAnimationFrame(render);
}
}
//第一帧渲染
window.requestAnimationFrame(render);
Object.observe与Promise
Object.observe - 观察者模式
Object.observe是一个提供数据监视的API,在chrome中已经可以使用。是ECMAScript 7的一个提案规范,官方建议的是谨慎使用级别,但是个人认为这个API非常有用,例如可以对现在流行的MVVM框架作一些简化和优化。虽然标准还没定,但是标准往往是滞后于实现的,只要是有用的东西,肯定会有越来越多的人去使用,越来越多的引擎会支持,最终促使标准的生成。从observe字面意思就可以知道,这玩意儿就是用来做观察者模式之类。
var obj = {a: 1};
Object.observe(obj, output);
obj.b = 2;
obj.a = 2;
Object.defineProperties(obj, {a: { enumerable: false}}); //修改属性设定
delete obj.b;
function output(change) {
console.log(1)
}
Promise对象
Promise是对异步编程的一种抽象。它是一个代理对象,代表一个必须进行异步处理的函数返回的值或抛出的异常。也就是说Promise对象代表了一个异步操作,可以将异步对象和回调函数脱离开来,通过then方法在这个异步操作上面绑定回调函数。
在Promise中最直观的例子就是Promise.all统一去请求,返回结果。
var p1 = Promise.resolve(3);
var p2 = 42;
var p3 = new Promise(function(resolve, reject) {
setTimeout(resolve, 100, 'foo');
});
Promise.all([p1, p2, p3]).then(function(values) {
console.log(values);
});
// expected output: Array [3, 42, "foo"]
Generator与Async/Await
Generator与Async/Await
ES6的Generator却给异步操作又提供了新的思路,马上就有人给出了如何用Generator来更加优雅的处理异步操作。Generator函数是协程在ES6的实现,最大特点就是可以交出函数的执行权(即暂停执行)。整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。Generator函数的执行方法如下。
function * greneratorDome(){
yield "Hello";
yield "World";
return "Ending";
}
let grenDome = greneratorDome();
console.log(grenDome.next());
// {value: "Hello", done: false}
console.log(grenDome.next());
// {value: "World", done: false}
console.log(grenDome.next());
// {value: "Ending", done: true}
console.log(grenDome.next());
// {value: undefined, done: true}
粗略实现Generator示例
function makeIterator(array) {
var nextIndex = 0;
return {
next: function() {
return nextIndex < array.length ?
{value: array[nextIndex++], done: false} :
{value: undefined, done: true};
}
};
}
var it = makeIterator(['a', 'b']);
it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }
Async/Await与Generator类似,Async/await是Javascript编写异步程序的新方法。以往的异步方法无外乎回调函数和Promise。但是Async/await建立于Promise之上,个人理解是使用了Generator函数做了语法糖。async函数就是隧道尽头的亮光,很多人认为它是异步操作的终极解决方案。
function a(){
return new Promise((resolve,reject) => {
console.log("a函数")
resolve("a函数")
})
}
function b (){
return new Promise((resolve,reject) => {
console.log("b函数")
resolve("b函数")
})
}
async function dome (){
let A = await a();
let B = await b();
return Promise.resolve([A,B]);
}
dome().then((res) => {
console.log(res);
});
Node.js异步I/O与事件循环
Node.js异步I/O机制
当我们发起IO请求时,调用的是各个不同平台的操作系统内部实现的线程池内的线程。这里的IO请求可不仅仅是读写磁盘文件,在*nix中,将计算机抽象了一层,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为文件,常说的IO请求就是抽象后的文件。完成Node整个异步IO环节的有事件循环、观察者、请求对象。
事件循环机制
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。于是就有一个概念,任务队列。如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
事件循环是Node的自身执行模型,正是事件循环使得回调函数得以在Node中大量的使用。在进程启动时Node会创建一个while(true)死循环,这个和Netty也是一样的,每次执行循环体,都会完成一次Tick。每个Tick的过程就是查看是否有事件等待被处理。如果有,就取出事件及相关的回调函数,并执行关联的回调函数。如果不再有事件处理就退出进程。

线程只会做一件事情,就是从事件队列里面取事件、执行事件,再取事件、再事件。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。
while(true) {
var message = queue.get();
execute(message);
}
我们可以把整个事件循环想象成一个事件队列,在进入事件队列时开始对事件进行弹出操作,直至事件为0为止。
process.nextTick机制
process.nextTick()方法可以在当前"执行栈"的尾部-->下一次Event Loop(主线程读取"任务队列")之前-->触发process指定的回调函数。也就是说,它指定的任务总是发生在所有异步任务之前,当前主线程的末尾。(nextTick虽然也会异步执行,但是不会给其他io事件执行的任何机会);
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});
setTimeout(function C() {
console.log(3');
}, 0);
// 1
// 2
// 3
异步过程的构成要素
异步过程的构成要素
异步函数实际上很快就调用完成了,但是后面还有工作线程执行异步任务,通知主线程,主线程调用回调函数等很多步骤。我们把整个过程叫做异步过程,异步函数的调用在整个异步过程中只是一小部分。
一个异步过程的整个过程:主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到通知(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,通知主线程;主线程收到通知后,执行一定的动作(调用回调函数)。
它可以叫做异步过程的发起函数,或者叫做异步任务注册函数。args是这个函数需要的参数,callbackFn(回调函数)也是这个函数的参数,但是它比较特殊所以单独列出来。所以,从主线程的角度看,一个异步过程包括下面两个要素:
1. 发起函数; 2. 回调函数callbackFn
它们都是主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果。
具体示例:
setTimeout(function,1000);
其中setTimeout就是异步过程的发起函数,function是回调函数。
注:前面说得形式A(args...,callbackFn)只是一种抽象的表示,并不代表回调函数一定要作为发起函数的参数,例如:
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx;
xhr.open('GET', url);
xhr.send();
总结
总结
JavaScript的异步编程模式不仅是一种趋势,而且是一种必要,因此作为HTML5开发者是非常有必要掌握的。采用第三方的异步编程库和异步同步化的方法,会让代码结构相对简洁,便于维护,推荐开发人员掌握一二,提高团队开发效率。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。
JS处理含Emoji或中文的字符串转数组乱码问题
2026-11-05 16:12:28
JS怎么使用展开运算符合并对象
2026-11-05 14:42:18
ES6+环境下判断JS对象为空的简洁写法
2026-11-05 12:12:23
怎么通过replacer函数自定义JS中JSON的格式化输出
2026-11-05 11:19:44
JS正则表达式基础:字面量与RegExp构造函数的区别及常用元字符
2026-11-05 11:16:58
