vue中nextTick实现原理
# 简介
大家所熟悉的 Vue API Vue.nextTick
全局方法和 vm.$nextTick
实例方法的内部都是调用 nextTick
函数,该函数的作用可以理解为异步执行传入的函数。
nextTick中的回调是在下次 DOM 更新循环结束之后执行的延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。原理就是异步方法(promise,mutationObserver,setImmediate,setTimeout)经常与事件环一起来问(宏任务和微任务)
vue多次更新数据,最终会进行批处理更新。内部调用的就是nextTick实现了延迟更新,用户自定义的nextTick中的回调会被延迟到更新完成后调用,从而可以获取更新后的DOM。
# 原理
nextTick
方法主要使用了宏任务和微任务,定义了一个异步方法。多次调用nextTick
会将方法存入队列中,通过这个异步方法清空当前队列。所以这个nextTick
方法就是异步方法;
Vue.nextTick 内部逻辑; 源码位置:
src/core/global-api/index.js:45
在执行 initGlobalAPI(Vue)
初始化 Vue 全局 API 中,这么定义 Vue.nextTick
。
function initGlobalAPI(Vue) {
//...
Vue.nextTick = nextTick;
}
2
3
4
可以看出是直接把 nextTick
函数赋值给 Vue.nextTick
,就可以了,非常简单。
vm.$nextTick 内部逻辑; 源码位置:
src/core/observer/watcher.js:179
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};
2
3
源码位置:src/core/util/env.js:78
export const nextTick = (function () {
/*存放异步执行的回调*/
const callbacks = [];
/*一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送*/
let pending = false;
/*一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用*/
let timerFunc;
/*下一个tick时的回调*/
function nextTickHandler() {
/*一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行),这样就不需要在push多个回调到callbacks时将timerFunc多次推入任务队列或者主线程*/
pending = false;
/*执行所有callback*/
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
// 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 if */
/*
这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法。
优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法的回调函数都会在microtask中执行,它们会比setTimeout更早执行,所以优先使用。
如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。
*/
if (typeof Promise !== "undefined" && isNative(Promise)) {
/*使用Promise*/
var p = Promise.resolve();
var logError = (err) => {
console.error(err);
};
timerFunc = () => {
p.then(nextTickHandler).catch(logError);
// 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 if (
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 IE11, iOS7, Android 4.4
/*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会加入该回调*/
var counter = 1;
var observer = new MutationObserver(nextTickHandler);
var textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true,
});
timerFunc = () => {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
} else {
// fallback to setTimeout
/*使用setTimeout将回调推入任务队列尾部*/
timerFunc = () => {
setTimeout(nextTickHandler, 0);
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 分析
# Promise 创建异步执行函数
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
timerFunc = function() {
p.then(flushCallbacks);
if (isIOS) {
setTimeout(noop);
}
};
isUsingMicroTask = true;
}
2
3
4
5
6
7
8
9
10
执行 if (typeof Promise !== 'undefined' && isNative(Promise))
判断浏览器是否支持 Promise,
其中 typeof Promise
支持的话为 function ,不是 undefined,故该条件满足,这个条件好理解。
来看另一个条件,其中 isNative
方法是如何定义,代码如下。
function isNative(Ctor) {
return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}
2
3
当 Ctor
是函数类型时,执行 /native code/.test(Ctor.toString())
,检测函数 toString 之后的字符串中是否带有 native code 片段,那为什么要这么监测。这是因为这里的 toString 是 Function 的一个实例方法,如果是浏览器内置函数调用实例方法 toString 返回的结果是function Promise() { [native code] }
。
若浏览器支持,执行 var p = Promise.resolve()
,Promise.resolve()
方法允许调用时不带参数,直接返回一个resolved状态的 Promise 对象。
那么在 timerFunc
函数中执行 p.then(flushCallbacks)
会直接执行 flushCallbacks
函数,在其中会遍历去执行每个 nextTick
传入的函数,因 Promise 是个微任务 (micro task)类型,故这些函数就变成异步执行了。
执行 if (isIOS) { setTimeout(noop)}
来在 IOS 浏览器下添加空的计时器强制刷新微任务队列。
# MutationObserver 创建异步执行函数
if (!isIE && typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
var counter = 1;
var observer = new MutationObserver(flushCallbacks);
var textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true
});
timerFunc = function() {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
isUsingMicroTask = true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MutationObserver() 创建并返回一个新的 MutationObserver 它会在指定的 DOM 发生变化时被调用,IE11浏览器才兼容,故干脆执行 !isIE
排除 IE浏览器。执行 typeof MutationObserver !== 'undefined' && (isNative(MutationObserver)
判断,其原理在上面已介绍过了。执行 MutationObserver.toString() === '[object MutationObserverConstructor]')
这是对 PhantomJS 浏览器 和 iOS 7.x版本浏览器的支持情况进行判断。
执行 var observer = new MutationObserver(flushCallbacks)
,创建一个新的 MutationObserver 赋值给常量 observer
, 并且把 flushCallbacks
作为回到函数传入,当 observer
指定的 DOM 要监听的属性发生变化时会调用 flushCallbacks
函数。
执行 var textNode = document.createTextNode(String(counter))
创建一个文本节点。
执行 var counter = 1
,counter
做文本节点的内容。
执行 observer.observe(textNode, { characterData: true })
,调用 MutationObserver 的实例方法 observe
去监听 textNode
文本节点的内容。
这里很巧妙利用 counter = (counter + 1) % 2
,让 counter
在 1 和 0 之间变化。再执行 textNode.data = String(counter)
把变化的 counter
设置为文本节点的内容。这样 observer
会监测到它所观察的文本节点的内容发生变化,就会调用 flushCallbacks
函数,在其中会遍历去执行每个 nextTick
传入的函数,因 MutationObserver 是个微任务 (micro task)类型,故这些函数就变成异步执行了。
# setImmediate 创建异步执行函数
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = function() {
setImmediate(flushCallbacks);
};
}
2
3
4
5
setImmediate
只兼容 IE10 以上浏览器,其他浏览器均不兼容。其是个宏任务 (macro task),消耗的资源比较小
# setTimeout 创建异步执行函数
timerFunc = function() {
setTimeout(flushCallbacks, 0);
}
2
3
兼容 IE10 以下的浏览器,创建异步任务,其是个宏任务 (macro task),消耗资源较大。
# 创建异步执行函数的顺序
Vue 历来版本中在 nextTick
函数中实现 timerFunc
的顺序时做了几次调整,直到 2.6+ 版本才稳定下来
第一版的 nextTick
函数中实现 timerFunc
的顺序为 Promise
,MutationObserver
,setTimeout
。
在2.5.0版本中实现 timerFunc
的顺序改为 setImmediate
,MessageChannel
,setTimeout
。 在这个版本把创建微任务的方法都移除,原因是微任务优先级太高了,其中一个 issues 编号为 #6566, 情况如下:
<div class="header" v-if="expand"> // block 1
<i @click="expand = false;">Expand is True</i> // element 1
</div>
<div class="expand" v-if="!expand" @click="expand = true;"> // block 2
<i>Expand is False</i> // element 2
</div>
2
3
4
5
6
按正常逻辑 点击 element 1 时,会把 expand
置为 false
,block 1 不会显示,而 block 2 会显示,在点击 block 2 ,会把 expand
置为 false
,那么 block 1 会显示。
当时实际情况是 点击 element 1 ,只会显示 block 1。这是为什么,什么原因引起这个BUG。Vue 官方是这么解释的
点击事件是宏任务,
<i>
上的点击事件触发 nextTick(微任务)上的第一次更新。在事件冒泡到外部div之前处理微任务。在更新过程中,将向外部div添加一个click侦听器。因为DOM结构相同,所以外部div和内部元素都被重用。事件最终到达外部div,触发由第一次更新添加的侦听器,进而触发第二次更新。为了解决这个问题,您可以简单地给两个外部div不同的键,以强制在更新期间替换它们。这将阻止接收冒泡事件。
当然当时官方还是给出了解决方案,把 timerFunc
都改为用创建宏任务的方法实现,其顺序是 setImmediate
,MessageChannel
,setTimeout
,这样 nextTick 是个宏任务。
点击事件是个宏任务,当点击事件执行完后触发的 nextTick(宏任务)上的更新,只会在下一个事件循环中进行,这样其事件冒泡早已执行完毕。就不会出现 BUG 中的情况。
但是过不久,实现 timerFunc
的顺序又改为 Promise
,MutationObserver
,setImmediate
,setTimeout
,在任何地方都使用宏任务会产生一些很奇妙的问题,其中代表 issue 编号为 #6813,代码就打出来,可以看这里 (opens new window)。 这里有两个关键的控制
- 媒体查询,当页面宽度大于 1000px 时,li 显示类型为行内框,小于1000px时,显示类型为块级元素。
- 监听页面缩放,当页面宽度小于 1000px 时,ul 用
v-show="showList"
控制隐藏。
初始状态:
当快速拖动网页边框缩小页面宽度时,会先显示下面第一张图,然后快速的隐藏,而不是直接隐藏。
那为出现这种BUG,首先要了解一个概念,UI Render (UI渲染)的执行时机,如下所示:
- macro 取一个宏任务。
- micro 清空微任务队列。
- 判断当前帧是否值得更新,否则重新进入1步骤
- 一帧欲绘制前,执行requestAnimationFrame队列任务。
- UI更新,执行 UI Render。
- 如果宏任务队列不为空,重新进入步骤
这个过程也比较好理解,之前执行监听窗口缩放是个宏任务,当窗口大小小于 1000px 时,showList
会变为 flase
,会触发一个 nextTick 执行,而其是个宏任务。在两个宏任务之间,会进行 UI Render ,这时,li 的行内框设置失效,展示为块级框,在之后的 nextTick 这个宏任务执行了,再一次 UI Render 时,ul 的 display 的值切换为 none,列表隐藏。
所以 Vue 觉得用微任务创建的 nextTick 可控性还可以,不像用宏任务创建的 nextTick 会出现不可控场景。
在 2.6 + 版本中采用一个时间戳来解决 #6566 这个BUG,设置一个变量 attachedTimestamp
,在执行传入 nextTick
函数中的 flushSchedulerQueue
函数时,执行 currentFlushTimestamp = getNow()
获取一个时间戳赋值给变量 currentFlushTimestamp
,然后再监听 DOM 上事件前做个劫持。其在 add
函数中实现。
function add(name, handler, capture, passive) {
if (useMicrotaskFix) {
var attachedTimestamp = currentFlushTimestamp;
var original = handler;
handler = original._wrapper = function(e) {
if (
e.target === e.currentTarget ||
e.timeStamp >= attachedTimestamp ||
e.timeStamp <= 0 ||
e.target.ownerDocument !== document
) {
return original.apply(this, arguments)
}
};
}
target.addEventListener(
name,
handler,
supportsPassive ? {
capture: capture,
passive: passive
} : capture
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
执行 if (useMicrotaskFix)
,useMicrotaskFix
在用微任务创建异步执行函数时置为 true
。
执行 var attachedTimestamp = currentFlushTimestamp
把 nextTick 回调函数执行时的时间戳赋值给变量 attachedTimestamp
,然后执行 if(e.timeStamp >= attachedTimestamp)
,其中 e.timeStamp
DOM 上的事件被触发时的时间戳大于 attachedTimestamp
,这个事件才会被执行。
为什么呢,回到 #6566 BUG 中。由于micro task的执行优先级非常高,在 #6566 BUG 中比事件冒泡还要快,就会导致此 BUG 出现。当点击 i
标签时触发冒泡事件比 nextTick 的执行还早,那么 e.timeStamp
比 attachedTimestamp
小,如果让冒泡事件执行,就会导致 #6566 BUG,所以只有冒泡事件的触发比 nextTick 的执行晚才会避免此 BUG,故 e.timeStamp
比 attachedTimestamp
大才能执行冒泡事件。
# 自定义
let cbs = [];
let pending = false;
function flushCallbacks() {
cbs.forEach(fn=>fn());
}
function nextTick(fn) {
cbs.push(fn);
if (!pending) {
pending = true;
setTimeout(() => {
flushCallbacks();
}, 0);
}
}
function render() {
console.log('rerender');
};
nextTick(render)
nextTick(render)
nextTick(render);
console.log('sync...')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21