Vue 組件中 data 為什么必須是函數


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 方法的實現:

  1.  
    // src/core/observer/watcher.js
  2.  
     
  3.  
    /* Subscriber接口,當依賴發生改變的時候進行回調 */
  4.  
    update() {
  5.  
    if ( this.computed) {
  6.  
    // 一個computed watcher有兩種模式:activated lazy(默認)
  7.  
    // 只有當它被至少一個訂閱者依賴時才置activated,這通常是另一個計算屬性或組件的render function
  8.  
    if ( this.dep.subs.length === 0) { // 如果沒人訂閱這個計算屬性的變化
  9.  
    // lazy時,我們希望它只在必要時執行計算,所以我們只是簡單地將觀察者標記為dirty
  10.  
    // 當計算屬性被訪問時,實際的計算在this.evaluate()中執行
  11.  
    this.dirty = true
  12.  
    } else {
  13.  
    // activated模式下,我們希望主動執行計算,但只有當值確實發生變化時才通知我們的訂閱者
  14.  
    this.getAndInvoke(() => {
  15.  
    this.dep.notify() // 通知渲染watcher重新渲染,通知依賴自己的所有watcher執行update
  16.  
    })
  17.  
    }
  18.  
    } else if ( this.sync) { // 同步
  19.  
    this.run()
  20.  
    } else {
  21.  
    queueWatcher( this) // 異步推送到調度者觀察者隊列中,下一個tick時調用
  22.  
    }
  23.  
    }

如果不是 computed watcher 也非 sync 會把調用update的當前watcher推送到調度者隊列中,下一個tick時調用,看看 queueWatcher :

  1.  
    // src/core/observer/scheduler.js
  2.  
     
  3.  
    /* 將一個觀察者對象push進觀察者隊列,在隊列中已經存在相同的id則
  4.  
    * 該watcher將被跳過,除非它是在隊列正被flush時推送
  5.  
    */
  6.  
    export function queueWatcher (watcher: Watcher) {
  7.  
    const id = watcher.id
  8.  
    if (has[id] == null) { // 檢驗id是否存在,已經存在則直接跳過,不存在則標記哈希表has,用於下次檢驗
  9.  
    has[id] = true
  10.  
    queue.push(watcher) // 如果沒有正在flush,直接push到隊列中
  11.  
    if (!waiting) { // 標記是否已傳給nextTick
  12.  
    waiting = true
  13.  
    nextTick(flushSchedulerQueue)
  14.  
    }
  15.  
    }
  16.  
    }
  17.  
     
  18.  
    /* 重置調度者狀態 */
  19.  
    function resetSchedulerState () {
  20.  
    queue.length = 0
  21.  
    has = {}
  22.  
    waiting = false
  23.  
    }

這里使用了一個 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 做了什么:

  1.  
    // src/core/observer/scheduler.js
  2.  
     
  3.  
    /* nextTick的回調函數,在下一個tick時flush掉兩個隊列同時運行watchers */
  4.  
    function flushSchedulerQueue () {
  5.  
    flushing = true
  6.  
    let watcher, id
  7.  
     
  8.  
    queue.sort( (a, b) => a.id - b.id) // 排序
  9.  
     
  10.  
    for (index = 0; index < queue.length; index++) { // 不要將length進行緩存
  11.  
    watcher = queue[index]
  12.  
    if (watcher.before) { // 如果watcher有before則執行
  13.  
    watcher.before()
  14.  
    }
  15.  
    id = watcher.id
  16.  
    has[id] = null // 將has的標記刪除
  17.  
    watcher.run() // 執行watcher
  18.  
    if (process.env.NODE_ENV !== 'production' && has[id] != null) { // 在dev環境下檢查是否進入死循環
  19.  
    circular[id] = (circular[id] || 0) + 1 // 比如user watcher訂閱自己的情況
  20.  
    if (circular[id] > MAX_UPDATE_COUNT) { // 持續執行了一百次watch代表可能存在死循環
  21.  
    warn() // 進入死循環的警告
  22.  
    break
  23.  
    }
  24.  
    }
  25.  
    }
  26.  
    resetSchedulerState() // 重置調度者狀態
  27.  
    callActivatedHooks() // 使子組件狀態都置成active同時調用activated鈎子
  28.  
    callUpdatedHooks() // 調用updated鈎子
  29.  
    }

