1. 前言
在學習vue
的時候,一直納悶一件事:組件的data數據為什么必須要以函數返回的形式,為什么不是簡單的對象形式呢?遂帶着問題去翻官方文檔,文檔中自然也寫明了這么做的原因,本篇博文以官方文檔給出的原因為基礎,並加上具體的例子,來闡述這么設計的原因。
2.正文
組件是可復用的vue
實例,一個組件被創建好之后,就可能被用在各個地方,而組件不管被復用了多少次,組件中的data
數據都應該是相互隔離,互不影響的,基於這一理念,組件每復用一次,data
數據就應該被復制一次,之后,當某一處復用的地方組件內data
數據被改變時,其他復用地方組件的data
數據不受影響,如下面這個例子:
<template>
<div class="title">
<h1>按鈕被點擊了{{ count }}次</h1>
<button v-on:click="count++">點擊</button>
</div>
</template>
<script> export default { name: 'BtnCount', data () { return { count: 0 } } } </script>
<style scoped> .title { background-color: red } </style>
該組件被復用了三次,但每個復用的地方組件內的count
數據相互不受影響,它們各自維護各自內部的count
。
能有這樣效果正是因為上述例子中的data
不是一個單純的對象,而是一個函數返回值的形式,所以每個組件實例可以維護一份被返回對象的獨立拷貝,如果我們將上述例子中的data
修改為:
data : {
count: 0
}
那么就會造成無論在哪個組件里改變了count
值,都會影響到其他兩個組件里的count
。
這是因為當data
如此定義后,這就表示所有的組件實例共用了一份data
數據,因此,無論在哪個組件實例中修改了data
,都會影響到所有的組件實例。
3.總結
組件中的data
寫成一個函數,數據以函數返回值形式定義,這樣每復用一次組件,就會返回一份新的data
,類似於給每個組件實例創建一個私有的數據空間,讓各個組件實例維護各自的數據。而單純的寫成對象形式,就使得所有組件實例共用了一份data
,就會造成一個變了全都會變的結果。
目標Vue版本:2.5.17-beta.0
vue源碼注釋:https://github.com/SHERlocked93/vue-analysis
聲明:文章中源碼的語法都使用 Flow,並且源碼根據需要都有刪節(為了不被迷糊 @_@),如果要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~
1. 異步更新
上一篇文章我們在依賴收集原理的響應式化方法 defineReactive
中的 setter
訪問器中有派發更新 dep.notify()
方法,這個方法會挨個通知在 dep
的 subs
中收集的訂閱自己變動的watchers執行update。一起來看看 update
方法的實現:
如果不是 computed watcher
也非 sync
會把調用update的當前watcher推送到調度者隊列中,下一個tick時調用,看看 queueWatcher
:
這里使用了一個 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
做了什么:
在 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
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
方法到底是如何實現的:
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點擊預覽
執行以下看看結果:
為什么是這樣的結果呢,解釋一下:
- 同步方式: 當把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
同理。