首頁>技術>

<html><head> <meta charset="utf-8"> <script src="https://cdn.staticfile.org/vue/2.5.17/vue.min.js"></script></head> <body><div id="app"> <p>{{ JSON.stringify(this.testObj) }}</p> <button @click="set('a')">設定testObj屬性a</button> <button @click="set('b')">設定testObj屬性b</button></div><script> new Vue({ el: '#app', data: { testObj: {}, }, watch: { 'testObj.a'() { alert('a') }, 'testObj.b'() { alert('b') }, }, methods: { set(val) { Vue.set(this.testObj, val, {}); } }, })</script></body></html>

答案是:

點a的時候alert a,點b的時候alert a,接著alert b。

如果再接著點a,點b,提示什麼?

答案是:

點a的時候alert a,點b的時候alert b。

<html><head> <meta charset="utf-8"> <script src="https://cdn.staticfile.org/vue/2.5.17/vue.min.js"></script></head> <body><div id="app"> <p>{{ JSON.stringify(this.testObj) }}</p> <button @click="set('a')">設定testObj屬性a</button> <button @click="set('b')">設定testObj屬性b</button></div><script> new Vue({ el: '#app', data: { testObj: {}, }, watch: { 'testObj.a'() { alert('a') }, 'testObj.b'() { alert('b') }, }, methods: { set(val) { Vue.set(this.testObj, val, true); } }, })</script></body></html>

答案是:

點a的時候alert a,點b的時候alert b。

如果再接著點a,點b,提示什麼?

答案是:

沒有提示。

先總結一下發現的現象:用Vue.set為物件o新增屬性,如果新增的屬性是一個物件,那麼o的所有屬性會被觸發響應。

是不是不明白?且請聽我講解一下。

要回答上面這些問題,我們首先需要理解一下Vue的響應式原理。

從Vue官網這幅圖上我們可以看出:當我們訪問data裡某個資料屬性p時,會通過getter將這個屬性對應的Watcher加入該屬性的依賴列表;當我們修改屬性p的值時,通過setter通知p依賴的Watcher觸發相應的回撥函式,從而讓虛擬節點重新渲染。

所以響不響應關鍵是看依賴列表有沒有這個屬性的watcher。

為了把依賴列表和實際的資料結構聯絡起來,我畫出了vue響應式的主要資料結構,箭頭表示它們之間的包含關係:

Vue裡的依賴就是一個Dep物件,它內部有一個subs陣列,這個數組裡每個元素都是一個Watcher,分別對應物件的每個屬性。Dep物件裡的這個subs陣列就是依賴列表。

從圖中我們可以看到這個Dep物件來自於__ob__物件的dep屬性,這個__ob__物件又是怎麼來的呢?這就是我們new Vue物件時候Vue初始化做的工作了。Vue初始化最重要的工作就是讓物件的每個屬性成為響應式,具體則是通過observe函式對每個屬性呼叫下面的defineReactive來完成的:

