1 为什么要判断?
可能有些同学看到这个标题就会产生疑惑, 为什么我们要判断 JavaScript 中的两个变量是否相等, JavaScript 不是已经提供了双等号 == 以及三等号 === 给我们使用了吗?
其实, JavaScript 虽然给我们提供了相等运算符, 但是还是存在一些缺陷, 这些缺陷不符合我们的思维习惯, 有可能在使用的时候得到一些意外的结果为了避免这种情况的出现, 我们需要自己函数来实现 JavaScript 变量之间的对比
2 JavaScript 等号运算符存在哪些缺陷?
2.1 0 与 - 0
在 JavaScript 中:
- 0 === 0
- //true
- +0 === -0
- //true
相等运算符认为 + 0 和 - 0 是相等的, 但是我们应当认为两者是不等的, 具体原因源码中给出了一个链接: Harmony egal proposal.
2.2 null 和 undefined
在 JavaScript 中:
- null == undefined
- //true
- null === undefined
- //false
我们应当认为 null 不等于 undefined, 所以在比较 null 和 undefined 时, 应当返回 false
2.3 NaN
前文有说过, NaN 是一个特殊的值, 它是 JavaScript 中唯一一个自身不等于自身的值
- NaN == NaN
- //false
- NaN === NaN
- //false
但是我们在对比两个 NaN 时, 我们应当认为它们是相等的
2.4 数组之间的对比
由于在 JavaScript 中, 数组是一个对象, 所以如果两个变量不是引用的同一个数组的话, 即使两个数组一模一样也不会返回 true
- var a = [];
- //undefined
- var b = [];
- //undefined
- a=== b
- //false
- a==b
- //false
但是我们应当认为, 两个元素位置顺序以及值相同的数组是相等的
2.5 对象之间的对比
凡是涉及到对象的变量, 只要不是引用同一个对象, 都会被认为不相等我们需要做出一些改变, 两个完全一致的对象应当被认为是相等的
- var a = {};
- //undefined
- var b = {};
- //undefined
- a == b
- //false
- a === b
- //false
这种情况在所有 JavaScript 内置对象中也适用, 比如我们应当认为两个一样的 RegExp 对象是相等的
2.6 基本数据类型与包装数据类型之间的对比
在 JavaScript 中, 数值 2 和 Number 对象 2 是不严格相等的:
- 2 == new Number(2);
- //true
- 2 === new Number(2);
- //false
但是我们在对比 2 和 new Number(2)时应当认为两者相等
3 underscore 的实现方法
我们实现的方法当然还是依赖于 JavaScript 相等运算符的, 只不过针对特例需要有特定的处理我们在比较之前, 首先应该做的就是处理特殊情况
underscore 的代码中, 没有直接将逻辑写在_.isEqual 方法中, 而是定义了两个私有方法: eq 和 deepEq 在 GitHub 用户 @hanzichi 的 repo 中, 我们可以看到 1.8.3 版本的 underscore 中并没有 deepEq 方法, 为什么后来添加了呢? 这是因为 underscore 的作者把一些特例的处理提取了出来, 放到了 eq 方法中, 而更加复杂的对象之间的对比被放到了 deepEq 中 (同时使得 deepEq 方法更加便于递归调用) 这样的做法使得代码逻辑更加鲜明, 方法的功能也更加单一明确, 维护代码更加简洁快速
eq 方法的源代码:
- var eq = function (a, b, aStack, bStack) {
- // Identical objects are equal. `0 === -0`, but they aren't identical.
- // See the [Harmony `egal` proposal](http://wiki.ecmascript.org/doku.php?id=harmony:egal).
- // 除了 0 === -0 这个特例之外, 其余所有 a === b 的例子都代表它们相等
- // 应当判断 0 !== -0, 但是 JavaScript 中 0 === -0
- // 下面这行代码就是为了解决这个问题
- // 当 a !== 0 或者 1/a === 1/b 时返回 true, 一旦 a === 0 并且 1/a !== 1/b 就返回 false
- // 而 a === 0 且 1/a !== 1/b 就代表 a,b 有一个为 0, 有一个为 - 0
- if (a === b) return a !== 0 || 1 / a === 1 / b;
- // 一旦 ab 不严格相等, 就进入后续检测
- //a == b 成立但是 a === b 不成立的例子中需要排除 null 和 undefined, 其余例子需要后续判断
- // `null` or `undefined` only equal to itself (strict comparison).
- // 一旦 a 或者 b 中有一个为 null 就代表另一个为 undefined, 这种情况可以直接排除
- if (a == null || b == null) return false;
- // `NaN`s are equivalent, but non-reflexive.
- // 自身不等于自身的情况, 一旦 a,b 都为 NaN, 则可以返回 true
- if (a !== a) return b !== b;
- // Exhaust primitive checks
- // 如果 a,b 都不为 JavaScript 对象, 那么经过以上监测之后还不严格相等的话就可以直接断定 a 不等于 b
- var type = typeof a;
- if (type !== 'function' && type !== 'object' && typeof b != 'object') return false;
- // 如果 a,b 是 JavaScript 对象, 还需要做后续深入的判断
- return deepEq(a, b, aStack, bStack);
- };
对于源码的解读我已经作为注释写在了源码中 那么根据源码, 可以将其逻辑抽象出来:
deepEq 的源码:
- var deepEq = function (a, b, aStack, bStack) {
- // Unwrap any wrapped objects.
- // 如果 a,b 是_的一个实例的话, 需要先把他们解包出来再进行比较
- if (a instanceof _) a = a._wrapped;
- if (b instanceof _) b = b._wrapped;
- // Compare `[[Class]]` names.
- // 先根据 a,b 的 Class 字符串进行比较, 如果两个对象的 Class 字符串都不一样,
- // 那么直接可以认为两者不相等
- var className = toString.call(a);
- if (className !== toString.call(b)) return false;
- // 如果两者的 Class 字符串相等, 再进一步进行比较
- // 优先检测内置对象之间的比较, 非内置对象再往后检测
- switch (className) {
- // Strings, numbers, regular expressions, dates, and booleans are compared by value.
- // 如果 a,b 为正则表达式, 那么转化为字符串判断是否相等即可
- case '[object RegExp]':
- // RegExps are coerced to strings for comparison (Note: ''+ /a/i ==='/a/i')
- case '[object String]':
- // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is
- // equivalent to `new String("5")`.
- // 如果 a, b 是字符串对象, 那么转化为字符串进行比较因为一下两个变量:
- //var x = new String('12');
- //var y = new String('12');
- //x === y 是 false,x === y 也是 false, 但是我们应该认为 x 与 y 是相等的
- // 所以我们需要将其转化为字符串进行比较
- return ''+ a ==='' + b;
- case '[object Number]':
- // 数字对象转化为数字进行比较, 并且要考虑 new Number(NaN) === new Number(NaN)应该要成立的情况
- // `NaN`s are equivalent, but non-reflexive.
- // Object(NaN) is equivalent to NaN.
- if (+a !== +a) return +b !== +b;
- // An `egal` comparison is performed for other numeric values.
- // 排除 0 === -0 的情况
- return +a === 0 ? 1 / +a === 1 / b : +a === +b;
- case '[object Date]':
- //Date 类型以及 Boolean 类型都可以转换为 number 类型进行比较
- // 在变量前加一个加号 +, 可以强制转换为数值型
- // 在 Date 型变量前加一个加号 + 可以将 Date 转化为毫秒形式; Boolean 类型同上(转换为 0 或者 1)
- case '[object Boolean]':
- // Coerce dates and booleans to numeric primitive values. Dates are compared by their
- // millisecond representations. Note that invalid dates with millisecond representations
- // of `NaN` are not equivalent.
- return +a === +b;
- case '[object Symbol]':
- return SymbolProto.valueOf.call(a) === SymbolProto.valueOf.call(b);
- }
- var areArrays = className === '[object Array]';
- // 如果不是数组对象
- if (!areArrays) {
- if (typeof a != 'object' || typeof b != 'object') return false;
- // Objects with different constructors are not equivalent, but `Object`s or `Array`s
- // from different frames are.
- // 比较两个非数组对象的构造函数
- var aCtor = a.constructor, bCtor = b.constructor;
- if (aCtor !== bCtor && !(_.isFunction(aCtor) && aCtor instanceof aCtor &&
- _.isFunction(bCtor) && bCtor instanceof bCtor)
- && ('constructor' in a && 'constructor' in b)) {
- return false;
- }
- }
- // Assume equality for cyclic structures. The algorithm for detecting cyclic
- // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`.
- // Initializing stack of traversed objects.
- // It's done here since we only need them for objects and arrays comparison.
- // 初次调用 eq 函数时, aStack 以及 bStack 均未被传递, 在循环递归的时候, 会被传递进来
- //aStack 和 bStack 存在的意义在于循环引用对象之间的比较
- aStack = aStack || [];
- bStack = bStack || [];
- var length = aStack.length;
- while (length--) {
- // Linear search. Performance is inversely proportional to the number of
- // unique nested structures.
- if (aStack[length] === a) return bStack[length] === b;
- }
- // Add the first object to the stack of traversed objects.
- // 初次调用 eq 函数时, 就把两个参数放入到参数堆栈中去, 保存起来方便递归调用时使用
- aStack.push(a);
- bStack.push(b);
- // Recursively compare objects and arrays.
- // 如果是数组对象
- if (areArrays) {
- // Compare array lengths to determine if a deep comparison is necessary.
- length = a.length;
- // 长度不等, 直接返回 false 认定为数组不相等
- if (length !== b.length) return false;
- // Deep compare the contents, ignoring non-numeric properties.
- while (length--) {
- // 递归调用
- if (!eq(a[length], b[length], aStack, bStack)) return false;
- }
- } else {
- // Deep compare objects.
- // 对比纯对象
- var keys = _.keys(a), key;
- length = keys.length;
- // Ensure that both objects contain the same number of properties before comparing deep equality.
- // 对比属性数量, 如果数量不等, 直接返回 false
- if (_.keys(b).length !== length) return false;
- while (length--) {
- // Deep compare each member
- key = keys[length];
- if (!(_.has(b, key) && eq(a[key], b[key], aStack, bStack))) return false;
- }
- }
- // Remove the first object from the stack of traversed objects.
- // 循环递归结束, 把 a,b 堆栈中的元素推出
- aStack.pop();
- bStack.pop();
- return true;
- };
对于源码的解读我已经作为注释写在了源码中 那么根据源码, 可以将其逻辑抽象出来:
1 使用 Object.prototype.toString 方法获取两参数类型, 如果两参数的原始数据类型都不同, 那么可以认为两个参数不相等
2 如果进入了第二步, 那么说明两个参数的原始类型相同针对获取到的字符串进行分类, 如果是除 Object 和 Array 之外的类型, 进行处理
RegExp 以及 String 对象转化为字符串进行比较
Number 类型的话, 需要先使用 + 运算符强制转化为基本数据类型中的数值型, 然后处理特例比如 NaN === NaN,0 !== -0.
Date 以及 Boolean 对象转化为数字类型进行对比(+ 运算符强制转换, Date 转化为 13 位的毫秒形式, Boolean 转化为 0 或 1)
Symbol 类型使用 Symbol.prototype.valueOf 获取字符串, 然后进行对比(即认为传递给 Symbol 函数相同字符串所获取到的 Symbol 对象应该相等)
3 经过以上比较, 所剩类型基本只剩 Array 和基本对象了如果不是数组对象, 那么构造函数不同的对象可以被认为是不相等的对象
4 初始化对象栈 aStack 以及 bStack, 因为初次调用 deepEq 函数时不会传递这两个参数, 所以需要手动初始化因为之后比较的数组对象以及基本对象需要用到对象栈, 所以现在应该把当前的 a,b 推入到两个栈中
5 针对数组, 先比较长度, 长度不等则数组不等长度相等再递归调用 deepGet 比较数组的每一项, 有一项不等则返回 false
6 基本对象类型比较, 先使用_.keys 获取对象的所有键键数量不同的两对象不同, 如果键数目相等, 再递归调用 deepEq 比较每一个键的属性, 有一个键值不等则返回 false
7 经过所有检测如果都没有返回 false 的话, 可以认为两参数相等, 返回 true 在返回之前会把栈中的数据推出一个
4 underscore 的精髓
4.1 将 RegExp 对象和 String 对象用相同方法处理
有同学可能会疑惑:/[a-z]/gi 与 /[a-z]ig / 在意义上是一样的, 但是转化为字符串之后比较会不会是不相等的?
这是一个非常好的问题, 同时也是 underscore 处理的巧妙之所在在 JavaScript 中, RegExp 对象重写了 toString 方法, 所以在强制将 RegExp 对象转化为字符串时, flags 会按规定顺序排列, 所以将之前两个 RegExp 对象转化为字符串, 都会得到 /[a-z]/gi 这就是 underscore 可以放心大胆的将 RegExp 对象转化为字符串处理的原因
4.2 Date 对象和 Boolean 对象使用相同方法处理
underscore 选择将 Date 对象和 Boolean 对象都转化为数值进行处理, 这避免了纷繁复杂的类型转换, 简单粗暴而且作者没有使用强制转换方法进行转换, 而是只使用了一个 + 符号, 就强制将 Date 对象和 Boolean 对象转换成了数值型数据
4.3 使用对象栈保存当前比较对象的上下文
很多童鞋在阅读源码时, 可能会很疑惑 aStack 以及 bStack 的作用在哪里 aStack 和 bStack 用于保存当前比较对象的上下文, 这使得我们在比较某个对象的子属性时, 还可以获取到其自身这样做的好处就在于我们可以比较循环引用的对象
- var a = {
- name: 'test'
- };
- a['test1'] = a;
- var b = {
- name: 'test'
- };
- b['test1'] = b;
- _.isEqual(a, b);
- //true
underscore 使用 aStack 和 bStack 作比较的代码:
- aStack = aStack || [];
- bStack = bStack || [];
- var length = aStack.length;
- while (length--) {
- // Linear search. Performance is inversely proportional to the number of
- // unique nested structures.
- if (aStack[length] === a) return bStack[length] === b;
- }
上面的测试代码中, ab 对象的 test1 属性都引用了它们自身, 这样的对象在比较时会消耗不必要的时间, 因为只要 a 和 b 的 test1 属性都等于其某个父对象, 那么可以认为 a 和 b 相等, 因为这个被递归的方法返回之后, 还要继续比较它们对应的那个父对象, 父对象相等, 则引用的对象属性必相等, 这样的处理方法节省了很多的时间, 也提高了 underscore 的性能
4.4 优先级分明, 有的放矢
underscore 的处理具有很强的优先级, 比如在比较数组对象时, 先比较数组的长度, 数组长度不相同则数组必定不相等; 比如在比较基本对象时, 优先比较对象键的数目, 键数目不等则对象必定不等; 比如在比较两个对象参数之前, 优先对比 Object.prototype.toString 返回的字符串, 如果基本类型不同, 那么两个对象必定不相等
这样的主次分明的对比, 大大提高了 underscore 的工作效率所以说每一个小小的细节, 都可以体现出作者的处心积虑阅读源码, 能够使我们学习到太多的东西
5 underscore 的缺陷之处
我们可以在其他方法中看到 underscore 对 ES6 中新特征的支持, 比如_.is[Type]方法已经支持检测 Map(_.isMap)和 Set(_.isSet)等类型了但是_.isEqual 却没有对 Set 和 Map 结构的支持如果我们使用_.isEqual 比较两个 Map 或者两个 Set, 总是会得到 true 的结果, 因为它们可以通过所有的检测
在 underscore 的官方 GitHub repo 上, 我看到有同学已经提交了 PR 添加了_.isEqual 对 Set 和 Map 的支持
我们可以看一下源码:
- var size = a.size;
- // Ensure that both objects are of the same size before comparing deep equality.
- if (b.size !== size) return false;
- while (size--) {
- // Deep compare the keys of each member, using SameValueZero (isEq) for the keys
- if (!(isEq(a.keys().next().value, b.keys().next().value, aStack, bStack))) return false;
- // If the objects are maps deep compare the values. Value equality does not use SameValueZero.
- if (className === '[object Map]') {
- if (!(eq(a.values().next().value, b.values().next().value, aStack, bStack))) return false;
- }
- }
可以看到其思路如下:
1 比较两参数的长度(或者说是键值对数), 长度不一者即为不等, 返回 false
2 如果长度相等, 就逐一递归比较它们的每一项, 有任意一项不等者就返回 false
3 全部通过则可以认为是相等的, 返回 true
这段代码有一个很巧妙的地方在于它没有区分到底是 Map 对象还是 Set 对象, 先直接使用
a.keys().next().value
以及
b.keys().next().value
获取 Set 的元素值或者 Map 的键后面再进行类型判断, 如果是 Map 对象的话, 再使用
a.values().next().value
以及
b.values().next().value
获取 Map 的键值, Map 对象还需要比较其键值是否相等
个人认为, 这段代码也有其局限性, 因为 Set 和 Map 可以认为是一个数据集, 这区别于数组对象我们可以说 [1,2,3] 不等于 [2,1,3], 因为其相同元素的位置不同; 但是我认为 new Set([1,2,3]) 应该认为等于 new Set([2,1,3]), 因为 Set 是无序的, 它内部的元素具有单一性
来源: https://juejin.im/post/5a9bc1b9f265da2389252e2e