從Javascript 事件循環(huán)看 Vue.nextTick 的原理和執(zhí)行機(jī)制
拋磚引玉
Vue 的特點(diǎn)之一就是響應(yīng)式,但是有些時(shí)候數(shù)據(jù)更新了,我們看到頁面上的 DOM 并沒有立刻更新。如果我們需要在 DOM 更新之后再執(zhí)行一段代碼時(shí),可以借助 nextTick 實(shí)現(xiàn)。
我們先來看一個(gè)例子
- export default {
- data() {
- return {
- msg: 0
- }
- },
- mounted() {
- this.msg = 1
- this.msg = 2
- this.msg = 3
- },
- watch: {
- msg() {
- console.log(this.msg)
- }
- }
- }
這里的結(jié)果是只輸出一個(gè) 3,而非依次輸出 1,2,3。這是為什么呢?
vue 的官方文檔是這樣解釋的:
Vue 異步執(zhí)行 DOM 更新。只要觀察到數(shù)據(jù)變化,Vue 將開啟一個(gè)隊(duì)列,并緩沖在同一事件循環(huán)中發(fā)生的所有數(shù)據(jù)改變。如果同一個(gè)watcher 被多次觸發(fā),只會被推入到隊(duì)列中一次。這種在緩沖時(shí)去除重復(fù)數(shù)據(jù)對于避免不必要的計(jì)算和 DOM 操作上非常重要。然后,在下一個(gè)的事件循環(huán)“tick”中,Vue 刷新隊(duì)列并執(zhí)行實(shí)際 (已去重的) 工作。Vue 在內(nèi)部嘗試對異步隊(duì)列使用原生的Promise.then和 MessageChannel,如果執(zhí)行環(huán)境不支持,會采用setTimeout(fn, 0)代替。 |
假如有這樣一種情況,mounted鉤子函數(shù)下一個(gè)變量 a 的值會被++循環(huán)執(zhí)行 1000 次。每次++時(shí),都會根據(jù)響應(yīng)式觸發(fā)setter->Dep->Watcher->update->run。如果這時(shí)候沒有異步更新視圖,那么每次++都會直接操作 DOM 一次,這是非常消耗性能的。所以 Vue 實(shí)現(xiàn)了一個(gè)queue隊(duì)列,在下一個(gè) Tick(或者是當(dāng)前 Tick 的微任務(wù)階段)的時(shí)候會統(tǒng)一執(zhí)行queue中Watcher的run。同時(shí),擁有相同 id 的Watcher不會被重復(fù)加入到該queue中去,所以不會執(zhí)行 1000 次Watcher的run。最終的結(jié)果是直接把 a 的值從 1 變成 1000,大大提升了性能。
在 vue 中,數(shù)據(jù)監(jiān)測都是通過Object.defineProperty來重寫里面的 set 和 get 方法實(shí)現(xiàn)的,vue 更新 DOM 是異步的,每當(dāng)觀察到數(shù)據(jù)變化時(shí),vue 就開始一個(gè)隊(duì)列,將同一事件循環(huán)內(nèi)所有的數(shù)據(jù)變化緩存起來,等到下一次 eventLoop,將會把隊(duì)列清空,進(jìn)行 DOM 更新。
想要了解 vue.nextTick 的執(zhí)行機(jī)制,我們先來了解一下 javascript 的事件循環(huán)。
js 事件循環(huán)
js 的任務(wù)隊(duì)列分為同步任務(wù)和異步任務(wù),所有的同步任務(wù)都是在主線程里執(zhí)行的。異步任務(wù)可能會在 macrotask 或者 microtask 里面,異步任務(wù)進(jìn)入 Event Table 并注冊函數(shù)。當(dāng)指定的事情完成時(shí),Event Table 會將這個(gè)函數(shù)移入 Event Queue。主線程內(nèi)的任務(wù)執(zhí)行完畢為空,會去 Event Queue 讀取對應(yīng)的函數(shù),進(jìn)入主線程執(zhí)行。上述過程會不斷重復(fù),也就是常說的 Event Loop(事件循環(huán))。
1. macro-task(宏任務(wù)):
每次執(zhí)行棧執(zhí)行的代碼就是一個(gè)宏任務(wù)(包括每次從事件隊(duì)列中獲取一個(gè)事件回調(diào)并放到執(zhí)行棧中執(zhí)行)。瀏覽器為了能夠使得 js 內(nèi)部(macro)task與 DOM 任務(wù)能夠有序執(zhí)行,會在一個(gè)(macro)task執(zhí)行結(jié)束后,在下一個(gè)(macro)task執(zhí)行開始前,對頁面進(jìn)行重新渲染。宏任務(wù)主要包含:
- script(整體代碼)
- setTimeout / setInterval
- setImmediate(Node.js 環(huán)境)
- I/O
- UI render
- postMessage
- MessageChannel
2. micro-task(微任務(wù)):
可以理解是在當(dāng)前 task 執(zhí)行結(jié)束后立即執(zhí)行的任務(wù)。也就是說,在當(dāng)前 task 任務(wù)后,下一個(gè) task 之前,在渲染之前。所以它的響應(yīng)速度相比 setTimeout(setTimeout 是 task)會更快,因?yàn)闊o需等渲染。也就是說,在某一個(gè) macrotask 執(zhí)行完后,就會將在它執(zhí)行期間產(chǎn)生的所有 microtask 都執(zhí)行完畢(在渲染前)。microtask 主要包含:
- process.nextTick(Node.js 環(huán)境)
- Promise
- Async/Await
- MutationObserver(html5 新特性)
3. 小結(jié)
- 先執(zhí)行主線程
- 遇到宏隊(duì)列(macrotask)放到宏隊(duì)列(macrotask)
- 遇到微隊(duì)列(microtask)放到微隊(duì)列(microtask)
- 主線程執(zhí)行完畢
- 執(zhí)行微隊(duì)列(microtask),微隊(duì)列(microtask)執(zhí)行完畢
- 執(zhí)行一次宏隊(duì)列(macrotask)中的一個(gè)任務(wù),執(zhí)行完畢
- 執(zhí)行微隊(duì)列(microtask),執(zhí)行完畢
- 依次循環(huán)。。。
Vue.nextTick 源碼
vue 是采用雙向數(shù)據(jù)綁定的方法驅(qū)動(dòng)數(shù)據(jù)更新的,雖然這樣能避免直接操作 DOM,提高了性能,但有時(shí)我們也不可避免需要操作 DOM,這時(shí)就該 Vue.nextTick(callback)出場了,它接受一個(gè)回調(diào)函數(shù),在 DOM 更新完成后,這個(gè)回調(diào)函數(shù)就會被調(diào)用。不管是 vue.nextTick 還是vue.prototype.\$nextTick 都是直接用的nextTick這個(gè)閉包函數(shù)。
- export const nextTick = (function () {
- const callbacks = []
- let pending = false
- let timerFunc
- function nextTickHandler () {
- pending = false
- const copies = callbacks.slice(0)
- callbacks.length = 0
- for (let i = 0; i < copies.length; i++) {
- copies[i]()
- }
- }
- ...
- })()
使用數(shù)組callbacks保存回調(diào)函數(shù),pending表示當(dāng)前狀態(tài),使用函數(shù)nextTickHandler 來執(zhí)行回調(diào)隊(duì)列。在該方法內(nèi),先通過slice(0)保存了回調(diào)隊(duì)列的一個(gè)副本,通過設(shè)置 callbacks.length = 0清空回調(diào)隊(duì)列,最后使用循環(huán)執(zhí)行在副本里的所有函數(shù)。
- if (typeof Promise !== 'undefined' && isNative(Promise)) {
- var p = Promise.resolve()
- var logError = err => {
- console.error(err)
- }
- timerFunc = () => {
- p.then(nextTickHandler).catch(logError)
- if (isIOS) setTimeout(noop)
- }
- } else 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)
- }
- } else {
- timeFunc = () => {
隊(duì)列控制的最佳選擇是microtask,而microtask的最佳選擇是Promise。但如果當(dāng)前環(huán)境不支持 Promise,就檢測到瀏覽器是否支持 MO,是則創(chuàng)建一個(gè)文本節(jié)點(diǎn),監(jiān)聽這個(gè)文本節(jié)點(diǎn)的改動(dòng)事件,以此來觸發(fā)nextTickHandler(也就是 DOM 更新完畢回調(diào))的執(zhí)行。此外因?yàn)榧嫒菪詥栴},vue 不得不做了microtask向macrotask 的降級方案。
為讓這個(gè)回調(diào)函數(shù)延遲執(zhí)行,vue 優(yōu)先用promise來實(shí)現(xiàn),其次是 html5 的 MutationObserver,然后是setTimeout。前兩者屬于microtask,后一個(gè)屬于 macrotask。下面來看最后一部分。
- return function queueNextTick(cb?: Function, ctx?: Object) {
- let _resolve
- callbacks.push(() => {
- if (cb) cb.call(ctx)
- if (_resolve) _resolve(ctx)
- })
- if (!pending) {
- pending = true
- timerFunc()
- }
- if (!cb && typeof Promise !== 'undefined') {
- return new Promise(resolve => {
- _resolve = resolve
- })
- }
- }
這就是我們真正調(diào)用的nextTick函數(shù),在一個(gè)event loop內(nèi)它會將調(diào)用 nextTick的cb 回調(diào)函數(shù)都放入 callbacks 中,pending 用于判斷是否有隊(duì)列正在執(zhí)行回調(diào),例如有可能在 nextTick 中還有一個(gè) nextTick,此時(shí)就應(yīng)該屬于下一個(gè)循環(huán)了。最后幾行代碼是 promise 化,可以將 nextTick 按照 promise 方式去書寫(暫且用的較少)。
應(yīng)用場景
場景一、點(diǎn)擊按鈕顯示原本以 v-show = false 隱藏起來的輸入框,并獲取焦點(diǎn)。
- <input id="keywords" v-if="showit">
- showInput(){
- this.showit = true
- document.getElementById("keywords").focus()
- }
以上的寫法在第一個(gè) tick 里,因?yàn)楂@取不到輸入框,自然也獲取不到焦點(diǎn)。如果我們改成以下的寫法,在 DOM 更新后就可以獲取到輸入框焦點(diǎn)了。
- showsou(){
- this.showit = true
- this.$nextTick(function () {
- // DOM 更新了
- document.getElementById("keywords").focus()
- })
- }
場景二、獲取元素屬,點(diǎn)擊獲取元素寬度。
- <div id="app">
- <p ref="myWidth" v-if="showMe">{{ message }}</p>
- <button @click="getMyWidth">獲取p元素寬度</button>
- </div>
- getMyWidth() {
- this.showMe = true;
- thisthis.message = this.$refs.myWidth.offsetWidth;
- //報(bào)錯(cuò) TypeError: this.$refs.myWidth is undefined
- this.$nextTick(()=>{
- //dom元素更新后執(zhí)行,此時(shí)能拿到p元素的屬性
- thisthis.message = this.$refs.myWidth.offsetWidth;
- })
- }