Event Loop【JS深入知识汇点9】

309 阅读7分钟

Event Loop 也就是事件循环,可以理解为实现异步的一种方式。

它是在 HTML Standard 定义的, 规范中定义了浏览器何时进行渲染更新,了解它有助于性能优化。

macrotask 宏任务

事件、用户交互、脚本、渲染、网络这些都是由 Event Loop 协调的。 一个 Event Loop 有一个或多个 task(macrotask) 队列,当用户安排一个任务,必须将该任务增加到相应的 Event Loop 中的一个 task 队列(先进先出)中

task 任务源:

  • DOM 操作任务源:被用来响应dom操作,比如一个元素以非阻塞的方式插入文档。
  • 用户交互任务源:被用来对用户交互作出响应,比如键盘输入或者鼠标点击。
  • 网络任务源:被用来响应网络活动
  • history traversal 任务源:当调用 history.back() 等类似 api 时,将任务插进 task 队列。
  • setTimeout、setInterval、setImmediate等也都是

microtask 微任务

每一个 Event Loop 都只有一个 microsoft 队列。 通常被认为是 microtask 的任务源有:

  • process.nextTick
  • promises:promise 的定义不在HTML规范中,在 ECMAScript 规范中,在 Promises/A+规范的Notes 3.1中提及:promise 的then 方法可以采用 macrotask 或者 microtask 机制来实现,所以,有些浏览器会有差异。一个普遍的共识是 promises 属于 microtask 队列
  • Object.observe:异步地监视一个对象的修改
  • MutationObserver:创建并返回一个新的 MutationObserver,它会在指定的 DOM 发生变化时被调用

Event Loop

先执行同步任务,然后所有微任务,一个宏任务,所有微任务,一个宏任务。。。在当前的微任务没有执行完成时,是不会执行下一个宏任务的

有两种 Evnet Loop,一种在浏览器上下文中,一种在 works 中。浏览器可以有多个 Event Loop, Event Loop 至少有一个浏览器上下文,所有同源的浏览器上下文可以共用 Event Loop,这样它们之间就可以互相通信。

浏览器上下文是一个将 Document 对象呈现给用户的环境。在一个 web 浏览器内,一个标签页或窗口常包含一个浏览器上下文,

Event Loop 的处理过程: 一个 Event Loop 只要存在,就会不断执行下边的步骤:

  1. 在tasks队列中,选择最老的一个 task,如果没有可选的,跳去 microtask 部分的步骤6
  2. 将上边选择的task设置为 正在运行的task
  3. Run:运行被选择的 task
  4. 将 Event Loop 的正在运行的 task 设置为 null
  5. 从 task 队列里移除前边运行的 task
  6. 执行 microtask 任务检查点,也就是执行 Microtask 队列里的任务,直至清空,如果任务检查点的 falg 是 false,需要以下步骤
    1. 将 microtask 任务检查点的 flag 设为 true
    2. 如果 Event Loop 的 microtask 队列为空,直接跳去第7步,否则在队列中选择一个最老的任务
    3. 将上一个选择的任务设为 正在运行的task
    4. Run:运行被选择的task
    5. 将Event Loop 的正在运行的 task 设置为 null
    6. 从 task 队列中移除前边运行的 task,然后返回到第2步
    7. Done:给 Environment Settings Object 发一个 Rejected Promises 的通知
    8. 清理 IndexedDB 的事物
    9. 将 microtask 任务检查点的 flag 设置为false
  7. 更新渲染,此时的渲染有以下特性:
    • 在一轮 Eveent Loop 里多次修改同一个 dom,只有最后一次会进行绘制
    • 渲染更新会在 Event Loop 中的 macrotask 和 microtask 完成后进行,但并不是每一轮 Event Loop 都会重新渲染,这取决于是否修改了 dom 和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在很短的时间内修改多处dom,浏览器可能会把变动攒起来,只进行一次绘制,这是合理的
    • 如果希望每次 Event Loop 都及时呈现变动,可以使用 requestAnimationFeame
  8. 返回第一步 由于 JS 是单线程的,所以很多时候计时器并不是表现的和我们的直观想象一样,计时器设定的延时是没有保证的

定时器

定时器内部工作原理

计时器回调函数特性:

  • 在哪儿初始化就在那儿开始计时
  • 回调函数排在 macrotask 队列的最后
  • 如果把所有的定时函数都放在队尾,一大串定时回调函数将会没有时间间隔的一起执行,直到完成。
  • 如果回调函数在队列前面,浏览器会静静等待

