一. js是什么?
JavaScript是一个单线程、非阻塞、异步、解释性脚本语言
- V8: chrome里的JavaScript运行环境
js运行环境示意图:
堆:记录内存分配
调用栈:存储栈帧,执行上下文
JS本身就没有I/O(网络请求,磁盘读写,用户交互)的,所有的I/O操作都是由宿主(WebAPIs)执行的,宿主提供相关的API供JS调用。当JS调用IO API时并不会等待宿主的执行结果而是继续执行后面的代码,当IO执行完成后宿主再通知JS,即在eventQueue中插入回调task
- 异步
js可以同时做很多事情的原因是浏览器提供了额外有效线程的WebAPI,如setTimeout,ajax
灵魂三问
1. js为什么是单线程?
防止处理DOM时出现并发竞争问题:js是主要运行在浏览器的脚本语言,最终操作的是页面的DOM结构,当两个js脚本同时修改页面的同一个DOM节点时,浏览器不知道该执行哪个,所以在最初设计js时,便要求当前修改操作完成后方可进行下一步修改操作。
2. 为什么需要异步?
让用户体验更流畅:如果js中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。
对于用户而言,阻塞就意味着"卡死",这样就导致了很差的用户体验。
3. js单线程是如何实现异步的?
通过事件循环(event loop)
二. 浏览器的Event Loop,即js事件循环机制(js引擎执行机制)
1. js中的任务划分
- 同步任务:console.log、new Promise实例、...
- 异步任务:
- 宏任务(macro-task):script(全局任务),setTimeout,setInterval,I/O,UI rendering(视图渲染)
- 微任务(micro-task):Promise的then、catch方法,process.nextTick
- 动画回调(animation-callbacks):
requestAnimationFrame(通常用来处理渲染相关的工作,下面拆出来单独讲)
2. 一些概念理解
- 引擎:解释并编译代码,让它变成能交给机器运行的代码(runnable commands)
- runtime:就是运行环境,它提供一些对外接口供Js调用,以跟外界打交道,比如,浏览器环境、Node.js环境。不同的runtime,会提供不同的接口,比如,在 Node.js 环境中,我们可以通过 require 来引入模块;而在浏览器中,我们有window、DOM。下图中的“异步处理模块”,就是 runtime 提供的,拥有和js引擎互不干扰的线程(js引擎负责维护任务队列,并通过 Event Loop 的机制,按顺序把任务放入栈中执行)
- 栈:就像是一个容器,任务都是在栈中执行。
- 主线程:就像是操作员,负责执行栈中的任务。
- 任务队列:就像是等待被加工的物品。
- 同步任务会捆绑在一起被浏览器执行,然后以执行结果为当前状态进行渲染。如:
document.body.appendChild(el)
el.style.display = 'none'
无论顺序如何,页面都不会发生闪动(除非同步代码中有获取当前计算样式的代码, 下文讲)
- 异步任务完成注册后会将回调函数加入任务队列等待主线程执行。
- 执行栈中的同步任务执行完毕后,会查看并读取任务队列中的事件函数,于是任务队列的函数结束等待状态,进入执行栈,开始执行。
- 当函数执行栈为空时,每次event-loop取宏任务队列中最靠前的1个宏任务执行,然后执行微任务队列中所有的微任务,直至空,进入下一轮event-loop
3. 事件循环图解
完整的事件循环机制(js执行机制):
- 取且仅取一个宏任务来执行(第一个宏任务就是script任务)。执行过程中判断是同步还是异步任务,如果是同步任务就进入主线程执行栈中,如果是异步任务就进入异步处理模块,这些异步处理模块的任务当满足触发条件后,进入任务队列,进入任务队列后,按照宏任务和微任务进行划分,划分完毕后,执行下一步。
- 如果微任务队列不为空,则依次取出微任务来执行,直到微任务队列为空(即当前loop所有微任务执行完),执行下一步。
- 进入下一轮loop或更新UI渲染。
4. 谈谈 setTimeout
setTimeout(function(){
console.log('执行了')
}, 3000)
3秒后,会执行setTimeout里的那个函数
更准确的解释是: 3秒后,setTimeout里的函数被会推入event queue,而event queue(事件队列)里的任务,只有在主线程空闲时才会执行。
所以只有(1)3秒后(2)主线程空闲,同时满足时,才会3秒后执行该函数。
W3C在 HTML标准中规定,规定要求 setTimeout中低于 4ms的时间间隔算为 4ms
5. 主进(线)程及渲染阻塞
以下两段代码会使异步任务永远执行,造成阻塞
button.addEventListener('click', () => {
while(true);
})
页面无法再选中(因为选中时页面表现有所变化,文字有背景色,鼠标也变成 text),也无法再更换内容。(但鼠标却可以动!)
function callback() {
Promise.resolve().then(callback)
}
callback()
在执行 microtasks 的时候,又把自己添加到了 microtasks 中,然后立即执行,再添加,无限循环...
-
阻塞原理
-
阻塞解决方案
function loop() {
setTimeout(loop, 0)
}
loop()
每个异步任务的执行效果都是加入一个新的异步任务,新的异步任务将在下一次被执行,因此就不会存在阻塞。主进程和渲染过程都能正常进行。
6. 谈谈 requestAnimationFrame
一个特别的异步任务
注册的方法不加入异步队列,而是加入渲染这一边的队列中,它在浏览器渲染的三个步骤之前被执行
render steps:
- Structure - 构建 DOM 树的结构
- Layout - 确认每个 DOM 的大致位置(排版)
- Paint - 绘制每个 DOM 具体的内容(绘制)
与setTimeout的区别
- setTimeout 并不适合用来处理渲染相关的工作。因此和渲染动画相关的,多用 requestAnimationFrame,不会有掉帧的问题(即某一帧没有渲染,下一帧把两次的结果一起渲染了)
例:
方法 1
function callback() {
// 使元素右移1px
moveBoxForwardOnePixel();
requestAnimationFrame(callback)
}
callback()
方法 2
function callback() {
moveBoxForwardOnePixel();
setTimeout(callback, 0)
}
callback()
setTimeout 移动的 box 要比 requestAnimationFrame 速度快得多。这表明单位时间内 callback 被调用的次数是不一样的。
这是因为 setTimeout 在每次运行结束时都把自己添加到异步队列。等渲染过程的时候(不是每次执行异步队列都会进到渲染循环)异步队列已经运行过很多次了,所以渲染部分会一下会更新很多像素,而不是 1 像素。requestAnimationFrame 只在渲染过程之前运行,因此严格遵守“执行一次渲染一次”,所以一次只移动 1 像素,是我们预期的方式。
2. 在一次事件循环里执行队列里的全部任务,直至为空,进入下一轮循环(类似微任务)
如何防止同步代码的合并
box.style.transform = 'translateX(1000px)'
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
理想状态:让 box 元素的位置从 0 一下子 移动到 1000,然后 动画移动 到 500
实际状态:从 0 动画移动 到 500
解决方案:
方案1:RAF嵌套
思路:让设置 box 的初始位置(第一句代码)在同步代码执行;让设置 box 的动画效果(第二句代码)和设置 box 的重点位置(第三句代码)放到下一帧执行(即第一次渲染完成后再执行)。
box.style.transform = 'translateX(1000px)'
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
})
})
使用两次嵌套,目的是在第一次渲染之前,再一次注册 requestAnimationFrame,这就能够让后两句代码放到第二次渲染之前去执行了,问题解决。(当然代码看上去有点奇怪)
方案2:getComputedStyle
思路:打断浏览器的合并优化
box.style.transform = 'translateX(1000px)'
getComputedStyle(box) // 伪代码,只要获取一下当前的计算样式即可
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
7. 典型案例分析
例1:
析1:
- 首先执行script下的宏任务,遇到setTimeout,将其放到【宏任务队列】里
- 遇到 new Promise直接执行,打印"马上执行for循环啦"
- 遇到then方法,是微任务,将其放到【微任务队列】里
- 打印 "代码执行结束"
- 本轮宏任务执行完毕,查看本轮的【微任务队列】,发现有一个then方法里的函数, 打印"执行then函数啦"。到此,本轮的event loop 全部完成。
- 下一轮的循环里,先执行一个宏任务,发现【宏任务队列】里有一个 setTimeout里的函数,执行打印"定时器开始啦"
例2:
析2:
evernotecid://47720C6E-3AC2-403F-B4C7-5C187F5EF40A/appyinxiangcom/16791493/ENResource/p528
例3:
输出顺序为:1 5 11 13 6 12 2 3 4 7 8 9 10
析3:
- 全局任务script:1 5 11 13
- 微任务:6 12(执行微任务队列中的所有微任务,且是在宏任务后立即执行的) ---- loop 1 end
- 宏任务:2 3
- 微任务:4 ---- loop 2 end
- 宏任务:7 8 9
- 微任务:10 ---- loop 3 end
例4:
析4:
- 用户直接点击的时候,浏览器先后触发 2 个 listener。第一个 listener 触发完成 (listener 1) 之后,任务队列(宏任务?。。。)空了,就先打印了 microtask 1。然后再执行下一个 listener。重点在于浏览器并不实现知道有几个 listener,因此它发现一个执行一个,执行完了再看后面还有没有。
- 而使用 button.click() 时,浏览器的内部实现是把 2 个 listener 都同步执行。因此 listener 1 之后,执行队列还没空,还要继续执行 “listener 2” 之后才行。所以 listener 2 会早于 microtask 1。重点在于浏览器的内部实现,click 方法会先采集有哪些 listener,再依次触发。
这个差别最大的应用在于自动化测试脚本。在这里可以看出,使用自动化脚本测试和真正的用户操作还是有细微的差别。如果代码中有类似的情况,要格外注意了。
三. 浏览器是如何运作的?
- 浏览器支持多进程
浏览器的每个tab页都是一个独立的进程,但浏览器有自己的优化机制,当打开多个空白tab页时,浏览器会将这多个空白页的进程合并为一个,从而减少进程的数量
思考:为什么浏览器是多进程?
- 使tab间展现互不影响
因为如果非多进程的话,如果浏览器中的一个tab网页崩溃,将会导致其他被打开的网页应用。
- 避免资源空间共享造成安全隐患
相对于线程,进程之间是不共享资源和地址空间的,所以不会存在太多的安全问题,而由于多个线程共享着相同的地址空间和资源,所以会存在线程之间有可能会恶意修改或者获取非授权数据等复杂的安全问题。
浏览器每16ms完成一次页面重绘
在调用栈里的代码会阻塞浏览器的渲染
浏览器的 Event Loop 遵循的是 HTML5 标准,而 NodeJs 的 Event Loop 遵循的是 libuv
四、NodeJs的Event Loop
参考资料
10分钟理解JS引擎的执行机制
事件循环机制的那些事
深入浏览器的事件循环 (GDD@2018)
Philip Roberts: What the heck is the event loop anyway?
JavaScript 事件循环
JavaScript 异步、栈、事件循环、任务队列
阮一峰:JavaScript 运行机制详解:再谈Event Loop
聊聊 JavaScript 与浏览器的那些事 - 引擎与线程