我们知道,用户键盘输入的事件有3个:keydown、keypress、keyup。可这三位各有各的缺点,没一个让人省心的。

keypress,无法拿到用户最新的输入值,在输入中文时还不触发。keyup,能拿到最新输入值了,但已经无法通过 preventDefault() 阻止输入。

比如这个场景:把用户输入的小写字母即时的转换成大写

我们分别在keypress和keyup的监听函数里执行:this.value = this.value.toUpperCase(),得到的画面是这样的:

keypress:  keyup:

可以看到,keypress无法转换最后一个字母,keyup有一个从小写跳到大写的动画,体验并不好。

我终于在阮一峰老师的《Javascript教程》里看到了完美的解决方案:

<input type="text" id="haha" />
<script>
	document.getElementById('haha').onkeypress = function(e) {
		setTimeout(() => {
			this.value = this.value.toUpperCase();
		}, 0);
	};
</script>

好了,本文到此为止!(狗头)

问题已经解决了,可如果我们就此打住,我们的收获也仅仅是这个问题而已。我们必须弄明白:为什么会这样?

带着这个疑问,我们把浏览器的事件循环好好捋一下:

一、进程和线程

现代浏览器都是多进程的,主要包括:1个浏览器进程、1个网络进程、1个GPU进程、多个渲染进程、多个插件进程。

其中,每个页面标签各自一个渲染进程(有时几个标签会共享一个渲染进程,详情请看李兵老师的)。

每个渲染进程里,会有多个线程。我们说JS是单线程的,其实是说一个渲染进程里只有一个主线程。

一个渲染进程,主要有以下几个线程:

(1)主线程:JavaScript执行、HTML解析与DOM构建、CSS解析与样式计算布局与回流(Reflow)、绘制与重绘(Repaint)、事件处理资源加载等。

(2)渲染线程:负责将构建好的帧绘制到屏幕上。

(3)合成线程:负责页面的滚动和动画等操作,可以在不需要主线程参与的情况下独立完成部分工作,以提高性能。

(4)GPU 线程:处理 GPU 任务,例如 WebGL 或者 CSS 3D 变换等。

(5)I/O 线程:处理磁盘和网络 I/O。

(6)工作线程(Worker Thread):执行 Web Worker 或 Service Worker 的代码。

(7)定时器线程:setTimeout、setInterval的计时在这上面进行。

 除了主线程,这里面最重要的是I/O线程,渲染进程与浏览器其他进程的交互,都是通过I/O线程来完成的。比如页面点击、网络请求完成,就分别是浏览器进程、网络进程通过I/O线程来通知主线程的。

二、什么是事件循环?

只有一个主线程,同步任务没问题,一行行往下执行就是了。那异步任务呢?网络请求、定时器都那么耗时,如果都放在主线程执行,后果不堪设想。浏览器是通过什么方式,在只有一个主线程的情况下,让页面可以流畅运行的呢?

答案就是:主线程执行同步任务,异步任务则交给其他线程来执行。比如:网络请求由IO线程负责,setTimeout的计时由定时器线程负责等。

同步任务、异步任务都安排好了,但是,异步任务一般都会有回调函数,也就是异步任务执行结束后的回调。它们呢,浏览器如何对待?

异步任务的回调,还有事件监听函数、Promise的then等,它们则是放入到一个叫“消息队列”的东东里。浏览器执行完同步任务后,就开始从消息队列里取任务,执行完一个再取下一个。比如setTimeout在计时结束后,回调函数就进入消息队列排队,等待被取出执行。Ajax请求也是一样,请求返回后,回调函数就进入消息队列排队,等待被取出执行。消息队列是一个队列结构,先进先出。如果消息队列是空的,事件循环就进入等待状态,直到新的任务被放入消息队列里。

总结就是:主线程执行同步任务,其他线程执行异步任务,异步任务的回调则进入消息队列排队待命。

这个过程是循环进行的,你可以简单理解为是一个无限的for循环,这就是浏览器的事件循环。

三、宏任务队列和微任务队列

事件循环的关键,就是这个“消息队列”。

其实消息队列是我们的习惯叫法,它还被叫做任务队列或者事件队列。具体来说,一个渲染进程有两个消息队列:一个宏任务队列和一个微任务队列。我们平时说的消息队列,其实是指宏任务队列。

哪些任务属于宏任务?

(1)整体的 script 代码(也就是一开始的全局代码)

(2)`setTimeout` 和 `setInterval` 的回调

(3)`setImmediate` 的回调(Node.js 环境)

(4)I/O 操作的回调函数(主要是早期,I/O 操作通常使用回调函数来处理异步结果。现代JavaScript中不管是浏览器的网络请求,还是nodejs的网络请求和文件读写,普遍返回promise,因此属于微任务)

(5)用户交互事件(如 click、keydown 等)

(6)UI 渲染更新

