vue 2.X 深入響應(yīng)式原理的異步更新隊(duì)列中說(shuō)明如下:
只要偵聽(tīng)到數(shù)據(jù)變化,Vue 將開(kāi)啟一個(gè)隊(duì)列,并緩沖在同一事件循環(huán)中發(fā)生的所有數(shù)據(jù)變更。如果同一個(gè) watcher 被多次觸發(fā),只會(huì)被推入到隊(duì)列中一次。這種在緩沖時(shí)去除重復(fù)數(shù)據(jù)對(duì)于避免不必要的計(jì)算和 DOM 操作是非常重要的。然后,在下一個(gè)的事件循環(huán)“tick”中,Vue 刷新隊(duì)列并執(zhí)行實(shí)際 (已去重的) 工作。Vue 在內(nèi)部對(duì)異步隊(duì)列嘗試使用原生的
Promise.then、MutationObserver 和 setImmediate
,如果執(zhí)行環(huán)境不支持,則會(huì)采用setTimeout(fn, 0)
代替。
用法如下:
<div id="example">{{message}}</div>
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // 更改數(shù)據(jù)
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message' // true
})
盡管MVVM框架并不推薦訪問(wèn)DOM,但有時(shí)候確實(shí)會(huì)有這樣的需求,尤其是和第三方插件進(jìn)行配合的時(shí)候,免不了要進(jìn)行DOM操作。而nextTick就提供了一個(gè)橋梁,確保我們操作的是更新后的DOM。
本文從這樣一個(gè)問(wèn)題開(kāi)始探索:vue如何檢測(cè)到DOM更新完畢呢?
檢索一下自己的前端知識(shí)庫(kù),能監(jiān)聽(tīng)到DOM改動(dòng)的API好像只有MutationObserver了,后面簡(jiǎn)稱(chēng)MO.
源碼如下:
var nextTick = (function () {
var callbacks = []; // 存儲(chǔ)需要觸發(fā)的回調(diào)函數(shù)
var pending = false; // 是否正在等待的標(biāo)識(shí)(false:允許觸發(fā)在下次事件循環(huán)觸發(fā)callbacks中的回調(diào), true: 已經(jīng)觸發(fā)過(guò),需要等到下次事件循環(huán))
var timerFunc; // 設(shè)置在下次事件循環(huán)觸發(fā)callbacks的 觸發(fā)函數(shù)
//處理callbacks的函數(shù)
function nextTickHandler () {
pending = false;// 可以觸發(fā)timeFunc
var copies = callbacks.slice(0);//復(fù)制callback
callbacks.length = 0;//清空callback
for (var i = 0; i < copies.length; i++) {
copies[i]();//觸發(fā)callback回調(diào)函數(shù)
}
}
//如果支持Promise,使用Promise實(shí)現(xiàn)
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
var logError = function (err) { console.error(err); };
timerFunc = function () {
p.then(nextTickHandler).catch(logError);
// ios的webview下,需要強(qiáng)制刷新隊(duì)列,執(zhí)行上面的回調(diào)函數(shù)
if (isIOS) { setTimeout(noop); }
};
//如果Promise不支持,但是支持MutationObserver
} 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
var counter = 1;
var observer = new MutationObserver(nextTickHandler);
//創(chuàng)建一個(gè)textnode dom節(jié)點(diǎn),并讓MutationObserver 監(jiān)視這個(gè)節(jié)點(diǎn);而 timeFunc正是改變這個(gè)dom節(jié)點(diǎn)的觸發(fā)函數(shù)
var textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
} else {// 上面兩種不支持的話,就使用setTimeout
timerFunc = function () {
setTimeout(nextTickHandler, 0);
};
}
//nextTick接受的函數(shù), 參數(shù)1:回調(diào)函數(shù) 參數(shù)2:回調(diào)函數(shù)的執(zhí)行上下文
return function queueNextTick (cb, ctx) {
var _resolve;//用于接受觸發(fā) promise.then中回調(diào)的函數(shù)
//向回調(diào)數(shù)據(jù)中pushcallback
callbacks.push(function () {
//如果有回調(diào)函數(shù),執(zhí)行回調(diào)函數(shù)
if (cb) { cb.call(ctx); }
if (_resolve) { _resolve(ctx); }//觸發(fā)promise的then回調(diào)
});
if (!pending) {//是否執(zhí)行刷新callback隊(duì)列
pending = true;
timerFunc();
}
//如果沒(méi)有傳遞回調(diào)函數(shù),并且當(dāng)前瀏覽器支持promise,使用promise實(shí)現(xiàn)
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve) {
_resolve = resolve;
})
}
}
})();
理解MutationObserver
MutationObserver是HTML5新增的屬性,用于監(jiān)聽(tīng)DOM修改事件,能夠監(jiān)聽(tīng)到節(jié)點(diǎn)的屬性、文本內(nèi)容、子節(jié)點(diǎn)等的改動(dòng),是一個(gè)功能強(qiáng)大的利器,基本用法如下:
//MO基本用法
var observer = new MutationObserver(function(){
//這里是回調(diào)函數(shù)
console.log('DOM被修改了!');
});
var article = document.querySelector('article');
observer.observer(article);
MO的使用不是本篇重點(diǎn)。這里我們要思考的是:vue是不是用MO來(lái)監(jiān)聽(tīng)DOM更新完畢的呢?
那就打開(kāi)vue的源碼看看吧,在實(shí)現(xiàn)nextTick的地方,確實(shí)能看到這樣的代碼:
//vue@2.2.5 /src/core/util/env.js
if (typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]')) {
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)
}
}
簡(jiǎn)單解釋一下,如果檢測(cè)到瀏覽器支持MO,則創(chuàng)建一個(gè)文本節(jié)點(diǎn),監(jiān)聽(tīng)這個(gè)文本節(jié)點(diǎn)的改動(dòng)事件,以此來(lái)觸發(fā)nextTickHandler(也就是DOM更新完畢回調(diào))的執(zhí)行。后面的代碼中,會(huì)執(zhí)行手工修改文本節(jié)點(diǎn)屬性,這樣就能進(jìn)入到回調(diào)函數(shù)了。
大體掃了一眼,似乎可以得到實(shí)錘了:哦!vue是用MutationObserver監(jiān)聽(tīng)DOM更新完畢的!
難道不感覺(jué)哪里不對(duì)勁嗎?讓我們細(xì)細(xì)想一下:
我們要監(jiān)聽(tīng)的是模板中的DOM更新完畢,vue為什么自己創(chuàng)建了一個(gè)文本節(jié)點(diǎn)來(lái)監(jiān)聽(tīng),這有點(diǎn)說(shuō)不通啊!
難道自己創(chuàng)建的文本節(jié)點(diǎn)更新完畢,就能代表其他DOM節(jié)點(diǎn)更新完畢嗎?這又是什么道理!
看來(lái)我們上面得出的結(jié)論并不對(duì),這時(shí)候就需要講講js的事件循環(huán)機(jī)制了。
事件循環(huán)(Event Loop)
在js的運(yùn)行環(huán)境中,我們這里光說(shuō)瀏覽器吧,通常伴隨著很多事件的發(fā)生,比如用戶(hù)點(diǎn)擊、頁(yè)面渲染、腳本執(zhí)行、網(wǎng)絡(luò)請(qǐng)求,等等。為了協(xié)調(diào)這些事件的處理,瀏覽器使用事件循環(huán)機(jī)制。
簡(jiǎn)要來(lái)說(shuō),事件循環(huán)會(huì)維護(hù)一個(gè)或多個(gè)任務(wù)隊(duì)列(task queues),以上提到的事件作為任務(wù)源往隊(duì)列中加入任務(wù)。有一個(gè)持續(xù)執(zhí)行的線程來(lái)處理這些任務(wù),每執(zhí)行完一個(gè)就從隊(duì)列中移除它,這就是一次事件循環(huán)了,如下圖所示:
我們平時(shí)用setTimeout來(lái)執(zhí)行異步代碼,其實(shí)就是在任務(wù)隊(duì)列的末尾加入了一個(gè)task,待前面的任務(wù)都執(zhí)行完后再執(zhí)行它。
關(guān)鍵的地方來(lái)了,每次event loop的最后,會(huì)有一個(gè)UI render步驟,也就是更新DOM。標(biāo)準(zhǔn)為什么這樣設(shè)計(jì)呢?考慮下面的代碼:
for(let i=0; i<100; i++){
dom.style.left = i + 'px';
}
瀏覽器會(huì)進(jìn)行100次DOM更新嗎?顯然不是的,這樣太耗性能了。事實(shí)上,這100次for循環(huán)同屬一個(gè)task,瀏覽器只在該task執(zhí)行完后進(jìn)行一次DOM更新。
那我們的思路就來(lái)了:只要讓nextTick里的代碼放在UI render步驟后面執(zhí)行,豈不就能訪問(wèn)到更新后的DOM了?
vue就是這樣的思路,并不是用MO進(jìn)行DOM變動(dòng)監(jiān)聽(tīng),而是用隊(duì)列控制的方式達(dá)到目的。那么vue又是如何做到隊(duì)列控制的呢?我們可以很自然的想到setTimeout,把nextTick要執(zhí)行的代碼當(dāng)作下一個(gè)task放入隊(duì)列末尾。
然而事情卻沒(méi)這么簡(jiǎn)單,vue的數(shù)據(jù)響應(yīng)過(guò)程包含:數(shù)據(jù)更改->通知Watcher->更新DOM。而數(shù)據(jù)的更改不由我們控制,可能在任何時(shí)候發(fā)生。如果恰巧發(fā)生在repaint之前,就會(huì)發(fā)生多次渲染。這意味著性能浪費(fèi),是vue不愿意看到的。
所以,vue的隊(duì)列控制是經(jīng)過(guò)了深思熟慮的(也經(jīng)過(guò)了多次改動(dòng))。在這之前,我們還需了解event loop的另一個(gè)重要概念,microtask.
microtask
從名字看,我們可以把它稱(chēng)為微任務(wù)。對(duì)應(yīng)的,task隊(duì)列中的任務(wù)也被叫做macrotask。名字相似,性質(zhì)可不一樣了。
每一次事件循環(huán)都包含一個(gè)microtask隊(duì)列,在循環(huán)結(jié)束后會(huì)依次執(zhí)行隊(duì)列中的microtask并移除,然后再開(kāi)始下一次事件循環(huán)。
在執(zhí)行microtask的過(guò)程中后加入microtask隊(duì)列的微任務(wù),也會(huì)在下一次事件循環(huán)之前被執(zhí)行。也就是說(shuō),macrotask總要等到microtask都執(zhí)行完后才能執(zhí)行,microtask有著更高的優(yōu)先級(jí)。
microtask的這一特性,簡(jiǎn)直是做隊(duì)列控制的最佳選擇啊!vue進(jìn)行DOM更新內(nèi)部也是調(diào)用nextTick來(lái)做異步隊(duì)列控制。而當(dāng)我們自己調(diào)用nextTick的時(shí)候,它就在更新DOM的那個(gè)microtask后追加了我們自己的回調(diào)函數(shù),從而確保我們的代碼在DOM更新后執(zhí)行,同時(shí)也避免了setTimeout可能存在的多次執(zhí)行問(wèn)題。
常見(jiàn)的microtask有:Promise、MutationObserver、Object.observe(廢棄),以及nodejs中的process.nextTick.
咦?好像看到了MutationObserver,難道說(shuō)vue用MO是想利用它的microtask特性,而不是想做DOM監(jiān)聽(tīng)?對(duì)嘍,就是這樣的。核心是microtask,用不用MO都行的。事實(shí)上,vue在2.5版本中已經(jīng)刪去了MO相關(guān)的代碼,因?yàn)樗荋TML5新增的特性,在iOS上尚有bug。
那么最優(yōu)的microtask策略就是Promise了,而令人尷尬的是,Promise是ES6新增的東西,也存在兼容問(wèn)題呀~ 所以vue就面臨一個(gè)降級(jí)策略。
vue的降級(jí)策略
上面我們講到了,隊(duì)列控制的最佳選擇是microtask,而microtask的最佳選擇是Promise.但如果當(dāng)前環(huán)境不支持Promise,vue就不得不降級(jí)為macrotask來(lái)做隊(duì)列控制了。
macrotask有哪些可選的方案呢?前面提到了setTimeout是一種,但它不是理想的方案。因?yàn)閟etTimeout執(zhí)行的最小時(shí)間間隔是約4ms的樣子,略微有點(diǎn)延遲。還有其他的方案嗎?
不賣(mài)關(guān)子了,在vue2.5的源碼中,macrotask降級(jí)的方案依次是:setImmediate、MessageChannel、setTimeout.
setImmediate是最理想的方案了,可惜的是只有IE和nodejs支持。
MessageChannel的onmessage回調(diào)也是microtask,但也是個(gè)新API,面臨兼容性的尷尬...
所以最后的兜底方案就是setTimeout了,盡管它有執(zhí)行延遲,可能造成多次渲染,算是沒(méi)有辦法的辦法了。
總結(jié)
以上就是vue的nextTick方法的實(shí)現(xiàn)原理了,總結(jié)一下就是:
vue用異步隊(duì)列的方式來(lái)控制DOM更新和nextTick回調(diào)先后執(zhí)行
microtask因?yàn)槠涓邇?yōu)先級(jí)特性,能確保隊(duì)列中的微任務(wù)在一次事件循環(huán)前被執(zhí)行完畢
因?yàn)榧嫒菪詥?wèn)題,vue不得不做了microtask向macrotask的降級(jí)方案
相關(guān)資料:
event loop標(biāo)準(zhǔn)
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
vue2.5的nextTick更改記錄
https://github.com/vuejs/vue/commit/6e41679a96582da3e0a60bdbf123c33ba0e86b31
源碼解析文章
選自:
全面解析Vue.nextTick實(shí)現(xiàn)原理
Vuejs中nextTick()異步更新隊(duì)列源碼解析