Event Loop

我们知道JavaScript是一门单线程的语言,他只能一行一行的执行代码,于是我们的代码应该都是同步的,这里我们暂时忘掉所有的异步,看一下这么做会有什么问题。

用户进入了我们的网页,点击了一个按钮,触发加载更多功能,此时浏览器发起ajax请求,如果我们的代码都是同步的,那么页面会在这个请求完成之前是卡主状态的,因为需要等待该代码的完成,此时用户什么都做不了,既不能滚动页面,也无法点击其他内容,如果这个请求需要60s的时间,用户肯定会觉得,这是什么狗屎页面。

这显然是不行的,因为代码阻塞导致体验特别的差,解决这个问题的办法就是加入异步功能,我们将请求的回调作为异步处理,不需要去同步等待它的完成,而是先去执行其他的内容。

当请求完成后再运行这个回调,那么如何协调同步与异步的运行,使用的就是事件循环Event Loop。

上图是一个简单的代码可视化运行环境的图,其中运行栈就是我们代码实际运行的地方,而web api则是我们的运行环境通过的一系列api钩子,比如setTimeout,这是浏览器为我们提供的,而任务队列我们待会再讲它是做什么的。

console.log(1);

setTimeout(() => {
    console.log(2);
},1000);

console.log(3);

我们看这段代码,首先他会将console.log(1)压入运行栈中,运行,运行完毕后出栈,然后运行setTimeout,注意,此时setTimeout其实存在于Web Api中,而不是在运行栈中。

接着console.log(3)被压入运行栈中运行,然后完毕出栈。

我们的setTimeout会被浏览器处理,当1000ms后,回调函数会被压入任务队列中。

此时,如果我们的运行栈是空的,那么就会去查询任务队列中是否存在待运行的代码,然后它发现了一个回调需要运行,此时运行回调函数,完毕后出栈。

所以上述代码最终的log输出顺序是:

// 1
// 3
// 2

我们的事件循环名字都写着循环了,所以上述的运行过程是在不断循环处理的,不是说代码运行完毕就结束了,它会不断的重复这个过程。

而任务队列是存放异步回调的地方,它会在运行栈空的时候被查询,然后获取一个任务,运行后,如果运行栈没有需要运行的了,又会去任务队列查询是否有需要运行的代码,如果无了又返回自己,空了又来查询,如此往复,形成了一个无限的循环,这个过程就称为事件循环。

上述的例子还是比较简单的,对于入门理解是足够的,但是随着我们的代码深入,你会发现,我们的异步代码有很多啊,比如:promise、setTimeout、ajax、onclick,这些的执行顺序都是怎么样的,这里就得讲讲宏任务和微任务了。

宏任务和微任务

由于不同的异步代码并不相同,有的是定时器,有的是请求,有的是协议promise,他们的执行被划分为两个区域:宏任务区域、微任务区域。

我们可以将上面的任务队列拆分成两个容器,一个是存放宏任务的,一个存放微任务的。

宏任务:setTimeoutsetIntervalDOM事件ajax请求setImmediate(node独有的)requestAnimationFrame(浏览器独有)IOUI render(浏览器独有)

微任务:PromiseObject.observeMutationObserverprocess.nextTick(node 独有)

虽然看上去分类很多,其实不用太考虑一个日常用不到的东西,比如setImmediate这些,我们精简一下之后:

宏任务:setTimeoutsetIntervalDOM事件ajax请求requestAnimationFrame

微任务:Promise

注意:这两个任务中,微任务优先级是最高的。

看这段代码:

console.log(1);

setTimeout(() => {
    console.log("定时器");
});

new Promise((resolve) => {
    console.log(2);
    resolve();
    console.log(3);
}).then(() => {
    console.log("Promise1");
});

console.log(4);

log(1)先被压入运行栈运行,完毕后出栈!

接着是触发webapi的setTimeout,没有等待时间,回调被推入宏任务队列中。

触发new Promise,注意new Promise的回调函数执行还是同步的,所以log(2)被推入栈中,完毕后出栈。

此时触发了resolve(),then接收的回调被推入微任务队列中。

log(3)被推入栈中,完毕后出栈。

log(4)被推入栈中,完毕后出栈。

此时运行栈中已经没有需要运行的代码了,于是先去微任务队列中查询是否有需要运行的内容,发现then的回调,于是log("Promise1")推入栈中运行,完毕后出栈。

此时运行栈又空了,再去微任务队列查询,发现微任务也空了,于是去宏任务队列查询,发现有需要运行的,于是log("定时器")被推入栈中,完事后出栈。

最终我们的打印结果是:

// 1 
// 2 
// 3 
// 4 
// Promise1 
// 定时器

这里你会发现,当微任务运行完一个后还会去微任务队列里面查询,这就会产生一个现象,如果我在运行微任务的是又往微任务里面追加一个任务,那么就会导致线程死循环了。

举个例子:

console.log(1);

setTimeout(() => {
    console.log("定时器");
});

new Promise((resolve) => {
        console.log(2);
        resolve();
        console.log(3);
    })
    .then(() => {
        console.log("Promise1");
    })
    .then(() => {
        console.log("Promise2");
    })
    .then(() => {
        console.log("Promise3");
    })
    .then(() => {
        console.log("Promise4");
    })
    .then(() => {
        console.log("Promise5");
    });

console.log(4);

