JS异步那些事

1. 事件循环

JS是单线程执行的(浏览器渲染进程的渲染主线程),它怎么处理各种各样的异步操作和事件呢?最早的方案是回调,如SetTimeout来设置定时器,通过XmlHttpRequest(ActiveXObject)来异步下载文件或调用后端API,如Node中可以使用readFile来读取文件,他们都是通过传入回调函数,当浏览器和Node执行完了之后把返回的数据作为参数给回调函数进行调用。

那么多的事件,诸如有触发绘制页面的事件,有鼠标点击、拖拽、放大缩小的事件,有资源下载、文件读写的事件,等等这些事件怎么在一个UI线程里执行呢?

JS为UI线程设计出一个消息队列,并将这些待执行的事件添加到消息队列(FIFO)中依次排序,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件(任务,task)。

那么异步回调的调用时机是什么时候呢,当异步操作完成后,会把回调函数封装为一个新的事件,放入任务队列的末尾,等待JS主线程的轮循执行。

注意,任务队列中的事件都是的一个个的宏任务。

 

 

2. SetTimeout(callback,ms)

实际上他是放到一个延迟队列(实际不是队列这种线性数据结构,而是类似于Hash table)里的,每完成一个宏任务,他就会去检查是否有已经到期的任务,若有,则把他的回调函数封装为一个事件放入消息队列中等待主线程的轮循执行。

这个基础api,是用于延迟执行的操作,注意,指定的延迟时间,比如100ms,实际执行时间往往多于100ms的。即使指定0ms,也一定不会在当前的宏任务中执行,存在嵌套setTimeout调用时候,浏览器还会默认把小于4ms的设置为4ms。如果当前不是活动页面,setTimeout的最小时间为1000ms,同时注意,这个ms数的最大值是int的上限值,21多亿,也就是24.8天,超过这个值会溢出从0开始计算。

这里还最容易出现this指针混淆问题,由于他的回调已经脱离了当前函数上下文,非严格模式下,他指向的是window对象,一般可以用箭头函数解决,或给回调函数显式绑定this。

3. XmlHttpRequest 或 (ActiveXObject(‘microsoft xmlhttp‘))

他的回调函数也是一个宏任务,他与Html5中的fetch相比,兼容性更好,书写上也比较冗余(好在有axios,jquery等库进行封装简化操作),但形式化,趋势上,fetch更简洁,且使用的promise 微任务方案,时效性更好,且是未来异步请求的标准。

局部刷新方案之一,他从server side get data,然后使用JS操作DOM,从而实现局部刷新网页的某一部分内容,不至于像以前一样,要全部重新获取整个页面,使用局部刷新之后,体验更好,UX更佳。

4.  宏任务 VS 微任务

宏任务很简单,就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。

微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。这里需要pay attention to the time of execution,微任务的执行时机是当前正在执行的宏任务之后,下一个宏任务之前。但是当前这个宏任务不一定就是创建这个微任务的按个宏任务。比如从后台获取一个api要花10s,采用promise异步方案,那么主线程会继续执行其他宏任务的,只是主线程每次执行完一个宏任务,就要去检测是否有需要执行的微任务。

JavaScript 中之所以要引入微任务,主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,那么微任务可以在实时性和效率之间做一个有效的权衡。

另外使用微任务,可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用。

宏任务: setTimeout、setInterval、XmlHttpRequest,鼠标输入点击移动事件,文件读写,解析DOM,样式计算,布局计算,CSS动画等等

微任务:Promise,MutationObserver(DOM属性发生变化,立刻更新响应,一种观察者模式)

下面这段代码,分析下输出,有利于理解宏任务和微任务执行顺序

function bar(){
  console.log(‘bar‘)
  Promise.resolve().then(
    (str) =>console.log(‘micro-bar‘)
  ) 
  setTimeout((str) =>console.log(‘macro-bar‘),0)
}


function foo() {
  console.log(‘foo‘)
  Promise.resolve().then(
    (str) =>console.log(‘micro-foo‘)
  ) 
  setTimeout((str) =>console.log(‘macro-foo‘),0)
  
  bar()
}
foo()
console.log(‘global‘)
Promise.resolve().then(
  (str) =>console.log(‘micro-global‘)
) 
setTimeout((str) =>console.log(‘macro-global‘),0)

