事件循环和异步操作
单线程模型
What
JS 引擎有多个线程,而 JS 执行线程只有一个,其他线程在后台配合完成任务。也就是说,同一时刻只能跑一段 JS 代码,其他任务都在后边排队。
Why
回想 JS 诞生的初衷:一个为网页提供一些简单交互操作的脚本语言。
如果是多线程,那么需要考虑线程间的竞争关系、互操作能力、内存屏障、原子操作等等,作为一个网页脚本语言,它不需要那么高的复杂度。
所以, JS 从设计之初就是单线程的语言。
Result
这种单线程模式实现相对简单。但如果遇到耗时任务,后面的任务就必须排队等待。
如果说确实是一个重要的大计算量任务,那确实得等,但对于 Ajax 等 I/O操作,如果网络出现了问题,会导致长时间的假死无响应的状态。
而在浏览器这种强交互环境下,这个问题必须得到处理。如果可以把这种耗时的外部 I/O 任务给挂起,先执行后面的代码,等 I/O 结果返回了再执行操作,那就可以避免堵塞。这就是 JS 的事件循环机制的设计初衷。
Web Worker
HTML5 Web Worker 标准允许 JS 创建多个线程,子线程由主线程控制,且不可操作 DOM 。所以,JS 执行线程仍然是唯一的 UI 渲染和 DOM 操作线程。Web Worker 的子线程只是提供了在后台执行计算的能力,结果需要回传给主线程处理。
同步任务与异步任务
同步任务
在主线程上顺序执行的任务。只有前一个任务执行完毕,才能执行后一个任务。
异步任务
是那些被 JS 引擎放在一边,不进入主线程、而进入任务队列的任务。
只有 JS 引擎认为某个异步任务可以执行了(比如 Ajax 操作得到了响应),该任务的回调函数才会进入主线程执行。
任务队列与事件循环
事件循环是一个程序结构,用于等待和发送消息和事件
a programming construct that waits for and dispatches events or messages in a program.
事件循环的作用机制
1. 事件循环发生的Context
在浏览器或 Node.js 等宿主环境中,JS 引擎在执行主线程代码的同时,宿主环境还会维护着一个或多个任务队列(也常被称为消息队列)用于存放异步任务的回调函数。
2. 事件循环的作用过程
首先,主线程会执行当前执行栈中的所有同步任务。当执行栈清空后,事件循环(Event Loop) 机制就会开始工作。它会不断检查任务队列。一旦任务队列中有可执行的回调函数,事件循环就会将这些回调函数取出,并将其推入执行栈中,主线程开始执行。这个过程会一直重复。
3. 异步任务的解决方案:回调函数与任务队列
异步操作通常通过回调函数来定义其后续行为。当异步操作完成并产生结果时,宿主环境会将与之关联的回调函数放入任务队列。然后,事件循环机制会将其推入主线程的执行栈中执行。如果一个异步操作没有关联任何回调函数,那么即使它完成了,也没有任何代码需要在主线程中执行,因此它不会产生可进入任务队列的任务。
宿主环境负责监听这些异步事件(如网络响应、定时器到期)。一旦异步事件完成,宿主环境就会将对应的回调函数推入任务队列。而事件循环正是负责不断检查执行栈是否为空,并在为空时,从任务队列中取出最旧的回调函数推入执行栈中执行。
➕ 补充:关于浏览器的进程/线程的理解
浏览器是一个多进程的应用程序,每个进程又包含多个线程。
例如,一个tab页面通常会有一个独立的渲染进程,这个渲染进程里包含了:
- 主线程(JS 引擎线程):负责执行 JavaScript 代码,维护执行栈。
- 网络线程:专门用于处理网络请求。
- 定时器线程:专门用于处理定时器。
- UI 渲染线程:负责页面的绘制和渲染。
当你在 JS 主线程中调用 fetch() 时,fetch 函数会把网络请求的任务交给网络线程去处理。这个网络线程会在后台默默地等待服务器响应。一旦请求完成,网络线程就会将对应的回调函数推入任务队列,等待事件循环来调度执行。
同理,当你调用 setTimeout(callback, 1000) 时,定时器 Web API 会把这个任务交给 定时器线程。1000 毫秒后,定时器线程会将 callback 函数推入 宏任务队列。
异步操作的模式
回调函数
回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序的流程难以追踪(尤其是多个回调函数嵌套的情况)
发布/订阅的事件模式
通过一个中间的事件总线来管理事件和回调,实现事件发布(trigger),事件订阅(on) ,当然也是在事件的完成回调来派发事件啦(本质上还是回调,不过更优雅,可以避免回调嵌套)
异步操作的流程控制
串行执行
当有一堆异步任务需要串行执行时(后一个任务依赖前一个任务的返回结果),如果光采用回调函数的形式,那就会造成回调地狱。
这种一般在回调函数里边递归调用下一个任务,以此实现运行完一个后运行下一个的效果
const tasks = [......]
const results = []
function run(i){
// 递归调用要有终止条件
if(i>=tasks.length) {
console.log('finish all')
return
}
const fn = tasks[i]
fn().then(res=>{
results[i] = res
run(i + 1)
})
}并行执行
同时启动异步任务,通过计数实现下一个
const tasks = [......]
const results = []
function run(){
let finishCount = 0
for(let i = 0; i < tasks.length; i++){
const task = tasks[i]
const idx = i
task().then(res=>{
results[idx] = res
finishCount++
if(finishCount === tasks.length) {
console.log("finish tasks !")
}
})
}
}限流的并行执行
在并行执行的基础上,设置最大并发阈值。
const tasks = []
const results = []
const limit = 3
// 模拟任务执行
for (let i = 0; i < 10; i++) {
const id = i
tasks.push(
() => new Promise((resolve) => {
setTimeout(() => {
console.log(`task ${id} finished !`)
resolve()
}, 1000);
})
)
}
let runningCount = 0
let todoTasks = tasks.slice()
function run() {
// todo.len === 0 并且 running == 0 ,避免重复触发 all finished
if (todoTasks.length == 0 && runningCount == 0) {
console.log('all finished !')
return
}
while (runningCount < limit && todoTasks.length > 0) {
const taskID = tasks.length - todoTasks.length
const task = todoTasks.shift()
runningCount++
task().then(res => {
results[taskID] = res
runningCount--
run()
})
}
}
run()定时器
setTimeout
- 第二个参数省略时,默认是 0
- 还允许传入更多参数,从第二个往后的参数会传入给回调函数当参数
setInterval
与setTimeout的区别在于,setInterval会一直执行,直到进程关闭。
常见应用:
- 实现页面动画,但更好的实现是使用
requestAnimationFrame - 轮询服务接口
注意: setInterval 第二个参数指定的是开始执行之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间,如下图示 
如果需要严格控制两次任务的间隔,那应该使用 setTimeout 去做
function runInterval(fn, gapTime) {
let timer = null
function loop() {
fn()
timer = setTimeout(loop, gapTime)
}
timer = setTimeout(loop, gapTime)
return () => {
clearTimeout(timer)
}
}
const fn = () => {
console.log(new Date().getSeconds())
}
const stop = runInterval(fn, 1000)
setTimeout(() => {
console.log('stop interval ')
stop()
}, 4900);应用
控制事件发生的顺序
通过promise或定时器,把任务放入异步的任务队列里,比如想让子元素事件回调在父元素回调之后再执行
与浏览器默认事件博弈
比如有这样一个需求,让 input 中输入的字符自动转大写
<input type="text" id="ipt">
<script>
const input = document.querySelector('#ipt')
console.log(input)
input.addEventListener('keydown', function (e) {
console.log(e.target === input, input === this) // true, true
console.log(e.target.value)
// 只能拿到上一状态的值,因为这个回调在浏览器更新 input.value 之前触发
setTimeout(() => {
// 这时候就能拿到输入的 value,并做操作
const upperCase = e.target.value.toUpperCase()
e.target.value = upperCase
});
})
</script>耗时型任务后置
把任务放在setTimeout的回调中,当执行时,主线程代码、微任务代码都已经执行完毕,意味着浏览器“暂时空闲”,在此基础上,还可以将大任务进行分块处理,各个子任务都通过setTimeout回调执行。
比如要渲染 1000 条数据,就可以 setTimeout ,分批渲染,每个 batch 200条这样子。这种方案也叫 任务分块 chunking 或 时间切片 Time Slicing
const list = new Array(1000).fill(0);
const BATCH_SIZE = 200;
function renderChunk(data) {
for (let i = 0; i < data.length; i++) {
const item = document.createElement('div');
item.textContent = `Item ${data[i]}`;
document.body.appendChild(item);
}
}
function processBatch(data, batchSize) {
let index = 0;
function run() {
// 递归终止条件
if (index >= data.length) {
console.log('所有数据渲染完成');
return;
}
const end = Math.min(index + batchSize, data.length);
const chunk = data.slice(index, end); // 数据切片
renderChunk(chunk);
index = end;
// 使用 setTimeout 将下一个批次放入宏任务队列
setTimeout(run, 0);
}
run();
}
processBatch(list, BATCH_SIZE);Promise
从 Promise A+ 规范到官方的 Promise 标准,这是 ES6 之后的事情了,这里先不讨论。