本文主要和大家分享對Vue
中的DOM
非同步更新策略和nextTick
機制的解析,需要讀者有一定的Vue
使用經驗並且熟悉掌握JavaScript事件循環模型。希望能幫助大家。
<template> <p> <p ref="test">{{test}}</p> <button @click="handleClick">tet</button> </p> </template>
export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; console.log(this.$refs.test.innerText);//打印“begin” } } }
列印的結果是begin
而不是我們設定的end
。這個結果足以說明Vue
中DOM
的更新並非同步。
在Vue
官方文件中是這樣說明的:
可能你還沒注意到,Vue
非同步執行DOM
更新。只要觀察到資料變化,Vue
將開啟一個佇列,並緩衝在同一事件循環中發生的所有資料改變。如果同一個watcher
被多次觸發,只會被推入到佇列中一次。這種在緩衝時移除重複資料對於避免不必要的計算和DOM
操作上非常重要。然後,在下一個的事件循環「tick
」中,Vue
刷新佇列並執行實際 (已去重的) 工作。
簡而言之,就是在一個事件循環中發生的所有資料改變都會在下一個事件循環的Tick
中來觸發視圖更新,這也是一個「批次」的過程。 (注意下一個事件循環的Tick
有可能是在目前的Tick
微任務執行階段執行,也可能是在下一個Tick
執行,主要取決於nextTick
函數到底是使用Promise/MutationObserver
或setTimeout
)
在Watcher
的原始碼中,我們發現watcher
的update
其實是非同步的。 (註:sync
屬性預設為false
,也就是非同步)
update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { /*同步则执行run直接渲染视图*/ this.run() } else { /*异步推送到观察者队列中,下一个tick时调用。*/ queueWatcher(this) } }
queueWatcher(this)
函數的程式碼如下:
/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/ export function queueWatcher (watcher: Watcher) { /*获取watcher的id*/ const id = watcher.id /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/ if (has[id] == null) { has[id] = true if (!flushing) { /*如果没有flush掉,直接push到队列中即可*/ queue.push(watcher) } else { ... } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } } }
這段原始碼有幾個需要注意的地方:
首先需要知道的是watcher
執行update
的時候,預設情況下肯定是異步的,它會做以下的兩件事:
#判斷has
數組中是否有這個watcher
的id
如果有的話是不需要把watcher
加入queue
中的,否則不做任何處理。
這裡面的nextTick(flushSchedulerQueue)
中,flushScheduleQueue
函數的作用主要是執行視圖更新的操作,它會把queue
中所有的watcher
拿出來並執行對應的視圖更新。
核心其實是nextTick
函數了,下面我們可以具體看一下nextTick
到底有什麼用。
nextTick
函數其實做了兩件事情,一是產生一個timerFunc
,把回呼當作microTask
或macroTask
參與到事件循環中。二是把回呼函數放入一個callbacks
隊列,等待適當的時機執行。 (這個時機和timerFunc
不同的實作有關)
首先我們先來看它是怎麼產生一個timerFunc
把回呼當作microTask
或macroTask
的。
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 /* istanbul ignore next */ /*使用setTimeout将回调推入任务队列尾部*/ timerFunc = () => { setTimeout(nextTickHandler, 0) } }
值得注意的是,它會依照Promise
、MutationObserver
、setTimeout
優先權去呼叫傳入的回呼函數。前兩者會產生一個microTask
任務,而後者會產生一個macroTask
。 (微任務和巨集任務)
之所以會設定這樣的優先權,主要是考慮到瀏覽器之間的兼容性(IE
沒有內建Promise
) 。另外,設定Promise
最優先是因為Promise.resolve().then
回呼函數屬於一個微任務,瀏覽器在一個Tick
中執行完macroTask
後會清空目前Tick
所有的microTask
再進行UI
渲染,把DOM
更新的操作放在Tick
執行microTask
的階段來完成,相較於使用setTimeout
產生的一個macroTask
會少一次UI
的渲染。
而nextTickHandler
函數,其實才是我們真正要執行的函數。
function nextTickHandler () { pending = false /*执行所有callback*/ const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } }
這裡的callbacks
變數供nextTickHandler
消費。而前面我們所說的nextTick
函數第二點功能中“等待適當的時機執行”,其實就是因為timerFunc
的實現方式有差異,如果是Promise\ MutationObserver
則nextTickHandler
回呼是一個microTask
,它會在目前Tick
的結尾來執行。如果是setTiemout
則nextTickHandler
#回呼是一個macroTask
,它會在下一個Tick
來執行。
还有就是callbacks
中的成员是如何被push
进来的?从源码中我们可以知道,nextTick
是一个自执行的函数,一旦执行是return
了一个queueNextTick
,所以我们在调用nextTick
其实就是在调用queueNextTick
这个函数。它的源代码如下:
return function queueNextTick (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) } }) if (!pending) { pending = true timerFunc() } if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => { _resolve = resolve }) } }
可以看到,一旦调用nextTick
函数时候,传入的function
就会被存放到callbacks
闭包中,然后这个callbacks
由nextTickHandler
消费,而nextTickHandler
的执行时间又是由timerFunc
来决定。
我们再回来看Watcher
中的一段代码:
/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/ export function queueWatcher (watcher: Watcher) { /*获取watcher的id*/ const id = watcher.id /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/ if (has[id] == null) { has[id] = true if (!flushing) { /*如果没有flush掉,直接push到队列中即可*/ queue.push(watcher) } else { ... } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } } }
这里面的nextTick(flushSchedulerQueue)
中的flushSchedulerQueue
函数其实就是watcher
的视图更新。每次调用的时候会把它push
到callbacks
中来异步执行。
另外,关于waiting
变量,这是很重要的一个标志位,它保证flushSchedulerQueue
回调只允许被置入callbacks
一次。
所以,也就是说DOM
确实是异步更新,但是具体是在下一个Tick
更新还是在当前Tick
执行microTask
的时候更新,具体要看nextTcik
的实现方式,也就是具体跑的是Promise/MutationObserver
还是setTimeout
。
附:nextTick
源码带注释),有兴趣可以观摩一下。
这里面使用Promise
和setTimeout
来执行异步任务的方式都很好理解,比较巧妙的地方是利用MutationObserver
执行异步任务。MutationObserver
是H5
的新特性,它能够监听指定范围内的DOM
变化并执行其回调,它的回调会被当作microTask
来执行,具体参考MDN
,。
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) }
可以看到,通过借用MutationObserver
来创建一个microTask
。nextTickHandler
作为回调传入MutationObserver
中。
这里面创建了一个textNode
作为观测的对象,当timerFunc
执行的时候,textNode.data
会发生改变,便会触发MutatinObservers
的回调函数,而这个函数才是我们真正要执行的任务,它是一个microTask
。
注:2.5+
版本的Vue
对nextTick
进行了修改,具体参考下面“版本升级”一节。
nextTick
继续来看下面的这段代码:
<p id="example"> <p ref="test">{{test}}</p> <button @click="handleClick">tet</button> </p>
var vm = new Vue({ el: '#example', data: { test: 'begin', }, methods: { handleClick() { this.test = 'end'; console.log('1') setTimeout(() => { // macroTask console.log('3') }, 0); Promise.resolve().then(function() { //microTask console.log('promise!') }) this.$nextTick(function () { console.log('2') }) } } })
在Chrome
下,这段代码执行的顺序的1、2、promise、3
。
可能有同学会以为这是1、promise、2、3
,其实是忽略了一个标志位pending
。
我们回到nextTick
函数return
的queueNextTick
可以发现:
return function queueNextTick (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) } }) if (!pending) { pending = true timerFunc() } if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => { _resolve = resolve }) } }
这里面通过对pending
的判断来检测是否已经有timerFunc
这个函数在事件循环的任务队列等待被执行。如果存在的话,那么是不会再重复执行的。
最后异步执行nextTickHandler
时又会把pending
置为false
。
function nextTickHandler () { pending = false /*执行所有callback*/ const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } }
所以回到我们的例子:
handleClick() { this.test = 'end'; console.log('1') setTimeout(() => { // macroTask console.log('3') }, 0); Promise.resolve().then(function() { //microTask console.log('promise!') }) this.$nextTick(function () { console.log('2') }) }
代码中,this.test = 'end'
必然会触发watcher
进行视图的重新渲染,而我们在文章的Watcher
一节中就已经有提到会调用nextTick
函数,一开始pending
变量肯定就是false
,因此它会被修改为true
并且执行timerFunc
。之后执行this.$nextTick
其实还是调用的nextTick
函数,只不过此时的pending
为true
说明timerFunc
已经被生成,所以this.$nextTick(fn)
只是把传入的fn
置入callbacks
之中。此时的callbacks
有两个function
成员,一个是flushSchedulerQueue
,另外一个就是this.$nextTick()
的回调。
因此,上面这段代码中,在Chrome
下,有一个macroTask
和两个microTask
。一个macroTask
就是setTimeout
,两个microTask
:分别是Vue
的timerFunc
(其中先后执行flushSchedulerQueue
和function() {console.log('2')}
)、代码中的Promise.resolve().then()
。
上面讨论的nextTick
实现是2.4
版本以下的实现,2.5
以上版本对于nextTick
的内部实现进行了大量的修改。
首先是从Vue 2.5+
开始,抽出来了一个单独的文件next-tick.js
来执行它。
在代码中,有这么一段注释:
其大概的意思就是:在Vue 2.4
之前的版本中,nextTick
几乎都是基于microTask
实现的(具体可以看文章nextTick
一节),但是由于microTask
的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,就会导致一些诡异的问题;但是如果全部都改成macroTask
,对一些有重绘和动画的场景也会有性能的影响。所以最终nextTick
采取的策略是默认走microTask
,对于一些DOM
的交互事件,如v-on
绑定的事件回调处理函数的处理,会强制走macroTask
。
具体做法就是,在Vue
执行绑定的DOM
事件时,默认会给回调的handler
函数调用withMacroTask
方法做一层包装,它保证整个回调函数的执行过程中,遇到数据状态的改变,这些改变而导致的视图更新(DOM
更新)的任务都会被推到macroTask
。
function add$1 (event, handler, once$$1, capture, passive) { handler = withMacroTask(handler); if (once$$1) { handler = createOnceHandler(handler, event, capture); } target$1.addEventListener( event, handler, supportsPassive ? { capture: capture, passive: passive } : capture ); } /** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a Task instead of a MicroTask. */ function withMacroTask (fn) { return fn._withTask || (fn._withTask = function () { useMacroTask = true; var res = fn.apply(null, arguments); useMacroTask = false; return res }) }
而对于macroTask
的执行,Vue
优先检测是否支持原生setImmediate
(高版本IE和Edge支持),不支持的话再去检测是否支持原生MessageChannel
,如果还不支持的话为setTimeout(fn, 0)
。
最后,写一段demo来测试一下:
<p id="example"> <span>{{test}}</span> <button @click="handleClick">change</button> </p>
var vm = new Vue({ el: '#example', data: { test: 'begin', }, methods: { handleClick: function() { this.test = end; console.log('script') this.$nextTick(function () { console.log('nextTick') }) Promise.resolve().then(function () { console.log('promise') }) } } })
在Vue 2.5+
中,这段代码的输出顺序是script
、promise
、nextTick
,而Vue 2.4
输出script
、nextTick
、promise
。nextTick
执行顺序的差异正好说明了上面的改变。
在Vue 2.4
版本以前使用的MutationObserver
来模拟异步任务。而Vue 2.5
版本以后,由于兼容性弃用了MutationObserver
。
Vue 2.5+
版本使用了MessageChannel
来模拟macroTask
。除了IE
以外,messageChannel
的兼容性还是比较可观的。
const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) }
可见,新建一个MessageChannel
对象,该对象通过port1
来检测信息,port2
发送信息。通过port2
的主动postMessage
来触发port1
的onmessage
事件,进而把回调函数flushCallbacks
作为macroTask
参与事件循环。
为什么要优先MessageChannel
创建macroTask
而不是setTimeout
?
HTML5
中规定setTimeout
的最小时间延迟是4ms
,也就是说理想环境下异步回调最快也是4ms
才能触发。
Vue
使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel
的延迟明显是小于setTimeout
的。对比传送门
看下面的代码:
<template> <p> <p>{{test}}</p> </p> </template>
export default { data () { return { test: 0 }; }, mounted () { for(let i = 0; i < 1000; i++) { this.test++; } } }
现在有这样的一种情况,mounted
的时候test
的值会被++
循环执行1000
次。 每次++
时,都会根据响应式触发setter->Dep->Watcher->update->run。 如果这时候没有异步更新视图,那么每次<code>++
都会直接操作DOM
更新视图,这是非常消耗性能的。 所以Vue
实现了一个queue
队列,在下一个Tick
(或者是当前Tick
的微任务阶段)的时候会统一执行queue
中Watcher
的run
。同时,拥有相同id
的Watcher
不会被重复加入到该queue
中去,所以不会执行1000
次Watcher
的run
。最终更新视图只会直接将test
对应的DOM
的0
变成1000
。 保证更新视图操作DOM
的动作是在当前栈执行完以后下一个Tick
(或者是当前Tick
的微任务阶段)的时候调用,大大优化了性能。
在操作DOM
节点无效的时候,就要考虑操作的实际DOM
节点是否存在,或者相应的DOM
是否被更新完毕。
比如说,在created
钩子中涉及DOM
节点的操作肯定是无效的,因为此时还没有完成相关DOM
的挂载。解决的方法就是在nextTick
函数中去处理DOM
,这样才能保证DOM
被成功挂载而有效操作。
还有就是在数据变化之后要执行某个操作,而这个操作需要使用随数据改变而改变的DOM
时,这个操作应该放进Vue.nextTick
。
之前在做慕课网音乐webApp
的时候关于播放器内核的开发就涉及到了这个问题。下面我把问题简化:
现在我们要实现一个需求是点击按钮变换audio
标签的src
属性来实现切换歌曲。
<p id="example"> <audio ref="audio" :src="url"></audio> <span ref="url"></span> <button @click="changeUrl">click me</button> </p>
const musicList = [ 'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112003137.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3', 'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112002493.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3', 'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112004168.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3' ]; var vm = new Vue({ el: '#example', data: { index: 0, url: '' }, methods: { changeUrl() { this.index = (this.index + 1) % musicList.length this.url = musicList[this.index]; this.$refs.audio.play(); } } });
毫无悬念,这样肯定是会报错的:
Uncaught (in promise) DOMException: The element has no supported sources.
原因就在于audio.play()
是同步的,而这个时候DOM
更新是异步的,src
属性还没有被更新,结果播放的时候src
属性为空,就报错了。
解决办法就是在play
的操作加上this.$nextTick()
。
this.$nextTick(function() { this.$refs.audio.play(); });
以上是Vue中DOM的非同步更新策略以及nextTick機制詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!