正确结果是:
foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

5. Promise

如果要请求多个资源或要调用多个api,但api之间有依赖,使用回调的形式,就会出现大量的嵌套,可读性和可维护性都会变得很差,也就是大家说的回调地狱问题,所以从ES6开始,引入Promise来缓解这一个问题。

所以Promise异步编码风格,让代码看起来更线性连续,可读性更好,每一个task队列有成功和失败两个状态,还可以合并多个任务的错误处理,错误可以冒泡到有catch捕获为止。

6. Generator

生成器函数是一个带星号函数,配合 yield 就可以实现函数的暂停和恢复.

function* getResult() {

let v1 = yield ‘getUserID‘
console.log(v1); //1
let v2 = yield ‘getUserName‘
console.log(v2); //2
return ‘name‘

}

let result = getResult()
console.log(result.next().value) // getUserID
console.log(result.next("1").value)// getUserName
console.log(result.next("2").value) //name

输出结果
getUserID
1
getUserName
2
name

执行上面这段代码,观察输出结果,你会发现函数 getResult 并不是一次执行完的,而是全局代码和 getResult 函数交替执行。

其实这就是生成器函数的特性,在生成器内部,如果遇到 yield 关键字,那么 V8 将返回关键字后面的内容给外部,并暂停该生成器函数的执行。

生成器暂停执行后,外部的代码便开始执行,外部代码如果想要恢复生成器的执行,可以使用 result.next 方法。

Note:

yield "传出" ,这里的“”传出“”是返回给外面函数的值,即next().value,如果外面函数需要传入一个值给generator内部,需要在next(“传入”)

那么,V8 是怎么实现生成器函数的暂停执行和恢复执行的呢?

这背后的魔法就是协程(很多脚本语言就是使用协程来实现类似多线程的处理),协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。比如,当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

 

生成器来实现异步编程

function* getResult() {
    let id_res = yield fetch(id_url);
    console.log(id_res)
    let id_text = yield id_res.text();
    console.log(id_text)


    let new_name_url = name_url + "?id=" + id_text
    console.log(new_name_url)


    let name_res = yield fetch(new_name_url)
    console.log(name_res)
    let name_text = yield name_res.text()
    console.log(name_text)
}


let result = getResult()
result.next().value.then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value

可以使用CO框架来自动执行。

比如:co(getResult())

也就是执行器。

后面JS内置实现执行器机制。

7. Async/Await, 异步编程的终极解决方案

由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,不过生成器依然需要使用额外的 co 函数来驱动生成器函数的执行,这一点非常不友好。

基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。

async function getResult() {
    try {
        let id_res = await fetch(id_url)
        let id_text = await id_res.text()
        console.log(id_text)
  
        let new_name_url = name_url+"?id="+id_text
        console.log(new_name_url)


        let name_res = await fetch(new_name_url)
        let name_text = await name_res.text()
        console.log(name_text)
    } catch (err) {
        console.error(err)
    }
}
getResult()

这段代码看起来更符合人的思维方式,也可以是try catch来处理。

8. 异步编程总结

Callback 模式的异步编程模型需要实现大量的回调函数,大量的回调函数会打乱代码的正常逻辑,使得代码变得不线性、不易阅读,这就是我们所说的回调地狱问题。

使用 Promise 能很好地解决回调地狱的问题,我们可以按照线性的思路来编写代码,这个过程是线性的,非常符合人的直觉。但是这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,语义化不明显,代码不能很好地表示执行流程。

我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码 (实现该逻辑的核心是协程),但是在生成器之外,我们还需要一个触发器来驱动生成器的执行,因此这依然不是我们最终想要的方案。

我们的最终方案就是 async/await,async 是一个可以暂停和恢复执行的函数,我们会在 async 函数内部使用 await 来暂停 async 函数的执行,await 等待的是一个 Promise 对象,如果 Promise 的状态变成 resolve 或者 reject,那么 async 函数会恢复执行。因此,使用 async/await 可以实现以同步的方式编写异步代码这一目标。

 

JS异步那些事

上一篇:PB调用C#编写的Dll类库


下一篇:03-CSS文字文本样式