对 JavaScript Object 的一些思考

淮城一只猫 编程技术
阅读量 0 评论量 0

前言

前几天在做项目的时候遇到一个关于数组的问题,今天抽空来看看这个问题,因为之前也遇到,但是不长记性所有有了该笔记。之前在项目给后端提交一条数据,这个数据是数组类型,但需要改变数组里面的字段又不能改变原来的数组结构,所有我直接赋值新的变量再 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会比较其内部引用,当且仅当他们的引用指向内存中的相同对象(区域)时才相等,即他们在栈内存中的引用地址相同。

上述声明的 obj1obj2 在声明初始化的时候,在内存中开辟俩个新的地址值,所以在比较的时候,其实是比较俩个地址值是否相等,所以最终会输出 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

但在我们认知里,应该是

  • NaNNaN 是相等的;
  • [1][1] 是相等的;
  • {value: 1}{value: 1} 是相等的;
  • 1new 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.isEqualUnderscore.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 的属性被忽略,当然还有包含 undefinedSymbol 属性也会被忽略。

/**
 * 深度拷贝
 * @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"}}

从上面输出的结果和想象中显然不一样,根据上面数据类型描述对于基本数据类型,不存在深拷贝的问题,因为它们是值类型的数据。值类型的数据存放在栈内存中,重新赋值就是独立的。而对于众多的引用数据类型,需要分别进行处理,集中处理 ObjectArray,这也是在业务中遇到最多的情况:

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 方法即可。

总结

喵~