前言:
我在学习浏览器的Event Loop时看了大量的文章,那些文章都写的很好,但是往往是每篇文章有那么几个关键的点,很多篇文章凑在一起综合来看,才可以对这些概念有较为深入的理解,于是,我在看了大量文章之后,想要写这么一篇作文分享出来,希望可以对走在进步之路上的同学有所帮助。
一、综述:
JavaScript是一门非阻塞单线程脚本语言。但是你又听过“浏览器”、“多核”,可以“多线程处理事务”等等之类的语言。在写这篇作文之前我也是比较混沌的。所以在浏览了大量文章后,有了一定的概念,下面是我的理解:
浏览器是多进程的(看好:是进程哟~),每打开一个Tab页,就相当于创建了一个独立的浏览器进程,简单的理解为渲染进程(Render); 而Render进程包括:
-
GUI渲染线程
:负责渲染浏览器界面的工人,与JS引擎线程互斥。 -
JS引擎线程
:负责解释JS代码的工人,与GUI渲染线程互斥 -
事件触发线程
: 一个会添加任务,发布任务的流水线 -
定时触发器线程
:传说中的setInterval与setTimeout所在线程 -
异步http请求线程
:处理网络请求,管理请求任务状态的工人
他们之间的关系用车间的例子来形象说明:JS引擎线程、GUI渲染线程、定时触发器线程、异步http请求线程相当于4个不同工种的工人站在同一个位置领取流水线(事件触发线程)的任务,领取到任务后各自进行处理。如果执行过程中,发现有需要其他工人配合的任务,将任务添加到流水线(任务队列)中,直到流水线上任务做完。
需要注意的是:这几条线程是并列的同级关系,真正进行的调度的是Render进程在5个进程之间进行来回调度,而JS引擎
处理JS代码是我们开发人员主要的关注的,所以会产生JS引擎
是主线程的说法,并且Javascript引擎是 。
(若javascript的运行存在两个线程,彼此操作了同一个资源,这样会造成同步问题,修改到底以谁为标准。所以,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变)
二、浏览器多核
对于普通的前端操作来说,最终要的是什么呢?答案是渲染进程。可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行。接下来重点分析这个进程;请牢记,浏览器的渲染进程是多线程的( 终于到了线程这个概念了,好亲切)。那么接下来看看它都包含了哪些线程(列举一些主要常驻线程):
1.GUI渲染线程:
负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
- 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
2.JS引擎线程
- 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
- JS引擎线程负责解析Javascript脚本,运行代码。
- JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
- 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
3.事件触发线程
- 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
- 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
- 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
4.定时触发器线程
- 传说中的setInterval与setTimeout所在线程
- 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
- 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
- 注意,W3C在HTML标准中规定,规定要求。
5.异步http请求线程
- 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。
三、事件循环
事件循环(Event Loop) 是让 JavaScript 做到既是单线程,又绝对不会阻塞的核心机制,也是 JavaScript 并发模型(Concurrency Model )的基础,是用来协调各种事件、用户交互、脚本执行、UI 渲染、网络请求等的一种机制。
说的更简单一点:Event Loop 只不过是实现异步的一种机制而已。
Event Loop 分为两种,一种存在于 Browsing Context] 中,还有一种在 Worker中。
- Browsing Context 是指一种用来将 Document]展现给用户的环境。例如浏览器中的 tab,window 或 iframe 等,通常都包含 Browsing Context。
- Worker 是指一种独立于 UI 脚本,可在后台执行脚本的 API。常用来在后台处理一些计算密集型的任务。
本章节重点介绍的是 Browsing Context 中的 Event Loop,相比 Worker 中的 Event Loop,它也更加复杂一些。
另外,还需要注意的是:Event Loop 并不是在 ECMAScript 标准中定义的,而是在 HTML 标准中定义的:
To coordinate events, user interaction, scripts, rendering, networking, and so forth...
在 JavaScript Engine 中(以 V8 为例),只是实现了 ECMAScript 标准,而并不关心什么 Event Loop。也就是说 Event Loop 是属于 JavaScript Runtime 的,是由宿主环境提供的(比如浏览器)。所以千万不要搞错了。
Event Loop 中的任务队列
在执行和协调各种任务时,Event Loop 会维护自己的任务队列。任务队列又分为 Task Queue 和 Microtask Queue 两种。
实际上,称任务队列为事件队列(Event Queue)可能会更容易理解。所谓的事件驱动(Event-driven),就是将一切抽象为事件(Event),比如 AJAX 完成、鼠标点击、I/O 操作等等,都是一个个的事件,而 Event Loop 就是一个事件循环的过程。
不过本文还是以 HTML 标准中的叫法作为参考。
1. Task Queue
一个 Event Loop 会有一个或多个 Task Queue,这是一个先进先出(FIFO)的有序列表,存放着来自不同 Task Source(任务源)的 Task。
关于 Task,常有人称它为 Marcotask,但其实 HTML 标准中并没有这种说法。
在 HTML 标准中,定义了几种常见的 Task Source:
- DOM manipulation(DOM 操作);
- User interaction(用户交互);
- Networking(网络请求);
- History traversal(History API操作)。
Task Source 的定义非常的宽泛,常见的鼠标、键盘事件,AJAX,数据库操作(例如 IndexedDB,以及定时器相关的 setTimeout、setInterval 等等都属于 Task Source,所有来自这些 Task Source 的 Task 都会被放到对应的 Task Queue 中等待处理。
对于 Task、Task Queue 和 Task Source,有如下规定:
- 来自相同 Task Source 的 Task,必须放在同一个 Task Queue 中;
- 来自不同 Task Source 的 Task,可以放在不同的 Task Queue 中;
- 同一个 Task Queue 内的 Task 是按顺序执行的;
- 但对于不同的 Task Queue(Task Source),浏览器会进行调度,允许优先执行来自特定 Task Source 的 Task。
例如,鼠标、键盘事件和网络请求都有各自的 Task Queue,当两者同时存时,浏览器可以优先从用户交互相关的 Task Queue 中挑选 Task 并执行,比如这里的鼠标、键盘事件,从而保证流畅的用户体验。
2. Microtask Queue
Microtask Queue 与 Task Queue 类似,也是一个有序列表。不同之处在于,一个 Event Loop 只有一个 Microtask Queue。
在 HTML 标准中,并没有明确规定 Microtask Source,通常认为有以下几种:
- Promise
在 [Promises/A+ Note 3.1] 中提到了 then、onFulfilled、onRejected 的实现方法,但 Promise 本身属于平台代码,由具体实现来决定是否使用 Microtask,因此在不同浏览器上可能会出现执行顺序不一致的问题。不过好在目前的共识是用 Microtask 来实现事件队列。
- MutationObserver
- Object.observe (已废弃)
这里要特别提一下:有很多文章把 Node.js 的 process.nextTick
和 Microtask 混为一谈,事实上虽然两者层级(运行时机)非常接近,但并不是同一个东西。process.nextTick
是 Node.js 自身定义实现的一种机制,有自己的 nextTickQueue,与 HTML 标准中的 Microtask 不是一回事。在 Node.js 中,process.nextTick
会先于 Microtask Queue 被执行。
JavaScript Runtime 的运行机制
了解了 Event Loop 和队列的基本概念后,就可以从相对宏观的角度先了解一下 JavaScript Runtime 的运行机制了,简化后的步骤如下:
1. 主线程不断循环;
2. 对于同步任务,创建执行上下文 ,按顺序进入执行栈 ;
3. 对于异步任务:
- 与步骤 2 相同,同步执行这段代码;
- 将相应的 Task(或 Microtask)添加到 Event Loop 的任务队列;
- 由其他线程来执行具体的异步操作。
其他线程是指:尽管 JavaScript 是单线程的,但浏览器内核是多线程的,它会将 GUI 渲染、定时器触发、HTTP 请求等工作交给专门的线程来处理。
另外,在 Node.js 中,异步操作会优先由 OS 或第三方系统提供的异步接口来执行,然后才由线程池处理。
4. 当主线程执行完当前执行栈中的所有任务,就会去读取 Event Loop 的任务队列,取出并执行任务;
5. 重复以上步骤。
还是拿 setTimeout 举个例子:
主线程同步执行这个 setTimeout 函数本身。
将负责执行这个 setTimeout 的回调函数的 Task 添加到 Task Queue。
定时器开始工作(实际上是靠 Event Loop 不断循环检查系统时间来判断是否已经到达指定的时间点)。
主线程继续执行其他任务。
当执行栈为空,且定时器触发时,主线程取出 Task 并执行相应的回调函数。
很明显,执行 setTimeout 不会导致阻塞。当然,如果主线程很忙的话(执行栈一直非空),就会出现明明时间已经到了,却也不执行回调的现象,所以类似 setTimeout 这样的回调函数都是没法保证执行时机的。
Event Loop 处理模型
前面简单介绍了 JavaScript Runtime 的整个运行流程,而 Event Loop 作为其中的重要一环,它的每一次循环过程也相当复杂,因此将它单独拿出来介绍。下面我会尽量保持 HTML 标准中对处理模型(Processing Model)的定义,并尽量简化,步骤如下(3 步):
- 执行 Task:从 Task Queue 中取出最老的一个 Task 并执行;如果没有 Task,直接跳过。
- 执行 Microtasks:遍历 Microtask Queue 并执行所有 Microtask。
- 进入 Update the rendering(更新渲染)阶段:
-
- 设置 Performance API 中 now() 的返回值。Performance API属于 W3C High Resolution Time API的一部分,用于前端性能测量,能够细粒度的测量首次渲染、首次渲染内容等的各项绘制指标,是前端性能追踪的重要技术手段,感兴趣的同学可关注。
-
- 遍历本次 Event Loop 相关的 Documents,执行更新渲染。在迭代执行过程中,浏览器会根据各种因素判断是否要跳过本次更新。
-
- 当浏览器确认继续本次更新后,处理更新渲染相关工作:
-
- 触发各种事件:Resize、Scroll、Media Queries、CSS Animations、Fullscreen API。
-
- 执行 animation frame callbacks,window.requestAnimationFrame就在这里。
-
- 更新 intersection observations,也就是 Intersection Observer API(可用于图片懒加载)。更新渲染和 UI,将最终结果提交到界面上。
至此,Event Loop 的一次循环结束。。。
Microtask Queue 执行时机
在上面介绍的 Event Loop 处理模型中,Microtask Queue 会在第 2 步时被执行。实际上按照 HTML 标准,在以下几种情况中 Microtask Queue 都会被执行:
- 某个 Task 执行完毕时(即上述情况)。
- 进入脚本执行(Calling scripts)的清理阶段(Clean up after running script)时。
- 创建和插入节点时。
- 解析 XML 文档时。
同时,在当前 Event Loop 轮次中动态添加进来的 Microtasks,也会在本次 Event Loop 循环中全部执行完(上图其实已经画出来了)。
最后一定要注意的是,执行 Microtasks 是有前提的:当前执行栈必须为空,且没有正在运行的执行上下文。否则,就必须等到执行栈中的任务全部执行完毕,才能开始执行 Microtasks。
也就是说:JavaScript 会确保当前执行的同步代码不会被 Microtasks 打断。
这样就会导致一些初看上去很诡异的现象,拿一个经典的例子来验证一下:
首先创建一个由内外两个 DIV 嵌套组成的简单结构:
JavaScript 代码如下:
const inner = document.getElementById("inner");
const outer = document.getElementById("outer");
// 监听 outer 的属性变化。
new MutationObserver(() => console.log("mutate outer"))
.observe(outer, { attributes: true });
// 处理 click 事件。
function onClick()
{
console.log("click");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
outer.setAttribute("data-mutation", Math.random());
}
// 监听 click 事件。
inner.addEventListener("click", onClick);
outer.addEventListener("click", onClick);
这个东西看起来是这样的:
接下来,分别通过 鼠标点击和 代码调用的方式来触发 inner 和 outer 的 click 事件,我们分别来看:
第一种方式:鼠标点击黄色方块,输出结果如下 :
click
promise
mutate outer
click
promise
mutate outer
timeout
timeout
为了容易看明白整个过程,录了一个简单的动画演示:
(视频来源:请点击)
第二种方式:改成通过代码调用方式触发,在上面例子的最后一行加上
inner.click();
输出结果如下
click
click
promise
mutate outer
promise
timeout
timeout
看视频吧:(视频来源:请点击)
总结一下,两次执行过程的本质区别,就在于执行 Microtask Queue 前,当前执行栈是否为空。因此在例子 2 的两次 onClick 之间,就不会执行 Microtask Queue
,也就不会有控制台输出了。
PS:有兴趣的朋友可以在例子 2最后再加上一行
console.log("end");
,看看输出结果是怎样的。
四、Promise
Promise是一个构造函数,自己身上有all、reject、resolve这几个眼熟的方法,原型上有then、catch等同样很眼熟的方法。那就new一个
var p = new Promise(function(resolve, reject){
//做一些异步操作
setTimeout(function(){
console.log('执行完成');
resolve('随便什么数据');
}, 2000);
});
Promise的构造函数接收一个参数,是函数,并且传入两个参数:resolve,reject,分别表示异步操作执行成功后的回调函数和异步操作执行失败后的回调函数。其实这里用“成功”和“失败”来描述并不准确,按照标准来讲,resolve是将Promise的状态置为fullfiled,reject是将Promise的状态置为rejected。不过在我们开始阶段可以先这么理解,后面再细究概念。
在上面的代码中,我们执行了一个异步操作,也就是setTimeout,2秒后,输出“执行完成”,并且调用resolve方法。
运行代码,会在2秒后输出“执行完成”。注意!我只是new了一个对象,并没有调用它,我们传进去的函数就已经执行了,这是需要注意的一个细节。所以我们用Promise的时候一般是包在一个函数中,在需要的时候去运行这个函数,如:
function runAsync(){
var p = new Promise(function(resolve, reject){
//做一些异步操作
setTimeout(function(){
console.log('执行完成');
resolve('随便什么数据');
}, 2000);
});
return p;
}
runAsync()
这时候你应该有两个疑问:1.包装这么一个函数有什么用?2.resolve('随便什么数据');这又是干什么的?
我们继续来讲。在我们包装好的函数最后,会return出Promise对象,也就是说,执行这个函数我们得到了一个Promise对象。还记得Promise对象上有then、catch方法吧?这就是强大之处了,看下面的代码
runAsync().then(function(data){
console.log(data);
//后面可以用传过来的数据做些其他操作
//......
});
在runAsync()的返回上直接调用then方法,then接收一个参数,是函数,并且会拿到我们在runAsync中调用resolve时传的的参数。运行这段代码,会在2秒后输出“执行完成”,紧接着输出“随便什么数据”。
这时候你应该有所领悟了,原来then里面的函数就跟我们平时的回调函数一个意思,能够在runAsync这个异步任务执行完成之后被执行。这就是Promise的作用了,简单来讲,就是能把原来的回调写法分离出来,在异步操作执行完后,用链式调用的方式执行回调函数。
你可能会不屑一顾,那么牛逼轰轰的Promise就这点能耐?我把回调函数封装一下,给runAsync传进去不也一样吗,就像这样:
function runAsync(callback){
setTimeout(function(){
console.log('执行完成');
callback('随便什么数据');
}, 2000);
}
runAsync(function(data){
console.log(data);
});
效果也是一样的,还费劲用Promise干嘛。那么问题来了,有多层回调该怎么办?如果callback也是一个异步操作,而且执行完后也需要有相应的回调函数,该怎么办呢?总不能再定义一个callback2,然后给callback传进去吧。而Promise的优势在于,可以在then方法中继续写Promise对象并返回,然后继续调用then来进行回调操作。
1.链式操作的用法
从表面上看,Promise只是能够简化层层回调的写法,而实质上,Promise的精髓是“状态”,用维护状态、传递状态的方式来使得回调函数能够及时调用,它比传递callback函数要简单、灵活的多。所以使用Promise的正确场景是这样的:
runAsync1()
.then(function(data){
console.log(data);
return runAsync2();
})
.then(function(data){
console.log(data);
return runAsync3();
})
.then(function(data){
console.log(data);
});
这样能够按顺序,每隔两秒输出每个异步回调中的内容,在runAsync2中传给resolve的数据,能在接下来的then方法中拿到。运行结果如下:
异步任务1执行完成
随便什么数据1
异步任务2执行完成
随便什么数据2
异步任务3执行完成
随便什么数据3
猜猜runAsync1、runAsync2、runAsync3这三个函数都是如何定义的?没错,就是下面这样
function runAsync1(){
var p = new Promise(function(resolve, reject){
//做一些异步操作
setTimeout(function(){
console.log('异步任务1执行完成');
resolve('随便什么数据1');
}, 1000);
});
return p;
}
function runAsync2(){
var p = new Promise(function(resolve, reject){
//做一些异步操作
setTimeout(function(){
console.log('异步任务2执行完成');
resolve('随便什么数据2');
}, 2000);
});
return p;
}
function runAsync3(){
var p = new Promise(function(resolve, reject){
//做一些异步操作
setTimeout(function(){
console.log('异步任务3执行完成');
resolve('随便什么数据3');
}, 2000);
});
return p;
}
在then方法中,你也可以直接return数据而不是Promise对象,在后面的then中就可以接收到数据了,比如我们把上面的代码修改成这样
runAsync1()
.then(function(data){
console.log(data);
return runAsync2();
})
.then(function(data){
console.log(data);
return '直接返回数据'; //这里直接返回数据
})
.then(function(data){
console.log(data);
});
那么输出就变成了这样:
异步任务1执行完成
随便什么数据1
异步任务2执行完成
随便什么数据2
直接返回数据
2.reject的用法
到这里,你应该对“Promise是什么玩意”有了最基本的了解。那么我们接着来看看ES6的Promise还有哪些功能。我们光用了resolve,还没用reject呢,它是做什么的呢?事实上,我们前面的例子都是只有“执行成功”的回调,还没有“失败”的情况,reject的作用就是把Promise的状态置为rejected,这样我们在then中就能捕捉到,然后执行“失败”情况的回调。看下面的代码。
function getNumber(){
var p = new Promise(function(resolve, reject){
//做一些异步操作
setTimeout(function(){
var num = Math.ceil(Math.random()*10); //生成1-10的随机数
if(num<=5){
resolve(num);
}
else{
reject('数字太大了');
}
}, 2000);
});
return p;
}
getNumber()
.then(
function(data){
console.log('resolved');
console.log(data);
},
function(reason, data){
console.log('rejected');
console.log(reason);
}
);
getNumber函数用来异步获取一个数字,2秒后执行完成,如果数字小于等于5,我们认为是“成功”了,调用resolve修改Promise的状态。否则我们认为是“失败”了,调用reject并传递一个参数,作为失败的原因。
运行getNumber并且在then中传了两个参数,then方法可以接受两个参数,第一个对应resolve的回调,第二个对应reject的回调。所以我们能够分别拿到他们传过来的数据。多次运行这段代码,你会随机得到下面两种结果:
resolved rejected
1 或者 数字太大了
3.catch的用法
我们知道Promise对象除了then方法,还有一个catch方法,它是做什么用的呢?其实它和then的第二个参数一样,用来指定reject的回调,用法是这样:
getNumber()
.then(function(data){
console.log('resolved');
console.log(data);
})
.catch(function(reason){
console.log('rejected');
console.log(reason);
});
效果和写在then的第二个参数里面一样。不过它还有另外一个作用:在执行resolve的回调(也就是上面then中的第一个参数)时,如果抛出异常了(代码出错了),那么并不会报错卡死js,而是会进到这个catch方法中。请看下面的代码:
getNumber()
.then(function(data){
console.log('resolved');
console.log(data);
console.log(somedata); //此处的somedata未定义
})
.catch(function(reason){
console.log('rejected');
console.log(reason);
});
在resolve的回调中,我们console.log(somedata);而somedata这个变量是没有被定义的。如果我们不用Promise,代码运行到这里就直接在控制台报错了,不往下运行了。但是在这里,会得到这样的结果:
resolved
4
rejected
ReferenceError:somedata is not defined(...)|
也就是说进到catch方法里面去了,而且把错误原因传到了reason参数中。即便是有错误的代码也不会报错了,这与我们的try/catch语句有相同的功能。
4.all的用法
Promise的all方法提供了并行执行异步操作的能力,并且在所有异步操作执行完后才执行回调。我们仍旧使用上面定义好的runAsync1、runAsync2、runAsync3这三个函数,看下面的例子:
Promise
.all([runAsync1(), runAsync2(), runAsync3()])
.then(function(results){
console.log(results);
});
用Promise.all来执行,all接收一个数组参数,里面的值最终都算返回Promise对象。这样,三个异步操作的并行执行的,等到它们都执行完后才会进到then里面。那么,三个异步操作返回的数据哪里去了呢?都在then里面呢,all会把所有异步操作的结果放进一个数组中传给then,就是上面的results。所以上面代码的输出结果就是:
异步任务1执行完毕
异步任务2执行完毕
异步任务3执行完毕
["随便什么数据1","随便什么数据2","随便什么数据3"]
有了all,你就可以并行执行多个异步操作,并且在一个回调中处理所有的返回数据,是不是很酷?有一个场景是很适合用这个的,一些游戏类的素材比较多的应用,打开网页时,预先加载需要用到的各种资源如图片、flash以及各种静态文件。所有的都加载完后,我们再进行页面的初始化。
5.race的用法
all方法的效果实际上是「谁跑的慢,以谁为准执行回调」,那么相对的就有另一个方法「谁跑的快,以谁为准执行回调」,这就是race方法,这个词本来就是赛跑的意思。race的用法与all一样,我们把上面runAsync1的延时改为1秒来看一下:
Promise
.race([runAsync1(), runAsync2(), runAsync3()])
.then(function(results){
console.log(results);
});
这三个异步操作同样是并行执行的。结果你应该可以猜到,1秒后runAsync1已经执行完了,此时then里面的就执行了。结果是这样的:
异步任务1执行完毕
随便什么数据1
异步任务2执行完毕
异步任务3执行完毕
你猜对了吗?不完全,是吧。在then里面的回调开始执行时,runAsync2()和runAsync3()并没有停止,仍旧再执行。于是再过1秒后,输出了他们结束的标志。
这个race有什么用呢?使用场景还是很多的,比如我们可以用race给某个异步请求设置超时时间,并且在超时后执行相应的操作,代码如下:
//请求某个图片资源
function requestImg(){
var p = new Promise(function(resolve, reject){
var img = new Image();
img.onload = function(){
resolve(img);
}
img.src = 'xxxxxx';
});
return p;
}
//延时函数,用于给请求计时
function timeout(){
var p = new Promise(function(resolve, reject){
setTimeout(function(){
reject('图片请求超时');
}, 5000);
});
return p;
}
Promise
.race([requestImg(), timeout()])
.then(function(results){
console.log(results);
})
.catch(function(reason){
console.log(reason);
});
requestImg函数会异步请求一张图片,我把地址写为"xxxxxx",所以肯定是无法成功请求到的。timeout函数是一个延时5秒的异步操作。我们把这两个返回Promise对象的函数放进race,于是他俩就会赛跑,如果5秒之内图片请求成功了,那么遍进入then方法,执行正常的流程。如果5秒钟图片还未成功返回,那么timeout就跑赢了,则进入catch,报出“图片请求超时”的信息。
五、事件冒泡
事件冒泡 :当一个元素接收到事件的时候 会把他接收到的事件传给自己的父级,一直到window 。(注意这里传递的仅仅是事件 并不传递所绑定的事件函数。所以如果父级没有绑定事件函数,就算传递了事件 也不会有什么表现 但事件确实传递了。)
只看这句话,或许不是那么好理解,下面来看个栗子:
var div1 = document.getElementById("div1");
var div2 = document.getElementById("div2");
div2.onclick = function(){alert(1);};
div1.onclick = function(){alert(2);};//父亲
//html代码
代码很简单,就是两个父子关系的div,然后分别加了点击事件,当我们在div2里面点击的时候,会发现弹出了一次1,接着又弹出了2,这说明点击的时候,不仅div2的事件被触发了,它的父级的点击事件也触发了,这种现象就叫做冒泡。点击了div1,自己父级的点击事件也会被触发。再看个例子:
var div1 = document.getElementById("div1");
var div2 = document.getElementById("div2")
div1.onclick = function(){alert(2);}; // 父亲
//html代码
大家可以看一下效果图,相比于第一个例子,代码已经把儿子的点击事件去掉,只留下了父级的,测试的结果是当只点击了儿子,会弹出2,由此证明了当点击了儿子,父亲的点击事件被触发,执行了自己绑定的函数。由于一些人会以为显示出来儿子在父亲里面的时候,自然点了儿子相当于点了父亲,所以这个例子我故意把两个盒子绝对定位在了两个不同的位置,所以点击事件给页面显示出来的位置是没关系的,而是跟html代码中的位置有关系。
可能有人会有疑惑下面这种情况,为啥没有弹出两次:
var div1 = document.getElementById("div1");
var div2 = document.getElementById("div2");
div2.onclick = function(){alert(1);};
div1.onclick = function(){}; //父亲
//html代码
这里我们要注意,我们传递的仅仅是事件触发,也就是说当点击div2仅仅触发了父级的点击事件,并没有把自己的绑定的函数给父级,父级的执行情况,取决于自己所绑定的函数,因为在这里它绑定的函数是空,自然没什么表现。有些人在这里有误解,所以强调一下。
六、 事件委托
首先呢,你一定写过这样的程序,有一个列表,当鼠标移入每个li,背景颜色变红,于是我们写出了这样的代码:
window.onload = function(){
var oUl = document.getElementById('ull');
var aLi = document.getElementsByTagName('li'); //获取所有列
for(var i =0;i < aLi.length;i++){
aLi[i].onmouseover = function(){
this.style.background = "red";
}
}
当然这样一看代码也没什么问题,通过循环给每个li加事件,但想一想如果我们有很多个li,是不是要加很多次事件,这样其实是非常耗性能的。那么我们会想,能不能只加一个事件就能实现呢。当然是能的,不然我就不会在这扯了。
那就是通过冒泡原理进行事件委托,我们可以把事件只加给父级oUL,这样不管移入哪个li,都会触发父级的移入事件,但这个时候也有个问题,因为我的需求是,让对应的li变颜色,不是让整个列表变,它怎么知道我鼠标移入的是哪个LI,这个时候万能的事件对象中的一个属性就要出场了,就是事件源 (不管事件绑定在那个元素中 都指的是实际触发事件的那个的目标),就是能获取到你当前鼠标所在的LI,
假定我们又有一个需求,点击某个按钮,可以在列表中再创建一个li,这个时候一般方法,因为新创建的li没有加事件,所以是不具备移入变红的功能的,但是用事件委托的方法,新的li,同样有这个事件。原理也很容易相同,因为事件是加在父亲上面的,父亲在,事件在,大家可以自己测试一下。
参考资料:
浏览器的多线程与js引擎的单线程- [耳东蜗牛]
Tasks, microtasks, queues and schedules - 【Jake for Google Chrome.】
深入理解 JavaScript Event Loop - [盛世来了]
ES6 Promise 用法讲解 -[王汉炎]
浅谈JS事件冒泡 - [全凭一口仙气儿活着]
JS事件委托 - [全凭一口仙气儿活着]
MutationObserver简介