在 nextTick 方法中執行 flushSchedulerQueue 方法,這個方法挨個執行 queue 中的watcher的 run 方法。我們看到在首先有個 queue.sort() 方法把隊列中的watcher按id從小到大排了個序,這樣做可以保證:

  1. 組件更新的順序是從父組件到子組件的順序,因為父組件總是比子組件先創建。
  2. 一個組件的user watchers(偵聽器watcher)比render watcher先運行,因為user watchers往往比render watcher更早創建
  3. 如果一個組件在父組件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 對這個方法做了什么。不過首先要了解一下瀏覽器中的 EventLoopmacro taskmicro task幾個概念,不了解可以參考一下 JS與Node.js中的事件循環 這篇文章,這里就用一張圖來表明一下后兩者在主線程中的執行關系:

clipboard.png

解釋一下,當主線程執行完同步任務后:

  1. 引擎首先從macrotask queue中取出第一個任務,執行完畢后,將microtask queue中的所有任務取出,按順序全部執行;
  2. 然后再從macrotask queue中取下一個,執行完畢后,再次將microtask queue中的全部取出;
  3. 循環往復,直到兩個queue中的任務都取完。

瀏覽器環境中常見的異步任務種類,按照優先級:

  • macro task :同步代碼、setImmediateMessageChannelsetTimeout/setInterval
  • micro taskPromise.thenMutationObserver

有的文章把 micro task 叫微任務,macro task 叫宏任務,因為這兩個單詞拼寫太像了 -。- ,所以后面的注釋多用中文表示~

先來看看源碼中對 micro task 與 macro task 的實現: macroTimerFuncmicroTimerFunc

  1.  
    // src/core/util/next-tick.js
  2.  
     
  3.  
    const callbacks = [] // 存放異步執行的回調
  4.  
    let pending = false // 一個標記位,如果已經有timerFunc被推送到任務隊列中去則不需要重復推送
  5.  
     
  6.  
    /* 挨個同步執行callbacks中回調 */
  7.  
    function flushCallbacks() {
  8.  
    pending = false
  9.  
    const copies = callbacks.slice( 0)
  10.  
    callbacks.length = 0
  11.  
    for ( let i = 0; i < copies.length; i++) {
  12.  
    copies[i]()
  13.  
    }
  14.  
    }
  15.  
     
  16.  
    let microTimerFunc // 微任務執行方法
  17.  
    let macroTimerFunc // 宏任務執行方法
  18.  
    let useMacroTask = false // 是否強制為宏任務,默認使用微任務
  19.  
     
  20.  
    // 宏任務
  21.  
    if ( typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  22.  
    macroTimerFunc = () => {
  23.  
    setImmediate(flushCallbacks)
  24.  
    }
  25.  
    } else if ( typeof MessageChannel !== 'undefined' && (
  26.  
    isNative(MessageChannel) ||
  27.  
    MessageChannel.toString() === '[object MessageChannelConstructor]' // PhantomJS
  28.  
    )) {
  29.  
    const channel = new MessageChannel()
  30.  
    const port = channel.port2
  31.  
    channel.port1.onmessage = flushCallbacks
  32.  
    macroTimerFunc = () => {
  33.  
    port.postMessage( 1)
  34.  
    }
  35.  
    } else {
  36.  
    macroTimerFunc = () => {
  37.  
    setTimeout(flushCallbacks, 0)
  38.  
    }
  39.  
    }
  40.  
     
  41.  
    // 微任務
  42.  
    if ( typeof Promise !== 'undefined' && isNative( Promise)) {
  43.  
    const p = Promise.resolve()
  44.  
    microTimerFunc = () => {
  45.  
    p.then(flushCallbacks)
  46.  
    }
  47.  
    } else {
  48.  
    microTimerFunc = macroTimerFunc // fallback to macro
  49.  
    }

