細(xì)聊Vue.set 的副作用
Vue雖然用挺久了,還是會(huì)踩到坑,來(lái)看下面這段很簡(jiǎn)單的:點(diǎn)擊a和b按鈕,下面代碼會(huì)提示什么?
- <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')">設(shè)置testObj屬性a</button>
 - <button @click="set('b')">設(shè)置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>
 
答案是:
點(diǎn)a的時(shí)候alert a,點(diǎn)b的時(shí)候alert a,接著alert b。
如果再接著點(diǎn)a,點(diǎn)b,提示什么?
答案是:
點(diǎn)a的時(shí)候alert a,點(diǎn)b的時(shí)候alert b。
我們把代碼做一個(gè)很小的改動(dòng):把Vue.set的值由對(duì)象改為true。這時(shí)候點(diǎn)擊a和b按鈕,下面代碼會(huì)提示什么?
- <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')">設(shè)置testObj屬性a</button>
 - <button @click="set('b')">設(shè)置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>
 
答案是:
點(diǎn)a的時(shí)候alert a,點(diǎn)b的時(shí)候alert b。
如果再接著點(diǎn)a,點(diǎn)b,提示什么?
答案是:
沒(méi)有提示。
先總結(jié)一下發(fā)現(xiàn)的現(xiàn)象:用Vue.set為對(duì)象o添加屬性,如果添加的屬性是一個(gè)對(duì)象,那么o的所有屬性會(huì)被觸發(fā)響應(yīng)。
是不是不明白?且請(qǐng)聽(tīng)我講解一下。
要回答上面這些問(wèn)題,我們首先需要理解一下Vue的響應(yīng)式原理。

從Vue官網(wǎng)這幅圖上我們可以看出:當(dāng)我們?cè)L問(wèn)data里某個(gè)數(shù)據(jù)屬性p時(shí),會(huì)通過(guò)getter將這個(gè)屬性對(duì)應(yīng)的Watcher加入該屬性的依賴(lài)列表;當(dāng)我們修改屬性p的值時(shí),通過(guò)setter通知p依賴(lài)的Watcher觸發(fā)相應(yīng)的回調(diào)函數(shù),從而讓虛擬節(jié)點(diǎn)重新渲染。
所以響不響應(yīng)關(guān)鍵是看依賴(lài)列表有沒(méi)有這個(gè)屬性的watcher。
為了把依賴(lài)列表和實(shí)際的數(shù)據(jù)結(jié)構(gòu)聯(lián)系起來(lái),我畫(huà)出了vue響應(yīng)式的主要數(shù)據(jù)結(jié)構(gòu),箭頭表示它們之間的包含關(guān)系:

