一、为什么我们需要封装数据比较的方法?
1.经典的比较方法
绝对等于运算符
比较两个数据是否相等是开发工作中的一个常见的操作。在JavaScript中我们通常使用比较运算符来进行数据比较,其中具有代表性就是“绝对等于运算符”===
,它可以判断两个数据的值和类型是否都相等,使用绝对等于运算符可以应对日常工作中绝大多数的数据比较场景。
局限性
“绝对等于运算符" 虽然强大但绝非完美,它也有着自己的局限性,例如在以下的一些特殊情况会得到意料之外的结果:
console.log(+0 === -0);//true
console.log(0 === -0);//true
console.log(NaN === NaN);//false
2.现代化的比较方法
Object.is()
ECMAScript6规范新增了Object.is()
方法,它与===
很像,同时还可以解决上面提到的问题。因此Object.is()
可以完美替代===
。
console.log(Object.is(+0 , -0));//false
console.log(Object.is(0 , -0));//false
console.log(Object.is(NaN , NaN));//true
3.对引用类型数据的比较
引用悖论
Object.is()
看起来已经是数据比较的终极解决方案了,但是它无法解决一个巨大问题:引用类型数据的比较。
引用类型数据的比较涉及到一个问题,我将其称之为“引用悖论”。
“引用悖论”就是指,两个引用类型的数据内容完全一样但两者却不相等。出现这种现象是因为两个引用类型数据值相同但引用不同。这种结果是符合JavaScript语法的,但很多时候不符合实际的需求。
// 对象
console.log({} === {}); //false
console.log(Object.is({} , {})); //false
// 数组
console.log([] === []); //false
console.log(Object.is([] , []));//false
// Set
console.log(new Set === new Set);//false
console.log(Object.is(new Set , new Set));//false
// Map
console.log(new Map === new Map);//false
console.log(Object.is(new Map , new Map));//false
// 正则
console.log(/123/ === /123/)//false
console.log(Object.is(/123/ , /123/));//false
// 时间
console.log(new Date === new Date);//false
console.log(Object.is(new Date , new Date));//false
// 字节数组 (ArrayBuffer 、 DataView 、 TypedArray)
const buffer1 = new ArrayBuffer(8);
const buffer2 = new ArrayBuffer(8);
console.log(Object.is(buffer1 , buffer2));//false
console.log(Object.is(new DataView(buffer1) , new DataView(buffer1)));//false
console.log(Object.is(new Int8Array(buffer1) , new Int8Array(buffer1)));//false
// 函数
const func1 = () =>{}
const func2 = () =>{}
console.log(func1 === func2) //false
console.log(Object.is(func1 , func2)) //false
对引用数据类型的分类
上面我介绍了"引用悖论"问题,想要解决这个问题就要实现引用类型数据的值比较。我根据不同的引用类型进行值比较的方式以及它们的特点将其分为了以下几类:
- 正则,正则的需要转换为字符串进行值比较
- 时间,时间需要转换为数字进行值比较
- 集合类型(包括:
Object
、Array
、Map
、Set
),这些集合类型的数据可以存储任意类型的数据,因此会出现深度嵌套的情况,它们在进行值比较时需要通过递归进行“深度比较”。 - 字节数组(包括:
ArrayBuffer
、DataView
、TypedArray
),这些类型类似于一维数组,它们不存在深层钱客通的情况,因此相对比较简单。 - 函数,函数非常特殊,它虽然是一种常见的类型,但在实际工作中我们几乎是不会去比较两个函数是否相同的,因此在封装方法的时候不会考虑函数的值比较。
二、怎样去封装数据比较的方法?
1.基本的封装思路
封装数据比较方法主要按照如下的步骤进行:
第一步,相等比较
检查两个数据是否相等,一般采用使用 ===
或者 Object.is()
进行检查,如果出现如下的两种情况则返回true
,否则继续处理。
- 两个相等的普通数据
- 两个引用相同的引用数据
第二步,类型比较
检查并比较两个数据的数据类型,若出现如下的两种情况则返回false
,否则继续处理。
- 两个数据的类型不同
- 两个数据虽然类型相同,但它们都是普通数据类型
第三步,引用类型值比较
此时两个数据必定为类型相同引用不同的两个引用类型数据,需要对它们进行值比较,因此要根据类型的不同采用不同的比较方式,若值相同则返回true
,反之则返回false
。
2.lodash中的实现
lodash的源码以复杂和晦涩而著称,因此我不会直接介绍源码(其实原来是准备这样做的,后来才发现这难如登天且效果不好)而是会介绍我提炼过后的代码。
2.1 准备工具函数
lodash在实现数据比较的过程中使用了许多的工具函数,主要用来检查数据的类型,我们需要先实现它们。
getTag 方法
getTag
方法可以获取数据被Object.prototype.toString()
方法调用时返回的字符串标签,字符串标签的格式为"[object <类型标识>]"
(后面我就将Object.prototype.toString()
的返回值简称为tag了)。
lodash中主要通过getTag
方法来检查数据的类型。
function getTag(value) {
return Object.prototype.toString.call(value);
}
isObjectLike 方法
isObjectLike
方法用于检查值是否是 类对象。一个值如果不为null
且typeof
的结果是object
,那它就是一个类对象。需要特别注意的是函数不符合前面的这个标准,所以它不是类对象。
isObjectLike
方法的主要用于判断数据是否是引用数据类型。
function isObjectLike(value) {
return typeof value == "object" && value !== null;
}
isTypedArray 方法
isTypedArray
方法用于检查值是否为类型数组。
为什么要专门检查类型数组呢?因为类型数组是一系列类型的统称(包括 Uint8Array
, Int8Array
, Uint16Array
, Int16Array
, Uint32Array
, Int32Array
, Float32Array
, Float64Array
),它们每一个的tag都不同,所以需要有一个方法来统一的识别它们。
function isTypedArray(value) {
return (
isObjectLike(value) &&
/^\[object (?:Uint8|Uint8Clamped|Uint16|Uint32|Int8|Int16|Int32|Float32|Float64)Array]$/.test(
getTag(value)
)
);
}
2.2 相等比较
相等比较的部分非常简单,lodash中是用===
实现的,需要注意的是===
是有局限性的(例如两个值都为NaN
的情况),这些特殊情况会在后面去做处理。
function lodashIsEqual(value, other) {
//第一步 相等比较
if (value === other) {
return true;
}
}
2.3 类型比较
类型比较稍微复杂一些,分为普通类型比较和引用类型比较,注意上面提到的特殊情况再普通类型比较部分就会被处理。
function lodashIsEqual(value, other) {
//第一步 相等比较
if (value === other) {
return true;
}
//第二步 类型比较
// 普通类型比较
if (
value == null ||
other == null ||
(!isObjectLike(value) && !isObjectLike(other))
) {
return value !== value && other !== other;
}
// 引用类型比较
const valueTag = getTag(value);
const otherTag = getTag(other);
const isSameTag = valueTag == otherTag;
if (!isSameTag) return false;
//第三步引用类型值比较
}
2.4 引用类型值比较- 时间和正则
下面就要进行引用类型的值比较了,需要编写各个引用类型的指标方法。首先是时间类型和正则类型的比较方法。
时间比较的原理是通过加运算符将Date
数据转换为时间戳(从 1970 年 1 月 1 日 00:00:00 UTC 到指定日期时间所经过的毫秒数)然后进行比较。
正则比较则是将其中一个正则先转换为字符串,然后再进行比较,这样就可以正确的判断两者是否相等了。因为这样做就将正则与正则的比较转换为了正则与字符串的比较,这种写法很巧妙。
// 时间比较
function equalDates(value, other) {
return +value === +other;
}
// 正则比较
function equalRegExps(value, other) {
return value == other + "";
}
2.5 引用类型值比较- 对象
对象的比较方法就较为简单了,采取了获取对象的键数组 -> 检查键的数量是否相同 -> 检查键是否相同 -> 递归比较值是否相同
的流程进行比较。
// 对象比较
function equalObjects(value, other) {
// 获取对象的键数组
const valueKeys = Object.keys(value),
valueLength = valueKeys.length,
otherKeys = Object.keys(other),
otherLength = otherKeys.length;
// 检查键的数量是否相同
if (valueLength != otherLength) return false;
// 检查键是否相同
let index = valueLength;
while (index--) {
const key = valueKeys[index];
if (!(key in other)) return false;
}
// 递归比较值是否相同
while (index++ < valueLength) {
const key = valueKeys[index];
if (!lodashIsEqual(value[key], other[key])) return false;
}
return true;
}
2.6 引用类型值比较- 数组
数组的比较方法也很简单,也是递归的进行深度比较,这里就不详细阐述了。
// 数组比较
function equalArrays(value, other) {
const valueLength = value.length,
otherLength = other.length;
// 检查数组长度是否相同
if (valueLength != otherLength) return false;
// 递归比较值是否相同
let index = valueLength;
while (index--) {
if (!lodashIsEqual(value[index], other[index])) return false;
}
return true;
}
2.7 引用类型值比较- Map和Set
Map和Set的比较方法的基本思路就是将它们转换为数组进行比较,这样可以重复利用之前写好的方法。值得注意的是,因为Map和Set都是可迭代对象可以很方便的通过解构将它们转换为数组。
// Map比较
function equalMaps(value, other) {
if (value.size != other.size) return false;
return equalArrays([...value], [...other]);
}
// Set比较
function equalSets(value, other) {
return equalMaps(value, other);
}
2.8 引用类型值比较- 字节数组(ArrayBuffer、DataView和TypedArray)
这几种类型在进行值比较是不太方便的,但是其实有一个“突破口”,那就是 TypedArray
。
TypedArray
的特点在于它们具有表示数组长度的length
属性,并且它们也支持方括号表示法(可以使用方括号读取和修改内容),因此TypedArray
完全可以使用数组的比较方法。而 ArrayBuffer
和DataView
可以转化为TypedArray
。
它们三者的比较方法如下:
// ArrayBuffer比较
function equalArrayBuffers(value, other) {
if (value.byteLength != other.byteLength) return false;
return equalTypedArrays(new Uint8Array(value), new Uint8Array(other));
}
// DataView比较
function equalDataViews(value, other) {
if (
value.byteLength != other.byteLength ||
value.byteOffset != other.byteOffset
)
return false;
return equalArrayBuffers(value.buffer, other.buffer);
}
// TypedArray比较
function equalTypedArrays(value, other) {
return equalArrays(value, other);
}
2.9 完整的代码
// 获取对象的标签
function getTag(value) {
return Object.prototype.toString.call(value);
}
// 数据是否为类对象
function isObjectLike(value) {
return typeof value == "object" && value !== null;
}
// 数据是否为TypedArray
function isTypedArray(value) {
return (
isObjectLike(value) &&
/^\[object (?:Uint8|Uint8Clamped|Uint16|Uint32|Int8|Int16|Int32|Float32|Float64)Array]$/.test(
getTag(value)
)
);
}
// 时间比较
function equalDates(value, other) {
return +value === +other;
}
// 正则比较
function equalRegExps(value, other) {
return value == other + "";
}
// 对象比较
function equalObjects(value, other) {
// 获取对象的键数组
const valueKeys = Object.keys(value),
valueLength = valueKeys.length,
otherKeys = Object.keys(other),
otherLength = otherKeys.length;
// 检查键的数量是否相同
if (valueLength != otherLength) return false;
// 检查键是否相同
let index = valueLength;
while (index--) {
const key = valueKeys[index];
if (!(key in other)) return false;
}
// 递归比较值是否相同
while (index++ < valueLength) {
const key = valueKeys[index];
if (!lodashIsEqual(value[key], other[key])) return false;
}
return true;
}
// 数组比较
function equalArrays(value, other) {
const valueLength = value.length,
otherLength = other.length;
// 检查数组长度是否相同
if (valueLength != otherLength) return false;
// 递归比较值是否相同
let index = valueLength;
while (index--) {
if (!lodashIsEqual(value[index], other[index])) return false;
}
return true;
}
// Map比较
function equalMaps(value, other) {
if (value.size != other.size) return false;
return equalArrays([...value], [...other]);
}
// Set比较
function equalSets(value, other) {
return equalMaps(value, other);
}
// ArrayBuffer比较
function equalArrayBuffers(value, other) {
if (value.byteLength != other.byteLength) return false;
return equalTypedArrays(new Uint8Array(value), new Uint8Array(other));
}
// DataView比较
function equalDataViews(value, other) {
if (
value.byteLength != other.byteLength ||
value.byteOffset != other.byteOffset
)
return false;
return equalArrayBuffers(value.buffer, other.buffer);
}
// TypedArray比较
function equalTypedArrays(value, other) {
return equalArrays(value, other);
}
// lodash的数据比较方法
function lodashIsEqual(value, other) {
//第一步 相等比较
if (value === other) {
return true;
}
//第二步 类型比较
// 普通类型比较
if (
value == null ||
other == null ||
(!isObjectLike(value) && !isObjectLike(other))
) {
return value !== value && other !== other;
}
// 引用类型比较
const valueTag = getTag(value);
const otherTag = getTag(other);
const isSameTag = valueTag == otherTag;
if (!isSameTag) return false;
//第三步引用类型值比较
switch (valueTag) {
case "[object Date]":
return equalDates(value, other);
case "[object RegExp]":
return equalRegExps(value, other);
case "[object Object]":
return equalObjects(value, other);
case "[object Array]":
return equalArrays(value, other);
case "[object Map]":
return equalMaps(value, other);
case "[object Set]":
return equalSets(value, other);
case "[object ArrayBuffer]":
return equalArrayBuffers(value, other);
case "[object DataView]":
return equalDataViews(value, other);
default:
if (isTypedArray(value)) {
return equalTypedArrays(value, other);
}
return false;
}
}
3.radash中的实现
radash中实现方式就要简单凝练太多了,以下就是全部的代码:
function isEqual(x, y) {
if (Object.is(x, y)) return true;
if (x instanceof Date && y instanceof Date) {
return x.getTime() === y.getTime();
}
if (x instanceof RegExp && y instanceof RegExp) {
return x.toString() === y.toString();
}
if (
typeof x !== "object" ||
x === null ||
typeof y !== "object" ||
y === null
) {
return false;
}
const keysX = Reflect.ownKeys(x);
const keysY = Reflect.ownKeys(y);
if (keysX.length !== keysY.length) return false;
for (let i = 0; i < keysX.length; i++) {
if (!Reflect.has(y, keysX[i])) return false;
if (!isEqual(x[keysX[i]], y[keysX[i]])) return false;
}
return true;
}
我还是按照前面提出的封装思路来简单的分析一下。
3.1 相等比较
radash中使用了Object.is
实现了相等比较。
if (Object.is(x, y)) return true;
3.2 类型比较
这一部分代码实际上处理了“两个数据中至少有一个是普通数据类型且类型不同的情况”。那如果“数据都是引用类型且类型不同”时该怎么办呢?
实际上在radash的实现中,“ 引用类型值比较”部分是可以处理这种情况的。
if (
typeof x !== "object" ||
x === null ||
typeof y !== "object" ||
y === null
) {
return false;
}
3.3 引用类型值比较
引用类型值比较是radash的实现中最精妙的部分,这一部分代码写的非常的简洁精炼,不像lodash中那样冗长复杂。
首先是对时间和正则的比较,这部分到没什么好讲的,原理上跟lodash中也差不多。
if (x instanceof Date && y instanceof Date) {
return x.getTime() === y.getTime();
}
if (x instanceof RegExp && y instanceof RegExp) {
return x.toString() === y.toString();
}
之后对其它引用类型数据的比较方法就写的非常精彩了。其实在研究lodash中实现的时候,我们就会发现剩下的几种类型的比较方法是相互有联系的。
首先 Object
与Array
的比较方法是极为相似的,都是遍历之后比较它们的值然后再通过递归的进行深度比较。Map
和Set
则是转化为数组进行比较。BufferArray
和DataView
则是转化为TypedArray
,TypedArray
会被视为数组进行比较。
既然这几种类型的比较方法彼此联系,那么有没有一种方式能够将这些比较方法统一起来呢?还真有,那就是radash中使用的 反射 API (Reflect
)。反射API可以接收所有的“类对象”,因此Object
、Array
、Map
、Set
、TypedArray
都可以用以下的方式进行处理。处理流程为:
通过Reflect.ownKeys获取类对象的键数组
-> 检查键的数量是否相同
-> 通过Reflect.has检查键是否相同
-> 递归比较值是否相同
const keysX = Reflect.ownKeys(x);
const keysY = Reflect.ownKeys(y);
if (keysX.length !== keysY.length) return false;
for (let i = 0; i < keysX.length; i++) {
if (!Reflect.has(y, keysX[i])) return false;
if (!isEqual(x[keysX[i]], y[keysX[i]])) return false;
}
return true;
我在上面好像没有提到BufferArray
和DataView
。是的,这两种类型在radash的isEqual
方法中无法进行处理。原因有两个
BufferArray
和DataView
中没有键
const buffer = new ArrayBuffer(16);
const view = new DataView(buffer);
view.setInt16(1, 32767);
console.log(Reflect.ownKeys(buffer)); //[]
console.log(Reflect.ownKeys(view)); //[]
BufferArray
和DataView
不支持方括号表示法
4.我的实现
我综合了lodash和radash的实现方式编写一个我自己的isEqual
方法。
相等比较部分我使用了Object.is
实现。
类型比较部分,我使用自己封装的getType
方法来获取数据的类型。
引用类型值比较部分,我主要使用radash中的值比较方法,但是增加了对ArrayBuffer
和DataView
类型数据的处理。
const getType = value => {
return Object.prototype.toString.call(value).match(/^\[object (.+)\]/)[1];
};
const isEqual = (a, b) => {
if (Object.is(a, b)) return true;
if (
typeof a !== "object" ||
a === null ||
typeof b !== "object" ||
b === null
) {
return false;
}
const aType = getType(a);
const bType = getType(b);
if (aType !== bType) return false;
switch (aType) {
case "Date":
return a.getTime() === b.getTime();
case "RegExp":
return a.toString() === b.toString();
case "ArrayBuffer":
if (a.byteLength != b.byteLength) return false;
a = new Uint8Array(a);
b = new Uint8Array(b);
case "DataView":
if (a.byteLength != b.byteLength || a.byteOffset != b.byteOffset)
return false;
a = new Uint8Array(a.buffer);
b = new Uint8Array(b.buffer);
default:
const keysA = Reflect.ownKeys(a);
const keysB = Reflect.ownKeys(b);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
if (!Reflect.has(b, keysA[i])) return false;
if (!isEqual(a[keysA[i]], b[keysA[i]])) return false;
}
return true;
}
};