溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

怎樣理解從Vue.js源碼看異步更新DOM策略及nextTick

發(fā)布時間:2021-09-15 11:00:59 來源:億速云 閱讀:92 作者:柒染 欄目:web開發(fā)

怎樣理解從Vue.js源碼看異步更新DOM策略及nextTick,針對這個問題,這篇文章詳細介紹了相對應的分析和解答,希望可以幫助更多想解決這個問題的小伙伴找到更簡單易行的方法。

操作DOM

在使用vue.js的時候,有時候因為一些特定的業(yè)務場景,不得不去操作DOM,比如這樣:

<template>
 <div>
 <div ref="test">{{test}}</div>
 <button @click="handleClick">tet</button>
 </div>
</template>
export default {
 data () {
  return {
   test: 'begin'
  };
 },
 methods () {
  handleClick () {
   this.test = 'end';
   console.log(this.$refs.test.innerText);//打印“begin”
  }
 }
}

打印的結果是begin,為什么我們明明已經(jīng)將test設置成了“end”,獲取真實DOM節(jié)點的innerText卻沒有得到我們預期中的“end”,而是得到之前的值“begin”呢?

Watcher隊列

帶著疑問,我們找到了Vue.js源碼的Watch實現(xiàn)。當某個響應式數(shù)據(jù)發(fā)生變化的時候,它的setter函數(shù)會通知閉包中的Dep,Dep則會調(diào)用它管理的所有Watch對象。觸發(fā)Watch對象的update實現(xiàn)。我們來看一下update的實現(xiàn)。

update () {
 /* istanbul ignore else */
 if (this.lazy) {
  this.dirty = true
 } else if (this.sync) {
  /*同步則執(zhí)行run直接渲染視圖*/
  this.run()
 } else {
  /*異步推送到觀察者隊列中,下一個tick時調(diào)用。*/
  queueWatcher(this)
 }
}

我們發(fā)現(xiàn)Vue.js默認是使用異步執(zhí)行DOM更新。

當異步執(zhí)行update的時候,會調(diào)用queueWatcher函數(shù)。

 /*將一個觀察者對象push進觀察者隊列,在隊列中已經(jīng)存在相同的id則該觀察者對象將被跳過,除非它是在隊列被刷新時推送*/
export function queueWatcher (watcher: Watcher) {
 /*獲取watcher的id*/
 const id = watcher.id
 /*檢驗id是否存在,已經(jīng)存在則直接跳過,不存在則標記哈希表has,用于下次檢驗*/
 if (has[id] == null) {
 has[id] = true
 if (!flushing) {
  /*如果沒有flush掉,直接push到隊列中即可*/
  queue.push(watcher)
 } else {
  // if already flushing, splice the watcher based on its id
  // if already past its id, it will be run next immediately.
  let i = queue.length - 1
  while (i >= 0 && queue[i].id > watcher.id) {
  i--
  }
  queue.splice(Math.max(i, index) + 1, 0, watcher)
 }
 // queue the flush
 if (!waiting) {
  waiting = true
  nextTick(flushSchedulerQueue)
 }
 }
}

查看queueWatcher的源碼我們發(fā)現(xiàn),Watch對象并不是立即更新視圖,而是被push進了一個隊列queue,此時狀態(tài)處于waiting的狀態(tài),這時候會繼續(xù)會有Watch對象被push進這個隊列queue,等待下一個tick時,這些Watch對象才會被遍歷取出,更新視圖。同時,id重復的Watcher不會被多次加入到queue中去,因為在最終渲染時,我們只需要關心數(shù)據(jù)的最終結果。

那么,什么是下一個tick?

nextTick

vue.js提供了一個nextTick函數(shù),其實也就是上面調(diào)用的nextTick。

nextTick的實現(xiàn)比較簡單,執(zhí)行的目的是在microtask或者task中推入一個funtion,在當前棧執(zhí)行完畢(也行還會有一些排在前面的需要執(zhí)行的任務)以后執(zhí)行nextTick傳入的funtion,看一下源碼:

