從 lodash.merge 不能觸發 Vue 自動更新說開去

話很少說,直接來看:javascript

需求

接口請求一份 json 對象在頁面中顯示。Vue 的相關邏輯就是:html

  • state 中初始化 jsonData 爲 {}
  • 請求成功以後在 mutation 中更新 state 的 jsonData
  • 頁面從新渲染

坑的誕生

這個項目的基本架構是經過組內定製過的Vue 腳手架生成的,看代碼的時候發現同事在 mutaion 中用了一個叫deep-assign 的庫去變動 state,而後我去翻了一下 github,發現這個庫的做者說新版有問題但再也不維護了,並推薦用 lodash.merge。當時想着「這個我熟啊,那就用 lodash.merge 吧」(too youny too simple!)前端

因而我愉快的開始了 coding。精簡以後的重點代碼以下:vue

<!--app.vue-->
<template>            
    <div>
        {{ jsonData }}
    </div>
</template>
<script> import { mapState } from 'vuex' export default { computed: { ...mapState({ jsonData: state => state.jsonData }) } } </script>
複製代碼
// state.js
export default {
  isLoading: false,
  // 須要告訴你們的是,這裏我初始化爲 {},若是初始化爲 null,在 lodash.merge 的時候就不會有問題,這裏的原理等你們看完本文,或去看了 lodash.merge 的完整源碼就能理解
  jsonData: {}
}

// mutation.js
export default {
    [Types.M_CONTENTS_DETAIL_PACKAGE__SUCCESS]: (state, payload) => {
        // 使用 lodash/merge 更新state
        merge(state, {
            isLoading: false,
            jsonData: payload.jsonData
        });
    }
}
複製代碼

按照上面需求中的邏輯,預想的結果是 merge 以後,組件的 jsonData 數據更新,頁面從新渲染。可是我經過 vue-devtool 發現組件的 jsonData 數據確實已經變動爲最新的數據,可是頁面卻沒有從新渲染。爲何呢?🤔️java

而後我抱着試試看的心理用 Object.assign 替代了 lodash.mergereact

Object.assign(state, {
    isLoading: false,
    jsonData: payload.jsonData
});
複製代碼

🙀頁面居然正常渲染了! 這是爲何呢?因而我有了兩個疑問:git

  • Vue 的響應式變動 view 的原理究竟是怎樣的?
  • Object.assign 和 lodash.merge 又有什麼區別?

Vue 的響應式原理

首先,我去仔細看了一下 Vue 的文檔,總結出 3 個重點:github

  • 在初始化時,使用 Object.defineProperty 把這些屬性所有轉爲 getter/setter
  • 當依賴項的 setter 被調用時
  • 通知 watcher 從新計算

而後簡單翻了一下 Vue 的源碼:vuex

/** * Observer/index.js * 遍歷每個類型爲對象的屬性,將其轉化爲 getter/setter **/
 walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }

 export function defineReactive ( obj: Object, key: string, val: any, ... ) {
  ...
   // 使用 Object.defineProperty 把這些屬性所有轉爲 getter/setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      ...
      return value
    },
    set: function reactiveSetter (newVal) {
      /** * 這一段 if 是我測試加的 * 當 jsonData 的 set 函數被觸發,打出相關信息 **/
      if (key === 'jsonData') {
        console.log('set value of ' + key, newVal);
      }

      const value = getter ? getter.call(obj) : val
      ...
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      
      // 通知 watcher 更新 view
      dep.notify()
    } 
  })
}
複製代碼

從源碼中咱們能清晰的看到,在頁面初始化時,Vue 會遍歷全部類型爲對象的屬性,將其轉換爲 getter/setter ,而並在屬性 set 時 通知觀察者更新 view。那麼我這裏的組件數據已經變動,view 卻沒有更新,到底這 3 個環節的哪裏出現問題呢?json

經過測試,第一環節初始化爲 getter/setter 是正常的。而後我在 Vue 源碼中的 set 函數裏試着打印出 jsonData 的信息(如上面代碼中的註釋),判斷在 jsonData 更新時 set 函數有沒有被觸發。結果發如今使用 lodash.merge 時並無被觸發 jsonData 的 set 函數,而使用 Object.assign 時觸發了,也就是第二個環節「當依賴項的 setter 被調用時」有問題~ 🤔 ️那麼問題來了~

Object.assgin 和 lodash 的 merge 有什麼區別?

Object.assign

文檔中描述,

Object.assign() 方法用於將全部可枚舉屬性的值從一個或多個源對象複製到目標對象。它將返回目標對象。

嗯,我以前記得的也就是這句話。繼續往下看,

該方法使用源對象的[[Get]]和目標對象的[[Set]],因此它會調用相關 getter 和 setter

Hmmm,文檔說了,會調用目標對象的 set !繼續往下看到 Polyfill(一段代碼,用於在原本不支持它的舊瀏覽器上提供該功能,可勉強將其看爲源碼),咱們看到,Object.assign() 會遍歷源對象的可枚舉屬性,而後將其直接賦值給目標對象,這時,就會觸發目標對象的 set。

由此咱們也能夠看到,Object.assign 並非深拷貝。

