前言
我們都知道vue是數據驅動視圖,而vue中視圖更新是異步的。在業務開發中,有沒有經歷過當改變了數據,視圖卻沒有按照我們的期望渲染?而需要將對應的操作放在nextTick中視圖才能按照預期的渲染,有的時候nextTick也不能生效,而需要利用setTimeout來解決?
搞清楚這些問題,那么就需要搞明白以下幾個問題:
1、vue中到底是如何來實現異步更新視圖;
2、vue為什么要異步更新視圖;
3、nextTick的原理;
4、nextTick如何來解決數據改變視圖不更新的問題的;
5、nextTick的使用場景。
以下分享我的思考過程。
Vue中的異步更新DOM
Vue中的視圖渲染思想
vue中每個組件實例都對應一個 watcher實例,它會在組件渲染的過程中把“接觸”過的數據屬性記錄為依賴。之后當依賴項的 setter 觸發時,會通知 watcher,從而使它關聯的組件重新渲染。
如果對vue視圖渲染的思想還不是很清楚,可以參考這篇defineProperty實現視圖渲染用defineProty模擬的Vue的渲染視圖,來了解整個視圖渲染的思想。
Vue異步渲染思想和意義
但是Vue的視圖渲染是異步的,異步的過程是數據改變不會立即更新視圖,當數據全部修改完,最后再統一進行視圖渲染。
在渲染的過程中,中間有一個對虛擬dom進行差異化的計算過程(diff算法),大量的修改帶來頻繁的虛擬dom差異化計算,從而導致渲染性能降低,異步渲染正是對視圖渲染性能的優化。
Vue異步渲染視圖的原理
- 依賴數據改變就會觸發對應的watcher對象中的update
/** * Subscriber interface. * Will be called when a dependency changes. */ update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { this.run() } else { queueWatcher(this) } }
- 默認的調用queueWatcher將watcher對象加入到一個隊列中
/** * Push a watcher into the watcher queue. * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. */ export function queueWatcher (watcher: Watcher) { const id = watcher.id if (has[id] == null) { has[id] = true if (!flushing) { 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 > index && queue[i].id > watcher.id) { i-- } queue.splice(i + 1, 0, watcher) } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } } }
當第一次依賴有變化就會調用nextTick方法,將更新視圖的回調設置成微任務或宏任務,然后后面依賴更新對應的watcher對象都只是被加入到隊列中,只有當nextTick回調執行之后,才會遍歷調用隊列中的watcher對象中的更新方法更新視圖。
這個nextTick和我們在業務中調用的this.$nextTick()是同一個函數。
if (!waiting) { waiting = true nextTick(flushSchedulerQueue) }
flushSchedulerQueue刷新隊列的函數,用於更新視圖
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.sort((a, b) => a.id - b.id) // do not cache length because more watchers might be pushed // as we run existing watchers for (index = 0; index < queue.length; index++) { watcher = queue[index] id = watcher.id has[id] = null watcher.run() // in dev build, check and stop circular updates. 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 } } }
那么nextTick到底是個什么東西呢?
nextTick的原理
vue 2.5中nextTick的源碼如下(也可以跳過源碼直接看后面的demo,來理解nextTick的用處):
/** * Defer a task to execute it asynchronously. */ 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]() } } // An asynchronous deferring mechanism. // In pre 2.4, we used to use microtasks (Promise/MutationObserver) // but microtasks actually has too high a priority and fires in between // supposedly sequential events (e.g. #4521, #6690) or even between // bubbling of the same event (#6566). Technically setImmediate should be // the ideal choice, but it's not available everywhere; and the only polyfill // that consistently queues the callback after all DOM events triggered in the // same loop is by using MessageChannel. /* istanbul ignore if */ if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { setImmediate(nextTickHandler) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // Phantomjs MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = nextTickHandler timerFunc = () => { port.postMessage(1) } } else /* istanbul ignore next */ if (typeof Promise !== 'undefined' && isNative(Promise)) { // use microtask in non-DOM environments, e.g. Weex const p = Promise.resolve() timerFunc = () => { p.then(nextTickHandler) } } else { // fallback to setTimeout timerFunc = () => { setTimeout(nextTickHandler, 0) } } return function queueNextTick (cb?: Function, ctx?: Object) { let _resolve callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true timerFunc() } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => { _resolve = resolve }) } } })()
用下面這個demo來感受依賴更新時和nextTick的關系以及nextTick的用處:
function isNative(Ctor) { return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) } const nextTick = (function () { let pending = false; let callbacks = [] let timerFunc function nextTickHandler() { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { setImmediate(nextTickHandler) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // Phantomjs MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = nextTickHandler timerFunc = () => { port.postMessage(1) } } else /* istanbul ignore next */ if (typeof Promise !== 'undefined' && isNative(Promise)) { // use microtask in non-DOM environments, e.g. Weex const p = Promise.resolve() timerFunc = () => { p.then(nextTickHandler) } } else { // fallback to setTimeout timerFunc = () => { setTimeout(nextTickHandler, 0) } } console.log('timerFunc:', timerFunc) return function queueNextTick(cb, ctx) { callbacks.push(() => { if (cb) { cb.call(ctx) } }) // console.log('callbacks:', callbacks) if (!pending) { pending = true console.log('pending...', true) timerFunc() } } })() // 模擬異步視圖更新 // 第一次先將對應新值添加到一個數組中,然后調用一次nextTick,將讀取數據的回調作為nextTick的參數 // 后面的新值直接添加到數組中 console.time() let arr = [] arr.push(99999999) nextTick(() => { console.log('nextTick one:', arr, arr.length) }) function add(len) { for (let i = 0; i < len; i++) { arr.push(i) console.log('i:', i) } } add(4) // console.timeEnd() // add() // add() nextTick(() => { arr.push(888888) console.log('nextTick two:', arr, arr.length) }) add(8)的值之后 console.timeEnd()
在chrome運行結果如下:
可以看到第二個nextTick中push的值最后渲染在add(8)的值之后,這也就是nextTick的作用了,nextTick的作用就是用來處理需要在數據更新(在vue中手動調用nextTick時對應的是dom更新完成后)完才執行的操作。
nextTick的原理:
首先nextTick會將外部傳進的函數回調存在內部數組中,nextTick內部有一個用來遍歷這個內部數組的函數nextTickHandler,而這個函數的執行是異步的,什么時候執行取決於這個函數是屬於什么類型的異步任務:微任務or宏任務。
主線程執行完,就會去任務隊列中取任務到主線程中執行,任務隊列中包含了微任務和宏任務,首先會取微任務,微任務執行完就會取宏任務執行,依此循環。nextTickHandler設置成微任務或宏任務就能保證其總是在數據修改完或者dom更新完然后再執行。(js執行機制可以看promise時序問題&js執行機制)
為什么vue中對設置函數nextTickHandler的異步任務類型會有如下幾種判斷?
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { setImmediate(nextTickHandler) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = nextTickHandler timerFunc = () => { port.postMessage(1) } } else /* istanbul ignore next */ if (typeof Promise !== 'undefined' && isNative(Promise)) { // use microtask in non-DOM environments, e.g. Weex const p = Promise.resolve() timerFunc = () => { p.then(nextTickHandler) } } else { // fallback to setTimeout timerFunc = () => { setTimeout(nextTickHandler, 0) } }
瀏覽器環境中常見的異步任務種類,按照優先級:
- macro task:同步代碼、setImmediate、MessageChannel、setTimeout/setInterval
- micro task:Promise.then、MutationObserver
而為什么最后才判斷使用setTimeout?
vue中目的就是要盡可能的快地執行回調渲染視圖,而setTimeout有最小延遲限制:如果嵌套深度超過5級,setTimeout(回調,0)就會有4ms的延遲。
所以首先選用執行更快的setImmediate,但是setImmediate有兼容性問題,目前只支持Edge、Ie瀏覽器:
可以用同樣執行比setTimeout更快的宏任務MessageChannel來代替setImmediate。MessageChannel兼容性如下:
當以上都不支持的時候,就使用new Promise().then(),將回調設置成微任務,Promise不支持才使用setTimeout。
資源搜索網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com
總結:
nextTick就是利用了js機制執行任務的規則,將nextTick的回調函數設置成宏任務或微任務來達到在主線程的操作執行完,再執行的目的。
在vue中主要提供對依賴Dom更新完成后再做操作的情況的支持
nextTick的使用場景
當改變數據,視圖沒有按預期渲染時;都應該考慮是否是因為本需要在dom執行完再執行,然而實際卻在dom沒有執行完就執行了代碼,如果是就考慮使用將邏輯放到nextTick中,有的時候業務操作復雜,有些操作可能需要更晚一些執行,放在nextTick中仍然沒有達到預期效果,這個時候可以考慮使用setTimeout,將邏輯放到宏任務中。
基於以上分析,可以列舉幾個nextTick常用到的使用場景:
- 在created、mounted等鈎子函數中使用時。
- 對dom進行操作時,例如:使用$ref讀取元素時
// input 定位 scrollToInputBottom() { this.$nextTick(() => { this.$refs.accept_buddy_left.scrollTop = this.$refs.accept_buddy_left.scrollTop + 135 this.$refs.accept_buddy_ipt[ this.$refs.accept_buddy_ipt.length - 1 ].$refs.ipt.focus() }) },
- 計算頁面元素高度時:
// 監聽來自 url 的期數變化,跳到該期數 urlInfoTerm: { immediate: true, handler(val) { if (val !== 0) { this.$nextTick(function() { // 計算期數所在位置的高度 this.setCellsHeight() //設置滾動距離 this.spaceLenght = this.getColumnPositionIndex( this.list, ) setTimeout(() => { this.setScrollPosition(val) }, 800) }) } },