/**
 * Defer a task to execute it asynchronously.
 */
 /*
 延遲一個任務使其異步執(zhí)行,在下一個tick時執(zhí)行,一個立即執(zhí)行函數(shù),返回一個function
 這個函數(shù)的作用是在task或者microtask中推入一個timerFunc,在當前調(diào)用棧執(zhí)行完以后以此執(zhí)行直到執(zhí)行到timerFunc
 目的是延遲到當前調(diào)用棧執(zhí)行完以后執(zhí)行
*/
export const nextTick = (function () {
 /*存放異步執(zhí)行的回調(diào)*/
 const callbacks = []
 /*一個標記位,如果已經(jīng)有timerFunc被推送到任務隊列中去則不需要重復推送*/
 let pending = false
 /*一個函數(shù)指針,指向函數(shù)將被推送到任務隊列中,等到主線程任務執(zhí)行完時,任務隊列中的timerFunc被調(diào)用*/
 let timerFunc

 /*下一個tick時的回調(diào)*/
 function nextTickHandler () {
 /*一個標記位,標記等待狀態(tài)(即函數(shù)已經(jīng)被推入任務隊列或者主線程,已經(jīng)在等待當前棧執(zhí)行完畢去執(zhí)行),這樣就不需要在push多個回調(diào)到callbacks時將timerFunc多次推入任務隊列或者主線程*/
 pending = false
 /*執(zhí)行所有callback*/
 const copies = callbacks.slice(0)
 callbacks.length = 0
 for (let i = 0; i < copies.length; i++) {
  copies[i]()
 }
 }

 // the nextTick behavior leverages the microtask queue, which can be accessed
 // via either native Promise.then or MutationObserver.
 // MutationObserver has wider support, however it is seriously bugged in
 // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
 // completely stops working after triggering a few times... so, if native
 // Promise is available, we will use it:
 /* istanbul ignore if */

 /*
 這里解釋一下,一共有Promise、MutationObserver以及setTimeout三種嘗試得到timerFunc的方法
 優(yōu)先使用Promise,在Promise不存在的情況下使用MutationObserver,這兩個方法都會在microtask中執(zhí)行,會比setTimeout更早執(zhí)行,所以優(yōu)先使用。
 如果上述兩種方法都不支持的環(huán)境則會使用setTimeout,在task尾部推入這個函數(shù),等待調(diào)用執(zhí)行。
 */
 if (typeof Promise !== 'undefined' && isNative(Promise)) {
 /*使用Promise*/
 var p = Promise.resolve()
 var logError = err => { console.error(err) }
 timerFunc = () => {
  p.then(nextTickHandler).catch(logError)
  // in problematic UIWebViews, Promise.then doesn't completely break, but
  // it can get stuck in a weird state where callbacks are pushed into the
  // microtask queue but the queue isn't being flushed, until the browser
  // needs to do some other work, e.g. handle a timer. Therefore we can
  // "force" the microtask queue to be flushed by adding an empty timer.
  if (isIOS) setTimeout(noop)
 }
 } 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
 /*新建一個textNode的DOM對象,用MutationObserver綁定該DOM并指定回調(diào)函數(shù),在DOM變化的時候則會觸發(fā)回調(diào),該回調(diào)會進入主線程(比任務隊列優(yōu)先執(zhí)行),即textNode.data = String(counter)時便會觸發(fā)回調(diào)*/
 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 {
 // fallback to setTimeout
 /* istanbul ignore next */
 /*使用setTimeout將回調(diào)推入任務隊列尾部*/
 timerFunc = () => {
  setTimeout(nextTickHandler, 0)
 }
 }

 /*
 推送到隊列中下一個tick時執(zhí)行
 cb 回調(diào)函數(shù)
 ctx 上下文
 */
 return function queueNextTick (cb?: Function, ctx?: Object) {
 let _resolve
 /*cb存到callbacks中*/
 callbacks.push(() => {
  if (cb) {
  try {
   cb.call(ctx)
  } catch (e) {
   handleError(e, ctx, 'nextTick')
  }
  } else if (_resolve) {
  _resolve(ctx)
  }
 })
 if (!pending) {
  pending = true
  timerFunc()
 }
 if (!cb && typeof Promise !== 'undefined') {
  return new Promise((resolve, reject) => {
  _resolve = resolve
  })
 }
 }
})()

它是一個立即執(zhí)行函數(shù),返回一個queueNextTick接口。

