- 发布于
Vue-nextTick 原理
- Authors
- Name
- 田中原
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
这里用一张图来表明一下后两者在主线程中的执行关系:
当主线程执行完同步任务后:
- 引擎首先从macrotask queue中取出第一个任务,执行完毕后,将microtask queue中的所有任务取出,按顺序全部执行后,此时本轮循环结束,开始执行UI render;
- UI render完毕之后接着下一轮循环,然后再从macrotask queue中取下一个,执行完毕后,再次将microtask queue中的全部取出依次执行,UIrender;
- 循环往复,直到两个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时 无法触发微任务,在/src/platforms/web/runtime/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 更新后再使用
- 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方法可以将函数延迟到当前函数调用栈最末端,也就是函数调用栈最后调用该函数。从而做到延迟。
- 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
})
}
}