「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
前言
大家都知道 javascript 是单线程,非阻塞的语言。单线程、非阻塞、异步这些都是 javascript 的标签,也是 javascript 自身特点。在开始今天主角的事件循环之前,简单说一下浏览器内部机制。我们都知道当下主流的浏览器是多进程的结构,多进程好处是当一个进程发生了问题,不导致应用因为一个进程出现问题而挂掉,浏览器多进程包括用户界面进程、缓存进程、网络进程、GPU 进程、插件进程和渲染进程,其中渲染进程负责将页面呈现给用户,在进程中有一个主线程,javascript 就运行在这个主线程中。在这主线程不仅只做运行 javascript 代码,还需要做渲染界面等任务。
进程是cpu资源分配的最小单位,而线程是cpu调度的最小单位
对 javascript 单线程是必要的,为什么这么说呢,javascript 这门语言最初也是最主要的执行在浏览器环境中,现在也有 node 运行时环境,需要进行各种各样的 Dom 操作。假设 javascript 是多线程的,两个线程可能同时对 Dom 元素进行操作,一个向其添加事件,而另一个将删除 Dom 元素,也会引发许多问题,为了不会发生类似于这个例子中的情景,所以 javascript 选择只用一个主线程来执行代码,从而就确保了程序执行的一致性。那么 javascript 代码又是如何执行的呢? 我们先说一说执行栈,我们 javascript 代码就是在这里执行的
执行栈
现在来看一看 javascript 是如何一行一行执行代码的,在下面代码中有 3 个方法,分别是 add, add2 和 printAdd2 ,函数间相互调用的关系一目了然。因为今天主要研究的是事件循环,所以对一些 javascript 执行过的程细节进行了简化,先以介绍执行栈(也叫做调用栈)为主。
function add(a,b){
return a + b;
}
function add2(x){
return add(x,2);
}
function printAdd2(x){
var result = add2(x);
console.log(result);
}
printAdd2(3);
复制代码
一系列方法被依次调用的时候,因为 js 是单线程的,同一时间只能执行一个方法,这些方法会被放在先进后出的容器中,这个容器就是执行栈。javascript 解析器会从上向下依次读取、解析代码。每个函数对应函数私有内存空间的空间。当 printAdd2(3)
,会将 printAdd2(x)
压入栈
然后因为在 printAdd2
方法中又调用了add2
方法,所以将add2
方法也压入栈内,依此类推,add
也被压入到执行栈。然后就是依次执行这些函数,按照从add
到printAdd2
顺序依次计算出栈。
具体执行顺序我用图形式给大家一一列出,
非阻塞
其实所谓阻塞,其中阻塞是一种现象,只是有的代码块执行起来比较慢,给你感觉就是浏览器卡住了,例如网络请求、图片下载等等实现这样功能代码执行起来都会有一种阻塞感觉。下面我么通过 for 循环来实现一个线程休眠的效果。
function sleep(delay){
for(var start = Date.now(); Date.now() - start <= delay;){};
}
sleep(1000)
console.log("complete");
复制代码
这里sleep
函数模拟 java 和 python 这样提供对线程控制功能方法里 sleep 功能,因为 js 是单线,所以当执行 sleep(1000)
时,程序就不会向下执行,直到for(var start = Date.now(); Date.now() - start <= delay;){}
循环结束,才会输出complete
。
var btn = document.getElementById("btn");
btn.addEventListener("click",function(ev){
console.log("click on me");
});
function sleep(delay){
for(var start = Date.now(); Date.now() - start <= delay;){};
}
sleep(10000);
console.log("complete");
复制代码
有关阻塞问题 javascript 是如何解决的呢,因为 javascript 有着非阻塞的标签,这就要说一说今天主角事件循环(event loop)。其实上面代码没有什么可解释的。就是浏览器页面上添加了一个 btn 按钮,然后给 btn 添加点击事件,也就是在控制台输出click on me
。当运行到 ·sleep
时候,因为sleep
方法阻塞到了线程,这时世界一下子就停止了。
并发和事件循环
并发和多线程经常会同时出现,看起来 javaScript 这种单线程,是不是会有点弱。其实不然 javascript 中事件循环机制让你运行 javascript 有点并发感觉.
var btn = document.getElementById("btn");
btn.addEventListener("click",function(ev){
console.log("click on me");
});
setTimeout(function(){
console.log("do something...")
},1000);
console.log("complete");
复制代码
因为有了事件循环机制,让我们快乐自由地点击 btn,体验事件循环机制给我们带来无阻塞的快感。
先看这张图,我们看图中出现几块内容,然后通过对上面代码解释来将这几块内容联系起来来将 javascript 是如何基于事件循环来实现异步,也可以说平行运行代码。
- Web API : 在使用 JavaScript 编写 Web 代码时,浏览器提供有许多 Web API 可供 javascript 调用。
- 执行栈: 也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。
- 任务队列(Callback queue): 随后也叫做任务队列(task queue),
- 事件循环(event loop): 其实事件循环会一直循环,每次循环可以看成 tick ,每次事件循环都会做两件事,观察执行栈是否为空,如果为空这访问任务队列是否有任务,如果有任务将执行任务。
javascript 是执行到完成过程,所以先执行获取 button 元素然后将其赋值给变量 btn,这样 btn 就具有了 dom 元素一个引用,然后继续向下执行到 btn,addEventListener
, 因为事件监听是,这是异步 API 所以交给 WebAPI 处理,当用户点击按钮触发了点击事件,就会将回调函数发送到任务队列(TaskQueue), 图上用了 Callback queue 来表示。
遇到异步AP IsetTimeout
,将异步回调函数交给 Web API处理(此处为定时器触发线程,1000ms 之后,即满足触发条件后,将onTimeout
推入任务队列。
主线程继续往下执行,执行到 console.log
就会在控制台中输出 complete。现在在 WebAPI 有一个事件监听器和计时器。
当计时器计时指定的时间,就将 OnTimeout 这个回调任务放入到回调队列中,等待有事件循环(event loop)来从回调队列中将其取出放置到执行栈来执行任务
然后主线程中会执行 OnTimeout 任务中console.log
输出信息到控制台
当用户点击了按钮时候,onClickeEvent 监听到点击事件后,就将点击的回调任务从 WebAPI 放入到回调队列中,不断循环的循环事件(event loop) 先是查看执行栈,执行栈中没有要执行的函数,然后再观察回调队列,发现回调队列中有一个任务OnClick 等待处理
因为执行栈没有要执行的代码,所以事件循环就把 Onclick 任务放到执行栈中来执行,执行任务就会执行回调中输出代码 console.log
任务和任务队列(回调队列)
我们可以将事件循环,是一个不断循环,查看调用队列中是否有任务,那么什么又是任务呢? 所谓任务就是执行一段 javascript 的代码。那么任务队列又是什么,队列就是新进先出顺序。其中可以放置点击回调任务、计时任务和网络请求任务。任务执行顺序以执行至完成,所以就没有必要担心同步等等问题,任务队列与渲染流程一起工作,当我们添加 dom 元素或者更改 dom 元素样式时候,都会启动渲染流程将这个变化更新到屏幕上。
渲染流程是紧跟着任务队列中任务执行完成后才执行。而且浏览器也足够智慧到不会做没有必要做的事,我们屏幕刷新率是 1 秒 60 次。大概也就是每 16 毫秒更新一次,那么也就是即使任务队列任务全部执行完后,渲染流程也会等到时间才会执行,所以每次间隔可能在任务执行完和开始执行渲染之间可能会有一些空闲时间。
但是如果任务执行时间过长,可能导致渲染流程会滞后执行,超过 16 毫秒后才会执行,这样画面看起来就显得卡顿。我们可以将耗时任务拆分多个任务来放到下一次任务队列执行。
function veryLongTask(){
firstPartTask()
setTimeout(restPartTask)
}
复制代码
这里用代码就是通过 setTimeout 将一个耗时任务拆分两个任务进行执行。
因此,在一个任务完成后,网络化的管道可以运行。任务完成后,渲染管道可以运行,但浏览器是很聪明的,对吗?它们不喜欢做它们不需要做的工作,除非屏幕即将刷新,否则真的没有必要运行渲染管道,所以你的屏幕平均每秒刷新60次,每16毫秒一次,所以如果我们运行一个任务,渲染管道会在运行前等待这16毫秒。
while(true){
queue = getNextQueue();
task = queue.pop();
execute(task);
while(microtaskQueue.hasTasks())
doMicrotask();
if(isRepaintTime()) repaint();
}
复制代码
随后还会介绍 requestAnimationFrame 、宏任务和微任务。