flushCallbacks 這個方法就是挨個同步的去執行callbacks中的回調函數們,callbacks中的回調函數是在調用 nextTick 的時候添加進去的;那么怎么去使用 micro task 與 macro task 去執行 flushCallbacks 呢,這里他們的實現 macroTimerFuncmicroTimerFunc 使用瀏覽器中宏任務/微任務的API對flushCallbacks 方法進行了一層包裝。比如宏任務方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) },這樣在觸發宏任務執行的時候 macroTimerFunc() 就可以在瀏覽器中的下一個宏任務loop的時候消費這些保存在callbacks數組中的回調了,微任務同理。同時也可以看出傳給 nextTick 的異步回調函數是被壓成了一個同步任務在一個tick執行完的,而不是開啟多個異步任務。

注意這里有個比較難理解的地方,第一次調用 nextTick 的時候 pending 為false,此時已經push到瀏覽器event loop中一個宏任務或微任務的task,如果在沒有flush掉的情況下繼續往callbacks里面添加,那么在執行這個占位queue的時候會執行之后添加的回調,所以 macroTimerFuncmicroTimerFunc 相當於task queue的占位,以后 pending 為true則繼續往占位queue里面添加,event loop輪到這個task queue的時候將一並執行。執行 flushCallbacks 時 pending 置false,允許下一輪執行 nextTick 時往event loop占位。

可以看到上面 macroTimerFunc 與 microTimerFunc 進行了在不同瀏覽器兼容性下的平穩退化,或者說降級策略

  1. macroTimerFunc :setImmediate -> MessageChannel -> setTimeout 。首先檢測是否原生支持 setImmediate ,這個方法只在 IE、Edge 瀏覽器中原生實現,然后檢測是否支持 MessageChannel,如果對 MessageChannel 不了解可以參考一下這篇文章,還不支持的話最后使用 setTimeout 
    為什么優先使用 setImmediate 與 MessageChannel 而不直接使用 setTimeout 呢,是因為HTML5規定setTimeout執行的最小延時為4ms,而嵌套的timeout表現為10ms,為了盡可能快的讓回調執行,沒有最小延時限制的前兩者顯然要優於 setTimeout
  2. microTimerFuncPromise.then -> macroTimerFunc 。首先檢查是否支持 Promise,如果支持的話通過 Promise.then 來調用 flushCallbacks 方法,否則退化為 macroTimerFunc ;
    vue2.5之后 nextTick 中因為兼容性原因刪除了微任務平穩退化的 MutationObserver 的方式。

2.2 nextTick實現

最后來看看我們平常用到的 nextTick 方法到底是如何實現的:

  1.  
    // src/core/util/next-tick.js
  2.  
     
  3.  
    export function nextTick(cb?: Function, ctx?: Object) {
  4.  
    let _resolve
  5.  
    callbacks.push( () => {
  6.  
    if (cb) {
  7.  
    try {
  8.  
    cb.call(ctx)
  9.  
    } catch (e) {
  10.  
    handleError(e, ctx, 'nextTick')
  11.  
    }
  12.  
    } else if (_resolve) {
  13.  
    _resolve(ctx)
  14.  
    }
  15.  
    })
  16.  
    if (!pending) {
  17.  
    pending = true
  18.  
    if (useMacroTask) {
  19.  
    macroTimerFunc()
  20.  
    } else {
  21.  
    microTimerFunc()
  22.  
    }
  23.  
    }
  24.  
    if (!cb && typeof Promise !== 'undefined') {
  25.  
    return new Promise( resolve => {
  26.  
    _resolve = resolve
  27.  
    })
  28.  
    }
  29.  
    }
  30.  
     
  31.  
    /* 強制使用macrotask的方法 */
  32.  
    export function withMacroTask(fn: Function): Function {
  33.  
    return fn._withTask || (fn._withTask = function() {
  34.  
    useMacroTask = true
  35.  
    const res = fn.apply( null, arguments)
  36.  
    useMacroTask = false
  37.  
    return res
  38.  
    })
  39.  
    }

