日期:2024年4月20日标签:JavaScript

JavaScript Event Loop #

作为一名 web 开发者,肯定遇到过页面卡死的问题,但是你知道它为什么会发生呢?并且我们如何解决这个问题呢?这很关键。

解决这个问题前,首先要理解 Event Loop 是如何工作的。

Event Loop #

Event Loop 就是一个无止境的循环。Js 引擎内部维护了一个任务队列(macro task queue),它们遵循 first-come-first-served 的原则,Js 引擎会优先执行先进入任务队列的任务,每次执行完一个任务,会检查任务队列中还有没有其他任务,如果有则继续执行下一个任务,否则 Js 引擎会等待新的任务进入任务队列。

在两次执行任务的中间,浏览器会触发一次 render,即页面 render 的过程是发生在任务执行完成之后。所以如果一次任务的执行时间过长,浏览器没法处理其他任务,例如处理 UI 事件,浏览器就会弹出 alert ” Page Unresponsive“。

render and task

总结:

  1. Js 引擎执行任务的过程中,rendering 是永远不会发生的,无论这个任务执行需要多长时间,dom 的更新永远在任务执行完成之后。
  2. 如果一次任务执行时间过长,超过一定的时间,浏览器会弹出 alert 窗口 ”Page Unresponsive“。

加载 script、使用 setTimeout 添加定时任务、触发 mouse event 等都可以向任务队列中插入一个任务。

所以下面的代码输出顺序并不是 "step1 -> step2 -> step3",而是 "step1 -> step3 -> step2"。

console.log("step1");

setTimeout(() => {
    console.log("step2");
}, 0);

console.log("step3");

setTimeout 虽然设置定时为 0,但是并不是立即执行的,它表示在 0s 后将任务注册到任务队列中,而 js 引擎会在当前任务执行完成后,再检查任务队列是否有未执行的任务,所以输出顺序为 "step1 -> step3 -> step2"。

解决页面未响应的问题 #

页面未响应的原因就是一次执行任务的时间过长导致的,所以如何解决呢?

例如语法高亮任务就是一个非常耗时占用 cpu 高的任务。当 js 引擎忙于处理语法高亮时,它没空处理与 Dom 相关的工作以及用户事件,导致页面卡死。

请你暂停思考下,我们如何解决这个问题呢?

(.......)

既然一次任务执行时间过长,我们能否将任务拆分成多个小任务执行呢?答案是肯定的。我将用一个例子来演示这个过程。

let i = 0;

let start = Date.now();

function count() {
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

这段代码使用循环模拟了一个较长的任务,它会占用 js 引擎较长的时间,在循环执行过程中,你无法点击页面按钮或者滚动页面,因为 Rendering 永远在 task 执行完成后发生。

通过 setTimeout 将任务拆分,每个子任务单独作为一次任务的执行。

let i = 0;

let start = Date.now();

function count() {
  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); 
  }
}

count();

第二个版本的代码,通过拆分后,执行时,页面并不会出现卡死的现象,成功解决了页面未响应的问题。

  1. 第一个任务计算:i = 1 -> 1e6
  2. 第二个任务计算:i = 1e6 + 1 -> 2 x 1e6
  3. ..直到 i = 1e9

但是能否将任务执行的速度提高一点呢,虽然设置了定时 0,将任务插进任务队列,但是浏览器并不会立即执行,这里有一个知识点就是,浏览器在处理两轮 setTimeout 中最小延迟为 4ms,也就是用 setTimeout 立即添加了一个任务,最快也需要 4ms 才能执行,所以提前将任务插进任务队列,任务执行的速度会越快。

let i = 0;

let start = Date.now();

function count() {
  if (i < 1e9 - 1e6) {
    // 将 setTimeout 提前,将任务提前放到任务队列中
    setTimeout(count);
  }

  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  }
}
count();

你可以自己测试下第三个版本与第二个版本的速度。

宏任务与微任务 #

上文中提到的任务就是宏任务(macro task),js 引擎除了维护一个宏任务队列,还维护了一个微任务队列(micro task queue),在每个宏任务执行完成后,会检查微任务队列,当所有微任务执行完成后,浏览器会进行 rendering,最后进入下一个宏任务的执行。即每个宏任务执行过程中包含了微任务。微任务队列也是遵循先到先服务的原则。

宏任务与微任务

通过 promise.then, promise.catch, promise.finally,async...await 可以在当前宏任务执行过程中插入一个微任务。例如下面的代码输出 ”code -> promise -> timeout“。

setTimeout(() => console.log("timeout"));

Promise.resolve()
  .then(() => console.log("promise"));

console.log("code");

当我们希望异步执行一个方法,但是希望这个方法在 render 或者新的事件处理之前执行,可以通过 queueMicrotask 将这个方法排进当前宏任务的微任务队列中等待执行。

另外一点需要注意的是 定义 promise 传的方法属于同步代码,即当前宏任务,下面的代码输出 "step1 -> step2 -> step3 -> step4"。

console.log("step1");
new Promise((res, rej) => {
    console.log("step2");
    res();
}).then(() => {
    console.log("step4");
});
console.log("step3")

async 方法中,await 语句前的代码也属于当前宏任务的代码,后面的代码属于当前宏任务中的微任务中的代码。

console.log("step1");
async function test() {
    console.log("step2");
    
    await Promise.resolve().then(() => {
        console.log("step4");
    });
    
    console.log("step5");
}

test();

console.log("step3");

上述代码输出 "step1 -> step2 -> step3 -> step4 -> step5"。

(完)

目录