你真的了解Javascript执行顺序么?

1,265 阅读5分钟
原文链接: mp.weixin.qq.com

前言

大家都知道,Javascript是单线程、顺序执行的,通过事件循环来处理异步。而且稍有开发经验的同学也知道,利用 setTimeout、  setInterval以及  Promise可以延时代码的执行。如果在Node.js中,大家会用  process .nextTick来让代码在下一个周期执行;或者在Vue中,会利用  Vue .nextTick保证DOM全部更新完毕后再执行回调函数。但是,如果他们都放在一起呢?执行顺序又会是怎么样的?

来个样例

聪明的你知道下面代码的执行顺序么?

  1. console.log('script start')

  2. setTimeout(() => {

  3.  console.log('setTimeout')

  4. }, 0)

  5. console.log('script end')

看到这个问题,有经验的同学会脱口而出:太简单了,会输出如下内容:

  1. script start

  2. script end

  3. setTimeout

因为 setTimeout会加入到队列,延时执行。的确没错。那我们再看看下面的例子呢?

                            
  1. console .log( 'script start')

  2. setTimeout (() => {

  3.  console .log( 'setTimeout')

  4. }, 0)

  5. Promise .resolve(). then(() => {

  6.  console .log( 'promise1')

  7. }). then(() => {

  8.  console .log( 'promise2')

  9. })

  10. console .log( 'script end')

呃,这个问题好像难住了一部分同学,因为他们会有这样的想法:

setTimeout和  Promise到底谁先执行呢?听说  Promise是异步的,但是  setTimeout也是异步的,而且延时为0, 这可怎么好?

想不明白?没关系,先执行下看看结果:

  1. script start

  2. script end

  3. promise1

  4. promise2

  5. setTimeout

结果是不是蛮有意思的?为啥 Promise会先执行呢?我尝试着解释下,如果解释的不对,希望各位大牛多多指导。

大家知道,Javascript是基于事件循环(event loop)来处理事件的,用户的一些操作会放到事件队列里面,Javascript引擎会在合适的时候执行队列里面的操作。注意我们这里用到了“合适的时候“这个限定词,是因为Javascript单线程的,如果某段Javascript执行时间过长,那么它会阻塞主线程的执行。所以 setTimeout也并不说是一定会精确的执行。

在Javascript引擎里面,队列还分为 Task队列(也有人叫做  MacroTask)和  MicroTask队列,  MicroTask会优先于  Task执行。比如常见的点击事件、  setImmediate、  setTimeout、  MessageChannel等会放入  Task队列,但是  Promise以及  MutationObserver会放到  Microtask队列。同时,Javascript引擎在执行  Microtask队列的时候,如果期间又加入了新的  Microtask,则该  Microtask会加入到之前的  Microtask队列的尾部,保证  Microtask先于  Task队列执行。

这样,大家就清楚了为啥 Promise先执行吧,因为它是一个  Microtask呀!优先级高,真是没办法 :-)。大家也许会问,优先级高,会高到什么程度呢?我们可以简单量度下:

  1. const checkDuration = () => {

  2.    const start = Date.now()

  3.    let setTimeoutDuration = 0

  4.    let promiseDuration = 0

  5.    setTimeout(() => {

  6.        setTimeoutDuration = Date.now() - start

  7.    }, 0)

  8.    Promise.resolve().then(() => {

  9.        promiseDuration = Date.now() - start

  10.    })

  11.    setTimeout(() => {

  12.        console.log(`setTimeout耗时: ${setTimeoutDuration}`)

  13.        console.log(`Promise耗时: ${promiseDuration}`)

  14.    }, 100)

  15. }

  16. checkDuration()

我在Chrome的console里面执行多次,会输出:

  1. setTimeout耗时: 1

  2. Promise耗时: 0

  1. setTimeout耗时: 4

  2. Promise耗时: 1

当然,如果这个结果不是固定的,测试多次, setTimeout执行大慨在4ms左右,  Promise大慨在1ms左右。哈哈,其实就快了3ms,前端同学为了争取这3ms真是不懈努力而且煞费苦心呀,不过真的为他们爱专研的态度点赞!!!

