vue 2.X 深入響應(yīng)式原理的異步更新隊(duì)列中說明如下:
只要偵聽到數(shù)據(jù)變化般妙,Vue 將開啟一個(gè)隊(duì)列借笙,并緩沖在同一事件循環(huán)中發(fā)生的所有數(shù)據(jù)變更单料。如果同一個(gè) watcher 被多次觸發(fā)艇拍,只會(huì)被推入到隊(duì)列中一次眠蚂。這種在緩沖時(shí)去除重復(fù)數(shù)據(jù)對于避免不必要的計(jì)算和 DOM 操作是非常重要的私爷。然后雾棺,在下一個(gè)的事件循環(huán)“tick”中,Vue 刷新隊(duì)列并執(zhí)行實(shí)際 (已去重的) 工作衬浑。Vue 在內(nèi)部對異步隊(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框架并不推薦訪問DOM进统,但有時(shí)候確實(shí)會(huì)有這樣的需求,尤其是和第三方插件進(jìn)行配合的時(shí)候浪听,免不了要進(jìn)行DOM操作螟碎。而nextTick就提供了一個(gè)橋梁,確保我們操作的是更新后的DOM迹栓。
本文從這樣一個(gè)問題開始探索:vue如何檢測到DOM更新完畢呢抚芦?
檢索一下自己的前端知識庫,能監(jiān)聽到DOM改動(dòng)的API好像只有MutationObserver了迈螟,后面簡稱MO.
源碼如下:
var nextTick = (function () {
var callbacks = []; // 存儲(chǔ)需要觸發(fā)的回調(diào)函數(shù)
var pending = false; // 是否正在等待的標(biāo)識(false:允許觸發(fā)在下次事件循環(huán)觸發(fā)callbacks中的回調(diào), true: 已經(jīng)觸發(fā)過,需要等到下次事件循環(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();
}
//如果沒有傳遞回調(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)聽DOM修改事件,能夠監(jiān)聽到節(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來監(jiān)聽DOM更新完畢的呢耘拇?
那就打開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)
}
}
簡單解釋一下惫叛,如果檢測到瀏覽器支持MO倡勇,則創(chuàng)建一個(gè)文本節(jié)點(diǎn)献酗,監(jiān)聽這個(gè)文本節(jié)點(diǎn)的改動(dòng)事件诀艰,以此來觸發(fā)nextTickHandler(也就是DOM更新完畢回調(diào))的執(zhí)行。后面的代碼中趁桃,會(huì)執(zhí)行手工修改文本節(jié)點(diǎn)屬性仑最,這樣就能進(jìn)入到回調(diào)函數(shù)了扔役。
大體掃了一眼,似乎可以得到實(shí)錘了:哦警医!vue是用MutationObserver監(jiān)聽DOM更新完畢的亿胸!
難道不感覺哪里不對勁嗎?讓我們細(xì)細(xì)想一下:
我們要監(jiān)聽的是模板中的DOM更新完畢预皇,vue為什么自己創(chuàng)建了一個(gè)文本節(jié)點(diǎn)來監(jiān)聽侈玄,這有點(diǎn)說不通啊深啤!
難道自己創(chuàng)建的文本節(jié)點(diǎn)更新完畢拗馒,就能代表其他DOM節(jié)點(diǎn)更新完畢嗎?這又是什么道理溯街!
看來我們上面得出的結(jié)論并不對诱桂,這時(shí)候就需要講講js的事件循環(huán)機(jī)制了洋丐。
事件循環(huán)(Event Loop)
在js的運(yùn)行環(huán)境中,我們這里光說瀏覽器吧挥等,通常伴隨著很多事件的發(fā)生友绝,比如用戶點(diǎn)擊、頁面渲染肝劲、腳本執(zhí)行迁客、網(wǎng)絡(luò)請求,等等辞槐。為了協(xié)調(diào)這些事件的處理掷漱,瀏覽器使用事件循環(huán)機(jī)制。
簡要來說榄檬,事件循環(huán)會(huì)維護(hù)一個(gè)或多個(gè)任務(wù)隊(duì)列(task queues)卜范,以上提到的事件作為任務(wù)源往隊(duì)列中加入任務(wù)。有一個(gè)持續(xù)執(zhí)行的線程來處理這些任務(wù)鹿榜,每執(zhí)行完一個(gè)就從隊(duì)列中移除它海雪,這就是一次事件循環(huán)了,如下圖所示:
我們平時(shí)用setTimeout來執(zhí)行異步代碼舱殿,其實(shí)就是在任務(wù)隊(duì)列的末尾加入了一個(gè)task奥裸,待前面的任務(wù)都執(zhí)行完后再執(zhí)行它。
關(guān)鍵的地方來了沪袭,每次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更新。
那我們的思路就來了:只要讓nextTick里的代碼放在UI render步驟后面執(zhí)行叹螟,豈不就能訪問到更新后的DOM了鹃骂?
vue就是這樣的思路,并不是用MO進(jìn)行DOM變動(dòng)監(jiān)聽罢绽,而是用隊(duì)列控制的方式達(dá)到目的畏线。那么vue又是如何做到隊(duì)列控制的呢?我們可以很自然的想到setTimeout良价,把nextTick要執(zhí)行的代碼當(dāng)作下一個(gè)task放入隊(duì)列末尾寝殴。
然而事情卻沒這么簡單蒿叠,vue的數(shù)據(jù)響應(yīng)過程包含:數(shù)據(jù)更改->通知Watcher->更新DOM。而數(shù)據(jù)的更改不由我們控制蚣常,可能在任何時(shí)候發(fā)生市咽。如果恰巧發(fā)生在repaint之前,就會(huì)發(fā)生多次渲染抵蚊。這意味著性能浪費(fèi)施绎,是vue不愿意看到的。
所以贞绳,vue的隊(duì)列控制是經(jīng)過了深思熟慮的(也經(jīng)過了多次改動(dòng))谷醉。在這之前,我們還需了解event loop的另一個(gè)重要概念冈闭,microtask.
microtask
從名字看孤紧,我們可以把它稱為微任務(wù)。對應(yīng)的拒秘,task隊(duì)列中的任務(wù)也被叫做macrotask号显。名字相似,性質(zhì)可不一樣了躺酒。
每一次事件循環(huán)都包含一個(gè)microtask隊(duì)列押蚤,在循環(huán)結(jié)束后會(huì)依次執(zhí)行隊(duì)列中的microtask并移除,然后再開始下一次事件循環(huán)羹应。
在執(zhí)行microtask的過程中后加入microtask隊(duì)列的微任務(wù)揽碘,也會(huì)在下一次事件循環(huán)之前被執(zhí)行。也就是說园匹,macrotask總要等到microtask都執(zhí)行完后才能執(zhí)行雳刺,microtask有著更高的優(yōu)先級。
microtask的這一特性裸违,簡直是做隊(duì)列控制的最佳選擇耙磋搿!vue進(jìn)行DOM更新內(nèi)部也是調(diào)用nextTick來做異步隊(duì)列控制供汛。而當(dāng)我們自己調(diào)用nextTick的時(shí)候枪汪,它就在更新DOM的那個(gè)microtask后追加了我們自己的回調(diào)函數(shù),從而確保我們的代碼在DOM更新后執(zhí)行怔昨,同時(shí)也避免了setTimeout可能存在的多次執(zhí)行問題雀久。
常見的microtask有:Promise、MutationObserver趁舀、Object.observe(廢棄)赖捌,以及nodejs中的process.nextTick.
咦?好像看到了MutationObserver矮烹,難道說vue用MO是想利用它的microtask特性越庇,而不是想做DOM監(jiān)聽奋隶?對嘍,就是這樣的悦荒。核心是microtask唯欣,用不用MO都行的。事實(shí)上搬味,vue在2.5版本中已經(jīng)刪去了MO相關(guān)的代碼境氢,因?yàn)樗荋TML5新增的特性,在iOS上尚有bug碰纬。
那么最優(yōu)的microtask策略就是Promise了萍聊,而令人尷尬的是,Promise是ES6新增的東西悦析,也存在兼容問題呀~ 所以vue就面臨一個(gè)降級策略寿桨。
vue的降級策略
上面我們講到了,隊(duì)列控制的最佳選擇是microtask强戴,而microtask的最佳選擇是Promise.但如果當(dāng)前環(huán)境不支持Promise亭螟,vue就不得不降級為macrotask來做隊(duì)列控制了。
macrotask有哪些可選的方案呢骑歹?前面提到了setTimeout是一種预烙,但它不是理想的方案。因?yàn)閟etTimeout執(zhí)行的最小時(shí)間間隔是約4ms的樣子道媚,略微有點(diǎn)延遲扁掸。還有其他的方案嗎?
不賣關(guān)子了最域,在vue2.5的源碼中谴分,macrotask降級的方案依次是:setImmediate、MessageChannel镀脂、setTimeout.
setImmediate是最理想的方案了牺蹄,可惜的是只有IE和nodejs支持。
MessageChannel的onmessage回調(diào)也是microtask狗热,但也是個(gè)新API钞馁,面臨兼容性的尷尬...
所以最后的兜底方案就是setTimeout了,盡管它有執(zhí)行延遲匿刮,可能造成多次渲染,算是沒有辦法的辦法了探颈。
總結(jié)
以上就是vue的nextTick方法的實(shí)現(xiàn)原理了熟丸,總結(jié)一下就是:
vue用異步隊(duì)列的方式來控制DOM更新和nextTick回調(diào)先后執(zhí)行
microtask因?yàn)槠涓邇?yōu)先級特性,能確保隊(duì)列中的微任務(wù)在一次事件循環(huán)前被執(zhí)行完畢
因?yàn)榧嫒菪詥栴}伪节,vue不得不做了microtask向macrotask的降級方案
相關(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ì)列源碼解析