前言
vue的DOM更新時非同步執行的,只要監聽到資料變化,Vue將開啟一個佇列,並快取在同一事件迴圈中發生的所有資料變更,如果同一個Watcher被多次觸發,只會被推入到佇列中一次,避免了不必要的重複計算和頻繁的DOM操作,然後在下一個事件迴圈"tick"中(注意下一個tick可能是當前的tick微任務執行階段執行,也可能在下一個tick執行,主要取決於nextTick函式使用的是Promise/MutationObserver還是setTimeout),Vue重新整理佇列並執行更新試圖等操作.
例如, 當你設定vm.somData = 'new value',該元件不會立即重新渲染,當重新整理元件時,元件會在下一個事件迴圈的"tick"中更新.雖然大多數情況下,我們並不需要關心這個過程,但是如果我們想在資料改變之後進行獲取更新後的DOM,我們就需要呼叫Vue.nextTick(callback),這樣回撥函式會在DOM更新完成後呼叫.
<div id="example">{{message}}</div>
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // 更新資料
console.log(vm.$el.textContent) // '123'
Vue.nextTick(function() {
console.log(vm.$el.textContent) // 'new message'
})
複製程式碼
這就很像家裡有一個熊孩子今天要開party,可想而知會把家裡弄得亂七八糟,烏煙瘴氣,你要負責事後打掃戰場,但是你要是弄亂一點去收拾一點,就很浪費精力,得不償失.所以正確的方式應該是任由他折騰,等戰鬥結束完全結束後,再去清洗和整理.
非同步更新DOM
Watcher佇列
閱讀過vue原始碼的都知道,當某個響應式資料發生變化的時候,它的setter函式就會通知閉包中的Dep,Dep則會觸發對應的Watcher物件的update方法,我們來看一下update的實現:
update() {
if(this.lazy) {
this.dirty = true
} else if(this.sync) {
/*同步執行則run直接渲染檢視*/
this.run()
} else {
/*非同步則推送到觀察者佇列中,下一個tick時呼叫*/
queueWatcher(this)
}
}
// queueWatcher函式
// 將觀察者物件push進佇列,並記錄觀察者的id
// 如果對應的觀察者已存在,則跳過,避免重複的計算
export function queueWatcher(watcher: Watcher) {
const id = watcher.id
if(!has[id]) {
has[id] = true
if(!flushing) {
/*如果沒有被flush掉,直接push到佇列中即可*/
queue.push(watcher)
} else {
//...
}
// queue the flush
if(!wating) {
wating = true
nextTick(flushSchedulerQueue)
}
}
}
複製程式碼
通過檢視原始碼我們發現,watcher的update操作都被存入一個佇列queue了,等到下一個tick執行時,這些watcher會被遍歷執行,更新檢視.
那麼, 什麼是下一個tick?
Event Loop
想要知道什麼是下一個tick,我們先要了解下Event Loop(事件迴圈).js執行時單執行緒的,它是基於事件迴圈的,事件迴圈機制控制著js所有任務的有序執行,js中的任務分為同步任務和非同步任務,事件迴圈大致分為以下步驟:
我們平時用setTimeout來執行非同步程式碼,其實就是在任務佇列的末尾加入了一個task,待前面的任務執行完後在執行它,每次事件迴圈後,就會有一個UI Render步驟,也就是更新dom操作,那麼為什麼要這麼設計呢?程式碼示例:
- 所有同步任務在主執行緒上執行,形成一個執行棧.
- 主執行緒之外,還有一個任務佇列,這個佇列用於存放非同步任務, 只要非同步任務有了執行結果,就在"任務佇列"之中放置一個事件.
- 執行棧上的同步任務執行完畢後,主執行緒會讀取任務佇列中的任務執行,對應的非同步任務結束等待狀態,進入執行棧,開始執行.
- 主執行緒不斷重複以上操作,形成事件迴圈.
for(let i =0; i < 100; i++) {
dom.style.left = i + 'px'
}
複製程式碼
瀏覽器會進行100次dom更新嗎?顯然這樣太損耗效能了,事實上這100次for迴圈同屬一個task,瀏覽器只會在改task執行完後進行一次DOM更新.這也就意味著,只要讓nextTick中的回撥放在UI Render後執行,就可以訪問到更新後的DOM了.這樣我們很自然的想到把這些回撥邏輯放入任務佇列中去執行.
主執行緒的執行過程就是一個tick,所有的非同步結果都是通過"任務佇列"來排程,可想而知Vue中的DOM的非同步更新任務也是存放在任務佇列中的,下面我們就來看看nextTick的具體實現邏輯.
JS任務佇列
js中的任務佇列分為巨集任務(macrotask)佇列和微任務(microtask )佇列,每次事件迴圈結束後,都會先清空微任務佇列中的微任務,然後才會開始執行下一個巨集任務,微任務比巨集任務有著更高的優先順序.(注: 瀏覽器和NodeJs的事件迴圈的執行邏輯不一樣,這裡我們只研究瀏覽器中事件迴圈的執行邏輯,想要了解nodejs中的執行邏輯,可參考: segmentfault.com/a/119000001….)
所以事實上,我們呼叫nextTick的時候,就是在更新DOM那個microtask後執行了我們傳入的回撥函式,從而確保我們的程式碼在DOM更新後執行
Vue.nextTick()
nextTick的原始碼, 建議大家對照著原始碼來閱讀接下來的內容.
vue是如何監聽到DOM更新完畢,並執行我們傳入的回撥函式呢? HTML5新增了一個屬性MutationObserver,用於監聽DOM修改事件,能夠監聽到節點的屬性,文字內容,子節點等的改動,是一個功能強大的利器,基本用法如下:
// MO基本用法
var observer = new MutationObserver(function() {
// 這裡是回撥函式
console.log("DOM 被修改了!");
});
var article = document.querySelector('article');
observer.observer(article); // 監聽dom改變後執行回撥
複製程式碼
那麼vue是不是用MO來監聽DOM更新完畢的呢? 開啟vue的原始碼,確實看到這樣的程式碼:
// MutationObserver 有更廣泛的支援,但在iOS上的觸控事件處理程式中存在bug
// 所以我們優先採用原生的promise.來建立微任務
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是es6新增的api,在不支援原生Promise的瀏覽器中,我們採用HTML5的新屬性MutationObserver來監聽DOM更新
// 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)) {
// setImmediate, 目前只有IE和Node.js支援
// 技術上它是利用巨集任務佇列,
// 但是它仍是比setTimeout更好的選擇
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// setTimeout是以上方案都不支援的最後的選擇
// 儘管它有執行延遲,可能造成多次渲染
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 暴露出nextTick方法,控制在下一個tick中執行傳入的回撥
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
})
}
}
複製程式碼
總結
關於Vue中DOM的非同步更新以及Vue.nextTick的原理解析就說到這兒了,後續會推出vue-router的原始碼解析,持續關注奧~如果你有什麼建議,困惑或想法,歡迎留言或者加微信lj_de_wei_xin
與我交流~