/** * Define a reactive property on an Object. */function defineReactive ( obj, key, val, customSetter, shallow) { var dep = new Dep(); var property = Object.getOwnPropertyDescriptor(obj, key); if (property && property.configurable === false) { return } // cater for pre-defined getter/setters var getter = property && property.get; if (!getter && arguments.length === 2) { val = obj[key]; } var setter = property && property.set; var childOb = !shallow && observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { var value = getter ? getter.call(obj) : val; if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value }, set: function reactiveSetter (newVal) { var value = getter ? getter.call(obj) : val; /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return } /* eslint-enable no-self-compare */ if (process.env.NODE_ENV !== 'production' && customSetter) { customSetter(); } if (setter) { setter.call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep.notify(); } });}

讓一個物件成為響應式其實就是給物件的所有屬性加上getter和setter(defineReactive做的工作),然後在物件里加__ob__屬性(observe做的工作),因為__ob__裡包含了物件的依賴列表,所以這個物件就可以響應資料變化。

可以看到defineReactive裡也呼叫了observe,所以讓一個物件成為響應式這個動作是遞迴的。即如果這個物件的屬性又是一個物件,那麼屬性物件也會成為響應式。就是說這個屬性物件也會加__ob__然後所有屬性加上getter和setter。

剛才說有沒有響應看“依賴列表有沒有這個屬性的watcher”,但是實際上,ob 只存在屬性所在的物件上,所以依賴列表是在物件上的依賴列表,通過依賴列表裡Watcher的expression關聯到對應屬性(見圖2)。所以準確的說:有沒有響應應該是看“物件的依賴列表裡有沒有屬性的watcher”。

注意我們在data裡只定義了testObj空物件,testObj並沒有任何屬性,所以testObj的依賴列表一開始是空的。

但是因為程式碼有定義Vue物件的watch,初始化程式碼會對每個watch屬性新建watcher,並新增到testObj的依賴佇列__ob__.dep.subs裡。這裡的新增方法非常巧妙:新建watcher時候會一層層訪問watch的屬性。比如watch 'testObj.a',vue會先訪問testObj,再訪問testObj.a。因為testObj已經初始化成響應式的,訪問testObj時會呼叫defineReactive裡定義的getter,getter又會呼叫dep.depend()從而把testObj.a對應的watcher加到依賴佇列__ob__.dep.subs裡。於是新建watcher的同時完成了把watcher自動新增到對應物件的依賴列表這個動作。

小結一下:Vue物件初始化時會給data裡物件的所有屬性加上getter和setter,新增__ob__屬性,並把watch屬性對應的watcher放到__ob__.dep.subs依賴列表裡。

所以經過初始化,testObj的依賴列表裡已經有了屬性a和b對應的watcher。

有了以上基礎知識我們再來看Vue.set也就是下面的set函式做了些什麼。

/** * Set a property on an object. Adds the new property and * triggers change notification if the property doesn't * already exist. */function set (target, key, val) { if (process.env.NODE_ENV !== 'production' && (isUndef(target) || isPrimitive(target)) ) { warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target)))); } if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key); target.splice(key, 1, val); return val } if (key in target && !(key in Object.prototype)) { target[key] = val; return val } var ob = (target).__ob__; if (target._isVue || (ob && ob.vmCount)) { process.env.NODE_ENV !== 'production' && warn( 'Avoid adding reactive properties to a Vue instance or its root $data ' + 'at runtime - declare it upfront in the data option.' ); return val } if (!ob) { target[key] = val; return val } defineReactive(ob.value, key, val); ob.dep.notify(); return val}

我們關心的主要就最後這兩句:defineReactive(ob.value, key, val); 和ob.dep.notify();。

defineReactive的作用就是讓一個物件屬性成為響應式。ob.dep.notify()則是通知物件依賴列表裡面所有的watcher:資料變化了,看看你是不是要做點啥?具體做什麼就是圖2 Watcher裡面的cb。當我們在vue 裡面寫了 watch: { p: function(oldValue, newValue) {} } 時候我們就是為p的watcher添加了cb。

所以Vue.set實際上就做了這兩件事:把屬性變成響應式 。通知物件依賴列表裡所有watcher資料發生變化。

那麼問題來了,既然依賴列表一直包含a和b的watcher,那應該每次Vue.set時候,a和b的cb都應該被呼叫,為什麼結果不是這樣呢?奧妙就藏在下面的watcher的run函式裡。