(7)postMessage、MessageChannel

(8)WebWorker 的 message 事件

哪些任务属于微任务?

(1)`Promise` 的 `then` 和 `catch` 的回调

(2)`process.nextTick` 的回调(Node.js 环境)

(3)`MutationObserver` 的回调

(4)`queueMicrotask` 方法的回调

(5)`async/await`(实际上是通过 `Promise` 实现的)

为什么要有微任务队列呢?

你可以理解为,都是回调,但是有一些的优先级要比其他的更高,所以被单独放入了一个队列里,并把这些任务定义为微任务。

它们的执行顺序是这样的:主线程的同步代码执行完后,就去检查微任务队列,先把微任队列里的任务都清空,之后,再从宏任务队列里取出一个宏任务,开始下一轮事件循环。

注意,我们要区分“同步任务”和“宏任务”的概念。虽然主线程只执行同步代码,并且宏任务被取出后回到主线程执行。但并不是说宏任务都是同步代码,这是俩不同的概念。不管宏任务还是微任务,本质都是一个回调函数,里面既可以写同步代码,也可以写异步代码。当执行到它们里面的异步代码时,也是会交给其他线程执行,执行结束后把回调放入宏任务队列或微任务队列的。

还有一点值得注意,那就是Promise对象或者async函数,里面的代码是同步执行的,在开发中我们也有这个体验。那为什么总感觉Promise是异步的呢?那是因为我们一般都会在Promise里写网络请求,网络请求是异步的。由网络进程完成请求后,通过I/O线程,把回调函数放入到宏任务队列里。如果Promise里没有异步任务的话,它就完全是同步的。但是它们的回调,也就是then或者catch函数,却是被放入到微任务队列里,等待同步代码都执行完后再执行。

其实,宏任务队列也有好几种,比如:用户交互队列、定时器队列、网络事件队列。因为即便都是宏任务,也有不同的优先级。比如用户交互队列的宏任务,优先级就比定时器队列要高,因为用户体验是要首先保证的。但我们一般无需深入到这个程度,简单的理解为只有一个宏任务队列,也没什么问题。

另外,不要混淆“在主线程执行”和“宏任务”这俩概念。比如DOM的更新和页面的重新渲染,都是在主线程进行的。但是在vue中,DOM的更新会被放入微任务队列,DOM更新完后,主线程再进行页面的重新渲染。不管是宏任务队列还是微任务队列,最终都是要放入主线程执行的。所以,简单的说一个任务是宏任务还是微任务并不准确,因为它们都是要被放入主线程执行的。确切的说法应该是:他们被放入宏任务队列还是微任务队列。

四、一个完整的事件循环是什么样的?

(1)主线程先执行同步代码,包括一开始的全局代码,或者后面从宏任务队列取出的宏任务。

(2)同步代码执行完后,检查微任务队列,把微任务队列清空。

(3)尝试重新渲染页面。

浏览器会在每一轮事件循环结束的时候,尝试重新渲染页面。如果页面没有变化,什么都不做。反之,也不一定立刻重新渲染,浏览器为了提高渲染效率,可能会把几次渲染合并进行。另外,浏览器的渲染,考虑因素还有更多,比如显示器的刷新率等。

(4)从宏任务队列取出下一个宏任务,进入下一轮事件循环。

当宏任务队列和微任务队列都为空时,浏览器可能会进入一个“空闲”状态,等待新的任务被添加到队列中。这个状态通常被称为“事件循环的空闲阶段”。

五、再解释这个案例

好了,现在是时候解释为什么 setTimeout(fn,0) 这么神奇了!

浏览器的事件监听函数,和setTimeout的回调函数,都是被放入宏任务队列里的,浏览器把它们取出来放到主线程执行,就是开始一个事件循环。

1、为什么能拿到最新的输入结果?

前文说到,浏览器在每个事件循环结束的时候,会尝试重新渲染页面,这个过程就包括更新DOM。setTimeout把回调函数放入宏任务队列,也就会在最快下一个事件循环执行。此时JS访问的,就是在上个事件循环结束后更新的DOM,因此就能拿到最新的输入了。

2、为什么输入框直接显示大写,而不是像keyup那样,先显示小写然后跳成大写?

这儿我认为有两种可能:

(1)keypress事件的回调,和setTimeout的回调,是相邻的两个事件循环,浏览器把它俩结束后的渲染合并了,先变成大写,然后更新DOM,渲染到页面中。

(2)keypress事件回调这一轮事件循环结束后,其实是渲染了,但是接着进行setTimeout回调的这一轮事件循环,马上把小写变成了大写。肉眼根本反应不过来,看上去就是直接显示的大写。

至于哪一种是对的,我无法确定,有大佬能指点一下吗?

本人水平非常有限,写作主要是为了把自己学过的东西捋清楚。如有错误,还请指正,感激不尽。