相信很多人會好奇Vue內部的更新機制,或者平時工作中遇到的一些奇怪的問題需要使用$nextTick
來解決,今天我們就來聊一聊Vue中的非同步更新機制以及$nextTick
原理
Vue的非同步更新
可能你還沒有注意到,Vue非同步執行DOM更新。只要觀察到資料變化,Vue將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個watcher被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和DOM操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue重新整理佇列並執行實際 (已去重的) 工作。
DOM更新是非同步的
當我們在更新資料後立馬去獲取DOM中的內容是會發現獲取的依然還是舊的內容。
<template>
<div class="next_tick">
<div ref="title" class="title">{{name}}</div>
</div>
</template>
<script>
export default {
data() {
return {
name: '前端南玖'
}
},
mounted() {
this.name = 'front end'
console.log('sync',this.$refs.title.innerText)
this.$nextTick(() => {
console.log('nextTick',this.$refs.title.innerText)
})
}
}
</script>
從圖中我們可以發現資料改變後同步獲取dom元素中的內容是老的資料,而在nextTick裡面獲取的是更新後的資料,這是為什麼呢?
其實這裡你用微任務或巨集任務去獲取dom元素中的內容也是更新後的資料,我們可以來試試:
mounted() {
this.name = 'front end'
console.log('sync',this.$refs.title.innerText)
Promise.resolve().then(() => {
console.log('微任務',this.$refs.title.innerText)
})
setTimeout(() => {
console.log('巨集任務',this.$refs.title.innerText)
}, 0)
this.$nextTick(() => {
console.log('nextTick',this.$refs.title.innerText)
})
}
是不是覺得有點不可思議,其實沒什麼奇怪的,在vue原始碼中它的實現原理就是利用的微任務與巨集任務,慢慢往下看,後面會一一解釋。
DOM更新還是批量的
沒錯,vue中的DOM更新還是批量處理的,這樣做的好處無疑就是能夠最大程度的優化效能。OK這裡也有看點,彆著急
vue同時更新了多個資料,你覺得dom是更新多次還是更新一次?我們來試試
<template>
<div class="next_tick">
<div ref="title" class="title">{{name}}</div>
<div class="verse">{{verse}}</div>
</div>
</template>
<script>
export default {
name: 'nextTick',
data() {
return {
name: '前端南玖',
verse: '如若東山能再起,大鵬展翅上九霄',
count:0
}
},
mounted() {
this.name = 'front end'
this.verse = '世間萬物都是空,功名利祿似如風'
// console.log('sync',this.$refs.title.innerText)
// Promise.resolve().then(() => {
// console.log('微任務',this.$refs.title.innerText)
// })
// setTimeout(() => {
// console.log('巨集任務',this.$refs.title.innerText)
// }, 0)
// this.$nextTick(() => {
// console.log('nextTick',this.$refs.title.innerText)
// })
},
updated() {
this.count++
console.log('update:',this.count)
}
}
</script>
<style lang="less">
.verse{
font-size: (20/@rem);
}
</style>
我們可以看到updated鉤子只執行了一次,說明我們同時更新了多個資料,DOM只會更新一次
再來看另一種情況,同步與非同步混合,DOM會更新幾次?
mounted() {
this.name = 'front end'
this.verse = '世間萬物都是空,功名利祿似如風'
Promise.resolve().then(() => {
this.name = 'study ...'
})
setTimeout(() => {
this.verse = '半身風雨半身寒,一杯濁酒敬流年'
})
// console.log('sync',this.$refs.title.innerText)
// Promise.resolve().then(() => {
// console.log('微任務',this.$refs.title.innerText)
// })
// setTimeout(() => {
// console.log('巨集任務',this.$refs.title.innerText)
// }, 0)
// this.$nextTick(() => {
// console.log('nextTick',this.$refs.title.innerText)
// })
},
updated() {
this.count++
console.log('update:',this.count)
}
從圖中我們會發現,DOM會渲染三次,分別是同步的一次(2個同步一起更新),微任務的一次,巨集任務的一次。並且在用setTimeout更新資料時會明顯看見頁面資料變化的過程。(這句話是重點,記好小本本)這也就是為什麼nextTick原始碼中setTimeout做最後兜底用的,優先使用微任務。
事件迴圈
沒錯,這裡跟事件迴圈還有很大的關係,這裡稍微提一下,更詳細可以看探索JavaScript執行機制
由於JavaScript是單執行緒的,這就決定了它的任務不可能只有同步任務,那些耗時很長的任務如果也按同步任務執行的話將會導致頁面阻塞,所以JavaScript任務一般分為兩類:同步任務與非同步任務,而非同步任務又分為巨集任務與微任務。
巨集任務: script(整體程式碼)、setTimeout、setInterval、setImmediate、I/O、UI rendering
微任務: promise.then、MutationObserver
執行過程
- 同步任務直接放入到主執行緒執行,非同步任務(點選事件,定時器,ajax等)掛在後臺執行,等待I/O事件完成或行為事件被觸發。
- 系統後臺執行非同步任務,如果某個非同步任務事件(或者行為事件被觸發),則將該任務新增到任務佇列,並且每個任務會對應一個回撥函式進行處理。
- 這裡非同步任務分為巨集任務與微任務,巨集任務進入到巨集任務佇列,微任務進入到微任務佇列。
- 執行任務佇列中的任務具體是在執行棧中完成的,當主執行緒中的任務全部執行完畢後,去讀取微任務佇列,如果有微任務就會全部執行,然後再去讀取巨集任務佇列
- 上述過程會不斷的重複進行,也就是我們常說的 「事件迴圈(Event-Loop)」。
總的來說,在事件迴圈中,微任務會先於巨集任務執行。而在微任務執行完後會進入瀏覽器更新渲染階段,所以在更新渲染前使用微任務會比巨集任務快一些,一次迴圈就是一次tick 。
在一次event loop中,microtask在這一次迴圈中是一直取一直取,直到清空microtask佇列,而macrotask則是一次迴圈取一次。
如果執行事件迴圈的過程中又加入了非同步任務,如果是macrotask,則放到macrotask末尾,等待下一輪迴圈再執行。如果是microtask,則放到本次event loop中的microtask任務末尾繼續執行。直到microtask佇列清空。
原始碼深入
非同步更新佇列
在Vue中DOM更新一定是由於資料變化引起的,所以我們可以快速找到更新DOM的入口,也就是set時通過dep.notify
通知watcher更新的時候
// watcher.js
// 當依賴發生變化時,觸發更新
update() {
if(this.lazy) {
// 懶執行會走這裡, 比如computed
this.dirty = true
}else if(this.sync) {
// 同步執行會走這裡,比如this.$watch() 或watch選項,傳遞一個sync配置{sync: true}
this.run()
}else {
// 將當前watcher放入watcher佇列, 一般都是走這裡
queueWatcher(this)
}
}
從這裡我們可以發現vue預設就是走的非同步更新機制,它會實現一個佇列進行快取當前需要更新的watcher
// scheduler.js
/*將一個觀察者物件push進觀察者佇列,在佇列中已經存在相同的id則該觀察者物件將被跳過,除非它是在佇列被重新整理時推送*/
export function queueWatcher (watcher: Watcher) {
/*獲取watcher的id*/
const id = watcher.id
/*檢驗id是否存在,已經存在則直接跳過,不存在則標記在has中,用於下次檢驗*/
if (has[id] == null) {
has[id] = true
// 如果flushing為false, 表示當前watcher佇列沒有在被重新整理,則watcher直接進入佇列
if (!flushing) {
queue.push(watcher)
} else {
// 如果watcher佇列已經在被重新整理了,這時候想要插入新的watcher就需要特殊處理
// 保證新入隊的watcher重新整理仍然是有序的
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) {
// wating為false,表示當前瀏覽器的非同步任務佇列中沒有flushSchedulerQueue函式
waiting = true
// 這就是我們常見的this.$nextTick
nextTick(flushSchedulerQueue)
}
}
}
ok,從這裡我們就能發現vue並不是跟隨資料變化立即更新檢視的,它而是維護了一個watcher佇列,並且id重複的watcher只會推進佇列一次,因為我們關心的只是最終的資料,而不是它更新多少次。等到下一個tick時,這些watcher才會從佇列中取出,更新檢視。
nextTick
nextTick的目的就是產生一個回撥函式加入task或者microtask中,當前棧執行完以後(可能中間還有別的排在前面的函式)呼叫該回撥函式,起到了非同步觸發(即下一個tick時觸發)的目的。
// next-tick.js
const callbacks = []
let pending = false
// 批處理
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
// 依次執行nextTick的方法
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
export function nextTick (cb, ctx) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 因為內部會調nextTick,使用者也會調nextTick,但非同步只需要一次
if (!pending) {
pending = true
timerFunc()
}
// 執行完會會返回一個promise例項,這也是為什麼$nextTick可以呼叫then方法的原因
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
相容性處理,優先使用promise.then 優雅降級(相容處理就是一個不斷嘗試的過程,誰可以就用誰。
Vue 在內部對非同步佇列嘗試使用原生的 Promise.then、MutationObserver 和 setImmediate,如果執行環境不支援,則會採用 setTimeout(fn, 0) 代替。
// timerFunc
// promise.then -> MutationObserver -> setImmediate -> setTimeout
// vue3 中不再做相容性處理,直接使用的就是promise.then 任性
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
const observer = new MutationObserver(flushCallbacks) // 可以監聽DOM變化,監聽完是非同步更新的
// 但這裡並不是想用它做DOM監聽,而是利用它是微任務這一特點
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
$nextTick
我們平常呼叫的$nextTick
其實就是上面這個方法,只不過在原始碼中renderMixin
中將該方法掛在了vue的原型上方便我們使用
export function renderMixin (Vue) {
// install runtime convenience helpers
installRenderHelpers(Vue.prototype)
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
}
Vue.prototype._render = function() {
//...
}
// ...
}
總結
一般更新DOM是同步的
上面說了那麼多,相信大家對Vue的非同步更新機制以及$nextTick
原理已經有了初步的瞭解。每一輪事件迴圈的最後會進行一次頁面渲染,並且從上面我們知道渲染過程也是個巨集任務,這裡可能會有個誤區,那就是DOM tree的修改是同步的,只有渲染過程是非同步的,也就是說我們在修改完DOM後能夠立即獲取到更新的DOM,不信我們可以來試一下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="title">欲試人間煙火,怎料世道滄桑</div>
<script>
title.innerText = '萬卷詩書無一用,半老雄心剩疏狂'
console.log('updated',title)
</script>
</body>
</html>
既然更新DOM是個同步的過程,那為什麼Vue卻需要借用$nextTick來處理呢?
答案很明顯,因為Vue處於效能考慮,Vue會將使用者同步修改的多次資料快取起來,等同步程式碼執行完,說明這一次的資料修改就結束了,然後才會去更新對應DOM,一方面可以省去不必要的DOM操作,比如同時修改一個資料多次,只需要關心最後一次就好了,另一方面可以將DOM操作聚集,提升render效能。
看下面這個圖理解起來應該更容易一點
為什麼優先使用微任務?
這個應該不用多說吧,因為微任務一定比巨集任務優先執行,如果nextTick是微任務,它會在當前同步任務執行完立即執行所有的微任務,也就是修改DOM的操作也會在當前tick內執行,等本輪tick任務全部執行完成,才是開始執行UI rendering。如果nextTick是巨集任務,它會被推進巨集任務佇列,並且在本輪tick執行完之後的某一輪執行,注意,它並不一定是下一輪,因為你不確定巨集任務佇列中它之前還有所少個巨集任務在等待著。所以為了能夠儘快更新DOM,Vue中優先採用的是微任務,並且在Vue3中,它沒有了相容判斷,直接使用的是promise.then
微任務,不再考慮巨集任務了。
推薦閱讀
- 效能優化之html、css、js三者的載入順序
- HTTP發展史,HTTP1.1與HTTP2.0的區別
- 超全面總結Vue面試知識點,助力金三銀四
- 【面試必備】前端常見的排序演算法
- CSS效能優化的幾個技巧
- 前端常見的安全問題及防範措施
- 為什麼大廠前端監控都在用GIF做埋點?
- 前端人員不要只知道KFC,你應該瞭解 BFC、IFC、GFC 和 FFC
我是南玖,我們下期見!!!