underscore-analysis
underscore-analysis copied to clipboard
JavaScript 中是如何比较两个元素是否 "相同" 的
Why underscore
最近开始看 underscore.js 源码,并将 underscore.js 源码解读 放在了我的 2016 计划中。
阅读一些著名框架类库的源码,就好像和一个个大师对话,你会学到很多。为什么是 underscore?最主要的原因是 underscore 简短精悍(约 1.5k 行),封装了 100 多个有用的方法,耦合度低,非常适合逐个方法阅读,适合楼主这样的 JavaScript 初学者。从中,你不仅可以学到用 void 0 代替 undefined 避免 undefined 被重写等一些小技巧 ,也可以学到变量类型判断、函数节流&函数去抖等常用的方法,还可以学到很多浏览器兼容的 hack,更可以学到作者的整体设计思路以及 API 设计的原理(向后兼容)。
之后楼主会写一系列的文章跟大家分享在源码阅读中学习到的知识。
- underscore-1.8.3 源码解读项目地址 https://github.com/hanzichi/underscore-analysis
- underscore-1.8.3 源码全文注释 https://github.com/hanzichi/underscore-analysis/blob/master/underscore-1.8.3.js/underscore-1.8.3-analysis.js
- underscore-1.8.3 源码解读系列文章 https://github.com/hanzichi/underscore-analysis/issues
欢迎围观~ (如果有兴趣,欢迎 star & watch~)您的关注是楼主继续写作的动力
_.isEqual
本文跟大家聊聊 JavaScript 中如何判断两个参数 "相同",即 underscore 源码中的 _.isEqual 方法。这个方法可以说是 underscore 源码中实现最复杂的方法(用了百来行),几乎没有之一。
那么,我说的 "相同" 到底是什么意思?举个栗子,1
和 new Number(1)
被认为是 equal,[1]
和 [1]
被认为是 equal(尽管它们的引用并不相同),当然,两个引用相同的对象肯定是 equal 的了。
那么,如何设计这个 _.isEqual 函数呢?我们跟着 underscore 源码,一步步来看它的实现。后文中均假设比较的两个参数为 a 和 b。
首先我们判断 a === b
,为 true 的情况有两种,其一是 a 和 b 都是基本类型,那么就是两个基本类型的值相同,其二就是两个引用类型,那么就是引用类型的引用相同。那么如果 a === b
为 true,是否就是说 a 和 b 是 equal 的呢?事实上,99% 的情况是这样的,还得考虑 0 和 -0 这个 special case,0 === -0
为 true,而 0 和 -0 被认为是 unequal,至于原因,可以参考 http://wiki.ecmascript.org/doku.php?id=harmony:egal。
这部分代码可以这样表示:
// 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).
// a === b 时
// 需要注意 `0 === -0` 这个 special case
// 0 和 -0 不相同
// 至于原因可以参考上面的链接
if (a === b) return a !== 0 || 1 / a === 1 / b;
接下去的情况,也就是 a !== b
的情况了。
如果 a 和 b 中有一个是 null 或者 undefined,那么可以特判下,不用继续比较了。源码实现:
// A strict comparison is necessary because `null == undefined`.
// 如果 a 和 b 有一个为 null(或者 undefined)
// 判断 a === b
if (a == null || b == null) return a === b;
个人觉得这里写的有点多余,因为根据上面的判断过滤,a === b 肯定是返回 false 的。
ok,我们继续,接下来我们可以先根据 a 和 b 的类型来判断,如果类型不一样,那么就没必要继续判断了。如何获取变量类型?没错,就是神奇的 Object.prototype.toString.call
!
如果类型是 RegExp 和 String,我们可以将 a 和 b 分别转为字符串进行比较(如果是 String 就已经是字符串了),举个栗子:
var a = /a/;
var b = new RegExp("a");
console.log(_.isEqual(a, b)); // => true
其实它在 underscore 内部是这样判断的:
var a = /a/;
var b = new RegExp("a");
var _a = '' + a; // => /a/
var _b = '' + b; // => /a/
console.log(_a === _b); // => true
如果是 Number 类型呢?这里又有个 special case,就是 NaN!这里规定,NaN 仅和 NaN 相同,与别的 Number 类型均 unequal。这里我们将引用类型均转为基本类型,看如下代码:
var a = new Number(1);
console.log(+a); // 1
没错,加个 +
就解决了,其他的不难理解,都在注释里了。
// `NaN`s are equivalent, but non-reflexive.
// Object(NaN) is equivalent to NaN
// 如果 +a !== +a
// 那么 a 就是 NaN
// 判断 b 是否也是 NaN 即可
if (+a !== +a) return +b !== +b;
// An `egal` comparison is performed for other numeric values.
// 排除了 NaN 干扰
// 还要考虑 0 的干扰
// 用 +a 将 Number() 形式转为基本类型
// 如果 a 为 0,判断 1 / +a === 1 / b
// 否则判断 +a === +b
return +a === 0 ? 1 / +a === 1 / b : +a === +b;
// 如果 a 为 Number 类型
// 要注意 NaN 这个 special number
// NaN 和 NaN 被认为 equal
接下来我们看 Date 和 Boolean 两个类型。跟 Number 类型相似,它们也可以用 +
转化为基本类型的数字!看下面代码:
var a = new Date();
var b = true;
var c = new Boolean(false);
console.log(+a); // 1464180857222
console.log(+b); // 1
console.log(+c); // 0
非常简单,其实 +new Date() (或者也可以写成 +new Date)获取的正是当前时间和 1970 年 1 月 1 日 0 点的毫秒数(millisecond),可能你听说过时间戳,其实时间戳就是这个数据除以 1000,也就是秒数。在用 canvas 做动画时,我经常用 +new Date 来当时间戳。
so,如果 a 和 b 均是 Date 类型或者 Boolean 类型,我们可以用 +a === +b
来判断是否 equal。
程序接着走,我们接着看,似乎还有两类重要的类型没有判断?没错,Array 和 Object!underscore 对此采用递归方法展开来比较。
还是举个栗子吧,举例比较直观。
假设 a,b 如下:
var a = {name: "hanzichi", loveCity: [{cityName: "hangzhou", province: "zhenjiang"}], age: 30};
var b = {name: "hanzichi", loveCity: [{cityName: "hangzhou", province: "zhenjiang"}], age: 25};
首先 a,b 是对象,我们可以分别比较其键值对,如果有一个键值对不同(或者说一个键值对 a 和 b 有一个没有),则 a 和 b unequal。如果是数组呢?那就一个一个元素比较喽。因为数组可能嵌套对象,对象的 value 又可能是数组,所以这里用了递归。
还是以上面的例子,我们可以把它拆成三次比较,分别比较三个 key 的 value 值是否相同。对于 loveCity 这个 key 的 value,因为其 value 又是个数组,所以我们将这个 value 传入比较函数,通过这个比较的结果,来判断最后的比较结果。递归就是这样,可以将大的东西,拆成一个个小的,根据小的结果,来汇总得到大的结果。
最后,给出代码位置。关于 _.isEqual 方法的源码,大家可以参考 https://github.com/hanzichi/underscore-analysis/blob/master/underscore-1.8.3.js/src/underscore-1.8.3.js#L1094-L1190
var a =1,b=2;
if (a === b) return a !== 0 || 1 / a === 1 / b;
上面代码在chrome中报错,报错信息
VM546:1 Uncaught SyntaxError: Illegal return statement(…)
我放在jshint里试一下没有报语法错误啊,还望指点一下
@2lei return 只能在function内部有效
楼上正解~
if (a == null || b == null) return a === b; 个人觉得这里写的有点多余,因为根据上面的判断过滤,a === b 肯定是返回 false 的。
如果a == null && b == null , a === b 返回是true吧?
@EdwardZZZ a,b 都为 null,那就直接执行 if (a === b) return a !== 0 || 1 / a === 1 / b;
了吧?
@hanzichi 对,看断片了,忘记前面的了
源码中有这么一段:if (typeof a != 'object' || typeof b != 'object') return false;
,就是说如果a和b都是函数类型就直接返回false?但是个人觉得两个函数也可以有相等的情况啊!
@zhanyuzhang 看源码的确是这样,个人觉得比较两个函数是否相等没啥实用价值,可能设计的时候就忽略掉了
@zhanyuzhang 如果a或是b是Function的话,typeof出来的结果是‘function’,不是'object'。
f (a == null || b == null) return a === b; 个人觉得这里写的有点多余,因为根据上面的判断过滤,a === b 肯定是返回 false 的。 这个应该不是多余的,a===b确实返回是false,但是不能执行“if (a === b) return a !== 0 || 1 / a === 1 / b;”,eq()函数并没有返回值(return xxx),所以“f (a == null || b == null) return a === b;”还是必要的...
@qingzhu1224
我的意思是 if (a == null || b == null) return a === b;
可以写成 if (a == null || b == null) return false;
因为如果 a === b
为 true
,前面就 return 掉了。
而且 eq 函数是有返回值的,返回布尔值。
能否问下源码中这一段是做什么的:
while (length--) {
// Linear search. Performance is inversely proportional to the number of
// unique nested structures.
if (aStack[length] === a) return bStack[length] === b;
}
个人认为不会出现(aStack[length] === a)的情况啊?
@aircloud 这段和上面一段我都看的云里雾里,也没有过分解读,递归一笔带过了 .. (其实是看不大懂)
if (a == null || b == null) return a === b;
的确应该是直接 return false.
这里 a === b 的返回值一定是 false,否则这行代码也就不会执行了。所以当 a == null || b == null
为 true 的时候 a b 其中的一个变量的值会是 null 或者 undefined,但不管如何 a b 都是不可能是相同的值。
早上跑去 underscore 提了个 issue,结果也是觉得应该改成 false。
@ahonn 看到你的 pr 被 merge 了,看来我想的没错!Good job !
NaN不等于NaN啊
@Bubbfish underscore 中认为 _.isEqual(NaN, NaN)
为 true
,这也比较符合我们理想中对 equal 的期待
@hanzichi 哦哦,这样啊,谢谢
看到楼主注释的源码 isEqual 函数有对 aCtor instanceof aCtor 的疑惑,这个应该是用来判断是否是 Object 构造函数,因为:
Object instanceof Object // true
这个可以比较两个数组相等吗
@mjylfz 当然可以啦~
看了isEqual部分的源码,有几个问题。
1.aCtor instanceof aCtor
据我所知,这句能返回true的只有内置的Object和Function构造函数,而能走到这步判断的话,a和b只能是狭义上的object了,所以这个判断条件是为了排除不同frame间object构造函数Object不同的情况,也正如 @mjylfz说的一样。
而除了上面那种情况后,其他对象只要构造函数不同就认为这两个对象unequal。
可能这是作者的思路,因为从前面的判断可以看出作者认为function类型的话除了指针相同(a=b=function(){})这种情况是equal的,其他情况都认为是unequal,哪怕是a=function(){},b=function(){}这种情况也被认为是unequal的。
所以,只要构造函数不同就认为这两个对象不equal,因为不同的frame里是不能拿到同一个变量指针的,也就不存在楼主提的不同frame下构造函数相同的情况。
if (aCtor !== bCtor && !(_.isFunction(aCtor) && aCtor instanceof aCtor &&
_.isFunction(bCtor) && bCtor instanceof bCtor)
&& ('constructor' in a && 'constructor' in b)) {
return false;
}
这个地方有三个判断条件,第一个是判断constructor属性值不等,没有争议,第二个是判断上面提到的不同frame间Object构造函数不同的情况,也没有争议(_.isFunction
是为了保证instanceof调用合法,不是function的话js执行会报错),但是第三个'constructor' in a && 'constructor' in b
我就不理解了,我认为这个判断条件没有意义。
如果a和b都没有constructor属性(不管是不是继承的),那么aCtor一定等于bCtor因为都是undefined,第一个判断条件就一定是false,不会执行到第三个判断条件(&&短路);而如果a和b只有其中一个有constructor属性,那么这两个狭义上的对象的key-value对肯定是不相同的,已经可以判断出是unequal了。
所以我不是很懂为什么要写第三个判断条件。
2.对于调用栈的那部分我不太理解
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.
aStack.push(a);
bStack.push(b);
对于这部分代码,我理解的就是:
对于深层嵌套的对象比较,可以把这种对象看做一棵树,而这段代码会把目前操作分支推入调用栈里,栈顶是目前正在判断的对象,栈底是根对象。
而这棵树的最顶端一定是基本类型值判断equal(因为引用类型值会被继续递归)。如果判断不等则整个isEqual方法返回false,栈被销毁;如果返回true,则返回到栈顶的对象的判断,如果对象equal,则该对象退栈,返回到上一个对象分叉点。
代码块中的while语句把栈遍历了一遍判断aStack[length] === a
,而代码能执行到这已经确定a是一个引用类型值,用全等判断会返回true只有对象指针相同的情况。
如果这句判断返回true,就证明目前操作的对象和它所在分支的上端某对象指针相同,这不就是一个循环吗?a的某一个属性的值为a对象?据我所知js好像没有形成循环的情况,所以这句结果一定会是false,所以执行这句的意义在哪?所以进一步思考创建这个栈的意义是什么?我觉得不用栈直接递归就可以了。
望解答。。。
@Klaus1995 当然有,对象的属性引用自身
@ahonn 原来是这样,那这么做就很有意义了,防止无限递归。谢谢解答
@Klaus1995 关于第一个问题,给你举个例子:
var attrs = Object.create(null);
attrs.name = "Bob";
eq(attrs, {name: "Bob"}); // ???
你会发现,在 underscore 中,一个有构造函数,一个没有构造函数,但是这两个对象是“相等”的,之所以判断 'constructor' in a 就是为了处理这种情况。
@mqyqingfeng 我都快忘光了 - - 看到你最近有在看,感谢帮忙解答
@hanzichi 楼主客气了,我从楼主的系列文章中受益颇多,也希望能帮忙解答一些问题~
@mqyqingfeng 所以underscore里面对‘equal’的定义是这样的:
1.如果两个对象是由构造函数new出来的,那么两个对象的构造函数不同,他俩就是unequal的
2.如果只有一个是由构造函数new出来的,那么两个对象的构造函数虽然不同,但是会忽略掉这个因素,继续接下来的递归展开比较,如果递归成功则是equal的,就是你所举的例子
3.如果两个对象都不是构造函数的实例,则直接递归展开比较,也会忽略掉构造函数这个比较因素(因为都没有)
另外:我们定义的对象会默认调用内置的Object构造函数,所以想要建一个没有构造函数的对象,只能用Object.create(null)
这个方法
这是我的理解,你觉得对吗?
@Klaus1995 正是如此,不过要想建立一个没有构造函数的对象, Object.create({value: 1}) 之类的也是可以的,所以在 underscore 中
var a = Object.create({value: 1})
var b = Object.create({value: 2})
console.log(_.isEqual(a, b)) // true