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