最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 面试必问!一文带你走进异步编程

    正文概述 掘金(一点点糖醋)   2021-04-02   816

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

    异步的由来

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

    这个模式比较清晰,但是当任务耗时较长的时候,比如网络请求,定时器和事件监听等,这个时候后续任务继续等待,效率比较低。我们常见的页面无响应,有时候就是因为任务耗时长或者无限循环等造成的。那现在是怎么解决这个问题呢。。。。

    首先维护了一个“任务队列”。JavaScript 虽然是单线程的,但运行的宿主环境(浏览器)是多线程的,浏览器为这些耗时任务开辟了另外的线程,主要包括 http 请求线程,浏览器定时触发器,浏览器事件触发线程。这些线程主要把任务回调,放在任务队列里,等待主线程执行。
    简单介绍如下图: 面试必问!一文带你走进异步编程

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

    异步执行机制

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

    宏任务(macroTask)

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

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

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

    微任务(microTask)

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

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

    在执行 JavaScript 脚本,创建全局执行上下文的时候,JavaScript 引擎就会创建一个微任务队列,在执行当前宏任务时,产生的微任务都会保存到微任务队列里。在宏任务主函数执行结束之后,宏任务结束之前,清空微任务队列。
    微任务和宏任务是绑定的,每个宏任务都会创建自己的微任务: 面试必问!一文带你走进异步编程

    事件循环(Event loop)

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

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

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

    异步的实现历程

    回调函数

    回调函数是一个函数被当做参数传递给另一个函数,另一个函数完成之后执行回调。比如 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 引擎结束当前协程,并把结果返回给父协程。

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

    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

    总结

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

    参考资料

    • es6.ruanyifeng.com/
    • www.imooc.com/article/287…
    • www.ruanyifeng.com/blog/2012/1…
    • 极客时间 - 李兵(time.geekbang.org/column/intr…)

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


    起源地下载网 » 面试必问!一文带你走进异步编程

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元