前言
前几天在做项目的时候遇到一个关于数组的问题,今天抽空来看看这个问题,因为之前也遇到,但是不长记性所有有了该笔记。之前在项目给后端提交一条数据,这个数据是数组类型,但需要改变数组里面的字段又不能改变原来的数组结构,所有我直接赋值新的变量再 Map
遍历修改的时候发现原来的数组结构已经发生改变了。虽然知道其中的原因,所以决定写下笔记,告诫下次不能再犯了。
思考
先看一段简单的例子:
const arr1 = [1, 2, 3];
const arr2 = arr1;
arr2.push(4);
console.log(arr1); // => [1, 2, 3, 4]
console.log(arr2); // => [1, 2, 3, 4]
如果不仔细思考的话,arr1
的结果令人“惊讶”的,学过后端语言的朋友相信都知道这个答案,很显然在 JavaScript
中,数组它是引用传递,所有的对象(Array 也是 对象)也是引用传递。所以意味着在使用等号赋值的时候,俩者引用内存同一个地址值,无论其中一个怎么操作,另一个反馈是同样的结果。
如果换个方式来理解这个引用传递:
const obj1 = {a: 'test'};
const obj2 = {a: 'test'};
console.log(obj1 === obj2); // => false
在 MDN
文档对此解释:
当两个操作数都是对象时,JavaScript会比较其内部引用,当且仅当他们的引用指向内存中的相同对象(区域)时才相等,即他们在栈内存中的引用地址相同。
上述声明的 obj1
和 obj2
在声明初始化的时候,在内存中开辟俩个新的地址值,所以在比较的时候,其实是比较俩个地址值是否相等,所以最终会输出 false
结果:
变量名 | 地址值 | 对象 |
---|---|---|
obj1 | #001 | {a: 'test'} |
obj2 | #002 | {a: 'test'} |
比较对象
字符串比较
其实这个就是将对象使用 JSON.stringify
转换成静态字符串,然后再比较:
// 使用上例变量
const str1 = JSON.stringify(obj1);
const str2 = JSON.stringify(obj2);
console.log(str1 === str2); // => true
虽然这个是最简单方法,但是使用限制也很大,如果对象里面的键值是乱序的,那么这个对比是没啥意义的,例如:
const obj3 = {
test1: 'test1',
test2: 'test2'
};
const obj4 = {
test2: 'test2',
test1: 'test1'
};
console.log(JSON.stringify(obj3) === JSON.stringify(obj4)); // => false
俩个对象是一样的,但返回结果却是 false
,所以这个对比看情况使用,那么需要做个方法,不受到键值顺序影响对比。
使用对象属性遍历对比
把对象的 key
提取组成数组,然后对应对象中的 value
是否相等:
const isObjectEqual = (obj1, obj2) => {
// 获取对象属性名数组
const getAProperty1 = Object.getOwnPropertyNames(obj1);
const getAProperty2 = Object.getOwnPropertyNames(obj2);
// 如果获取的属性名数组长度不一样,这说明俩者对象内容不一样
if (getAProperty1.length !== getAProperty2.length) {
return false;
}
// 然后遍历里面的属性值是否相等
for (let i = 0; i < getAProperty1.lenth; i++) {
if (obj1[i] !== obj2[i]) {
return false;
}
}
return true;
}
console.log(isObjectEqual(obj3, obj4)); // => true
所以上面那个顺序问题对比解决了,这边主要用到 Object.getOwnPropertyNames 方法,不过这个也注定只能提取第一层次的 key
,如果对象的属性值包含一个对象,那么这个方法依然不通过,并且还有诸多原因:
例如可能有这样的情况:
const obj5 = {
test1: 'test1',
test2: 'test2'
};
const obj6 = {
test2: 'test2',
test1: NaN
}
console.log(isObjectEqual(obj5, obj6)); // => true
const obj7 = {
test1: 'test1',
test2: 'test2',
undefined: null
};
const obj8 = {
test2: 'test2',
test1: 'test1',
test3: ''
}
console.log(isObjectEqual(obj7, obj8)); // => true
const obj9 = {
test1: 'test1',
test2: 'test2',
test3: {
a: 'hello'
}
};
const obj10 = {
test2: 'test2',
test1: 'test1',
test3: {
b: 'world'
}
}
console.log(isObjectEqual(obj9, obj10)); // => true
一下子排出三个特例情况,明明不一样的对象,全都返回 true
,那么在更复杂的业务情况下,上面的方法不合适了。
通用函数
所以想要验证俩个对象是否相等是一件不容易的事情,因为 JavaScript
原因导致他们的数据类型验证很奇怪,这边就不细说,具体可以阅读下这篇文章:JavaScript 中的相等性判断 ,如果要去验证俩者是否相等需要做到如下要求:
console.log(NaN === NaN); // => false
console.log([1] === [1]); // => false
console.log({value: 1} === {value: 1}); // => false
console.log(1 === new Number(1)); // => false
console.log('hello' === new String('hello')); // => false
console.log(true === new Boolean(true)); // => false
但在我们认知里,应该是
NaN
和NaN
是相等的;[1]
和[1]
是相等的;{value: 1}
和{value: 1}
是相等的;1
和new Number(1)
是相等的;- ...
所以要针对上面的现象需要做很多判断,下面的写的例子可以参考下:
/**
* 对比俩条数据
* @param {*} obj1
* @param {*} obj2
*/
const isEqualObject = (obj1, obj2) => {
// 声明 obj1 对象类型
const type = Object.prototype.toString.call(obj1);
// 首先先判断这俩者对象的类型
// 使用 Object.prototype.toString.call 方法获取数据类型,如果俩者类型不一样,则数据不一样
if (type !== Object.prototype.toString.call(obj2)) return false;
// 到这里俩者对象类型确定下来了,所以继续往下面判断
// 现在判断 type 是对象还是数组,使用 Array.typeOf() 判断
if (['[object Object]', '[object Array]'].indexOf(type) < 0) return false;
// 无论是对象还是数组,获取他们的长度
// 如果是数组直接获取数组长度的方法,否则使用 Object.keys() 获取对象键值长度
// 注意:Object.keys() 支持 IE9 以上的现代浏览器,如果需要向后兼容使用 Polyfill 实现
const obj1Length = type === '[object Object]' ? obj1.length : Object.keys(obj1).length;
const obj2Length = type === '[object Object]' ? obj2.length : Object.keys(obj2).length;
// 如果长度不一样,返回 false
if (obj1Length !== obj2Length) return false;
// 到目前为止,基本检查通过了,也确定长度是一样的,下面进行对象的属性值
// 由于对象和数组遍历方法不一样,需要判断下类型
// 对比属性值都一样,直接复用一个函数来比较
/**
* 属性对比
* @param {*} item1 obj1 属性值
* @param {*} item2 obj2 属性值
*/
const compare = (item1, item2) => {
// 获取 item1 的数据类型
const itemType = Object.prototype.toString.call(item1);
// 如果是对象或者数组的话,递归 isEqualObject() 方法继续判断
if (['[object Object]', '[object Array]'].indexOf(itemType) >= 0) {
if (!isEqualObject(item1, item2)) return false;
} else {
// 否则是其他类型的数据,直接对比数据类型
if (itemType !== Object.prototype.toString.call(item2)) return false;
// 如果对比的属性值是函数,需要转换字符串类型再进行判断
// 判断是否是函数
if (itemType === '[object Function]') {
// 转换字符串对比
if (item1.toString() !== item2.toString()) return false;
} else {
// 其它数据类型对比
if (item1 !== item2) return false;
}
}
}
// 对比 obj1 和 obj2 的属性值
if (type === '[object Object]') {
for (const key in obj1) {
if (obj1.hasOwnProperty(key)) {
// 传人属性值
if (compare(obj1[key], obj2[key]) === false) return false
}
}
} else {
for (let i = 0; i < obj1Length; i++) {
// 传人属性值
if (compare(obj1[i], obj2[i]) === false) return false;
}
}
// 测试通过
return true;
}
然后来测试下效果:
const obj11 = {
test1: 'test1',
test2: 'test2',
test3: {
a: 'hello'
}
};
const obj12 = {
test2: 'test2',
test1: 'test1',
test3: {
a: 'hello'
}
}
console.log(isEqualObject(obj9, obj10)); // => false
console.log(isEqualObject(obj11, obj12)); // => true
到此为止,相信上面封装的函数能够解决大部分问题了。
但建议还是使用 Lodash.isEqual 和 Underscore.isEqual 来解决复杂业务的情景吧。
拷贝对象
上面说的复制过程是 浅拷贝
,如果想实现新的内存地址的对象,那么这就是 深拷贝
过程。
浅拷贝
被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。浅拷贝仅仅复制原有的对象,而不复制它所引用的对象,如果修改了副本
的值,那么原来的对象也会被修改。
深拷贝
如果需要深拷贝的可以试试这样的方法:
const arr3 = [1, 2, 3];
const arr4 = JSON.parse(JSON.stringify(arr3));
arr4.push(4);
console.log(arr3); // [1, 2, 3]
console.log(arr4); // [1, 2, 3, 4]
JSON.stringify 将 arr3 数组进行反序列化成字符串,然后再序列化字符串成新的对象,所以在 arr4 实现新的地址值,和 arr3 没关系了。
但这个方法也有很大的缺陷,例如:
const arr3 = [
{
'test': function() {
alert('xxx')
}
},
4,
3
];
const arr4 = JSON.parse(JSON.stringify(arr3));
console.log(arr4); // [ {}, 4, 3 ]
会发现 Function
的属性被忽略,当然还有包含 undefined
和 Symbol
属性也会被忽略。
/**
* 深度拷贝
* @param {Object} source
*/
const deepClone = (source) => {
// 判断数据类型,不是对象类型退出
if (!source && typeof source !== 'object') {
throw new Error('error arguments')
}
// 判断是否是数组对象,对应不同的容器
const targetObj = source.constructor === Array ? [] : {}
Object.keys(source).forEach(keys => {
if (source[keys] && typeof source[keys] === 'object') {
// 如果属性是对象继续递归
targetObj[keys] = deepClone(source[keys])
} else {
targetObj[keys] = source[keys]
}
})
return targetObj
}
const arr5 = deepClone(arr3);
console.log(arr5); // [ { test: [Function: test] }, 4, 3 ]
上面封装的函数可以应付大部分需求了。
对象合并
基于浅比较实现的对象的合并,可以使用 JavaScript Object
中的 assign
方法:
let newObj = Object.assign(obj1, obj2)
但如果像这样的对象:
const obj1 = {
test: {
test1: 'app'
}
}
const obj2 = {
test: {
test2: 'app2'
}
}
const obj3 = Object.assign(obj1, obj2)
console.log(obj3) // => {"test":{"test2":"app2"}}
从上面输出的结果和想象中显然不一样,根据上面数据类型描述对于基本数据类型,不存在深拷贝的问题,因为它们是值类型的数据。值类型的数据存放在栈内存中,重新赋值就是独立的。而对于众多的引用数据类型,需要分别进行处理,集中处理 Object
和 Array
,这也是在业务中遇到最多的情况:
const deepCloneTypes = ['Object', 'Array', 'Map', 'Set']
function isObject(source) {
const type = typeof source
return source !== null && (type === 'object' || type === 'function')
}
function getType(source) {
return (Object.prototype.toString.call(source)).split(' ')[1].slice(0, -1)
}
function processOtherType(source) {
const Ctor = source.constructor
return new Ctor(source)
}
function processFunctionType(source) {
const _source = source.toString()
// 区分是否是箭头函数
if (source.prototype) {
// 如果有prototype就是普通函数
const argsReg = /function\s*\w*\(([^\)]*)\)/
const bodyReg = /\{([\s\S]*)\}/
const fnArgs = (argsReg.exec(source))[1]
const fnBody = (bodyReg.exec(source))[1]
console.log(fnArgs, fnBody)
return new Function(fnArgs, fnBody)
} else {
// 箭头函数没有prototype
return eval(_source)
}
}
function deepClone(source, map = new WeakMap()) {
// 首先用typeof来筛选是否是引用数据类型,如果连引用数据类型都不是,那么就判断是基本数据类型,直接返回即可
if (!isObject(source)) {
return source
}
const type = getType(source)
let cloneTarget
// 防止循环引用
if (map.get(source)) {
return map.get(source)
}
map.set(source, cloneTarget)
// 接下来判断是否是需要进行循环拷贝的引用数据类型,诸如new Boolean, new Number这样的,也不需要循环拷贝
if (!deepCloneTypes.includes(type)) {
cloneTarget = processOtherType(source)
} else {
cloneTarget = new source.constructor()
// return Object.create(source.constructor.prototype) //不能这样,这样是创造了一个对象
if (type === 'Object' || type === 'Array') {
const keys = type === 'Object' ? Object.keys(source) : undefined; // 如果支持optional chaining的话可以写成?.
(keys || source).forEach((val, key) => {
if (keys) {
key = val
}
cloneTarget[key] = deepClone(source[key], map) // 在这里进行递归调用
})
}
if (type === 'Function') {
cloneTarget = processFunctionType(source)
}
if (type === 'Map') {
source.forEach((val, key) => {
cloneTarget.set(key, deepClone(val, map))
})
}
if (type === 'Set') {
source.forEach((val, key) => {
cloneTarget.add(deepClone(val, map))
})
}
}
return cloneTarget
}
上面写简单的例子,不过还存在一些问题,以及安全问题,所以推荐大家使用 lodash
中的 merge 方法即可。