值得说明的是, Vue中 Vue.nextTick也利用了该原理来保证在下次DOM更新循环结束之后执行延迟回调。如Vue 2.5.2里面就有这样的代码逻辑:

  1. var microTimerFunc;

  2. var macroTimerFunc;

  3. var useMacroTask = false;

  4. if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {

  5.  macroTimerFunc = function () {

  6.    setImmediate(flushCallbacks);

  7.  };

  8. } else if (typeof MessageChannel !== 'undefined' && (

  9.  isNative(MessageChannel) ||

  10.  // PhantomJS

  11.  MessageChannel.toString() === '[object MessageChannelConstructor]'

  12. )) {

  13.  var channel = new MessageChannel();

  14.  var port = channel.port2;

  15.  channel.port1.onmessage = flushCallbacks;

  16.  macroTimerFunc = function () {

  17.    port.postMessage(1);

  18.  };

  19. } else {

  20.  /* istanbul ignore next */

  21.  macroTimerFunc = function () {

  22.    setTimeout(flushCallbacks, 0);

  23.  };

  24. }

  25. // Determine MicroTask defer implementation.

  26. /* istanbul ignore next, $flow-disable-line */

  27. if (typeof Promise !== 'undefined' && isNative(Promise)) {

  28.  var p = Promise.resolve();

  29.  microTimerFunc = function () {

  30.    p.then(flushCallbacks);

  31.    // in problematic UIWebViews, Promise.then doesn't completely break, but

  32.    // it can get stuck in a weird state where callbacks are pushed into the

  33.    // microtask queue but the queue isn't being flushed, until the browser

  34.    // needs to do some other work, e.g. handle a timer. Therefore we can

  35.    // "force" the microtask queue to be flushed by adding an empty timer.

  36.    if (isIOS) { setTimeout(noop); }

  37.  };

  38. } else {

  39.  // fallback to macro

  40.  microTimerFunc = macroTimerFunc;

  41. }

在Vue,用 MacroTask就是我们上文说的  Task。可见执行的时机是:

Task(MacroTask)队列中:  setImmediate >  MessageChannel >  setTimeout  MicroTask队列中: 直接用了 Promise,新版本中弃用了  MutationObserver,因为其兼容性不好

扯了这么多,大家应该知道原因了吧?

再来个样例

为了巩固大家对 MicroTask的列举,我们再看一个例子

                                                                                                                                                    
  1. <div class ="outer" >

  2.   <div class= "inner"></div>

  3. </div>

  1. // 获取DOM

  2. const outer = document.querySelector('.outer')

  3. const inner = document.querySelector('.inner')

  4. // 利用MuationObserver监听DOM的变化

  5. new MutationObserver( () => {

  6.  console.log('mutate')

  7. }).observe(outer, {

  8.  attributes: true

  9. });

  10. // 事件处理

  11. const onClick = () => {

  12.  console.log('click')

  13.  setTimeout(() => {

  14.    console.log('timeout')

  15.  }, 0)

  16.  Promise.resolve().then(() => {

  17.    console.log('promise')

  18.  })

  19.  outer.setAttribute('data-random', Math.random())

  20. }

  21. // 事件绑定

  22. inner.addEventListener('click', onClick)

  23. outer.addEventListener('click', onClick)

如果我们点击 inneer区域, 输出内容为什么呢? 如果你理解了上文的内容,就会知道输出结果为:

  1. click

  2. promise

  3. mutate

  4. click

  5. promise

  6. mutate

  7. timeout

  8. timeout

好,今天就分享在这里。下篇文章,我们聊聊Node.js里面的事件。比如上文我们还没提到 setImmediate呢?这个东西只在IE里面支持,但是在Node.js里面是支持的,而且Node.js里面还有一个  Process .nextTick。下次我们再聊聊。

参考资料

  1. 什么是微任务与宏任务

  2. Vue.nextTick源码阅读

  3. Vue 中如何使用 MutationObserver 做批量处理?

  4. Node.js Event Loop 的理解 Timers,process.nextTick()

  5. what-is-the-event-loop

  6. Process.nextTick 和 setImmediate 的区别?