面试必问!一文带你走进异步编程

19c24ddc-99d7-461e-a782-a93b7de9cc5e.gif

陈晨,微医云服务团队前端工程师,一位“生命在于静止”的程序员。javascript

异步的由来

JavaScript 是单线程语言,浏览器只分配了一个主线程执行任务,意味着若是有多个任务,则必须按照顺序执行,前一个任务执行完成以后才能继续下一个任务。html

这个模式比较清晰,可是当任务耗时较长的时候,好比网络请求,定时器和事件监听等,这个时候后续任务继续等待,效率比较低。咱们常见的页面无响应,有时候就是由于任务耗时长或者无限循环等形成的。那如今是怎么解决这个问题呢。。。。前端

首先维护了一个“任务队列”。JavaScript 虽然是单线程的,但运行的宿主环境(浏览器)是多线程的,浏览器为这些耗时任务开辟了另外的线程,主要包括 http 请求线程,浏览器定时触发器,浏览器事件触发线程。这些线程主要把任务回调,放在任务队列里,等待主线程执行。
简单介绍以下图: 截图.pngjava

这样就实现了 JavaScript 的单线程异步,任务被分为同步任务和异步任务两种:
同步任务:排队执行的任务,后一个任务等待前一个任务结束。
异步任务:放入任务队列的任务,将来才会触发执行的事件。程序员

异步执行机制

异步任务分为宏任务和微任务。es6

宏任务(macroTask)

宏任务,其实就是标准机制下的常规任务,即”任务队列中“等待被主线程执行的事件,是由浏览器宿主发起的任务,例如:编程

  • script (能够理解为外层主程序同步代码)。
  • setTimeout,setInterval,requestAnimationFrame。
  • I/O。
  • 渲染事件(解析 DOM,布局,绘制等)。
  • 用户交互事件(鼠标点击,页面滚动,放大缩小等)。

宏任务会被放在宏任务队列里,先进先出的原则,两个宏任务中间可能会被插入其余系统任务,间隔时间不定,效率较低 。数组

微任务(microTask)

因为宏任务间隔不定,时间颗粒大,对于实时性要求比较高的场景就须要更精确地控制,须要把任务插入到当前宏任务执行,从而产生了微任务的概念。
微任务是 JavaScript 引擎发起的,是须要异步执行的函数。例如:promise

  • Promise:ES6 的异步编程,Promise 的各类 Api 会产生微任务,下面异步实现会作详细介绍。
  • MutationObserver(浏览器):监视 DOM 树更改,DOM 节点的变化是微任务。

在执行 JavaScript 脚本,建立全局执行上下文的时候,JavaScript 引擎就会建立一个微任务队列,在执行当前宏任务时,产生的微任务都会保存到微任务队列里。在宏任务主函数执行结束以后,宏任务结束以前,清空微任务队列。
微任务和宏任务是绑定的,每一个宏任务都会建立本身的微任务: image.png浏览器

事件循环(Event loop)

主线程运行 JavaScript 代码时,会生成个执行栈(先进后出),管理主线程上函数调用关系的数据结构。
当执行栈中的全部同步任务执行完毕,系统就会不断的从"任务队列"中读取事件,这个过程是循环不断的,称为 Event Loop(事件循环)。
事件循环机制调度宏任务和微任务,机制以下:

  1. 执行一个宏任务(第一次是最外层同步代码),执行过程当中若是遇到微任务会加入微任务队列;
  2. 代码执行完成后,查看是否有微任务,若是有的执行第 3 步,没有则执行第 4 步;
  3. 依次执行全部微任务,在执行微任务的过程当中产生的新的微任务也会被事件循环处理,直到队列清空,宏任务完成,执行第 4 步;
  4. 查看是否有下一个宏任务,有的话则执行第 1 步,没有则结束。

由于微任务自身能够入列更多的微任务,且事件循环会持续处理微任务直至队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增长微任务是要谨慎而行的。

image.png

异步的实现历程

回调函数

回调函数是一个函数被当作参数传递给另外一个函数,另外一个函数完成以后执行回调。好比 Ajax 请求、IO 操做、定时器的回调等。
下面是 setTimeout 例子:

console.log('setTimeout 调用以前')
setTimeout(() => {console.log('setTimeout 输出')}, 0);
console.log('setTimeout 调用以后')
// 结果
setTimeout 调用以前
setTimeout 调用以后
setTimeout 输出
复制代码

setTimeout 回调放入任务队列中,当主线程的同步代码执行完以后,才会执行任务队列的回调,因此是如上的输出结果。

优缺点

优势:回调函数相对比较简单、容易理解。
缺点:不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱,并且每一个任务只能指定一个回调函数,易造成回调函数地狱。以下:

setTimeout(function(){
    let value1 = step1()
    setTimeout(function(){
        let value2 = step2(value1)
        setTimeout(function(){
            step3(value2)
        },0);
    },0);
},0);
复制代码

Promise