当一个then的回调运行完毕后,会将下一个then的回调传入微任务中去,于是我们会发现打印顺序是这样的:

// 1 
// 2 
// 3 
// 4 
// Promise1 
// Promise2 
// Promise3 
// Promise4 
// Promise5 
// 定时器

如果我们的微任务无限添加下去,定时器的回调就永远无法运行,所以我们需要注意这点。

而且我们需要注意一点,不管是什么队列,每次拿取都只会拿取一个,运行完毕后再重复整个循环过程。

示例:

console.log(1);

setTimeout(() => {
    console.log(2);
    Promise.resolve().then(() => {
        console.log(3);
    });

    setTimeout(() => {
        console.log(4);
    });
});

Promise.resolve().then(() => {
    console.log(5);
});

console.log(6);

当我们在宏任务中创建了一个微任务,一个新的宏任务时,在该宏任务运行结束后,如果运行栈空了,他还是会先从微任务队列中查询,所以上述代码的打印顺序是:

1
6
5
2
3
4

略有特别的DOM事件

对于dom事件,我们可能需要通过一个小例子来进行记忆。

在页面中我们存在一个按钮:

<button id="btn">点击我</button>

加入JavaScript代码:

const btn = document.getElementById("btn");

btn.addEventListener("click", () => {
    console.log(1);
    Promise.resolve().then(() => console.log(2));
});

btn.addEventListener("click", () => {
    console.log(3);
    Promise.resolve().then(() => console.log(4));
});

当用户点击按钮时,我们的执行顺序是怎么样的呢?利用我们刚刚学到的知识。

首先是两个dom事件被触发,他们的回调被传入宏任务队列中。

此时运行栈和微任务都是空的,于是从宏任务队列中拿取第一个回调函数触发。

log(1)入栈出栈。

创建了一个微任务压入微任务队列中去。

此时运行栈为空,于是先去微任务队列中查询,拿到了刚刚压入的内容,运行回调,log(2)打印。

此时运行栈又空了,微任务队列也空了,在宏任务队列中拿到第二个回调运行。

log(3)入栈出栈。

创建了一个微任务压入微任务队列中去。

此时运行栈为空,于是先去微任务队列中查询,拿到了刚刚压入的内容,运行回调,log(4)打印。

代码结束。

最后的打印顺序如我们所料:

// 1
// 2
// 3
// 4

但是,如果我们通过js去触发click,故事就会变得魔幻起来。

const btn = document.getElementById("btn");

btn.addEventListener("click", () => {
    console.log(1);
    Promise.resolve().then(() => console.log(2));
});

btn.addEventListener("click", () => {
    console.log(3);
    Promise.resolve().then(() => console.log(4));
});

btn.click();

我们的输出结果是:

// 1
// 3
// 2
// 4

震惊!!!为什么???

当我们手动执行click事件时,处理方式会不同,我们可以这么去理解:

() => {
  click1();  //第一个click回调
  click2();  //第二个click回调
}

他会将按钮的两个click事件按顺序执行,而不是像上述所说的过程,两个事件回调被压入微任务队列中。这就是他们不同的地方。

由于是两个任务,在log(1)打印完成,微任务也压入了队列中的时候,并不是结束一个循环回合,因为下面还有click2的代码没有运行,运行栈没有清空,所以继续运行click2的内容,于是log(3)打印完成,微任务压入新的内容。

此时btn.click()运行结束了,运行栈清空了,于是查询微任务队列,运行刚刚压入的两个回调。

现在明白了吧!

我们再看一个例子:

<a href="https://www.mulingyuer.com" target="_blank" id="alink">点击跳转</a>

我们现在有一个a链接

const alink = document.getElementById("alink");

const promise1 = new Promise((resolve) => {
    alink.addEventListener("click", resolve, { once: true });
});

promise1.then((event) => {
    event.preventDefault();
    console.log("阻止默认行为");
});

// alink.click();

当用户点击时,我们是可以正常阻止默认行为的,也就是链接跳转的行为被阻止了,但是当我们通过js去触发click事件时,链接会被正常跳转。

a链接被阻止跳转是因为他会判断evnet对象是否是canceled状态,当我们调用了event.preventDefault()时,就会将evnet对象标记为canceled,所以它无法跳转。

正常情况下,会在所有click事件结束后判断evnet对象,我们可以理解为这个判断是一个宏任务。

于是then的回调会先触发,evnet对象被标记canceled,跳转被阻止。

但是直接通过alink.click()调用的事件,它在标准规范中的定义不同,他会同步执行完所有事件回调,也就是我们上面刚演示的那种:

() => {
  click1();  //第一个click回调
  click2();  //第二个click回调
}

执行完后会直接判断evnet对象,此时它不是一个宏任务了,是一个同步的代码,类似于:

() => {
  click1();  //第一个click回调
  click2();  //第二个click回调
  if(event.canceled) return;
  //链接跳转处理
  ***
}

此时我们的微任务根本没有运行,所以无法阻止跳转,当它运行时就已经来不及了。

其实本质上还是上面说的那种情况,事件回调被依次执行,不再是一个个宏任务了,只不过a链接有些特殊,它的跳转判定方式在不同的调用情况下会有不同。

分类: 你不知道的JavaScript 标签: 定时器promiseEvent Loop事件队列宏任务微任务setTimeout

评论

暂无评论数据

暂无评论数据

目录