Object.defineProperty(Object, "assign", {
    value: function assign(target, varArgs) { 
 'use strict';
       ...
            if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
              to[nextKey] = nextSource[nextKey];
            }
          }
        }
      }
      return to;
    },
    writable: true,
    configurable: true
  });
複製代碼

lodash.merge

看完 Object.assign(), 咱們繼續研究 lodash.merge。看 lodash.merge 的 文檔說,merge 函數是將源對象的自身可枚舉屬性遞歸地合到目標對象中。這裏咱們看到,比 Object.assign() 的文檔多了「遞歸地」三個字。爲了弄清 merge 是怎麼遞歸合併的,我翻看了 lodash 的源碼,其中的重點源碼及對應解釋以下( lodash.merge 的源碼不斷使用不一樣文件裏的函數,下面代碼會比較多,請仔細看註釋):

/** * merge 函數裏調用 baseMerge 函數去處理對象 * baseMerge 在 baseMergeDeep 裏被不斷遞歸調用,此時的object已再也不是目標對象,而是目標對象的某個屬性,該屬性爲對象類型 **/
function baseMerge(object, source, srcIndex, customizer, stack) {
  ...
  baseFor(source, (srcValue, key) => {
    // 遞歸深拷貝值爲對象的屬性,直到屬性值爲非對象,走 else 直接賦值
    if (isObject(srcValue)) {
      ...
      baseMergeDeep(object, source, key, srcIndex, baseMerge, customizer, stack)
    } else ...
  }, keysIn)
}

function baseMergeDeep(object, source, key, srcIndex, mergeFunc, customizer, stack) {
  /** * 重點:在執行第一次 baseMergeDeep 時,object[key] 對應我代碼中 state.jsonData,是一個空對象 * 而這裏是將 object[key] 直接賦值給 objValue * 因此 objValue 與 object[key] 的引用地址相同 **/
  const objValue = object[key]
  ...
  let newValue = customizer
    ? customizer(objValue, srcValue, `${key}`, object, source, stack)
    : undefined

  let isCommon = newValue === undefined

  if (isCommon) {
    ...
    // 判斷源對象的某個屬性值是對象,那麼將 objValue 賦值給 newValue
    else if (isPlainObject(srcValue) || isArguments(srcValue)) {
       /** * 重點:從上面第一次 baseMergeDeep 時給 objValue 賦值的時候咱們知道,objValue 與 state.jsonData 引用相同地址 * 這裏再次將 objValue 賦值給 newValue, 那麼 newValue 與 state.jsonData 也引用相同地址 * 這意味着後面對 newValue 進行的全部屬性合併操做,都將致使 state.jsonData 的屬性已經被改變 **/
      newValue = objValue
      ...
  }
  if (isCommon) {
    ...
    /** * 這裏會使用 baseMerge 函數去判斷更深層次的子屬性是不是對象 * 若是是對象,再進行相同的 baseMergeDeep 處理 **/
    mergeFunc(newValue, srcValue, srcIndex, customizer, stack)
    ...
  }
  /** * 最終 newValue 合併變動爲擁有最新的 jsonData 對象全部屬性的對象 * 此時第一次的 object[key],也就是我代碼中的 state.jsonData 已然隨着 newValue 的變化一塊兒變化了 * 因此執行 assignMergeValue 的時候判斷的 !eq(object[key], value) 是 false,再也不執行 baseAssignValue * 經過斷點測試,確實在最後合併 jsonData 時,沒有執行 baseAssignValue **/
  assignMergeValue(object, key, newValue)
}

/** * assignMergeValue.js * 給目標對象的屬性賦值 **/
function assignMergeValue(object, key, value) {
    /** * 這裏有個重點判斷 —— !eq(object[key], value) * 用 eq 函數去判斷目標對象的屬性 key 的值是否是和咱們即將要賦的值相等 **/
  if ((value !== undefined && !eq(object[key], value)) || (value === undefined && !(key in object))) {
    baseAssignValue(object, key, value);
  }
}

 function baseAssignValue(object, key, value) {
  ...
    /** * 給 object 的 key 屬性賦值爲 value * 若是最終處理完 state.jsonData 的全部深層次屬性對象合併,去合併 state 的 jsonData 屬性時,走到這一步 * 那麼就會觸發 Vue 爲 jsonData 初始化的 set 修飾符,就會觸發下一步-通知 wachter 更新 view * 可是 lodash.merge 在處理完 state.jsonData 的子屬性對象的合併時,已經將 state.jsonData 變動爲最新的數據了 * 因此,沒有觸發 jsonData 的 set 修飾符 **/
    object[key] = value;
  ...
}
複製代碼

這裏簡單畫了一下lodash.merge在處理深層次對象合併的流程圖幫助理解:

總結

至此,咱們弄清了到底爲何 lodash.merge 合併處理 Vue 的數據,沒有觸發頁面更新。簡單總結幾個注意點:

  • 在 Vue 中必定要初始化全部須要的數據,由於只有初始化了,Vue 才能監聽並響應式變動 view
  • lodash.merge 合併處理對象時不會觸發最外層對象的 set
  • lodash.merge 是深拷貝
  • Object.assign 不是深拷貝

此次踩坑之旅到此結束!

做者 丁香園前端團隊 玉潔

相關文章
相關標籤/搜索