专栏首页程序员成长指北面试必考:真的理解 $nextTick 么

面试必考:真的理解 $nextTick 么

作者:子奕,原文链接https://juejin.im/post/5cd9854b5188252035420a13。

为什么是nextTick

这里猜测一下为什么Vue有一个API叫nextTick

浏览器

浏览器(多进程)包含了「Browser进程」(浏览器的主进程)、「第三方插件进程」「GPU进程」(浏览器渲染进程),其中「GPU进程」(多线程)和Web前端密切相关,包含以下线程:

  • 「GUI渲染线程」
  • 「JS引擎线程」
  • 「事件触发线程」(和EventLoop密切相关)
  • 「定时触发器线程」
  • 「异步HTTP请求线程」

「GUI渲染线程」「JS引擎线程」是互斥的,为了防止DOM渲染的不一致性,其中一个线程执行时另一个线程会被挂起。

这些线程中,和Vue的nextTick息息相关的是「JS引擎线程」「事件触发线程」

JS引擎线程和事件触发线程

浏览器页面初次渲染完毕后,「JS引擎线程」结合「事件触发线程」的工作流程如下:

(1)同步任务在「JS引擎线程」(主线程)上执行,形成「执行栈」(Execution Context Stack)。

(2)主线程之外,「事件触发线程」管理着一个「任务队列」(Task Queue)。只要异步任务有了运行结果,就在「任务队列」之中放置一个事件。

(3)「执行栈」中的同步任务执行完毕,系统就会读取「任务队列」,如果有异步任务需要执行,将其加到主线程的「执行栈」并执行相应的异步任务。

主线程的执行流程如下图所示:

这里可能是不够严谨的,在本文中「事件队列」「任务队列」指向同一个概念。

事件循环机制(Event Loop)

「事件触发线程」管理的「任务队列」是如何产生的呢?事实上这些任务就是从「JS引擎线程」本身产生的,主线程在运行时会产生「执行栈」,栈中的代码调用某些异步API时会在「任务队列」中添加事件,栈中的代码执行完毕后,就会读取「任务队列」中的事件,去执行事件对应的回调函数,如此循环往复,形成事件循环机制,如下图所示:

任务类型

JS中有两种任务类型:「微任务」(microtask)和「宏任务」(macrotask),在ES6中,microtask称为 jobs,macrotask称为 task。

「宏任务」:script (主代码块)、setTimeoutsetIntervalsetImmediate 、I/O 、UI rendering

「微任务」process.nextTick(Nodejs) 、promiseObject.observeMutationObserver

这里要重点说明一下,「宏任务」并非全是异步任务,主代码块就是属于「宏任务」的一种(「Promises/A+规范」)。