傳入的cb會被push進callbacks中存放起來,然后執(zhí)行timerFunc(pending是一個狀態(tài)標記,保證timerFunc在下一個tick之前只執(zhí)行一次)。

timerFunc是什么?

看了源碼發(fā)現(xiàn)timerFunc會檢測當前環(huán)境而不同實現(xiàn),其實就是按照Promise,MutationObserver,setTimeout優(yōu)先級,哪個存在使用哪個,最不濟的環(huán)境下使用setTimeout。

這里解釋一下,一共有Promise、MutationObserver以及setTimeout三種嘗試得到timerFunc的方法。
優(yōu)先使用Promise,在Promise不存在的情況下使用MutationObserver,這兩個方法的回調(diào)函數(shù)都會在microtask中執(zhí)行,它們會比setTimeout更早執(zhí)行,所以優(yōu)先使用。
如果上述兩種方法都不支持的環(huán)境則會使用setTimeout,在task尾部推入這個函數(shù),等待調(diào)用執(zhí)行。

為什么要優(yōu)先使用microtask?我在顧軼靈在知乎的回答中學習到:

JS 的 event loop 執(zhí)行時會區(qū)分 task 和 microtask,引擎在每個 task 執(zhí)行完畢,從隊列中取下一個 task 來執(zhí)行之前,會先執(zhí)行完所有 microtask 隊列中的 microtask。

setTimeout 回調(diào)會被分配到一個新的 task 中執(zhí)行,而 Promise 的 resolver、MutationObserver 的回調(diào)都會被安排到一個新的 microtask 中執(zhí)行,會比 setTimeout 產(chǎn)生的 task 先執(zhí)行。

要創(chuàng)建一個新的 microtask,優(yōu)先使用 Promise,如果瀏覽器不支持,再嘗試 MutationObserver。

實在不行,只能用 setTimeout 創(chuàng)建 task 了。

為啥要用 microtask?

根據(jù) HTML Standard,在每個 task 運行完以后,UI 都會重渲染,那么在 microtask 中就完成數(shù)據(jù)更新,當前 task 結束就可以得到最新的 UI 了。

反之如果新建一個 task 來做數(shù)據(jù)更新,那么渲染就會進行兩次。

參考顧軼靈知乎的回答

首先是Promise,(Promise.resolve()).then()可以在microtask中加入它的回調(diào),

MutationObserver新建一個textNode的DOM對象,用MutationObserver綁定該DOM并指定回調(diào)函數(shù),在DOM變化的時候則會觸發(fā)回調(diào),該回調(diào)會進入microtask,即textNode.data = String(counter)時便會加入該回調(diào)。

setTimeout是最后的一種備選方案,它會將回調(diào)函數(shù)加入task中,等到執(zhí)行。

綜上,nextTick的目的就是產(chǎn)生一個回調(diào)函數(shù)加入task或者microtask中,當前棧執(zhí)行完以后(可能中間還有別的排在前面的函數(shù))調(diào)用該回調(diào)函數(shù),起到了異步觸發(fā)(即下一個tick時觸發(fā))的目的。

flushSchedulerQueue

/*Github:https://github.com/answershuto*/
/**
 * Flush both queues and run the watchers.
 */
 /*nextTick的回調(diào)函數(shù),在下一個tick時flush掉兩個隊列同時運行watchers*/