Promise 是 ES6 新增的异步编程的方式,在必定程度上解决了回调地域的问题。简单说就是一个容器,里面保存着某个将来才会结束的事件(一般是一个异步操做)的结果。从语法上说,Promise 是一个对象,从它能够获取异步操做的消息。
使用 Promise 首先要明白如下特色:

  1. Promise 有三种状态 pending、rejected、resolved,状态一旦肯定就不能改变,且只可以由 pending 状态变成 rejected 或者 resolved 状态;
  2. Promise 实例最主要的方法就是 then 的实现,有两个参数。 Promise 执行成功时,调用 then 方法的第一个回调函数,失败则调用第二个回调函数,并且 then 方法会返回一个新的 Promise 实例。
  3. 其次经常使用的就是 catch 方法,catch 方法实际是 then 方法第一个参数是 null 的状况,用于指定发生错误时的回调函数。
  4. 还有不少其余的 finally、all、race、allSettled、any、resolve、reject 等一系列 Api。

下面的例子就是常见的异步操做,主要是使用的 then 和 catch:

new Promise((resolve) => {
    resolve(step1())
}).then(res => {
    return step2(res)
}).catch(err => {
    console.log(err)
})
复制代码

step1 和 step2 是异步操做,step1 执行完以后的返回值会透传给 then 回调,当作 step2 的入参,经过 then 一层层的代替回调地域。其中 then 的回调会加入微任务队列。

Promise 为何是微任务呢?

当 Promise 入参是同步代码时:

console.log('start')
new Promise((resolve) => {
    console.log('开始 resolve')
    resolve('resolve 返回值')
}).then(data => {
    console.log(data)
})
console.log('end')

// 原生 promise 输出结果
start
开始 resolve
end
resolve 返回值

复制代码

首先看下 Promise 的极简实现:

class Promise {
    constructor (executor) {
        // 回调值
        this.value = ''
        // 成功的回调
        this.onResolvedCallbacks = []
        executor(this.resolve.bind(this))
    }
    resolve (value) {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    }
    then (onResolved, onRejected) {
        this.onResolvedCallbacks.push(() => {
            onResolved(this.value)
        })
    }
}

// 此时上面例子执行结果以下
start
开始 resolve
end

复制代码

因为 Promise 是延迟绑定机制(回调在业务代码的后面),executor 是同步代码时,在执行到 resolve 的时候,尚未执行 then,因此 onResolvedCallbacks 是空数组。这个时候须要让 resolve 延后执行,能够先加一个定时器。以下:

resolve (value) {
    setTimeout(() => {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    })
}
复制代码

输出结果和预期是一致的,这里使用 setTimeout 来延迟执行 resolve。可是 setTimeout 是宏任务,效率不高,这里只是用 setTimeout 代替,在浏览器中,JavaScript 引擎会把 Promise 回调映射到微任务,既能够延迟被调用,又提高了代码的效率。

优缺点

优势:

  • 将异步操做以同步操做的流程表达出来,避免了层层嵌套的回调函数。
  • 提供统一的接口,使得控制异步操做更加容易。

缺点:

  • 没法取消 Promise,一旦新建它就会当即执行,没法中途取消。
  • 若是不设置回调函数,Promise 内部抛出的错误,不会反应到外面。
  • 当处于 pending 状态时,没法得知目前进展到哪个阶段(刚刚开始仍是即将完成)。

Generator/yield

Generator 是 ES6 提供的异步解决方案,其最大的特色就是能够控制函数的执行。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器,异步操做须要暂停的地方,都用 yield 语句注明。
Generator 函数的特征:

  1. function 关键字与函数名之间有一个星号;
  2. 函数体内部使用 yield 表达式,定义不一样的内部状态;
  3. 经过 yield 暂停执行;
  4. next 恢复执行,而且返回一个包含 value 和 done 属性的对象,其中 value 表示 yield 表达式的值,done 表示遍历器是否完成;
  5. next 方法也能够接受参数, 做为上一次 yield 语句的返回值。
function* getData () {
  let value1 = yield 111
  let value2 = yield value1 + 111 // 这里的 value1 就是下面传入的 val1.value
  return value2
}
let meth = getData()
let val1 = meth.next() 
console.log(val1) // { value: 111, done: false }
let val2 = meth.next(val1.value)
console.log(val2) // { value: 222, done: false }
let val3 = meth.next(val2.value)
console.log(val3) // { value: 222, done: true }

复制代码
  1. 调用 getData 函数,会返回一个内部指针 meth(即遍历器);
  2. 调用指针 meth 的 next 方法,移动内部指针,指向第一个遇到的 yield 语句,输出返回值为 {value: 111, done: false}
  3. 再次调用指针 meth 的 next 方法,入参为 111,赋值给 value1,移动内部指针,指向下一个 yield 语句,输出表达式的返回值为 {value: 222, done: false}
  4. 持续调用指针 meth 的 next 方法,入参为 222,赋值给 value2,遇到 return 结束遍历器,输出返回值{ value: 222, done: true }

Generator 是怎么实现暂停和恢复执行的呢?