定时器函数

定时器函数:

  • setTimeout(fn, delay):创建一个简单的计时器,在经过给定的时间后,回调函数将会被执行。这个函数会返回一个唯一的ID,便于在之后某个时间可以注销这个计时器。
  • setInterval(fn, delay):每经过一段时间(给定的延时),所传递的回调函数就会执行一次,直到这个定时器被注销
  • setImmediate(fn):setImediate 是 setTimeout(0) 的替代版,是为了保证让代码在下一次 Event Loop 执行
  • clearInterval(id) / clearTimeout(id): 停止计时器回调函数的执行

实现一个发布订阅

JavaScript 事件最核心的有:事件监听(addListener)、事件触发(emit)、事件删除(removeListener). eventEmitter 的核心就是事件触发与事件监听器功能的封装。

// 手写一个 EventEmitter
class EventEmitter {
	constructor() {
   	this.listeners = {}
   }
   on(type, cb) { //type 是事件类型
   	if(!this.listeners[type]) {
       	this.listeners[type] = []
       }
       this.listeners[type].push(cb)
   }
   emit(type, ...args) {
   	if(this.listeners[type]) {
       	this.listeners[type].forEach(cb => cb(...args))
       }
   }
   off(type, cb) {
   	if (this.listeners[type]) {
       	const targetIndex = this.listeners[type].findindex(item => item === cb)
           if (tragetIndex !== -1) {
           	this.listeners[type].splice(targetIndex, 1)
           }
           if(this.listeners[type].length === 0) {
           	delete this.listeners[type]
           }
       }
   }
   offAll(type) {
   	if(this.listeners[type]) {
       	delete this.listeners[type]
       }
   }
}

发布-订阅设计模式

发布-订阅设计模式中,消息的发送方,叫做发布者,消息不会直接发送给特定的接收者,叫做订阅者,他俩之间有一个第三方组件,叫做信息中介,它将订阅者和发布者串联起来。

EventEmitter 就是一个典型的发布订阅模式,实现了事件调度中心,发布者通过 emit 方法发布事件,订阅者通过 on 进行订阅

观察者设计模式

观察者模式在软件设计中是一个对象,维护一个依赖列表,当任何状态发生改变,就会自动通知它们。

class Observer {
	constructor (cb) {
   	if (typeof cb === 'function') {
       	this.cb = cb;
       } else {
       	throw new Error('Observer 构造器必须传入函数类型')
       }
   }
   update() {
   	this.cb();
   }
}
// 目标对象
class Subject {
	constructor () {
   	this.observerList = [];
   }
   addObserver(observer) {
   	this.observerList.push(observer)
   }
   notify() {
   	this.observerList.forEach(observer => observer.update())
   }
}

观察者和发布-订阅的区别

  • 观察者模式中,观察者知道subject,subject一直保持对观察者进行记录。而在发布订阅模式内,发布者和订阅着不知道彼此
  • 在发布订阅模式中,组件是松散耦合的,和观察者相反
  • 观察者模式大部分都是同步的,而发布订阅模式大多时候是异步的
  • 观察者模式需要在单个应用程序地址空间中体现,而发布订阅模式更像交叉应用模式

##好题练习 练习题1

console.log('1');
setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

执行结果:

1
7
6
8
2
4
3
5

练习题2

console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end') 
}
async1()

setTimeout(function() {
  console.log('setTimeout')
}, 0)

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')

执行结果:

script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout

要先理解 await/async,当我们使用 await时,解释器回都会创建一个 Promise 对象,然后把剩下的 async 函数中的操作放在 then 回调函数里。

练习题3:

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')

分别输出浏览器和nodejs的执行结果

// 浏览器
start => end => promise3 => timer1 => promise1 => timer2 => promise2
// nodejs
start => end => promise3 => timer1 => timer2 => promise1 => promise2
  • 在 nodejs 中,microtask 在 Event Loop 的各个阶段之间执行
  • 在浏览器端,microtask 在 Event Loop 的 macrotask 执行完后执行

在 node 端,有以下需要注意:

  • process.nextTick 独立于 Event Loop,当每个阶段完成后,如果存在 nextTick 队列,就会清空列中所有回调函数,并且优先于其他 microtask
  • 在 I/O 回调(fs.readFile)中,setImmediate 永远比 setTimeout 早执行