nextTick 原理

Vue nextTick 原理

使用场景
在进行获取数据后,需要对新视图进行下一步操作或者其他操作时,发现获取不到 DOM。

这里就涉及到 Vue 一个很重要的概念:异步更新队列(JS运行机制 、 事件循环)

异步更新队列

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化并不是直接更新 DOM,而是开启一个队列,并缓冲同一事件循环中发生的所有数据变更。

如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。

然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

所以如果用 for 循环来动态改变数据100次,其实它只会应用最后一次改变,如果没有这种机制,DOM就要重绘100次,是一个很大的开销,损耗性能。

Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

列如

// 修改数据
vm.msg = 'Hello'
// 该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,
console.log(vm.$el.textContent) //并不会得到‘hello’

// 这样才可以 nextTick里面的代码会在DOM更新后执行
Vue.nextTick(function () {
 // DOM 更新了
})

// 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)  即不传回调
Vue.nextTick()
 .then(function () {
   // DOM 更新了
 })
 // Vue实例方法vm.$nextTick做了进一步封装,把context参数设置成当前Vue实例。

官方对Vue.nextTick的定义

Vue.nextTick( [callback, context] )

参数:

  • {Function} [callback] // 回调函数,不传时提供promise调用
  • {Object} [context] // 回调函数执行的上下文环境,不传默认是自动绑定到调用它的实例上。

用法:

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

宏任务/微任务

首先要了解一下浏览器中的 EventLoop、macro task、micro task几个概念,

JS执行是单线程的,它是基于事件循环的。
详细可以看JavaScript 运行机制详解:再谈Event Loop

这里用一张图来表明一下后两者在主线程中的执行关系:

执行关系

当主线程执行完同步任务后:

  1. 引擎首先从macrotask queue中取出第一个任务,执行完毕后,将microtask queue中的所有任务取出,按顺序全部执行,UIrender;
  2. 然后再从macrotask queue中取下一个,执行完毕后,再次将microtask queue中的全部取出依次执行,UIrender;
  3. 循环往复,直到两个queue中的任务都取完。

浏览器环境中常见的异步任务种类,按照优先级:

  • macro task :同步代码、setImmediate、MessageChannel、setTimeout/setInterval
  • micro task:Promise.then、MutationObserver

了解了这些只会我们就会发现:

异步任务执行的顺序是建立与优先级之上,vue的异步队列默认优先使用micro task就是利用其高优先级的特性,保证队列中的微任务在一次循环全部执行完毕。

下面看看nextTick源码中对 micro task 与 macro task 的具体实现:

首先,定义变量:


// 空函数,可用作函数占位符
import { noop } from 'shared/util' 
 // 错误处理函数
import { handleError } from './error'
 // 是否是IE、IOS、内置函数
import { isIE, isIOS, isNative } from './env'
// 使用 MicroTask 的标识符,这里是因为火狐在<=53时 无法触发微任务,在modules/events.js文件中引用进行安全排除
export let isUsingMicroTask = false 

var callbacks = [];   // 存放异步执行的回调
var pending = false;  // 用来标志是否正在执行回调函数
var timerFunc;  // 异步执行函数 用于异步延迟调用 flushCallbacks 函数

然后,创建 $nextTick 内实际调用的函数