nextTick 在這里分為三個部分,我們一起來看一下;

  1. 首先 nextTick 把傳入的 cb 回調函數用 try-catch 包裹后放在一個匿名函數中推入callbacks數組中,這么做是因為防止單個 cb 如果執行錯誤不至於讓整個JS線程掛掉,每個 cb 都包裹是防止這些回調函數如果執行錯誤不會相互影響,比如前一個拋錯了后一個仍然可以執行。
  2. 然后檢查 pending 狀態,這個跟之前介紹的 queueWatcher 中的 waiting 是一個意思,它是一個標記位,一開始是 false 在進入 macroTimerFuncmicroTimerFunc 方法前被置為 true,因此下次調用 nextTick 就不會進入 macroTimerFuncmicroTimerFunc 方法,這兩個方法中會在下一個 macro/micro tick 時候 flushCallbacks 異步的去執行callbacks隊列中收集的任務,而 flushCallbacks 方法在執行一開始會把 pending 置 false,因此下一次調用 nextTick 時候又能開啟新一輪的 macroTimerFuncmicroTimerFunc,這樣就形成了vue中的 event loop
  3. 最后檢查是否傳入了 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點擊預覽

  1.  
    <div id="app">
  2.  
    <span id='name' ref='name'>{{ name }} </span>
  3.  
    <button @click='change'>change name </button>
  4.  
    <div id='content'> </div>
  5.  
    </div>
  6.  
    <script>
  7.  
    new Vue({
  8.  
    el: '#app',
  9.  
    data() {
  10.  
    return {
  11.  
    name: 'SHERlocked93'
  12.  
    }
  13.  
    },
  14.  
    methods: {
  15.  
    change() {
  16.  
    const $name = this.$refs.name
  17.  
    this.$nextTick( () => console.log( 'setter前:' + $name.innerHTML))
  18.  
    this.name = ' name改嘍 '
  19.  
    console.log( '同步方式:' + this.$refs.name.innerHTML)
  20.  
    setTimeout( () => this.console( "setTimeout方式:" + this.$refs.name.innerHTML))
  21.  
    this.$nextTick( () => console.log( 'setter后:' + $name.innerHTML))
  22.  
    this.$nextTick().then( () => console.log( 'Promise方式:' + $name.innerHTML))
  23.  
    }
  24.  
    }
  25.  
    })
  26.  
    </script>

執行以下看看結果:

  1.  
    同步方式:SHERlocked93
  2.  
    setter前:SHERlocked93
  3.  
    setter后: name改嘍
  4.  
    Promise方式: name改嘍
  5.  
    setTimeout方式: name改嘍

為什么是這樣的結果呢,解釋一下:

  1. 同步方式: 當把data中的name修改之后,此時會觸發name的 setter 中的 dep.notify 通知依賴本data的render watcher去 updateupdate 會把 flushSchedulerQueue 函數傳遞給 nextTick,render watcher在 flushSchedulerQueue 函數運行時 watcher.run 再走 diff -> patch 那一套重渲染 re-render 視圖,這個過程中會重新依賴收集,這個過程是異步的;所以當我們直接修改了name之后打印,這時異步的改動還沒有被 patch 到視圖上,所以獲取視圖上的DOM元素還是原來的內容。
  2. 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元素仍然是原來的內容。
  3. setter后: setter后這時已經執行完 flushSchedulerQueue,這時render watcher已經把改動 patch 到視圖上,所以此時獲取DOM是改過之后的內容。
  4. Promise方式: 相當於 Promise.then 的方式執行這個函數,此時DOM已經更改。
  5. setTimeout方式: 最后執行macro task的任務,此時DOM已經更改。

注意,在執行 setter前函數 這個異步任務之前,同步的代碼已經執行完畢,異步的任務都還未執行,所有的 $nextTick 函數也執行完畢,所有回調都被push進了callbacks隊列中等待執行,所以在setter前函數 執行的時候,此時callbacks隊列是這樣的:[setter前函數flushSchedulerQueuesetter后函數Promise方式函數],它是一個micro task隊列,執行完畢之后執行macro task setTimeout,所以打印出上面的結果。

另外,如果瀏覽器的宏任務隊列里面有setImmediateMessageChannelsetTimeout/setInterval 各種類型的任務,那么會按照上面的順序挨個按照添加進event loop中的順序執行,所以如果瀏覽器支持MessageChannel, nextTick 執行的是 macroTimerFunc,那么如果 macrotask queue 中同時有 nextTick 添加的任務和用戶自己添加的 setTimeout 類型的任務,會優先執行 nextTick 中的任務,因為MessageChannel 的優先級比 setTimeout的高,setImmediate 同理。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM