事件循环
对于js运行中的任务,js有一套处理收集,排队,执行的特殊机制,我们称之为事件循环(Even Loop).
🍕js一大特点就是单线程
,同一个时间只能做一件事
。为了协调事件、用户交互、脚本、UI渲染和网络处理等行为,防止主线程的不阻塞,Event Loop 的方案应用而生。Even Loop 包含两类:Browsing Context,一种是基于 Worker.二者的运行是独立的,每个js运行的 “线程环境”都有一个独立的 Even Loop 。
单线程
🍔与他的用途有关。浏览器脚本语言,js的主要用途是与用户互动
,以及操作DOM。这个决定了它只能是单线程,否则会带来很复杂的同步问题。
从诞生之日起就是一门单线程的非阻塞的脚本语言。最初的用途来决定的:与浏览器交互。
- 🍟而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如 I/O 事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。
- 🚗🚗🚗单线程是必要的,也是 JavaScript 这门语言的基石,原因之一在其最初也是最主要的执行环境——浏览器中,我们需要进行各种各样的 dom 操作。试想一下 如果 JavaScript 是多线程的,那么当两个线程同时对 dom 进行一项操作,例如一个向其添加事件,而另一个删除了这个 dom,此时该如何处理呢?因此,为了保证不会 发生类似于这个例子中的情景,JavaScript 选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。
- 单线程在保证了执行顺序的同时也限制了 JavaScript 的效率,因此开发出了 web worker 技术。这项技术号称让 JavaScript 成为一门多线程语言。
- 使用 web worker 技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行 I/O 操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了 JavaScript 语言的单线程本质。
另一个特点 “非阻塞”,和事件循环的关系
执行上下文
总共有三种类型
- 全局执行上下文:只有一个,浏览器中的全局对象就是 window 对象,this 指向这个全局对象。
- 函数执行上下文:存在无数个,在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文。
- Eval 函数执行上下文:指的是运行在 eval 函数中的代码,很少用而且不建议使用。
执行上下文的创建
创建阶段
- 1、确定 this 的值,也被称为 This Binding。
- LexicalEnvironment(词法环境) 组件被创建。
- VariableEnvironment(变量环境) 组件被创建
this绑定
-全局执行上下文中,this 的值指向全局对象,在浏览器中 this 的值指向 window 对象,而在 nodejs 中指向这个文件的 module 对象。
- 函数执行上下文中,this 的值取决于函数的调用方式。具体有:默认绑定、隐式绑定、显式绑定(硬绑定)、new 绑定、箭头函数,
词法环境
有两个组成部分 = 自己的 + 外部引用
- 环境记录:存储变量和函数声明的实际位置
- 对外部环境的引用:可以访问其外部词法环境
词法环境有两种类型
- 全局环境:是一个没有外部环境的词法环境,其外部环境引用为 null。拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量,this 的值指向这个全局对象。
- 函数环境:用户在函数中定义的变量被存储在环境记录中,包含了 arguments 对象。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。
变量环境
- 变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性,在 ES6 中,他们的区别在于
- 词法环境用于存储函数声明和变量( let 和 const )绑定,变量环境仅用于存储变量( var )绑定。
- 变量提升和函数提升的原因:在创建阶段,函数和变量的声明已经存储在了执行上下文中,var声明的变量会被设置为 undefined,而在 let 和 const 的情况下,则会保持未初始化。
三、执行栈与任务队列
执行栈(Execution Stack)
- 执行栈,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。
首次运行 JS 代码时,会创建一个全局执行上下文并 Push 到当前的执行栈中。每当发生函数调用,引擎都会为该函数创建一个新的函数执行上下文并 Push 到当前执行栈的栈顶
根据执行栈 LIFO 规则,当栈顶函数运行完成后,其对应的函数执行上下文将会从执行栈中 Pop 出,上下文控制权将移到当前执行栈的下一个执行上下文。
- 当 Javascript 引擎遇到一段可执行代码时,新建一个执行上下文。
- 将它推入执行栈中。并设置为运行时执行上下文。
- 如果存在其他执行上下文。
- 那么将当前执行上下文挂起
- 然后再将新执行上下文推入执行栈中。
- 执行上下文运行完毕,弹出销毁恢复并将原执行上下文设为运行时。
任务队列(Task Queue)
根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。setTimeout/Promise 等 API 便是任务源,而进入任务队列的是他们指定的具体执行任务。
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
- 在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一次)
- 检查是否存在 微任务(Microtasks),如果存在则不停地执行,直至清空
- 更新 render
- 主线程重复执行上述步骤
在上述 tick 的基础上需要了解以下几点:
- JS 分为同步任务和异步任务
- 同步任务都在主线程上执行,形成一个执行栈
- 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的所有同步任务执行完毕(此时 JS 引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行
同步任务
同步任务是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能继续执行下一个任务,当我们打开网站时,网站的渲染过程,比如元素的渲染,其实就是一个同步任务
异步任务
异步任务是指不进入主线程,而进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程,当我们打开网站时,像图片的加载,音乐的加载,其实就是一个异步任务
常见异步任务
- ajax 的回调函数
- DOM 事件的回调函数
- setTimeout、setInterval 定时器的回调函数
四、事件循环机制
- JS 线程在同一时间只执行一个任务,期间可能创建多个函数执行环境,对应 Frame。
- 在执行任务的时候,随时执行环境栈的动态变化,相对应的变量对象不断创建销毁。对应 Object。
- 异步任务 ajax I/O 等得到结果时,会将其回调作为一个任务添加到任务队列,排队等待执行
- 当 JS 线程中的任务执行完毕,会读取任务队列 Queue,并将队列中的第一个任务添加到 JS 线程中并执行。
- 循环 3 4 步,异步任务完成后不断地往任务队列中添加任务,线程空闲时从任务列表读取任务并执行。
事件循环下的宏任务与微任务
通常我们把异步任务分为宏任务与微任务,它们的区分在于:
- 宏任务(macro-task):一般是 JS 引擎和宿主环境发生通信产生的回调任务,比如 setTimeout,setInterval 是浏览器进行计时的,其中回调函数的执行时间需要浏览器通知到 JS 引擎,网络模块, I/O 处理的通信回调也是。包含有 setTimeout,setInterval,DOM 事件回调,ajax 请求结束后的回调,整体 script 代码,setImmediate。
- 微任务(micro-task):一般是宏任务在线程中执行时产生的回调,如 Promise.then() .catch()(本身是同步任务),process.nextTick(node),Object.observe(已废弃), MutationObserver(DOM 监听)async/await,这些都是 JS 引擎自身可以监听到回调
优先级
- 宏任务优先级,主代码块 > setImmediate > MessageChannel > setTimeout / setInterval
- 微任务优先级,process.nextTick > Promise > MutationObserver
运行机制
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
五、浏览器环境下的事件循环机制
常见宏任务
- script(整体代码)
- setTimeout
- setInterval
- I/O
- 网络请求
- UI 交互事件(HTML解析、鼠标事件、键盘事件)
- postMessage
- MessageChannel
常见微任务
- Promise.then() .catch()
- Object.observe
- MutationObserver
microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前 task 任务后,下一个 task 之前,在渲染之前。
所以它的响应速度相比 setTimeout(setTimeout 是 task)会更快,因为无需等渲染。也就是说,在某一个宏任务 (macrotask) 执行完后,就会将在它执行期间产生的所有微任务 (microtask) 都执行完毕(在渲染前)。
setTimeout(() => {
//执行后 回调一个宏事件
console.log('内层宏事件3')
}, 0)
console.log('外层宏事件1');
new Promise((resolve) => {
console.log('外层宏事件2');
resolve()
}).then(() => {
console.log('微事件1');
}).then(()=>{
console.log('微事件2')
})
外层宏事件1
外层宏事件2
微事件1
微事件2
内层宏事件3