// 对callbacks进行遍历,然后执行相应的回调函数
function flushCallbacks  () {
  pending = false;
  //  拷贝出函数数组副本
   // 这里拷贝的原因是:
    // 有的cb 执行过程中又会往callbacks中加入内容
    // 比如 $nextTick的回调函数里还有$nextTick
    // 后者的应该放到下一轮的nextTick 中执行
    // 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
  var copies = callbacks.slice(0);
  //  把函数数组清空
  callbacks.length = 0;
  // 依次执行函数
  for (var i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

其次, Vue 会根据当前浏览器环境优先使用原生的 Promise.then 和 MutationObserver,如果都不支持,就会采用 setTimeout 代替,目的是 延迟函数到 DOM 更新后再使用

由于宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,再使用宏任务。

  1. Promise.then 的延迟调用
// 在2.5中,我们使用(宏)任务(与微任务结合使用)。
// 然而,当状态在重新绘制之前发生变化时,就会出现一些微妙的问题
// (例如#6813,out-in转换)。
// 同样,在事件处理程序中使用(宏)任务会导致一些奇怪的行为
// 因此,我们现在再次在任何地方使用微任务。
// 优先使用 Promise

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  var logError = function (err) { console.error(err); };
  timerFunc = function () {
    p.then(flushCallbacks ).catch(logError);
     // 用Promise模拟的,但是在iOS UIWebViews中有个bug,Promise.then并不会被触发
    // 除非浏览器中有其他事件触发,例如处理setTimeout。所以手动加了个空的setTimeout
    if (isIOS) { setTimeout(noop); }
  };
}

如果浏览器支持Promise,那么就用Promise.then的方式来延迟函数调用,Promise.then方法可以将函数延迟到当前函数调用栈最末端,也就是函数调用栈最后调用该函数。从而做到延迟。

  1. MutationObserver
// 当 原生Promise 不可用时,使用 原生MutationObserver
else if (typeof MutationObserver !== 'undefined' && (
 isNative(MutationObserver) ||
 MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {

 var counter = 1;
  // 创建MO实例,监听到DOM变动后会执行回调flushCallbacks
 var observer = new MutationObserver(flushCallbacks);
 var textNode = document.createTextNode(String(counter));
 observer.observe(textNode, {
   characterData: true // 设置true 表示观察目标的改变 -> 节点内容或节点文本的变动
 });
  // 每次执行timerFunc 都会让文本节点的内容在 0/1之间切换
   // 切换之后将新值复制到 MO 观测的文本节点上
   // 节点内容变化会触发回调->flushCallbacks会被调用
 timerFunc = function () {
   counter = (counter + 1) % 2;
   textNode.data = String(counter);
 };
}

MutationObserver是h5新加的一个功能,其功能是监听dom节点的变动,在所有dom变动完成后,执行回调函数。
Mutation Observer API


方法:

构造函数
用来实例化一个Mutation观察者对象,其中的参数是一个回调函数,它是会在指定的DOM节点发送变化后,执行的函数,并且会被传入两个参数,一个是变化记录数组(MutationRecord),另一个是观察者对象本身

let observer = new MutationObserver(function(records, itself){}); //实例化一个Mutation观察者对象

observe
在观察者对象上,注册需要观察的DOM节点,以及相应的参数

observer.observe(Node target, optional MutationObserverInit options)

其中的可选参数 MutationObserverInit的属性如下:

  • childList:观察目标节点的子节点的新增和删除

  • attributes:观察目标节点的属性变动

  • characterData:节点内容或节点文本的变动

  • subtree:布尔值,所有下属节点(包括子节点和子节点的子节点)的变动

  • attributeOldValue 布尔值, 在attributes属性已经设为true的前提下, 将发生变化的属性节点之前的属性值记录下来(记录到下面MutationRecord对象的oldValue属性中)

  • characterDataOldValue 在characterData 布尔值,属性已经设为true的前提下,将发生变化characterData节点之前的文本内容记录下来(记录到下面MutationRecord对象的oldValue属性中)

  • attributeFilter 数组,表示需要观察的特定属性(比如[‘class’,‘src’])。


可以看出,以上代码是创建了一个文本节点,来改变文本节点的内容来触发的变动,因为我们在数据模型更新后,将会引起dom节点重新渲染,所以,我们加了这样一个变动监听,用一个文本节点的变动触发监听,等所有dom渲染完后,执行函数,达到我们延迟的效果。

3.setImmediate实现

这个方法只在 IE、Edge 浏览器中原生实现,
为什么优先使用 setImmediate 而不直接使用 setTimeout 呢,是因为HTML5规定setTimeout执行的最小延时为4ms,而嵌套的timeout表现为10ms,为了尽可能快的让回调执行,没有最小延时限制的setImmediate显然要优于 setTimeout。

else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
 //回退到setImmediate。
//从技术上讲,它利用(宏)任务队列,
//但它仍然是比setTimeout更好的选择。
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
}

4.setTimeOut 延迟器

else {
    timerFunc = function () {
      setTimeout(flushCallbacks, 0);
    };
  }

利用setTimeout的延迟原理,setTimeout(func, 0)会将func函数延迟到下一次函数调用栈的开始,也就是当前函数执行完毕后再执行该函数,因此完成了延迟功能。

延迟调用优先级如下:
Promise > MutationObserver > setImmediate > setTimeout

为什么默认优先使用 micro task 呢,是利用其高优先级的特性,保证队列中的微任务在一次循环全部执行完毕。

闭包函数

// src/core/util/next-tick.js
export function nextTick(cb? Function, ctx: Object) {
    let _resolve
    // cb 回调函数会统一处理压入callbacks数组
    callbacks.push(() => {
        if(cb) {
            try {
                cb.call(ctx)
            } catch(e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    
    // pending 为false 说明本轮事件循环中没有执行过timerFunc()
    if(!pending) {
        pending = true
        timerFunc()
    }
    
    // 当不传入 cb 参数时,提供一个promise化的调用 
    // 如nextTick().then(() => {})
    // 当_resolve执行时,就会跳转到then逻辑中
    if(!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

next-tick.js 对外暴露了nextTick这一个参数,所以每次调用Vue.nextTick时会执行:

  • 首先 nextTick 把传入的 cb 回调函数用 try-catch 包裹后放在一个匿名函数中推入callbacks数组中,这么做是因为防止单个 cb 如果执行错误不至于让整个JS线程挂掉,每个 cb 都包裹是防止这些回调函数如果执行错误不会相互影响,比如前一个抛错了后一个仍然可以执行。
  • 然后检查 pending 状态,它是一个标记位,一开始是 false 在进入 timerFunc 方法前被置为 true,因此下次调用 nextTick 就不会进入 timerFunc 方法,这个方法中 会在下一个 macro/micro tick 时候 flushCallbacks 异步的去执行callbacks队列中收集的任务,而 flushCallbacks 方法在执行一开始会把 pending 置 false,因此下一次调用 nextTick 时候又能开启新一轮的 timerFunc,这样就形成了vue中的 event loop。
  • 最后检查是否传入了 cb,因为 nextTick 还支持Promise化的调用:nextTick().then(() => {}),所以如果没有传入 cb 就直接return了一个Promise实例,并且把resolve传递给_resolve,这样后者执行的时候就跳到我们调用的时候传递进 then 的方法中

$nextTick

最后再把nexttick函数挂到Vue原型上就OK了

Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
}

源码


/* globals MutationObserver */

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

export let isUsingMicroTask = false

const callbacks = [] // 声明公共数组,存储nextTick回调函数
let pending = false

function flushCallbacks () { // 执行timerFunc函数时执行这个回调函数,处理在执行nextTick时新增的方法
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

  // 这里,我们使用微任务异步延迟包装器。
  // 在2.5中,我们使用了(宏)任务(结合了微任务)。
  // 但是,当状态在重新绘制之前更改状态时(例如,#6813,由外而内的过渡),它存在一些细微的问题。
  // 另外,在事件处理程序中使用(宏)任务会导致一些无法规避的怪异行为(例如,#7109,#7153,#7546,#7834,#8109)。
  // 因此,我们现在再次在各处使用微任务。
  // 这种权衡的主要缺点是,在某些情况下,微任务的优先级过高,
  // 并且在假定的顺序事件之间(例如#4521,#6690,它们具有解决方法)甚至在同一事件冒泡之间也会触发(#6566) 。

let timerFunc // 定义全局的timerFunc

// nextTick行为利用了微任务队列,可以通过本机Promise.then或MutationObserver对其进行访问。
//  MutationObserver具有更广泛的支持,但是当在触摸事件处理程序中触发时,
// 它在iOS> = 9.3.3的UIWebView中严重错误。 触发几次后,它将完全停止工作...因此,如果本地Promise可用,
// 我们将使用它:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 但是它可能会陷入怪异的状态,在这种状态下,回调被推送到微任务队列中,
    // 但是队列没有被刷新,直到浏览器需要执行其他工作 ,
    // 例如 处理一个计时器。 因此,我们可以通过添加一个空计时器来“强制”刷新微任务队列。
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // 在本地Promise不可用的地方使用MutationObserver,
  // 例如 PhantomJS,iOS7,Android 4.4(#6466 MutationObserver在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)) {
  // 回退到setImmediate。
  // 从技术上讲,它利用了(宏)任务队列,
  // ,但它仍然是比setTimeout更好的选择。
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 后退到setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
  // 重点是这里判断,如果现在是在执行渲染结束的情况,渲染结束了,开始调用
  // 上面被赋值好的 timerFunc ,执行这个函数会
  // 触发执行 flushCallbacks 这个函数,他会遍历执行全部的callbacks
  // 为什么会有那么多的callback呢,因为nextTick每次被执行都会在callbacks中
  // 推送一个事件,形成一个事件组就是 callbacks
  // 这里的pending 是一个全局的变量,默认值false,在flushCallBacks里会把
  // pending = false;此处是一个锁保证nextTick仅有一次执行。
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()
  }
  // 如果没有回调函数,vue会让nextTick返回一个promise对象返回结果
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

猜你喜欢

转载自blog.csdn.net/kang_k/article/details/111041276