以下文章來源于前端下午茶 ,作者SHERlocked93
vue 已是目前國內(nèi)前端web端三分天下之一,同時(shí)也作為本人主要技術(shù)棧之一,在日常使用中知其然也好奇著所以然,另外最近的社區(qū)涌現(xiàn)了一大票 vue 源碼閱讀類的文章,在下借這個(gè)機(jī)會從大家的文章和討論中汲取了一些營養(yǎng),同時(shí)對一些閱讀源碼時(shí)的想法進(jìn)行總結(jié),出產(chǎn)一些文章,作為自己思考的輸出
目標(biāo)Vue版本:2.5.17-beta.0
vue 源碼注釋:github.com/SHERlocked93/vue-analysis
聲明:文章中源碼的語法都使用 Flow,并且源碼根據(jù)需要都有刪節(jié)(為了不被迷糊 @_@),如果要看完整版的請進(jìn)入上面的github地址
1. 異步更新
上一篇文章我們在依賴收集原理的響應(yīng)式化方法 defineReactive
中的 setter
訪問器中有派發(fā)更新 dep.notify()
方法,這個(gè)方法會挨個(gè)通知在 dep
的 subs
中收集的訂閱自己變動的 watchers 執(zhí)行 update 。一起來看看 update
方法的實(shí)現(xiàn):
// src/core/observer/watcher.js
/* Subscriber接口,當(dāng)依賴發(fā)生改變的時(shí)候進(jìn)行回調(diào) */
update() {
if (this.computed) {
// 一個(gè)computed watcher有兩種模式:activated lazy(默認(rèn))
// 只有當(dāng)它被至少一個(gè)訂閱者依賴時(shí)才置activated,這通常是另一個(gè)計(jì)算屬性或組件的render function
if (this.dep.subs.length === 0) { // 如果沒人訂閱這個(gè)計(jì)算屬性的變化
// lazy時(shí),我們希望它只在必要時(shí)執(zhí)行計(jì)算,所以我們只是簡單地將觀察者標(biāo)記為dirty
// 當(dāng)計(jì)算屬性被訪問時(shí),實(shí)際的計(jì)算在this.evaluate()中執(zhí)行
this.dirty = true
} else {
// activated模式下,我們希望主動執(zhí)行計(jì)算,但只有當(dāng)值確實(shí)發(fā)生變化時(shí)才通知我們的訂閱者
this.getAndInvoke(() => {
this.dep.notify() // 通知渲染watcher重新渲染,通知依賴自己的所有watcher執(zhí)行update
})
}
} else if (this.sync) { // 同步
this.run()
} else {
queueWatcher(this) // 異步推送到調(diào)度者觀察者隊(duì)列中,下一個(gè)tick時(shí)調(diào)用
}
}
如果不是 computed watcher
也非 sync
會把調(diào)用 update 的當(dāng)前 watcher 推送到調(diào)度者隊(duì)列中,下一個(gè) tick 時(shí)調(diào)用,看看 queueWatcher
:
// src/core/observer/scheduler.js
/* 將一個(gè)觀察者對象push進(jìn)觀察者隊(duì)列,在隊(duì)列中已經(jīng)存在相同的id則
* 該watcher將被跳過,除非它是在隊(duì)列正被flush時(shí)推送
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) { // 檢驗(yàn)id是否存在,已經(jīng)存在則直接跳過,不存在則標(biāo)記哈希表has,用于下次檢驗(yàn)
has[id] = true
queue.push(watcher) // 如果沒有正在flush,直接push到隊(duì)列中
if (!waiting) { // 標(biāo)記是否已傳給nextTick
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
/* 重置調(diào)度者狀態(tài) */
function resetSchedulerState () {
queue.length = 0
has = {}
waiting = false
}
這里使用了一個(gè) has
的哈希map用來檢查是否當(dāng)前 watcher 的 id 是否存在,若已存在則跳過,不存在則就 push 到 queue
隊(duì)列中并標(biāo)記哈希表 has,用于下次檢驗(yàn),防止重復(fù)添加。這就是一個(gè)去重的過程,比每次查重都要去 queue 中找要文明,在渲染的時(shí)候就不會重復(fù)patch
相同 watcher 的變化,這樣就算同步修改了一百次視圖中用到的 data,異步 patch
的時(shí)候也只會更新最后一次修改。
這里的 waiting
方法是用來標(biāo)記 flushSchedulerQueue
是否已經(jīng)傳遞給 nextTick
的標(biāo)記位,如果已經(jīng)傳遞則只 push 到隊(duì)列中不傳遞 flushSchedulerQueue
給 nextTick
,等到 resetSchedulerState
重置調(diào)度者狀態(tài)的時(shí)候 waiting
會被置回 false
允許 flushSchedulerQueue
被傳遞給下一個(gè) tick 的回調(diào),總之保證了 flushSchedulerQueue
回調(diào)在一個(gè) tick 內(nèi)只允許被傳入一次。來看看被傳遞給 nextTick
的回調(diào) flushSchedulerQueue
做了什么:
// src/core/observer/scheduler.js
/* nextTick的回調(diào)函數(shù),在下一個(gè)tick時(shí)flush掉兩個(gè)隊(duì)列同時(shí)運(yùn)行watchers */
function flushSchedulerQueue () {
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id) // 排序
for (index = 0; index < queue.length; index++) { // 不要將length進(jìn)行緩存
watcher = queue[index]
if (watcher.before) { // 如果watcher有before則執(zhí)行
watcher.before()
}
id = watcher.id
has[id] = null // 將has的標(biāo)記刪除
watcher.run() // 執(zhí)行watcher
if (process.env.NODE_ENV !== 'production' && has[id] != null) { // 在dev環(huán)境下檢查是否進(jìn)入死循環(huán)
circular[id] = (circular[id] || 0) + 1 // 比如user watcher訂閱自己的情況
if (circular[id] > MAX_UPDATE_COUNT) { // 持續(xù)執(zhí)行了一百次watch代表可能存在死循環(huán)
warn() // 進(jìn)入死循環(huán)的警告
break
}
}
}
resetSchedulerState() // 重置調(diào)度者狀態(tài)
callActivatedHooks() // 使子組件狀態(tài)都置成active同時(shí)調(diào)用activated鉤子
callUpdatedHooks() // 調(diào)用updated鉤子
}
在 nextTick
方法中執(zhí)行 flushSchedulerQueue
方法,這個(gè)方法挨個(gè)執(zhí)行 queue
中的watcher的 run
方法。我們看到在首先有個(gè) queue.sort()
方法把隊(duì)列中的 watcher 按 id 從小到大排了個(gè)序,這樣做可以保證:
- 組件更新的順序是從父組件到子組件的順序,因?yàn)楦附M件總是比子組件先創(chuàng)建。
- 一個(gè)組件的 user watchers (偵聽器watcher)比 render watcher 先運(yùn)行,因?yàn)?user watchers 往往比 render watcher 更早創(chuàng)建
- 如果一個(gè)組件在父組件 watcher 運(yùn)行期間被銷毀,它的 watcher 執(zhí)行將被跳過
在挨個(gè)執(zhí)行隊(duì)列中的 for 循環(huán)中,index < queue.length
這里沒有將 length 進(jìn)行緩存,因?yàn)樵趫?zhí)行處理現(xiàn)有 watcher 對象期間,更多的 watcher 對象可能會被 push 進(jìn) queue。
那么數(shù)據(jù)的修改從 model 層反映到 view 的過程:數(shù)據(jù)更改 -> setter -> Dep -> Watcher -> nextTick -> patch -> 更新視圖
2. nextTick原理
2.1 宏任務(wù)/微任務(wù)
這里就來看看包含著每個(gè) watcher 執(zhí)行的方法被作為回調(diào)傳入 nextTick
之后,nextTick
對這個(gè)方法做了什么。不過首先要了解一下瀏覽器中的 EventLoop
、macro task
、micro task
幾個(gè)概念,不了解可以參考一下 JS 與 Node.js 中的事件循環(huán) 這篇文章,這里就用一張圖來表明一下后兩者在主線程中的執(zhí)行關(guān)系:
解釋一下,當(dāng)主線程執(zhí)行完同步任務(wù)后:
- 引擎首先從 macrotask queue 中取出第一個(gè)任務(wù),執(zhí)行完畢后,將 microtask queue 中的所有任務(wù)取出,按順序全部執(zhí)行;
- 然后再從 macrotask queue 中取下一個(gè),執(zhí)行完畢后,再次將 microtask queue 中的全部取出;
- 循環(huán)往復(fù),直到兩個(gè) queue 中的任務(wù)都取完。
瀏覽器環(huán)境中常見的異步任務(wù)種類,按照優(yōu)先級:
macro task
:同步代碼、setImmediate
、MessageChannel
、setTimeout/setInterval
micro task
:Promise.then
、MutationObserver
有的文章把 micro task
叫微任務(wù),macro task
叫宏任務(wù),因?yàn)檫@兩個(gè)單詞拼寫太像了 -。- ,所以后面的注釋多用中文表示~
先來看看源碼中對 micro task
與 macro task
的實(shí)現(xiàn):macroTimerFunc
、microTimerFunc
// src/core/util/next-tick.js
const callbacks = [] // 存放異步執(zhí)行的回調(diào)
let pending = false // 一個(gè)標(biāo)記位,如果已經(jīng)有timerFunc被推送到任務(wù)隊(duì)列中去則不需要重復(fù)推送
/* 挨個(gè)同步執(zhí)行callbacks中回調(diào) */
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let microTimerFunc // 微任務(wù)執(zhí)行方法
let macroTimerFunc // 宏任務(wù)執(zhí)行方法
let useMacroTask = false // 是否強(qiáng)制為宏任務(wù),默認(rèn)使用微任務(wù)
// 宏任務(wù)
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
MessageChannel.toString() === '[object MessageChannelConstructor]' // PhantomJS
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 微任務(wù)
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
} else {
microTimerFunc = macroTimerFunc // fallback to macro
}
flushCallbacks
這個(gè)方法就是挨個(gè)同步的去執(zhí)行 callbacks 中的回調(diào)函數(shù)們, callbacks 中的回調(diào)函數(shù)是在調(diào)用 nextTick
的時(shí)候添加進(jìn)去的;那么怎么去使用 micro task
與 macro task
去執(zhí)行 flushCallbacks
呢,這里他們的實(shí)現(xiàn) macroTimerFunc
、microTimerFunc
使用瀏覽器中宏任務(wù)/微任務(wù)的 API 對flushCallbacks
方法進(jìn)行了一層包裝。比如宏任務(wù)方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) }
,這樣在觸發(fā)宏任務(wù)執(zhí)行的時(shí)候 macroTimerFunc()
就可以在瀏覽器中的下一個(gè)宏任務(wù) loop 的時(shí)候消費(fèi)這些保存在 callbacks 數(shù)組中的回調(diào)了,微任務(wù)同理。同時(shí)也可以看出傳給 nextTick
的異步回調(diào)函數(shù)是被壓成了一個(gè)同步任務(wù)在一個(gè) tick 執(zhí)行完的,而不是開啟多個(gè)異步任務(wù)。
注意這里有個(gè)比較難理解的地方,第一次調(diào)用 nextTick
的時(shí)候 pending
為 false ,此時(shí)已經(jīng) push 到瀏覽器 event loop 中一個(gè)宏任務(wù)或微任務(wù)的 task,如果在沒有 flush 掉的情況下繼續(xù)往 callbacks 里面添加,那么在執(zhí)行這個(gè)占位 queue 的時(shí)候會執(zhí)行之后添加的回調(diào),所以 macroTimerFunc
、microTimerFunc
相當(dāng)于 task queue 的占位,以后 pending
為 true 則繼續(xù)往占位 queue 里面添加,event loop 輪到這個(gè) task queue 的時(shí)候?qū)⒁徊?zhí)行。執(zhí)行 flushCallbacks
時(shí) pending
置 false,允許下一輪執(zhí)行 nextTick
時(shí)往 event loop 占位。
可以看到上面 macroTimerFunc
與 microTimerFunc
進(jìn)行了在不同瀏覽器兼容性下的平穩(wěn)退化,或者說降級策略:
macroTimerFunc
:setImmediate -> MessageChannel -> setTimeout
。首先檢測是否原生支持setImmediate
,這個(gè)方法只在 IE、Edge 瀏覽器中原生實(shí)現(xiàn),然后檢測是否支持 MessageChannel,如果對MessageChannel
不了解可以參考一下這篇文章,還不支持的話最后使用setTimeout
;為什么優(yōu)先使用setImmediate
與MessageChannel
而不直接使用setTimeout
呢,是因?yàn)?HTML5 規(guī)定 setTimeout 執(zhí)行的最小延時(shí)為4ms,而嵌套的 timeout 表現(xiàn)為10ms,為了盡可能快的讓回調(diào)執(zhí)行,沒有最小延時(shí)限制的前兩者顯然要優(yōu)于setTimeout
。microTimerFunc
:Promise.then -> macroTimerFunc
。首先檢查是否支持Promise
,如果支持的話通過Promise.then
來調(diào)用flushCallbacks
方法,否則退化為macroTimerFunc
;vue2.5之后nextTick
中因?yàn)榧嫒菪栽騽h除了微任務(wù)平穩(wěn)退化的MutationObserver
的方式。
2.2 nextTick實(shí)現(xiàn)
最后來看看我們平常用到的 nextTick
方法到底是如何實(shí)現(xiàn)的:
// src/core/util/next-tick.js
export function nextTick(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
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
/* 強(qiáng)制使用macrotask的方法 */
export function withMacroTask(fn: Function): Function {
return fn._withTask || (fn._withTask = function() {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
nextTick
在這里分為三個(gè)部分,我們一起來看一下;
- 首先
nextTick
把傳入的cb
回調(diào)函數(shù)用try-catch
包裹后放在一個(gè)匿名函數(shù)中推入callbacks數(shù)組中,這么做是因?yàn)榉乐箚蝹€(gè)cb
如果執(zhí)行錯(cuò)誤不至于讓整個(gè)JS線程掛掉,每個(gè)cb
都包裹是防止這些回調(diào)函數(shù)如果執(zhí)行錯(cuò)誤不會相互影響,比如前一個(gè)拋錯(cuò)了后一個(gè)仍然可以執(zhí)行。 - 然后檢查
pending
狀態(tài),這個(gè)跟之前介紹的queueWatcher
中的waiting
是一個(gè)意思,它是一個(gè)標(biāo)記位,一開始是false
在進(jìn)入macroTimerFunc
、microTimerFunc
方法前被置為true
,因此下次調(diào)用nextTick
就不會進(jìn)入macroTimerFunc
、microTimerFunc
方法,這兩個(gè)方法中會在下一個(gè)macro/micro tick
時(shí)候flushCallbacks
異步的去執(zhí)行callbacks隊(duì)列中收集的任務(wù),而flushCallbacks
方法在執(zhí)行一開始會把pending
置false
,因此下一次調(diào)用nextTick
時(shí)候又能開啟新一輪的macroTimerFunc
、microTimerFunc
,這樣就形成了vue中的event loop
。 - 最后檢查是否傳入了
cb
,因?yàn)?nextTick
還支持Promise化的調(diào)用:nextTick().then(() => {})
,所以如果沒有傳入cb
就直接return了一個(gè)Promise實(shí)例,并且把resolve傳遞給_resolve,這樣后者執(zhí)行的時(shí)候就跳到我們調(diào)用的時(shí)候傳遞進(jìn)then
的方法中。
Vue源碼中 next-tick.js
文件還有一段重要的注釋,這里就翻譯一下:
在vue2.5之前的版本中,nextTick基本上基于
micro task
來實(shí)現(xiàn)的,但是在某些情況下micro task
具有太高的優(yōu)先級,并且可能在連續(xù)順序事件之間(例如#4521,#6690)或者甚至在同一事件的事件冒泡過程中之間觸發(fā)(#6566)。但是如果全部都改成macro task
,對一些有重繪和動畫的場景也會有性能影響,如 issue #6813。vue2.5之后版本提供的解決辦法是默認(rèn)使用micro task
,但在需要時(shí)(例如在v-on附加的事件處理程序中)強(qiáng)制使用macro task
。
為什么默認(rèn)優(yōu)先使用 micro task
呢,是利用其高優(yōu)先級的特性,保證隊(duì)列中的微任務(wù)在一次循環(huán)全部執(zhí)行完畢。
強(qiáng)制 macro task
的方法是在綁定 DOM 事件的時(shí)候,默認(rèn)會給回調(diào)的 handler 函數(shù)調(diào)用withMacroTask
方法做一層包裝 handler = withMacroTask(handler)
,它保證整個(gè)回調(diào)函數(shù)執(zhí)行過程中,遇到數(shù)據(jù)狀態(tài)的改變,這些改變都會被推到 macro task
中。以上實(shí)現(xiàn)在 src/platforms/web/runtime/modules/events.js 的 add
方法中,可以自己看一看具體代碼。
剛好在寫這篇文章的時(shí)候思否上有人問了個(gè)問題 vue 2.4 和2.5 版本的 @input 事件不一樣 ,這個(gè)問題的原因也是因?yàn)?.5之前版本的DOM事件采用 micro task
,而之后采用 macro task
,解決的途徑參考 < Vue.js 升級踩坑小記> 中介紹的幾個(gè)辦法,這里就提供一個(gè)在mounted鉤子中用 addEventListener
添加原生事件的方法來實(shí)現(xiàn),參見 CodePen。
3. 一個(gè)例子
說這么多,不如來個(gè)例子,執(zhí)行參見 CodePen
<div id="app">
<span id='name' ref='name'>{{ name }}</span>
<button @click='change'>change name</button>
<div id='content'></div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
name: 'SHERlocked93'
}
},
methods: {
change() {
const $name = this.$refs.name
this.$nextTick(() => console.log('setter前:' + $name.innerHTML))
this.name = ' name改嘍 '
console.log('同步方式:' + this.$refs.name.innerHTML)
setTimeout(() => this.console("setTimeout方式:" + this.$refs.name.innerHTML))
this.$nextTick(() => console.log('setter后:' + $name.innerHTML))
this.$nextTick().then(() => console.log('Promise方式:' + $name.innerHTML))
}
}
})
</script>
執(zhí)行以下看看結(jié)果:
同步方式:SHERlocked93
setter前:SHERlocked93
setter后:name改嘍
Promise方式:name改嘍
setTimeout方式:name改嘍
為什么是這樣的結(jié)果呢,解釋一下:
- 同步方式: 當(dāng)把data中的name修改之后,此時(shí)會觸發(fā)name的
setter
中的dep.notify
通知依賴本data的render watcher去update
,update
會把flushSchedulerQueue
函數(shù)傳遞給nextTick
,render watcher在flushSchedulerQueue
函數(shù)運(yùn)行時(shí)watcher.run
再走diff -> patch
那一套重渲染re-render
視圖,這個(gè)過程中會重新依賴收集,這個(gè)過程是異步的;所以當(dāng)我們直接修改了name之后打印,這時(shí)異步的改動還沒有被patch
到視圖上,所以獲取視圖上的DOM元素還是原來的內(nèi)容。 - setter前: setter前為什么還打印原來的是原來內(nèi)容呢,是因?yàn)?
nextTick
在被調(diào)用的時(shí)候把回調(diào)挨個(gè)push進(jìn)callbacks數(shù)組,之后執(zhí)行的時(shí)候也是for
循環(huán)出來挨個(gè)執(zhí)行,所以是類似于隊(duì)列這樣一個(gè)概念,先入先出;在修改name之后,觸發(fā)把render watcher填入schedulerQueue
隊(duì)列并把他的執(zhí)行函數(shù)flushSchedulerQueue
傳遞給nextTick
,此時(shí)callbacks隊(duì)列中已經(jīng)有了setter前函數(shù)
了,因?yàn)檫@個(gè)cb
是在setter前函數(shù)
之后被push進(jìn)callbacks隊(duì)列的,那么先入先出的執(zhí)行callbacks中回調(diào)的時(shí)候先執(zhí)行setter前函數(shù)
,這時(shí)并未執(zhí)行render watcher的watcher.run
,所以打印DOM元素仍然是原來的內(nèi)容。 - setter后: setter后這時(shí)已經(jīng)執(zhí)行完
flushSchedulerQueue
,這時(shí)render watcher已經(jīng)把改動patch
到視圖上,所以此時(shí)獲取DOM是改過之后的內(nèi)容。 - Promise方式: 相當(dāng)于
Promise.then
的方式執(zhí)行這個(gè)函數(shù),此時(shí)DOM已經(jīng)更改。 - setTimeout方式: 最后執(zhí)行macro task的任務(wù),此時(shí)DOM已經(jīng)更改。
注意,在執(zhí)行 setter前函數(shù)
這個(gè)異步任務(wù)之前,同步的代碼已經(jīng)執(zhí)行完畢,異步的任務(wù)都還未執(zhí)行,所有的 $nextTick
函數(shù)也執(zhí)行完畢,所有回調(diào)都被push進(jìn)了callbacks隊(duì)列中等待執(zhí)行,所以在setter前函數(shù)
執(zhí)行的時(shí)候,此時(shí)callbacks隊(duì)列是這樣的:[setter前函數(shù)
,flushSchedulerQueue
,setter后函數(shù)
,Promise方式函數(shù)
],它是一個(gè)micro task隊(duì)列,執(zhí)行完畢之后執(zhí)行macro task setTimeout
,所以打印出上面的結(jié)果。
另外,如果瀏覽器的宏任務(wù)隊(duì)列里面有setImmediate
、MessageChannel
、setTimeout/setInterval
各種類型的任務(wù),那么會按照上面的順序挨個(gè)按照添加進(jìn)event loop中的順序執(zhí)行,所以如果瀏覽器支持MessageChannel
, nextTick
執(zhí)行的是macroTimerFunc
,那么如果 macrotask queue 中同時(shí)有 nextTick
添加的任務(wù)和用戶自己添加的 setTimeout
類型的任務(wù),會優(yōu)先執(zhí)行 nextTick
中的任務(wù),因?yàn)?code>MessageChannel 的優(yōu)先級比 setTimeout
的高,setImmediate
同理。
以上就是W3Cschool編程獅
關(guān)于Vue 進(jìn)階面試必問,異步更新機(jī)制和 nextTick 原理的相關(guān)介紹了,希望對大家有所幫助。