Generator 是协程的一种实现方式。
协程:协程是一种比线程更加轻量级的存在,协程处在线程的环境中,一个线程能够存在多个协程,能够将协程理解为线程中的一个个任务。经过应用程序代码进行控制。
上面的例子中协程具体流程以下:

  1. 经过生成器函数 getData 建立一个协程 meth,建立以后没有当即执行;
  2. 调用 meth.next() 让协程执行;
  3. 协程执行时,经过关键字 yield 暂停协程;
  4. 协程执行时,遇到 return,JavaScript 引擎结束当前协程,并把结果返回给父协程。

image.png

meth 协程和父协程在主线程上交替执行,经过 next() 和 yield 进行控制,只有用户态,切换效率高。

优缺点

优势:Generator 是以一种看似顺序、同步的方式实现了异步控制流程,加强了代码可读性。
缺点:须要手动 next 执行下一步。

async/await

async/await 将 Generator 函数和自动执行器,封装在一个函数中,是 Generator 的一种语法糖,简化了外部执行器的代码,同时利用 await 替代 yield,async 替代生成器的(*)号。

async 和 Generator 相比改进的地方:

  • 内置执行器,不须要使用 next() 手动执行。
  • await 命令后面能够是 Promise 对象或原始类型的值,若是是原始值,会 Promise 化。
  • async 返回值是 Promise。返回非 Promise 时,async 函数会把它包装成 Promise 返回。

下面来看个 sleep 的例子:

function sleep(time) {
    return new Promise((resolve, reject) => {
        time+=1000
        setTimeout(() => {
            resolve(time);
        }, 1000);
    });
}
    
async function test () {
    let time =  0 
    for(let i = 0; i < 4; i++) {
        time = await sleep(time);
        console.log(time);
    }
}

test()

// 输出结果
1000
2000
3000
复制代码

执行结果每隔一秒会输出 time,await 是等待的意思,等待 sleep 执行完毕后经过 resolve 返回,才会继续执行,间隔至少一秒。

把 async/await 转成 Generator 和 Promise 来实现。

function test () {
    let time =  0 
    // stepGenerator 生成器
    function* stepGenerator() {
        for (let i = 0; i < 4; i++) {
            let result = yield sleep(time);
            console.log(result);
        }
    }
    let step = stepGenerator()
    let info
    return new Promise((resolve) => {
        // 自执行 next()
        function stepNext ()  {
            info = step.next(time)
            //  执行结束则返回 value
            if (info.done) {
                resolve(info.value)
            } else {
            // 遍历没有结束 ,继续执行
                return Promise.resolve(info.value).then((res) => {
                    time = res
                    return stepNext()
                })
            }
        }
        stepNext()
    })
}
test()
复制代码
  1. 首先把 async 包装成 Promise,async/await 转换成 stepGenerator 生成器,yield 替换 await;
  2. 执行 stepNext();
  3. stepNext 里,step 遍历器会执行 next()。done 为 false 时,说明遍历没有完成,经过 Promise.resolve 等待执行结果,获取结果以后继续执行 next(),直到 done 为 true,async 的 resolve 把最终返回。

优缺点

优势:是 Generator 更简化的方式,至关于自动执行 Generator,代码更清晰,更简单。
缺点:滥用 await 可能会致使性能问题,由于 await 会阻塞代码,非依赖代码失去并发性。

多个异步的执行顺序问题

多个异步的执行顺序问题是很考验对异步的理解的。下面咱们把 setTimeout、Promise、async/await 放在一块儿,看下返回结果和预想的是否一致:

console.log('start')
setTimeout(function() {
    console.log('setTimeout')
}, 0);
async function test () {
    let a = await 'await-result'
    console.log(a)
}
test()
new Promise(function(resolve) {
    console.log('promise-resolve')
    resolve()
}).then(function() {
    console.log('promise-then')
})
console.log('end')

//执行结果
start
promise-resolve
end
await-result
promise-then
setTimeout
复制代码

上述例子中,外层主程序 和 setTimeout 都是宏任务,Promise 和 async/await 是微任务,因此整个流程以下:

  1. 第一个宏任务(主程序)开始执行 ------ 输出 start
  2. setTimeout 加入宏任务队列
  3. 执行 test(),async/await 加入微任务队列
  4. Promise 初始入参是同步代码,主程序一块儿执行 ------ 输出 promise-resolve
  5. Promise 的 then 回调加入微任务队列
  6. 继续执行主程序 ------ 输出 end
  7. 执行第一个微任务 ------ 输出 await-result
  8. 执行第二个微任务 ------ 输出 promise-then
  9. 再执行下一个宏任务(setTimeout) ------ 输出 setTimeout

总结

前端程序员平常代码常常会用到异步编程,了解异步运行的机制和顺序有助于更流畅清晰的实现异步代码,这里主要分析了异步的由来和异步代码实现,可结合不一样的场景和要求进行选择。

参考资料

e9a30897-8c14-4881-9e33-5428ee948e53.gif

相关文章
相关标签/搜索