我们知道,用户键盘输入的事件有3个:keydown、keypress、keyup。可这三位各有各的缺点,没一个让人省心的。
keypress,无法拿到用户最新的输入值,在输入中文时还不触发。keyup,能拿到最新输入值了,但已经无法通过 preventDefault() 阻止输入。
比如这个场景:把用户输入的小写字母即时的转换成大写。
我们分别在keypress和keyup的监听函数里执行:this.value = this.value.toUpperCase(),得到的画面是这样的:
keypress: keyup:
可以看到,keypress无法转换最后一个字母,keyup有一个从小写跳到大写的动画,体验并不好。
我终于在阮一峰老师的《Javascript教程》里看到了完美的解决方案:
document.getElementById('haha').服务器托管网onkeypress = function(e) {
setTimeout(() => {
this.value = this.value.toUpperCase();
}, 0);
};
好了,本文到此为止!(狗头)
问题已经解决了,可如果我们就此打住,我们的收获也仅仅是这个问题而已。我们必须弄明白:为什么会这样?
带着这个疑问,我们把浏览器的事件循环好好捋一下:
一、进程和线程
现代浏览器都是多进程的,主要包括:1个浏览器进程、1个网络进程、1个GPU进程、多个渲染进程、多个插件进程。
其中,每个页面标签各自一个渲染进程(有时几个标签会共享一个渲染进程,详情请看李兵老师的《浏览器工作原理与实践》)。
每个渲染进程里,会有多个线程。我们说JS是单线程的,其实是说一个渲染进程里只有一个主线程。
一个渲染进程,主要有以下几个线程:
(1)主线程:执行 JavaScript 代码、计算 CSS 样式、构建和更新 DOM 树、处理用户交互等。
(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 操作(如网络请求、文件读写等,主要在 Node.js 环境)
(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函数,却是被放入到微任务队列里,等待同步代码都执行完后再执行。
其实,宏任务队列也有好几种,比如:用户交互队列、定时器队列、网络事件队列。因为即便都是宏任务,也有不同的优先级。比如用户交互队列的宏任务,优先级就比定时器队列要高,因为用户体验是要首先保证的。但我们一般无需深入到这个程度,简单的理解为只有一个宏任务队列,也没什么问题。
四、一个完整的事件循环是什么样的?
(1)主线程先执行同步代码,包括一开始的全局代码,或者后面从宏任务队列取出的宏任务。
(2)同步代码执行完后,检查微任务队列,把微任务队列清空。
(3)尝试重新渲染页面。
浏览器会在每一轮事件循环结束的时候,尝试重新渲染页面。如果页面没有变化,什么都不做。反之,也不一定立刻重新渲染,浏览器为了提高渲染效率,可能会把几次渲染合并进行。另外,浏览器的渲染,考虑因素还有更多,比如显示器的刷新率等。
(4)从宏任务队列取出下一个宏任务,进入下一轮事件循环。
当宏任务队列和微任务队列都为空时,浏览器可能会进入一个“空闲”状态,等待新的任务被添加到队列中。这个状态通常被称为“事件循环的空闲阶段”。
五、再解释这个案例
好了,现在是时候解释为什么 setTimeout(fn,0) 这么神奇了!
浏览器的事件监听函数,和setTimeout的回调函数,都是被放入宏任务队列里的,浏览器把它们取出来放到主线程执行,就是开始一个事件循环。
1、为什么能拿到最新的输入结果?
前文说到,浏览器在每个事件循环结束的时候,会尝试重新渲染页面,这个过程就包括更新DOM。setTimeout把回调函数放入宏任务队列,也就会在最快下一个事件循环执行。此时JS访问的,就是在上个事件循环结束后更新的DOM,因此就能拿到最新的输入了。
2、为什么输入框直接显示大写,而不是像keyup那样,先显示小写然后跳成大写?
这儿我认为有两种可能:
(1)keypress事件的回调,和setTimeout的回调服务器托管网,是相邻的两个事件循环,浏览器把它俩结束后的渲染合并了,先变成大写,然后更新DOM,渲染到页面中。
(2)keypress事件回调这一轮事件循环结束后,其实是渲染了,但是接着进行setTimeout回调的这一轮事件循环,马上把小写变成了大写。肉眼根本反应不过来,看上去就是直接显示的大写。
至于哪一种是对的,我无法确定,有大佬能指点一下吗?
本人水平非常有限,写作主要是为了把自己学过的东西捋清楚。如有错误,还请指正,感激不尽。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net
软件定时器文档 软件定时器是一种在软件层面实现计时功能的机制,通过软件定时器,可以在特定时间点或时间间隔触发特定的事件。软件定时器常用于实现周期性任务、超时处理、定时器中断等功能。 软件定时器包含两个主要组件:定时服务器和定时客户端。 定时服务器用于时间管理和…