vue已經是目前國內前端web端三分天下之一,同時也做爲本人主要技術棧之一,在平常使用中知其然也好奇着因此然,另外最近的社區涌現了一大票vue源碼閱讀類的文章,在下借這個機會從你們的文章和討論中汲取了一些養分,同時對一些閱讀源碼時的想法進行總結,出產一些文章,做爲本身思考的輸出,本人水平有限,歡迎留言討論~javascript
目標Vue版本:2.5.17-beta.0
html
vue源碼註釋:github.com/SHERlocked9…前端
聲明:文章中源碼的語法都使用 Flow,而且源碼根據須要都有刪節(爲了避免被迷糊 @_@),若是要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~vue
感興趣的同窗能夠加文末的微信羣,一塊兒討論吧~java
上一篇文章咱們在依賴收集原理的響應式化方法 defineReactive
中的 setter
訪問器中有派發更新 dep.notify()
方法,這個方法會挨個通知在 dep
的 subs
中收集的訂閱本身變更的watchers執行update。一塊兒來看看 update
方法的實現:git
// 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
:github
// 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
的時候也只會更新最後一次修改。web
這裏的 waiting
方法是用來標記 flushSchedulerQueue
是否已經傳遞給 nextTick
的標記位,若是已經傳遞則只push到隊列中不傳遞 flushSchedulerQueue
給 nextTick
,等到 resetSchedulerState
重置調度者狀態的時候 waiting
會被置回 false
容許 flushSchedulerQueue
被傳遞給下一個tick的回調,總之保證了 flushSchedulerQueue
回調在一個tick內只容許被傳入一次。來看看被傳遞給 nextTick
的回調 flushSchedulerQueue
作了什麼:segmentfault
// 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從小到大排了個序,這樣作能夠保證:數組
在挨個執行隊列中的for循環中,index < queue.length
這裏沒有將length進行緩存,由於在執行處理現有watcher對象期間,更多的watcher對象可能會被push進queue。
那麼數據的修改從model層反映到view的過程:數據更改 -> setter -> Dep -> Watcher -> nextTick -> patch -> 更新視圖
這裏就來看看包含着每一個watcher執行的方法被做爲回調傳入 nextTick
以後,nextTick
對這個方法作了什麼。不過首先要了解一下瀏覽器中的 EventLoop
、macro task
、micro task
幾個概念,不瞭解能夠參考一下 JS與Node.js中的事件循環 這篇文章,這裏就用一張圖來代表一下後二者在主線程中的執行關係:
解釋一下,當主線程執行完同步任務後:
瀏覽器環境中常見的異步任務種類,按照優先級:
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
的方式。最後來看看咱們日常用到的 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。
說這麼多,不如來個例子,執行參見 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改嘍
複製代碼
爲何是這樣的結果呢,解釋一下:
setter
中的 dep.notify
通知依賴本data的render watcher去 update
,update
會把 flushSchedulerQueue
函數傳遞給 nextTick
,render watcher在 flushSchedulerQueue
函數運行時 watcher.run
再走 diff -> patch
那一套重渲染 re-render
視圖,這個過程當中會從新依賴收集,這個過程是異步的;因此當咱們直接修改了name以後打印,這時異步的改動尚未被 patch
到視圖上,因此獲取視圖上的DOM元素仍是原來的內容。nextTick
在被調用的時候把回調挨個push進callbacks數組,以後執行的時候也是 for
循環出來挨個執行,因此是相似於隊列這樣一個概念,先入先出;在修改name以後,觸發把render watcher填入 schedulerQueue
隊列並把他的執行函數 flushSchedulerQueue
傳遞給 nextTick
,此時callbacks隊列中已經有了 setter前函數
了,由於這個 cb
是在 setter前函數
以後被push進callbacks隊列的,那麼先入先出的執行callbacks中回調的時候先執行 setter前函數
,這時並未執行render watcher的 watcher.run
,因此打印DOM元素仍然是原來的內容。flushSchedulerQueue
,這時render watcher已經把改動 patch
到視圖上,因此此時獲取DOM是改過以後的內容。Promise.then
的方式執行這個函數,此時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:歡迎你們關注個人公衆號【前端下午茶】,一塊兒加油吧~
另外能夠加入「前端下午茶交流羣」微信羣,長按識別下面二維碼便可加我好友,備註加羣,我拉你入羣~