Skip to content

事件循环和异步操作 ​

单线程模型 ​

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页面通常会有一个独立的渲染进程,这个渲染进程里包含了:

  1. 主线程(JS 引擎线程):负责执行 JavaScript 代码,维护执行栈。
  2. 网络线程:专门用于处理网络请求。
  3. 定时器线程:专门用于处理定时器。
  4. UI 渲染线程:负责页面的绘制和渲染。

当你在 JS 主线程中调用 fetch() 时,fetch 函数会把网络请求的任务交给网络线程去处理。这个网络线程会在后台默默地等待服务器响应。一旦请求完成,网络线程就会将对应的回调函数推入任务队列,等待事件循环来调度执行。

同理,当你调用 setTimeout(callback, 1000) 时,定时器 Web API 会把这个任务交给 定时器线程。1000 毫秒后,定时器线程会将 callback 函数推入 宏任务队列。

异步操作的模式 ​

回调函数
回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序的流程难以追踪(尤其是多个回调函数嵌套的情况)

发布/订阅的事件模式
通过一个中间的事件总线来管理事件和回调,实现事件发布(trigger),事件订阅(on) ,当然也是在事件的完成回调来派发事件啦(本质上还是回调,不过更优雅,可以避免回调嵌套)

异步操作的流程控制 ​

串行执行 ​

当有一堆异步任务需要串行执行时(后一个任务依赖前一个任务的返回结果),如果光采用回调函数的形式,那就会造成回调地狱。

这种一般在回调函数里边递归调用下一个任务,以此实现运行完一个后运行下一个的效果

js
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)
    })
}

并行执行 ​

同时启动异步任务,通过计数实现下一个

js
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 !")
            }

        })
    }
}

限流的并行执行 ​

在并行执行的基础上,设置最大并发阈值。

js
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 去做

js
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 中输入的字符自动转大写

html
    <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

js
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 之后的事情了,这里先不讨论。