最近在读《你不知道的 Javascript》,在变量部分,觉得内容很是有趣,工作中其实每天都在使用变量,但有些骚操作(奇技淫巧)看到后还是想写出来与各位分享一下。
先从一个判断语句开始说起
js
let a = 3;
let b = [3];
a == b; // 这个语句会输出什么?
上面这个语句如果在实际开发中,相信各位肯定不会用 == 来进行判断。 ==
和 ===
的区别是: ==
会对比较类型进行强制的类型转换,而 ===
不会,所以平时我们总是说 ==
是宽松的比较,===
是严格的相等。
js
let a = "45";
let b = 45;
a == b; // true
a === b; // false
这里再说明一点,==
是怎样的转换逻辑?我们知道 ==
比较会自动进行强制类型转换,这种转换遵循的是什么规则?
摘录一下书中的引用
- 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果
- 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果
- 如果 Type(x) 是字符串/数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果
- 如果 Type(y) 是对象,Type(x) 是字符串/数字,则返回 ToPrimitive(x) == y 的结果
所以总结来说,如果比较的是基本数据类型,会先转换为数字进行比较,如果是对象比较,则会通过 ToPrimitive
进行隐式转换。这里提到的 ToNumber
在书中被称为「抽象操作」,在「抽象操作」中,不仅有 ToNumber
,还有 ToString
, ToBoolean
。话已到此,我们再开一个分支先来讲讲这些抽象操作的作用及用法。
分支:抽象操作
在 ES5 中定义了一些「抽象操作」,其作用是内部"虚拟"的一些操作或者方法,无法直接调用这些行为,是一种隐式的类型转换,接下来会介绍几种常见的抽象操作。
-
ToString :处理非字符串到字符串的转换。有一些常见的规则,例如 null -> 'null', undefined -> 'undefined',如果是普通的对象,toString() 会调用内部属性 [[class]] 的值,如 "[object Object]" 所以你可以自定义(复写)对象的 toString() 方法,以便能在 ToString 过程中输出你想要的字符串值。
-
没什么用的小知识 💡 JSON.stringify() 同样会调用 toString() 方法进行 JSON 对象的序列化,对于一些内置的「非安全」值会有特殊的处理,例如在数组中有 null、undefined 值会直接输出 null 作为替代。 如果你在对象中定义了 toJSON() 方法,那么 JSON.stringify() 时,会先调用 toJSON 得到正确的 JSON 对象后再进行序列化处理。 ⚠️ toJSON 需要返回一个对象而不是字符串。
jslet o = {}; let a = { b: 42, c: o, d: function () {}, }; o.e = a; // 创建循环引用,此时 a 对象中包含了「非安全」值,此时 JSON.stringify() 会报错 a.toJSON = function () { return { b: this.b }; }; JSON.stringify(a); // '{"b": 42}'
-
-
ToNumber : 处理非数字值到数字值的转换。对于 ToString 的抽象操作是通过 toString() 方法来实现,那么 ToNumber 是通过什么方法实现呢? 对于 ToNumber ,JS 会首先检查对象是否含有
valueOf
方法,如果没有则检查是否含有toString
方法,然后对其中某个函数返回的值进行强制类型转换,如果这两个方法都没有返回的「基本类型」值,则会输出TypeError
错误。jsvar a = { valueOf: () => { return "45"; }, }; Number(a); // 45
-
ToBoolean : 处理非布尔类型的值的转换。这个转换在实际开发过程中使用较多,常见的假值包含如下几种:
undefined
,null
,false
,+0
,-0
,NaN
,""
。对象一般都会被强制转换为 true,也有例外。- 没什么用的小知识 💡
document.all
是 JS 中的一个假值对象,因为它在现代浏览器中已经被废除,经常通过if (document.all)
来判读是否在 IE 浏览器环境。
- 没什么用的小知识 💡
回到主线
所以我们在来检查之前的问题,下述判断会输出什么?
js
let a = 3;
let b = [3];
a == b; // 这个语句会输出什么?
我们知道非严格相等 ==
会进行隐式的类型转换(抽象操作),所以对于上述比较,左侧 a
就是数字无需转换,右侧 b
是数组,对于数组来说,会进行 ToNumber
的隐式转换,调用数组的 ToPrimitive
方法(先检查 valueOf
,再检查 toString
)对于数组来说,toString
方法被重新定义过了,故等式右侧会被转换为 3。所以上述比较返回了 true
。
js
let a = [1, 2, 3];
String(a); // '1,2,3'
延伸
所以我们之前提到的 ToPrimitive
的含义是什么?即 ECMASCript 规范中定义的「抽象操作」,按照字面理解即返回原始值 。在执行 ToPrimitive
操作时,遵循下面的顺序
- 如果有
valueOf
方法,则优先调用该方法返回原始值 - 否则,则检查是否有
toString
方法,调用该方法返回原始值 - 否则,则直接返回类型错误 所以
ToPrimitive
实质是担任了将对象转换为原始值的作用,这里就不得不提「拆箱」和「装箱」的概念了。
分支:对象的「拆箱」和「装箱」
「装箱」:boxing,顾名思义,就是打包的意思,这里可以理解为将属性和方法打包 「拆箱」:unboxing,同理,拆除包装,还原为之前的状态 这两种行为我们在日常开发中经常用到,概括来说: 「装箱」即将基本类型的值转换为对应的对象 「拆箱」则是将对象转换为基本类型
js
let a = "123"; // JS 会自动为 a 进行装箱操作,这样可以像「字符串对象」一样使用变量 a
a.length; // 3
a.toUpperCase(); // "ABC"
上面的例子中,JS 会自动帮我们进行装箱操作,那么我们可以自己进行装箱吗?
-
没什么用的小知识 💡
jslet a = new Boolean(false); if (!a) { console.log("可以执行到这儿么?"); }
上面代码是无法进行到分支中的,我们自己对 false 进行了装箱操作,a 此刻变成了一个对象,在 if 条件中进行了隐式的类型转换,而此时
Boolean(a) === true
。 所以在实际开发中,我们应该优先使用 JS 自动的装箱逻辑,相对于自己进行装箱,JS 的装箱性能也较优,也可以规避一些不太明显的错误。 对于「拆箱」实质就是ToPrimitive
的过程,对于要使用到基本类型的地方,都会进行隐式的「拆箱」过程。
返回主线
思考如下代码:
js
let a = "abc";
let b = Object(a);
a == b; // true
a === b; // false
这里 Object
函数的作用类似「装箱」,将变量 a
封装为对象,所以在宽松比较 ==
时,会进行 ToPrimitive
逻辑,此时 b 会被隐式转换为 'abc',所以 a == b
等式成立。
-
没什么用的小知识 💡
jslet a = null; let b = Object(null); a == b; // false
由于
null
没有对象的包装对象,所以b
此刻是一个普通的对象,a == b
等式不成立。所以同理可以推断undefined
,NaN
这种特殊的字段也会是相同结果。
延伸
我们在日常开发中,经常会使用 ||
的方式设置默认值
js
let a = c || "default value";
上述语句中,如果 c
的 ToPrimitive
为 false/undefined/null
等假值时,就会使用 ||
右侧的值来为 a
赋值。
题外话
js
let a = c && b;
// 等效为
if (c) {
b;
}
对于 &&
来说,相当于是逻辑判断的简略写法。一些压缩代码工具压缩后,常见的逻辑判断会被转换为 xx && yy
。
总结和引用
JS 中由于灵活的类型声明,导致了隐式转换几乎遍布于整个代码中,日常开发有些驾轻就熟的使用技巧多理解些原理也会更加从容,有些知识是常看常新的。 欢迎大家互相交流。
本文中例子大部分引用于《你不知道的 JavaScript》(中)。