Vue.set 的副作用
Vue雖然用挺久了,還是會踩到坑,來看下面這段很簡單的:點擊a和b按鈕,下面代碼會提示什麼?
<html><head><metacharset="utf-8"><scriptsrc="https://cdn.staticfile.org/vue/2.5.17/vue.min.js"></script></head><body><divid="app"><p>{{ JSON.stringify(this.testObj) }}</p><button@click="set('a')">設置testObj屬性a</button><button@click="set('b')">設置testObj屬性b</button></div><script>newVue({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。
我們把代碼做一個很小的改動:把Vue.set的值由對象改為true。這時候點擊a和b按鈕,下面代碼會提示什麼?
<html><head><metacharset="utf-8"><scriptsrc="https://cdn.staticfile.org/vue/2.5.17/vue.min.js"></script></head><body><divid="app"><p>{{ JSON.stringify(this.testObj) }}</p><button@click="set('a')">設置testObj屬性a</button><button@click="set('b')">設置testObj屬性b</button></div><script>newVue({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.*/functiondefineReactive(obj,key,val,customSetter,shallow){vardep=newDep();varproperty=Object.getOwnPropertyDescriptor(obj,key);if(property&&property.configurable===false){return}// cater for pre-defined getter/settersvargetter=property&&property.get;if(!getter&&arguments.length===2){val=obj[key];}varsetter=property&&property.set;varchildOb=!shallow&&observe(val);Object.defineProperty(obj,key,{enumerable:true,configurable:true,get:functionreactiveGetter(){varvalue=getter?getter.call(obj):val;if(Dep.target){dep.depend();if(childOb){childOb.dep.depend();if(Array.isArray(value)){dependArray(value);}}}returnvalue},set:functionreactiveSetter(newVal){varvalue=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.*/functionset(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);returnval}if(keyintarget&&!(keyinObject.prototype)){target[key]=val;returnval}varob=(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.');returnval}if(!ob){target[key]=val;returnval}defineReactive(ob.value,key,val);ob.dep.notify();returnval}
我們關心的主要就最後這兩句: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=functionrun(){if(this.active){varvalue=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 valuevaroldValue=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(keyintarget&&!(keyinObject.prototype)){target[key]=val;returnval}
這個判斷會判斷對象屬性是否已經存在,如果存在的話只是做一個賦值操作。不會走到下面的defineReactive(ob.value, key, val); 和ob.dep.notify();裡,這樣watcher沒收到notify,就不會觸發cb回調了。那第二次點擊按鈕的回調是哪裡觸發的呢?還記得剛才的defineReactive裡定義的setter嗎?因為testObj已經成為了響應式,所以進行屬性賦值操作會觸發這個屬性的setter,在set函數最後有個dep.notify();就是它通知了watcher從而觸發cb回調。
就算是這樣第二次點擊不是應該a和b都觸發的嗎?依賴列表不是一直包含有a和b的watcher嗎?
這裡就要涉及到另一個概念“依賴收集”,不同於__ 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設置對象屬性。