function flushSchedulerQueue () {
 flushing = true
 let watcher, id

 // Sort queue before flush.
 // This ensures that:
 // 1. Components are updated from parent to child. (because parent is always
 // created before the child)
 // 2. A component's user watchers are run before its render watcher (because
 // user watchers are created before the render watcher)
 // 3. If a component is destroyed during a parent component's watcher run,
 // its watchers can be skipped.
 /*
 給queue排序,這樣做可以保證:
 1.組件更新的順序是從父組件到子組件的順序,因為父組件總是比子組件先創(chuàng)建。
 2.一個組件的user watchers比render watcher先運行,因為user watchers往往比render watcher更早創(chuàng)建
 3.如果一個組件在父組件watcher運行期間被銷毀,它的watcher執(zhí)行將被跳過。
 */
 queue.sort((a, b) => a.id - b.id)

 // do not cache length because more watchers might be pushed
 // as we run existing watchers
 /*這里不用index = queue.length;index > 0; index--的方式寫是因為不要將length進行緩存,因為在執(zhí)行處理現(xiàn)有watcher對象期間,更多的watcher對象可能會被push進queue*/
 for (index = 0; index < queue.length; index++) {
 watcher = queue[index]
 id = watcher.id
 /*將has的標記刪除*/
 has[id] = null
 /*執(zhí)行watcher*/
 watcher.run()
 // in dev build, check and stop circular updates.
 /*
  在測試環(huán)境中,檢測watch是否在死循環(huán)中
  比如這樣一種情況
  watch: {
  test () {
   this.test++;
  }
  }
  持續(xù)執(zhí)行了一百次watch代表可能存在死循環(huán)
 */
 if (process.env.NODE_ENV !== 'production' && has[id] != null) {
  circular[id] = (circular[id] || 0) + 1
  if (circular[id] > MAX_UPDATE_COUNT) {
  warn(
   'You may have an infinite update loop ' + (
   watcher.user
    ? `in watcher with expression "${watcher.expression}"`
    : `in a component render function.`
   ),
   watcher.vm
  )
  break
  }
 }
 }

 // keep copies of post queues before resetting state
 /**/
 /*得到隊列的拷貝*/
 const activatedQueue = activatedChildren.slice()
 const updatedQueue = queue.slice()

 /*重置調(diào)度者的狀態(tài)*/
 resetSchedulerState()

 // call component updated and activated hooks
 /*使子組件狀態(tài)都改編成active同時調(diào)用activated鉤子*/
 callActivatedHooks(activatedQueue)
 /*調(diào)用updated鉤子*/
 callUpdateHooks(updatedQueue)

 // devtool hook
 /* istanbul ignore if */
 if (devtools && config.devtools) {
 devtools.emit('flush')
 }
}

flushSchedulerQueue是下一個tick時的回調(diào)函數(shù),主要目的是執(zhí)行Watcher的run函數(shù),用來更新視圖

為什么要異步更新視圖

來看一下下面這一段代碼

<template>
 <div>
 <div>{{test}}</div>
 </div>
</template>
export default {
 data () {
  return {
   test: 0
  };
 },
 created () {
  for(let i = 0; i < 1000; i++) {
  this.test++;
  }
 }
}

現(xiàn)在有這樣的一種情況,created的時候test的值會被++循環(huán)執(zhí)行1000次。

每次++時,都會根據(jù)響應式觸發(fā)setter->Dep->Watcher->update->patch。

如果這時候沒有異步更新視圖,那么每次++都會直接操作DOM更新視圖,這是非常消耗性能的。

所以Vue.js實現(xiàn)了一個queue隊列,在下一個tick的時候會統(tǒng)一執(zhí)行queue中Watcher的run。同時,擁有相同id的Watcher不會被重復加入到該queue中去,所以不會執(zhí)行1000次Watcher的run。最終更新視圖只會直接將test對應的DOM的0變成1000。
保證更新視圖操作DOM的動作是在當前棧執(zhí)行完以后下一個tick的時候調(diào)用,大大優(yōu)化了性能。

訪問真實DOM節(jié)點更新后的數(shù)據(jù)

所以我們需要在修改data中的數(shù)據(jù)后訪問真實的DOM節(jié)點更新后的數(shù)據(jù),只需要這樣,我們把文章第一個例子進行修改。

<template>
 <div>
 <div ref="test">{{test}}</div>
 <button @click="handleClick">tet</button>
 </div>
</template>
export default {
 data () {
  return {
   test: 'begin'
  };
 },
 methods () {
  handleClick () {
   this.test = 'end';
   this.$nextTick(() => {
    console.log(this.$refs.test.innerText);//打印"end"
   });
   console.log(this.$refs.test.innerText);//打印“begin”
  }
 }
}

使用Vue.js的global API的$nextTick方法,即可在回調(diào)中獲取已經(jīng)更新好的DOM實例了。

關于怎樣理解從Vue.js源碼看異步更新DOM策略及nextTick問題的解答就分享到這里了,希望以上內(nèi)容可以對大家有一定的幫助,如果你還有很多疑惑沒有解開,可以關注億速云行業(yè)資訊頻道了解更多相關知識。

向AI問一下細節(jié)

免責聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權內(nèi)容。

AI