它们之间区别如下:

  • 「宏任务」是每次「执行栈」执行的代码(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
  • 浏览器为了能够使得「JS引擎线程」「GUI渲染线程」有序切换,会在当前「宏任务」结束之后,下一个「宏任务」执行开始之前,对页面进行重新渲染(「宏任务」 > 渲染 > 「宏任务」 > ...)
  • 「微任务」是在当前「宏任务」执行结束之后立即执行的任务(在当前 「宏任务」执行之后,UI渲染之前执行的任务)。「微任务」的响应速度相比setTimeout(下一个「宏任务」)会更快,因为无需等待UI渲染。
  • 当前「宏任务」执行后,会将在它执行期间产生的所有「微任务」都执行一遍。

自我灌输一下自己的理解:

  • 「宏任务」中的事件是由「事件触发线程」来维护的
  • 「微任务」中的所有任务是由「JS引擎线程」维护的(这只是自我猜测,因为「宏任务」执行完毕后会立即执行「微任务」,为了提升性能,这种无缝连接的操作放在「事件触发线程」来维护明显是不合理的)。

根据事件循环机制,重新梳理一下流程:

  • 执行一个「宏任务」(首次执行的主代码块或者「任务队列」中的回调函数)
  • 执行过程中如果遇到「微任务」,就将它添加到「微任务」的任务队列中
  • 「宏任务」执行完毕后,立即执行当前「微任务」队列中的所有任务(依次执行)
  • 「JS引擎线程」挂起,「GUI线程」执行渲染
  • 「GUI线程」渲染完毕后挂起,「JS引擎线程」执行「任务队列」中的下一个「宏任务」

举个栗子,以下示例无法直观的表述「UI渲染线程」的接管过程,只是表述了「JS引擎线程」的执行流程:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>

  <style>
    .outer {
      height: 200px;
      background-color: red;
      padding: 10px;
    }

    .inner {
      height: 100px;
      background-color: blue;
      margin-top: 50px;
    }
  </style>
</head>
<body>
  <div class="outer">
    <div class="inner"></div>
  </div>
</body>

<script>
let inner = document.querySelector('.inner')
let outer = document.querySelector('.outer')

// 监听outer元素的attribute变化
new MutationObserver(function() {
  console.log('mutate')
}).observe(outer, {
  attributes: true
})

// click监听事件
function onClick() {
  console.log('click')

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

  Promise.resolve().then(function() {
    console.log('promise')
  })

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

inner.addEventListener('click', onClick)

</script>
</html>

点击inner元素打印的顺序是:「建议放入浏览器验证。」

触发的click事件会加入「宏任务」队列,MutationObserverPromise的回调会加入「微任务」队列,setTimeout加入到「宏任务」队列,对应的任务用对象直观的表述一下(自我认知的一种表述,只有参考价值):

{
 // tasks是宏任务队列
  tasks: [{
 script: '主代码块'
  }, {
    script: 'click回调函数',
   // microtasks是微任务队列
    microtasks: [{ 
      script: 'Promise'
    }, {
      script: 'MutationObserver'
    }]
  }, {
    script: 'setTimeout'
  }]
}

稍微增加一下代码的复杂度,在原有的基础上给outer元素新增一个click监听事件:

outer.addEventListener('click', onClick)

点击inner元素打印的顺序是:「建议放入浏览器验证。」

由于冒泡,click函数再一次执行了,对应的任务用对象直观的表述一下(自我认知的一种表述,只有参考价值):

{
  tasks: [{
 script: '主代码块'
  }, {
    script: 'innter的click回调函数',
    microtasks: [{
      script: 'Promise'
    }, {
      script: 'MutationObserver'
    }]
  }, {
    script: 'outer的click回调函数',
    microtasks: [{
      script: 'Promise'
    }, {
      script: 'MutationObserver'
    }]
  }, {
    script: 'setTimeout'
  }, {
    script: 'setTimeout'
  }]
}

Node.js中的process.nextTick

Node.js中有一个nextTick函数和Vue中的nextTick命名一致,很容易让人联想到一起(Node.js的Event Loop和浏览器的Event Loop有差异)。重点讲解一下Node.js中的nextTick的执行机制,简单的举个栗子:

setTimeout(function() {
  console.log('timeout')
})

process.nextTick(function(){
  console.log('nextTick 1')
})

new Promise(function(resolve){
  console.log('Promise 1')
  resolve();
  console.log('Promise 2')
}).then(function(){
  console.log('Promise Resolve')
})

process.nextTick(function(){
  console.log('nextTick 2')
})

在Node环境(10.3.0版本)中打印的顺序:Promise 1 > Promise 2 > nextTick 1 > nextTick 2 > Promise Resolve > timeout

在Node.js的v10.x版本中对于process.nextTick的说明如下:

The process.nextTick() method adds the callback to the "next tick queue". Once the current turn of the event loop turn runs to completion, all callbacks currently in the next tick queue will be called.This is not a simple alias to setTimeout(fn, 0). It is much more efficient. It runs before any additional I/O events (including timers) fire in subsequent ticks of the event loop.

Vue的API命名nextTick

Vue官方对nextTick这个API的描述:

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

// 修改数据
vm.msg = 'Hello'
// DOM 还没有更新
Vue.nextTick(function () {
  // DOM 更新了
})

// 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
Vue.nextTick()
 .then(function () {
  // DOM 更新了
})

2.1.0 起新增:如果没有提供回调且在支持 Promise 的环境中,则返回一个 Promise。请注意 Vue 不自带 Promise 的 polyfill,所以如果你的目标浏览器不原生支持 Promise (IE:你们都看我干嘛),你得自己提供 polyfill。0

可能你还没有注意到,Vue 异步执行 DOM 更新。只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。

例如,当你设置 vm.someData = 'new value' ,该组件不会立即重新渲染。当刷新队列时,组件会在事件循环队列清空时的下一个“tick”更新。多数情况我们不需要关心这个过程,但是如果你想在 DOM 状态更新后做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员沿着“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们确实要这么做。为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用 Vue.nextTick(callback) 。这样回调函数在 DOM 更新完成后就会调用。

Vue对于这个API的感情是曲折的,在2.4版本、2.5版本和2.6版本中对于nextTick进行反复变动,原因是浏览器对于「微任务」的不兼容性影响、「微任务」「宏任务」各自优缺点的权衡。

看以上流程图,如果Vue使用setTimeout「宏任务」函数,那么势必要等待UI渲染完成后的下一个「宏任务」执行,而如果Vue使用「微任务」函数,无需等待UI渲染完成才进行nextTick的回调函数操作,可以想象在「JS引擎线程」「GUI渲染线程」之间来回切换,以及等待「GUI渲染线程」的过程中,浏览器势必要消耗性能,这是一个严谨的框架完全需要考虑的事情。

当然这里所说的只是nextTick执行用户回调之后的性能情况考虑,这中间当然不能忽略flushBatcherQueue更新Dom的操作,使用异步函数的另外一个作用当然是要确保同步代码执行完毕Dom更新性能优化(例如同步操作对响应式数据使用for循环更新一千次,那么这里只有一次DOM更新而不是一千次)。

到了这里,对于Vue中nextTick函数的命名应该是了然于心了,当然这个命名不知道和Node.js的process.nextTick还有没有什么必然联系。

Vue中NextTick源码(这里加了一些简单的注释说明)

2.5版本

/* @flow */
/* globals MessageChannel */

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 在2.4中使用了microtasks ,但是还是存在问题,
// 在2.5版本中组合使用macrotasks和microtasks,组合使用的方式是对外暴露withMacroTask函数
// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).

// 2.5版本在nextTick中对于调用microtask(微任务)还是macrotask(宏任务)声明了两个不同的变量
let microTimerFunc
let macroTimerFunc

// 默认使用microtask(微任务)
let useMacroTask = false


// 这里主要定义macrotask(宏任务)函数
// macrotask(宏任务)的执行优先级
// setImmediate -> MessageChannel -> setTimeout
// setImmediate是最理想的选择
// 最Low的状况是降级执行setTimeout

// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}


// 这里主要定义microtask(微任务)函数
// microtask(微任务)的执行优先级
// Promise -> macroTimerFunc
// 如果原生不支持Promise,那么执行macrotask(宏任务)函数

// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // in problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}


// 对外暴露withMacroTask 函数
// 触发变化执行nextTick时强制执行macrotask(宏任务)函数

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a (macro) task instead of a microtask.
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    try {
      return fn.apply(null, arguments)
    } finally {
      useMacroTask = false    
    }
  })
}

