话不多说,直接来看:
需求
接口请求一份 json 对象在页面中显示。Vue 的相关逻辑就是:
- state 中初始化 jsonData 为
{}
- 请求成功之后在 mutation 中更新 state 的 jsonData
- 页面重新渲染
坑的诞生
这个项目的基本架构是通过组内定制过的Vue
脚手架生成的,看代码的时候发现同事在 mutaion 中用了一个叫deep-assign
的库去变更 state,然后我去翻了一下 github,发现这个库的作者说新版有问题但不再维护了,并推荐用 lodash.merge。当时想着“这个我熟啊,那就用 lodash.merge 吧”(too youny too simple!)
于是我愉快的开始了 coding。精简之后的重点代码如下:
<!--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 数据确实已经变更为最新的数据,但是页面却没有重新渲染。为什么呢??️
然后我抱着试试看的心理用 Object.assign 替代了 lodash.merge
Object.assign(state, {
isLoading: false,
jsonData: payload.jsonData
});
复制代码
?页面竟然正常渲染了! 这是为什么呢?于是我有了两个疑问:
- Vue 的响应式变更 view 的原理到底是怎样的?
- Object.assign 和 lodash.merge 又有什么区别?
Vue 的响应式原理
首先,我去仔细看了一下 Vue 的文档,总结出 3 个重点:
- 在初始化时,使用 Object.defineProperty 把这些属性全部转为 getter/setter
- 当依赖项的 setter 被调用时
- 通知 watcher 重新计算
然后简单翻了一下 Vue 的源码:
/**
* 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 个环节的哪里出现问题呢?
通过测试,第一环节初始化为 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 不是深拷贝
这次踩坑之旅到此结束!
作者 丁香园前端团队 玉洁