Vue里的依賴(lài)就是一個(gè)Dep對(duì)象,它內(nèi)部有一個(gè)subs數(shù)組,這個(gè)數(shù)組里每個(gè)元素都是一個(gè)Watcher,分別對(duì)應(yīng)對(duì)象的每個(gè)屬性。Dep對(duì)象里的這個(gè)subs數(shù)組就是依賴(lài)列表。
從圖中我們可以看到這個(gè)Dep對(duì)象來(lái)自于__ob__對(duì)象的dep屬性,這個(gè)__ob__對(duì)象又是怎么來(lái)的呢?這就是我們new Vue對(duì)象時(shí)候Vue初始化做的工作了。Vue初始化最重要的工作就是讓對(duì)象的每個(gè)屬性成為響應(yīng)式,具體則是通過(guò)observe函數(shù)對(duì)每個(gè)屬性調(diào)用下面的defineReactive來(lái)完成的:
- /**
 - * 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();
 - }
 - });
 - }
 
讓一個(gè)對(duì)象成為響應(yīng)式其實(shí)就是給對(duì)象的所有屬性加上getter和setter(defineReactive做的工作),然后在對(duì)象里加__ob__屬性(observe做的工作),因?yàn)開(kāi)_ob__里包含了對(duì)象的依賴(lài)列表,所以這個(gè)對(duì)象就可以響應(yīng)數(shù)據(jù)變化。
可以看到defineReactive里也調(diào)用了observe,所以讓一個(gè)對(duì)象成為響應(yīng)式這個(gè)動(dòng)作是遞歸的。即如果這個(gè)對(duì)象的屬性又是一個(gè)對(duì)象,那么屬性對(duì)象也會(huì)成為響應(yīng)式。就是說(shuō)這個(gè)屬性對(duì)象也會(huì)加__ob__然后所有屬性加上getter和setter。
剛才說(shuō)有沒(méi)有響應(yīng)看“依賴(lài)列表有沒(méi)有這個(gè)屬性的watcher”,但是實(shí)際上,ob 只存在屬性所在的對(duì)象上,所以依賴(lài)列表是在對(duì)象上的依賴(lài)列表,通過(guò)依賴(lài)列表里Watcher的expression關(guān)聯(lián)到對(duì)應(yīng)屬性(見(jiàn)圖2)。所以準(zhǔn)確的說(shuō):有沒(méi)有響應(yīng)應(yīng)該是看“對(duì)象的依賴(lài)列表里有沒(méi)有屬性的watcher”。
注意我們?cè)赿ata里只定義了testObj空對(duì)象,testObj并沒(méi)有任何屬性,所以testObj的依賴(lài)列表一開(kāi)始是空的。
但是因?yàn)榇a有定義Vue對(duì)象的watch,初始化代碼會(huì)對(duì)每個(gè)watch屬性新建watcher,并添加到testObj的依賴(lài)隊(duì)列__ob__.dep.subs里。這里的添加方法非常巧妙:新建watcher時(shí)候會(huì)一層層訪問(wèn)watch的屬性。比如watch 'testObj.a',vue會(huì)先訪問(wèn)testObj,再訪問(wèn)testObj.a。因?yàn)閠estObj已經(jīng)初始化成響應(yīng)式的,訪問(wèn)testObj時(shí)會(huì)調(diào)用defineReactive里定義的getter,getter又會(huì)調(diào)用dep.depend()從而把testObj.a對(duì)應(yīng)的watcher加到依賴(lài)隊(duì)列__ob__.dep.subs里。于是新建watcher的同時(shí)完成了把watcher自動(dòng)添加到對(duì)應(yīng)對(duì)象的依賴(lài)列表這個(gè)動(dòng)作。
小結(jié)一下:Vue對(duì)象初始化時(shí)會(huì)給data里對(duì)象的所有屬性加上getter和setter,添加__ob__屬性,并把watch屬性對(duì)應(yīng)的watcher放到__ob__.dep.subs依賴(lài)列表里。
所以經(jīng)過(guò)初始化,testObj的依賴(lài)列表里已經(jīng)有了屬性a和b對(duì)應(yīng)的watcher。
有了以上基礎(chǔ)知識(shí)我們?cè)賮?lái)看Vue.set也就是下面的set函數(shù)做了些什么。
- /**
 - * 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
 - }
 
我們關(guān)心的主要就最后這兩句:defineReactive(ob.value, key, val); 和ob.dep.notify();。
defineReactive的作用就是讓一個(gè)對(duì)象屬性成為響應(yīng)式。ob.dep.notify()則是通知對(duì)象依賴(lài)列表里面所有的watcher:數(shù)據(jù)變化了,看看你是不是要做點(diǎn)啥?具體做什么就是圖2 Watcher里面的cb。當(dāng)我們?cè)趘ue 里面寫(xiě)了 watch: { p: function(oldValue, newValue) {} } 時(shí)候我們就是為p的watcher添加了cb。
所以Vue.set實(shí)際上就做了這兩件事:
- 把屬性變成響應(yīng)式 。
 - 通知對(duì)象依賴(lài)列表里所有watcher數(shù)據(jù)發(fā)生變化。
 
那么問(wèn)題來(lái)了,既然依賴(lài)列表一直包含a和b的watcher,那應(yīng)該每次Vue.set時(shí)候,a和b的cb都應(yīng)該被調(diào)用,為什么結(jié)果不是這樣呢?奧妙就藏在下面的watcher的run函數(shù)里。
- /**
 - * 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會(huì)執(zhí)行run函數(shù),這個(gè)函數(shù)才是真正調(diào)用cb的地方。我們可以看到有這樣一個(gè)判斷 if (value !==this.value || isObject(value) || this.deep) 就是說(shuō)值不相等或者值是對(duì)象或者是深度watch的時(shí)候,都會(huì)觸發(fā)cb回調(diào)。所以當(dāng)我們用Vue.set給對(duì)象添加新的對(duì)象屬性的時(shí)候,依賴(lài)列表里的每個(gè)watcher都會(huì)通過(guò)這個(gè)判斷(新添加屬性因?yàn)閧} !== {} 所以value !==this.value成立,已有屬性因?yàn)閕sObject(value)),都會(huì)觸發(fā)cb回調(diào)。而當(dāng)我們Vue.set給對(duì)象添加新的非對(duì)象屬性的時(shí)候,只有新添加的屬性通過(guò)value !==this.value 判斷會(huì)觸發(fā)cb,其他屬性因?yàn)橹禌](méi)變所以不會(huì)觸發(fā)cb回調(diào)。這就解釋了為什么第一次點(diǎn)擊按鈕b的時(shí)候場(chǎng)景一和場(chǎng)景二的效果不一樣了。
那既然依賴(lài)列表沒(méi)變?yōu)槭裁吹诙吸c(diǎn)擊按鈕效果就不一樣了呢?
這就是set函數(shù)里面這個(gè)判斷起的作用了:
- if (key in target && !(key in Object.prototype)) {
 - target[key] = val;
 - return val
 - }
 
這個(gè)判斷會(huì)判斷對(duì)象屬性是否已經(jīng)存在,如果存在的話(huà)只是做一個(gè)賦值操作。不會(huì)走到下面的defineReactive(ob.value, key, val); 和ob.dep.notify();里,這樣watcher沒(méi)收到notify,就不會(huì)觸發(fā)cb回調(diào)了。那第二次點(diǎn)擊按鈕的回調(diào)是哪里觸發(fā)的呢?還記得剛才的defineReactive里定義的setter嗎?因?yàn)閠estObj已經(jīng)成為了響應(yīng)式,所以進(jìn)行屬性賦值操作會(huì)觸發(fā)這個(gè)屬性的setter,在set函數(shù)最后有個(gè)dep.notify();就是它通知了watcher從而觸發(fā)cb回調(diào)。
就算是這樣第二次點(diǎn)擊不是應(yīng)該a和b都觸發(fā)的嗎?依賴(lài)列表不是一直包含有a和b的watcher嗎?
這里就要涉及到另一個(gè)概念“依賴(lài)收集”,不同于__ob__.dep.subs這個(gè)依賴(lài)列表,響應(yīng)式對(duì)象還有一個(gè)依賴(lài)列表,就是defineReactive里面定義的var dep,每個(gè)屬性都有一個(gè)dep,以閉包形式出現(xiàn),我暫且稱(chēng)它為內(nèi)部依賴(lài)列表。在前面的set函數(shù)判斷里,判斷通過(guò)會(huì)執(zhí)行target[key]= val; 這句賦值語(yǔ)句會(huì)首先觸發(fā)getter,把屬性key對(duì)應(yīng)的watcher添加到內(nèi)部依賴(lài)列表,這個(gè)步驟就是Vue官網(wǎng)那張圖里的“collect as dependencies”;然后觸發(fā)setter,調(diào)用dep.notify()通知watcher執(zhí)行watcher.run。因?yàn)檫@時(shí)候內(nèi)部依賴(lài)列表只有一個(gè)watcher也就是屬性對(duì)應(yīng)的watcher。所以只觸發(fā)了屬性本身的回調(diào)。
根據(jù)以上分析我們還原一下兩個(gè)場(chǎng)景:
場(chǎng)景1:Vue.set 一個(gè)對(duì)象屬性
- 點(diǎn)擊按鈕a: Vue.set把屬性a變成響應(yīng)式,通知依賴(lài)列表數(shù)據(jù)變化,依賴(lài)列表中watcher-a發(fā)現(xiàn)數(shù)據(jù)變化,執(zhí)行a的回調(diào)。
 - 點(diǎn)擊按鈕b: Vue.set把屬性b變成響應(yīng)式,通知依賴(lài)列表數(shù)據(jù)變化,依賴(lài)列表中watcher-a發(fā)現(xiàn)a是對(duì)象,watcher-b發(fā)現(xiàn)數(shù)據(jù)變化,均滿(mǎn)足觸發(fā)cb條件,于是執(zhí)行a和b的回調(diào)。
 - 再點(diǎn)擊按鈕a: Vue.set給a屬性賦值,觸發(fā)getter收集依賴(lài),內(nèi)部依賴(lài)列表收集到依賴(lài)watcher-a,觸發(fā)setter通知內(nèi)部依賴(lài)列表數(shù)據(jù)變化,watcher-a發(fā)現(xiàn)數(shù)據(jù)變化,執(zhí)行a的回調(diào)。
 - 再點(diǎn)擊按鈕b: Vue.set給b屬性賦值,觸發(fā)getter收集依賴(lài),內(nèi)部依賴(lài)列表收集到依賴(lài)watcher-b,觸發(fā)setter通知內(nèi)部依賴(lài)列表數(shù)據(jù)變化,watcher-b發(fā)現(xiàn)數(shù)據(jù)變化,執(zhí)行b的回調(diào)。
 
場(chǎng)景2:Vue.set 一個(gè)非對(duì)象屬性
- 點(diǎn)擊按鈕a: Vue.set把屬性a變成響應(yīng)式,通知依賴(lài)列表數(shù)據(jù)變化,依賴(lài)列表中watcher-a發(fā)現(xiàn)數(shù)據(jù)變化,執(zhí)行a的回調(diào)。
 - 點(diǎn)擊按鈕b: Vue.set把屬性b變成響應(yīng)式,通知依賴(lài)列表數(shù)據(jù)變化,watcher-b發(fā)現(xiàn)數(shù)據(jù)變化,執(zhí)行b的回調(diào)。
 - 再點(diǎn)擊按鈕a: Vue.set給a屬性賦值,觸發(fā)getter收集依賴(lài),內(nèi)部依賴(lài)列表收集到依賴(lài)watcher-a,觸發(fā)setter,發(fā)現(xiàn)數(shù)據(jù)沒(méi)變化,返回。
 - 再點(diǎn)擊按鈕b: Vue.set給b屬性賦值,觸發(fā)getter收集依賴(lài),內(nèi)部依賴(lài)列表收集到依賴(lài)watcher-b,觸發(fā)setter,發(fā)現(xiàn)數(shù)據(jù)沒(méi)變化,返回。
 
原因總結(jié):
- Vue響應(yīng)式對(duì)象有內(nèi)部、外部?jī)蓚€(gè)依賴(lài)列表。
 - Vue.set有添加屬性、修改屬性?xún)煞N功能。
 - Watcher在判斷是否需要觸發(fā)回調(diào)時(shí)有對(duì)象屬性、非對(duì)象屬性的區(qū)別。
 
結(jié)論:
- 用Vue.set添加對(duì)象屬性,對(duì)象的所有屬性都會(huì)觸發(fā)一次響應(yīng)。
 - 用Vue.set修改對(duì)象屬性,只有當(dāng)前修改的屬性會(huì)觸發(fā)一次響應(yīng)。
 
我個(gè)人覺(jué)得Vue.set這種添加和修改不一致的表現(xiàn)是vue的一個(gè)缺陷。還沒(méi)看Vue 3.0代碼,看過(guò)的朋友可以告訴我下,是不是也有這樣的問(wèn)題?
規(guī)避方法:
添加一個(gè)對(duì)象屬性會(huì)讓所有屬性觸發(fā)響應(yīng)這個(gè)特性應(yīng)該不是我們想要的效果。目前沒(méi)想到好的解決方法,只能在data里定義對(duì)象時(shí)先把對(duì)象的屬性全寫(xiě)上。避免使用Vue.set設(shè)置對(duì)象屬性。















 
 
 







 
 
 
 