从 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 不是深拷贝

此次踩坑之旅到此结束!

做者 丁香园前端团队 玉洁

相关文章
相关标签/搜索