/** * Scheduler job interface. * Will be called by the scheduler. */Watcher.prototype.run = function run () { if (this.active) { var value = this.get(); if ( value !== this.value || // Deep watchers and watchers on Object/Arrays should fire even // when the value is the same, because the value may // have mutated. isObject(value) || this.deep ) { // set new value var oldValue = this.value; this.value = value; if (this.user) { try { this.cb.call(this.vm, value, oldValue); } catch (e) { handleError(e, this.vm, ("callback for watcher \\"" + (this.expression) + "\\"")); } } else { this.cb.call(this.vm, value, oldValue); } } }};

dep.notify通知watcher後,watcher會執行run函式,這個函式才是真正呼叫cb的地方。我們可以看到有這樣一個判斷 if (value !==this.value || isObject(value) || this.deep) 就是說值不相等或者值是物件或者是深度watch的時候,都會觸發cb回撥。所以當我們用Vue.set給物件新增新的物件屬性的時候,依賴列表裡的每個watcher都會通過這個判斷(新新增屬性因為{} !== {} 所以value !==this.value成立,已有屬性因為isObject(value)),都會觸發cb回撥。而當我們Vue.set給物件新增新的非物件屬性的時候,只有新新增的屬性通過value !==this.value 判斷會觸發cb,其他屬性因為值沒變所以不會觸發cb回撥。這就解釋了為什麼第一次點選按鈕b的時候場景一和場景二的效果不一樣了。

這就是set函式裡面這個判斷起的作用了:

if (key in target && !(key in Object.prototype)) { target[key] = val; return val}

這個判斷會判斷物件屬性是否已經存在,如果存在的話只是做一個賦值操作。不會走到下面的defineReactive(ob.value, key, val); 和ob.dep.notify();裡,這樣watcher沒收到notify,就不會觸發cb回調了。那第二次點選按鈕的回撥是哪裡觸發的呢?還記得剛才的defineReactive裡定義的setter嗎?因為testObj已經成為了響應式,所以進行屬性賦值操作會觸發這個屬性的setter,在set函式最後有個dep.notify();就是它通知了watcher從而觸發cb回撥。

這裡就要涉及到另一個概念“依賴收集”,不同於__ob__.dep.subs這個依賴列表,響應式物件還有一個依賴列表,就是defineReactive裡面定義的var dep,每個屬性都有一個dep,以閉包形式出現,我暫且稱它為內部依賴列表。在前面的set函式判斷裡,判斷通過會執行target[key]= val; 這句賦值語句會首先觸發getter,把屬性key對應的watcher新增到內部依賴列表,這個步驟就是Vue官網那張圖裡的“collect as dependencies”;然後觸發setter,呼叫dep.notify()通知watcher執行watcher.run。因為這時候內部依賴列表只有一個watcher也就是屬性對應的watcher。所以只觸發了屬性本身的回撥。

根據以上分析我們還原一下兩個場景:

場景1:Vue.set 一個物件屬性

點選按鈕a: Vue.set把屬性a變成響應式,通知依賴列表資料變化,依賴列表中watcher-a發現數據變化,執行a的回撥。點選按鈕b: Vue.set把屬性b變成響應式,通知依賴列表資料變化,依賴列表中watcher-a發現a是物件,watcher-b發現數據變化,均滿足觸發cb條件,於是執行a和b的回撥。再點選按鈕a: Vue.set給a屬性賦值,觸發getter收集依賴,內部依賴列表收集到依賴watcher-a,觸發setter通知內部依賴列表資料變化,watcher-a發現數據變化,執行a的回撥。再點選按鈕b: Vue.set給b屬性賦值,觸發getter收集依賴,內部依賴列表收集到依賴watcher-b,觸發setter通知內部依賴列表資料變化,watcher-b發現數據變化,執行b的回撥。

場景2:Vue.set 一個非物件屬性

點選按鈕a: Vue.set把屬性a變成響應式,通知依賴列表資料變化,依賴列表中watcher-a發現數據變化,執行a的回撥。點選按鈕b: Vue.set把屬性b變成響應式,通知依賴列表資料變化,watcher-b發現數據變化,執行b的回撥。再點選按鈕a: Vue.set給a屬性賦值,觸發getter收集依賴,內部依賴列表收集到依賴watcher-a,觸發setter,發現數據沒變化,返回。再點選按鈕b: Vue.set給b屬性賦值,觸發getter收集依賴,內部依賴列表收集到依賴watcher-b,觸發setter,發現數據沒變化,返回。原因總結:

1、Vue響應式物件有內部、外部兩個依賴列表。

2、Vue.set有新增屬性、修改屬性兩種功能。

3、Watcher在判斷是否需要觸發回撥時有物件屬性、非物件屬性的區別。

結論:用Vue.set新增物件屬性,物件的所有屬性都會觸發一次響應。用Vue.set修改物件屬性,只有當前修改的屬性會觸發一次響應。

我個人覺得Vue.set這種新增和修改不一致的表現是vue的一個缺陷。還沒看Vue 3.0程式碼,看過的朋友可以告訴我下,是不是也有這樣的問題?

規避方法:

新增一個物件屬性會讓所有屬性觸發響應這個特性應該不是我們想要的效果。目前沒想到好的解決方法,只能在data裡定義物件時先把物件的屬性全寫上。避免使用Vue.set設定物件屬性。

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • LeaRun快速開發平臺,快速開發.net/java專案