// 这里需要注意pending
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

2.6版本

/* @flow */
/* globals MutationObserver */

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

export let isUsingMicroTask = false

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 在2.5版本中组合使用microtasks 和macrotasks,但是重绘的时候还是存在一些小问题,而且使用macrotasks在任务队列中会有几个特别奇怪的行为没办法避免,So又回到了之前的状态,在任何地方优先使用microtasks 。
// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */


// task的执行优先级
// Promise -> MutationObserver -> setImmediate -> setTimeout

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Techinically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

总结

本文的表述可能存在一些不严谨的地方。

1.看到这里了就点个在看支持下吧,你的「在看」是我创作的动力。

“在看转发”是最大的支持

本文分享自微信公众号 - 程序员成长指北(coder_growth),作者:子奕

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-09-01

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 一道面试题引发的事件循环深入思考

    面试题如下,大家可以先试着写一下输出结果,看与正确答案是否有出入,如果大家不能准确的做出答案,可以通过下面对微任务,事件循环,定时器等相关代码执行顺序的讲解,让...

    coder_koala
  • 深入理解Javacript从作用域作用域链开始

    作用域是你的代码在运行时,某些特定部分中的变量,函数和对象的可访问性。换句话说,作用域决定了变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。

    coder_koala
  • 【JS必知必会】高阶函数详解与实战

    当大家看到这个面试题的时候,能否在第一时间想到使用高阶函数实现?想到在实际项目开发过程中,用到哪些高级函数?有没有想过自己创造一个高阶函数呢?开始本篇文章的学习

    coder_koala
  • Java并发包源码分析:任务异步执行的结果Future和FutureTask

    Runnable任务在Executor线程执行器当中是异步执行的,而有些任务是需要返回执行结果的,故在Executor派生接口ExecutorService接口...

    IT大咖说
  • Java线程并发:知识点

    常见形式:将对象的的引用存储到公共静态域;非私有方法中返回引用;发布内部类实例,包含引用。

    WindWant
  • Event Loop(1)

    进程描述了CPU在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。

    用户3258338
  • Java并发编程笔记——J.U.C之executors框架:executors框架设计理念

    juc-executors框架是整个J.U.C包中类/接口关系最复杂的框架,真正理解executors框架的前提是理清楚各个模块之间的关系,高屋建瓴,从整体到局...

    须臾之余
  • Java多线程总结三

    用户2196435
  • Java多线程详解6【面试+工作】

    Java多线程详解【面试+工作】 Java线程:新特征-原子量 所谓的原子量即操作变量的操作是“原子的”,该操作不可再分,因此是线程安全的。 为何要使用原子变量...

    Java帮帮
  • Android 多线程实现方式

    通常来说,一个应用至少有一个进程,而一个进程至少有一个线程。 线程是 CPU 调度的基本单位,进程是系统资源分配的基本单位。

    字节流动

扫码关注云+社区

领取腾讯云代金券