vue已是目前國內前端web端三分天下之一,同時也作為本人主要技術棧之一,在日常使用中知其然也好奇著所以然,另外最近的社群湧現了一大票vue原始碼閱讀類的文章,在下借這個機會從大家的文章和討論中汲取了一些營養,同時對一些閱讀原始碼時的想法進行總結,出產一些文章,作為自己思考的輸出,本人水平有限,歡迎留言討論~
目標Vue版本:2.5.17-beta.0
vue原始碼註釋:github.com/SHERlocked9…
宣告:文章中原始碼的語法都使用 Flow,並且原始碼根據需要都有刪節(為了不被迷糊 @_@),如果要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~
1. 非同步更新
上一篇文章我們在依賴收集原理的響應式化方法 defineReactive
中的 setter
訪問器中有派發更新 dep.notify()
方法,這個方法會挨個通知在 dep
的 subs
中收集的訂閱自己變動的watchers執行update。一起來看看 update
方法的實現:
// src/core/observer/watcher.js
/* Subscriber介面,當依賴發生改變的時候進行回撥 */
update() {
if (this.computed) {
// 一個computed watcher有兩種模式:activated lazy(預設)
// 只有當它被至少一個訂閱者依賴時才置activated,這通常是另一個計算屬性或元件的render function
if (this.dep.subs.length === 0) { // 如果沒人訂閱這個計算屬性的變化
// lazy時,我們希望它只在必要時執行計算,所以我們只是簡單地將觀察者標記為dirty
// 當計算屬性被訪問時,實際的計算在this.evaluate()中執行
this.dirty = true
} else {
// activated模式下,我們希望主動執行計算,但只有當值確實發生變化時才通知我們的訂閱者
this.getAndInvoke(() => {
this.dep.notify() // 通知渲染watcher重新渲染,通知依賴自己的所有watcher執行update
})
}
} else if (this.sync) { // 同步
this.run()
} else {
queueWatcher(this) // 非同步推送到排程者觀察者佇列中,下一個tick時呼叫
}
}
複製程式碼
如果不是 computed watcher
也非 sync
會把呼叫update的當前watcher推送到排程者佇列中,下一個tick時呼叫,看看 queueWatcher
:
// src/core/observer/scheduler.js
/* 將一個觀察者物件push進觀察者佇列,在佇列中已經存在相同的id則
* 該watcher將被跳過,除非它是在佇列正被flush時推送
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) { // 檢驗id是否存在,已經存在則直接跳過,不存在則標記雜湊表has,用於下次檢驗
has[id] = true
queue.push(watcher) // 如果沒有正在flush,直接push到佇列中
if (!waiting) { // 標記是否已傳給nextTick
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
/* 重置排程者狀態 */
function resetSchedulerState () {
queue.length = 0
has = {}
waiting = false
}
複製程式碼
這裡使用了一個 has
的雜湊map用來檢查是否當前watcher的id是否存在,若已存在則跳過,不存在則就push到 queue
佇列中並標記雜湊表has,用於下次檢驗,防止重複新增。這就是一個去重的過程,比每次查重都要去queue中找要文明,在渲染的時候就不會重複 patch
相同watcher的變化,這樣就算同步修改了一百次檢視中用到的data,非同步 patch
的時候也只會更新最後一次修改。
這裡的 waiting
方法是用來標記 flushSchedulerQueue
是否已經傳遞給 nextTick
的標記位,如果已經傳遞則只push到佇列中不傳遞 flushSchedulerQueue
給 nextTick
,等到 resetSchedulerState
重置排程者狀態的時候 waiting
會被置回 false
允許 flushSchedulerQueue
被傳遞給下一個tick的回撥,總之保證了 flushSchedulerQueue
回撥在一個tick內只允許被傳入一次。來看看被傳遞給 nextTick
的回撥 flushSchedulerQueue
做了什麼:
// src/core/observer/scheduler.js
/* nextTick的回撥函式,在下一個tick時flush掉兩個佇列同時執行watchers */
function flushSchedulerQueue () {
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id) // 排序
for (index = 0; index < queue.length; index++) { // 不要將length進行快取
watcher = queue[index]
if (watcher.before) { // 如果watcher有before則執行
watcher.before()
}
id = watcher.id
has[id] = null // 將has的標記刪除
watcher.run() // 執行watcher
if (process.env.NODE_ENV !== 'production' && has[id] != null) { // 在dev環境下檢查是否進入死迴圈
circular[id] = (circular[id] || 0) + 1 // 比如user watcher訂閱自己的情況
if (circular[id] > MAX_UPDATE_COUNT) { // 持續執行了一百次watch代表可能存在死迴圈
warn() // 進入死迴圈的警告
break
}
}
}
resetSchedulerState() // 重置排程者狀態
callActivatedHooks() // 使子元件狀態都置成active同時呼叫activated鉤子
callUpdatedHooks() // 呼叫updated鉤子
}
複製程式碼
在 nextTick
方法中執行 flushSchedulerQueue
方法,這個方法挨個執行 queue
中的watcher的 run
方法。我們看到在首先有個 queue.sort()
方法把佇列中的watcher按id從小到大排了個序,這樣做可以保證:
- 元件更新的順序是從父元件到子元件的順序,因為父元件總是比子元件先建立。
- 一個元件的user watchers(偵聽器watcher)比render watcher先執行,因為user watchers往往比render watcher更早建立
- 如果一個元件在父元件watcher執行期間被銷燬,它的watcher執行將被跳過
在挨個執行佇列中的for迴圈中,index < queue.length
這裡沒有將length進行快取,因為在執行處理現有watcher物件期間,更多的watcher物件可能會被push進queue。
那麼資料的修改從model層反映到view的過程:資料更改 -> setter -> Dep -> Watcher -> nextTick -> patch -> 更新檢視
2. nextTick原理
2.1 巨集任務/微任務
這裡就來看看包含著每個watcher執行的方法被作為回撥傳入 nextTick
之後,nextTick
對這個方法做了什麼。不過首先要了解一下瀏覽器中的 EventLoop
、macro task
、micro task
幾個概念,不瞭解可以參考一下 JS與Node.js中的事件迴圈 這篇文章,這裡就用一張圖來表明一下後兩者在主執行緒中的執行關係:
解釋一下,當主執行緒執行完同步任務後:
- 引擎首先從macrotask queue中取出第一個任務,執行完畢後,將microtask queue中的所有任務取出,按順序全部執行;
- 然後再從macrotask queue中取下一個,執行完畢後,再次將microtask queue中的全部取出;
- 迴圈往復,直到兩個queue中的任務都取完。
瀏覽器環境中常見的非同步任務種類,按照優先順序:
macro task
:同步程式碼、setImmediate
、MessageChannel
、setTimeout/setInterval
micro task
:Promise.then
、MutationObserver
有的文章把 micro task
叫微任務,macro task
叫巨集任務,因為這兩個單詞拼寫太像了 -。- ,所以後面的註釋多用中文表示~
先來看看原始碼中對 micro task
與 macro task
的實現: macroTimerFunc
、microTimerFunc
// src/core/util/next-tick.js
const callbacks = [] // 存放非同步執行的回撥
let pending = false // 一個標記位,如果已經有timerFunc被推送到任務佇列中去則不需要重複推送
/* 挨個同步執行callbacks中回撥 */
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let microTimerFunc // 微任務執行方法
let macroTimerFunc // 巨集任務執行方法
let useMacroTask = false // 是否強制為巨集任務,預設使用微任務
// 巨集任務
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)
}
}
// 微任務
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
} else {
microTimerFunc = macroTimerFunc // fallback to macro
}
複製程式碼
flushCallbacks
這個方法就是挨個同步的去執行callbacks中的回撥函式們,callbacks中的回撥函式是在呼叫 nextTick
的時候新增進去的;那麼怎麼去使用 micro task
與 macro task
去執行 flushCallbacks
呢,這裡他們的實現 macroTimerFunc
、microTimerFunc
使用瀏覽器中巨集任務/微任務的API對flushCallbacks
方法進行了一層包裝。比如巨集任務方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) }
,這樣在觸發巨集任務執行的時候 macroTimerFunc()
就可以在瀏覽器中的下一個巨集任務loop的時候消費這些儲存在callbacks陣列中的回撥了,微任務同理。同時也可以看出傳給 nextTick
的非同步回撥函式是被壓成了一個同步任務在一個tick執行完的,而不是開啟多個非同步任務。
注意這裡有個比較難理解的地方,第一次呼叫 nextTick
的時候 pending
為false,此時已經push到瀏覽器event loop中一個巨集任務或微任務的task,如果在沒有flush掉的情況下繼續往callbacks裡面新增,那麼在執行這個佔位queue的時候會執行之後新增的回撥,所以 macroTimerFunc
、microTimerFunc
相當於task queue的佔位,以後 pending
為true則繼續往佔位queue裡面新增,event loop輪到這個task queue的時候將一併執行。執行 flushCallbacks
時 pending
置false,允許下一輪執行 nextTick
時往event loop佔位。
可以看到上面 macroTimerFunc
與 microTimerFunc
進行了在不同瀏覽器相容性下的平穩退化,或者說降級策略:
macroTimerFunc
:setImmediate -> MessageChannel -> setTimeout
。首先檢測是否原生支援setImmediate
,這個方法只在 IE、Edge 瀏覽器中原生實現,然後檢測是否支援 MessageChannel,如果對MessageChannel
不瞭解可以參考一下這篇文章,還不支援的話最後使用setTimeout
; 為什麼優先使用setImmediate
與MessageChannel
而不直接使用setTimeout
呢,是因為HTML5規定setTimeout執行的最小延時為4ms,而巢狀的timeout表現為10ms,為了儘可能快的讓回撥執行,沒有最小延時限制的前兩者顯然要優於setTimeout
。microTimerFunc
:Promise.then -> macroTimerFunc
。首先檢查是否支援Promise
,如果支援的話通過Promise.then
來呼叫flushCallbacks
方法,否則退化為macroTimerFunc
; vue2.5之後nextTick
中因為相容性原因刪除了微任務平穩退化的MutationObserver
的方式。
2.2 nextTick實現
最後來看看我們平常用到的 nextTick
方法到底是如何實現的:
// 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
})
}
}
/* 強制使用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
在這裡分為三個部分,我們一起來看一下;
- 首先
nextTick
把傳入的cb
回撥函式用try-catch
包裹後放在一個匿名函式中推入callbacks陣列中,這麼做是因為防止單個cb
如果執行錯誤不至於讓整個JS執行緒掛掉,每個cb
都包裹是防止這些回撥函式如果執行錯誤不會相互影響,比如前一個拋錯了後一個仍然可以執行。 - 然後檢查
pending
狀態,這個跟之前介紹的queueWatcher
中的waiting
是一個意思,它是一個標記位,一開始是false
在進入macroTimerFunc
、microTimerFunc
方法前被置為true
,因此下次呼叫nextTick
就不會進入macroTimerFunc
、microTimerFunc
方法,這兩個方法中會在下一個macro/micro tick
時候flushCallbacks
非同步的去執行callbacks佇列中收集的任務,而flushCallbacks
方法在執行一開始會把pending
置false
,因此下一次呼叫nextTick
時候又能開啟新一輪的macroTimerFunc
、microTimerFunc
,這樣就形成了vue中的event loop
。 - 最後檢查是否傳入了
cb
,因為nextTick
還支援Promise化的呼叫:nextTick().then(() => {})
,所以如果沒有傳入cb
就直接return了一個Promise例項,並且把resolve傳遞給_resolve,這樣後者執行的時候就跳到我們呼叫的時候傳遞進then
的方法中。
Vue原始碼中 next-tick.js
檔案還有一段重要的註釋,這裡就翻譯一下:
在vue2.5之前的版本中,nextTick基本上基於
micro task
來實現的,但是在某些情況下micro task
具有太高的優先順序,並且可能在連續順序事件之間(例如#4521,#6690)或者甚至在同一事件的事件冒泡過程中之間觸發(#6566)。但是如果全部都改成macro task
,對一些有重繪和動畫的場景也會有效能影響,如 issue #6813。vue2.5之後版本提供的解決辦法是預設使用micro task
,但在需要時(例如在v-on附加的事件處理程式中)強制使用macro task
。
為什麼預設優先使用 micro task
呢,是利用其高優先順序的特性,保證佇列中的微任務在一次迴圈全部執行完畢。
強制 macro task
的方法是在繫結 DOM 事件的時候,預設會給回撥的 handler 函式呼叫 withMacroTask
方法做一層包裝 handler = withMacroTask(handler)
,它保證整個回撥函式執行過程中,遇到資料狀態的改變,這些改變都會被推到 macro task
中。以上實現在 src/platforms/web/runtime/modules/events.js 的 add
方法中,可以自己看一看具體程式碼。
剛好在寫這篇文章的時候思否上有人問了個問題 vue 2.4 和2.5 版本的@input事件不一樣 ,這個問題的原因也是因為2.5之前版本的DOM事件採用 micro task
,而之後採用 macro task
,解決的途徑參考 < Vue.js 升級踩坑小記> 中介紹的幾個辦法,這裡就提供一個在mounted鉤子中用 addEventListener
新增原生事件的方法來實現,參見 CodePen。
3. 一個例子
說這麼多,不如來個例子,執行參見 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>
複製程式碼
執行以下看看結果:
同步方式:SHERlocked93
setter前:SHERlocked93
setter後:name改嘍
Promise方式:name改嘍
setTimeout方式:name改嘍
複製程式碼
為什麼是這樣的結果呢,解釋一下:
- 同步方式: 當把data中的name修改之後,此時會觸發name的
setter
中的dep.notify
通知依賴本data的render watcher去update
,update
會把flushSchedulerQueue
函式傳遞給nextTick
,render watcher在flushSchedulerQueue
函式執行時watcher.run
再走diff -> patch
那一套重渲染re-render
檢視,這個過程中會重新依賴收集,這個過程是非同步的;所以當我們直接修改了name之後列印,這時非同步的改動還沒有被patch
到檢視上,所以獲取檢視上的DOM元素還是原來的內容。 - setter前: setter前為什麼還列印原來的是原來內容呢,是因為
nextTick
在被呼叫的時候把回撥挨個push進callbacks陣列,之後執行的時候也是for
迴圈出來挨個執行,所以是類似於佇列這樣一個概念,先入先出;在修改name之後,觸發把render watcher填入schedulerQueue
佇列並把他的執行函式flushSchedulerQueue
傳遞給nextTick
,此時callbacks佇列中已經有了setter前函式
了,因為這個cb
是在setter前函式
之後被push進callbacks佇列的,那麼先入先出的執行callbacks中回撥的時候先執行setter前函式
,這時並未執行render watcher的watcher.run
,所以列印DOM元素仍然是原來的內容。 - setter後: setter後這時已經執行完
flushSchedulerQueue
,這時render watcher已經把改動patch
到檢視上,所以此時獲取DOM是改過之後的內容。 - Promise方式: 相當於
Promise.then
的方式執行這個函式,此時DOM已經更改。 - setTimeout方式: 最後執行macro task的任務,此時DOM已經更改。
注意,在執行 setter前函式
這個非同步任務之前,同步的程式碼已經執行完畢,非同步的任務都還未執行,所有的 $nextTick
函式也執行完畢,所有回撥都被push進了callbacks佇列中等待執行,所以在setter前函式
執行的時候,此時callbacks佇列是這樣的:[setter前函式
,flushSchedulerQueue
,setter後函式
,Promise方式函式
],它是一個micro task佇列,執行完畢之後執行macro task setTimeout
,所以列印出上面的結果。
另外,如果瀏覽器的巨集任務佇列裡面有setImmediate
、MessageChannel
、setTimeout/setInterval
各種型別的任務,那麼會按照上面的順序挨個按照新增進event loop中的順序執行,所以如果瀏覽器支援MessageChannel
, nextTick
執行的是 macroTimerFunc
,那麼如果 macrotask queue 中同時有 nextTick
新增的任務和使用者自己新增的 setTimeout
型別的任務,會優先執行 nextTick
中的任務,因為MessageChannel
的優先順序比 setTimeout
的高,setImmediate
同理。
本文是系列文章,隨後會更新後面的部分,共同進步~
網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~
參考:
PS:歡迎大家關注我的公眾號【前端下午茶】,一起加油吧~