TIPS
问题:JavaScript环境下,如何让a == 1 && a == 2 && a == 3这个表达式返回true?
参考文章
# 实现方法
使用
toStringconst a = { i: 1, toString() { return a.i++; // return this.a++; // 一样的 }, }; if (a == 1 && a == 2 && a == 3) { console.log('Hello World!'); }1
2
3
4
5
6
7
8
9
10
11使用
valueOfconst a = { i: 1, valueOf() { return this.i++; }, }; if (a == 1 && a == 2 && a == 3) { console.log('Hello World!'); }1
2
3
4
5
6
7
8
9
10使用
Proxy在ES6中JS新增了
Proxy对象,能够对一个对象进行劫持,接受两个参数,第一个是需要被劫持的对象,第二个参数也是一个对象,内部也可以配置每一个元素的get方法:const a = new Proxy({ i: 1 }, { get(target) { return () => target.i++; }, }); if (a == 1 && a == 2 && a == 3) { console.log('Hello World!'); }1
2
3
4
5
6
7
8
9同样的,
Proxy对象默认的toString和valueOf方法会返回这个被getter劫持过的结果,也能够在宽松相等的条件下满足题意。
JS特有的,变量宽松相等判断的真值表,里面列举了所有在宽松相等比较的情况下,两种变量可能出现的类型:

在上面的表格中,ToNumber(A)尝试在比较前将参数A转换为数字,这与+A(单目运算符+)的效果相同。ToPrimitive(A)通过尝试依次调用A的A.valueOf()和A.toString()方法,将参数A转换为原始值(Primitive)。
从上图中我们可以看到,当操作数B类型为number时,如果希望在宽松相等的情况下整个表达式的结果返回true,操作数A必须满足下面三个条件之一:
- 操作数
A类型为string,并且调用+A的结果与B严格相等。 - 操作数
A类型为boolean,并且调用+A的结果与B严格相等。 - 操作数
A类型为object,并且调用valueOf或者toString返回的结果与B严格相等。
在这里,如果我们要改变+A操作的结果相对来说比较困难,因为我们很难在JS中去重载+操作符的运算。但是在第三种情况下,使A的类型为object,调用valueOf或toString结果与B严格相等让我们自己实现就容易的多。
所以上面的答案就是新建了一个对象a,并有toString方法,当JS引擎每次读取a的值的时候,发现需要进行宽松判断一个对象和一个数字之间的结果,对于对象就会执行这里的toString方法,在这个方法内部,我们每次增加另一个变量的值并返回,就能够在这条表达式中使得a的结果有不同的值。同理,换一种写法,a为object,使用valueOf也是可以完成目标的。
# 严格相等下的实现
在ES5之后,Object新增defineProperty方法,它会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象,对于定义的这个对象有两种描述它的状态,一种称之为数据描述符,一种被称为存取描述符,分别举一个例子:
var a = {};
Object.defineProperty(a, 'value', {
enumerable: false,
configurable: false,
writable: false,
value: 'static',
});
2
3
4
5
6
7
这四个数据描述服分别作用是
enumerable:是否可以枚举。configurable:当前属性是否之后再被更改描述符。writable:是否可以继续赋值。value:这个结果的值。
经过这样的操作之后,a对象下就有了value这个key,他被赋予不可继续赋值,不可继续配置,不能被枚举,值为static,我们可以通过a.value拿到这里的static,但是不能继续通过a.value = 'relative'来继续赋值。
同样的,设置存取描述符也是四个属性:
var a = { i: 1 };
Object.defineProperty(a, 'value', {
enumerable: false,
configurable: false,
get() {
return a.i;
},
set() {
a.i++;
},
});
2
3
4
5
6
7
8
9
10
11
这里设置时就没有配置writable和value属性,转而配置了get和set方法,在这两种配置中,get、set方法和writable、value是不能共存的,否则就会抛出异常。类似上面这样的设置,当我们访问a.value时就会调用get方法,当我们通过a.value = 'test'时,就会执行set方法。
var i = 1;
Object.defineProperty(window, 'a', {
get() { return i++; },
});
if (a === 1 && a === 2 && a === 3) {
console.log('Hello World!');
}
2
3
4
5
6
7
8
同时,这种劫持getter和setter的方法本质上是执行了一个函数,内部除了用自增变量,还可以有更多的方法:
const value = (function* () {
let i = 1;
while (true) yield i++;
}());
Object.defineProperty(window, 'a', {
get() {
return value.next().value;
},
});
if (a === 1 && a === 2 && a === 3) {
console.log('Hello World!');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
对于严格相等的情况,一般来说只能通过劫持数据的getter来进行操作,但是里面具体操作的方法在上面列举的就有很多。
对于宽松相等的情况,除了劫持getter以外,因为宽松相等JS引擎的缘故,还能用Object,Proxy对象的valueOf和toString方法达到目的。
# 隐式转换
常见的很简单的问题:
'1' == 1; // true
1 == '1'; // true
0 == false; // true
2
3
但是:
![] == []; // true
# '=='作用规则
a == b,如果a、b类型相同,那很简单,值相同即为true,不同即为false。所以这里只讨论a、b类型不同的情况——虽然应该避免不同类型变量相比较,但是弄明白"比较中的隐式类型转换"却非常必要。
参照MDN文档梳理了一下不同类型的的值比较的规则:
- 当数字与字符串进行比较时,会尝试将字符串转换为数字值;
- 如果操作数之一是
boolean,则将布尔操作数转换为1或0; - 如果操作数之一是对象,另一个是数字或字符串,会尝试使用对象的
valueOf()和toString()方法将对象转换为数字或字符串; null == undefined为true,此外通常情况下null和undefined与其它对象都不相等。
可以看到,前三条规则中,都是试图转变为字符串和数字进行比较,在比较中,可以把布尔值当成数字。回到刚才的问题,![] == []就比较容易理解了,相当于false == [],有boolean操作数,先转为数字,相当于比较0 == [],而[]转为数字是0,所以返回true。
TIPS
[] == ![]结果为true过程分解:
!取反运算符根据规则得到[] == false;- 根据比较规则第2条
[] == false等同于[] == 0; - 根据比较规则的第3条
[] == 0尝试调用数组对象的[].valueOf()返回[]和[].toString()返回''最终得到'' == 0; - 根据比较规则的第1条
'' == 0尝试把空字符转为数字得到0 == 0最终结果就是为true。
# 对象的valueOf和toString
Object对象在隐式转换的时候,会尝试调用valueOf和toString函数,向字符串或者数字转换。那优先会采用哪一个函数的值呢?
结论:
- 如果
valueOf或者toString返回原始值,按valueOf大于toString的优先级取得返回值; - 若返回值是
null或者undefined,比较返回false; - 否则根据另一个比较值转为字符串或者数字进行比较;
- 如果
valueOf和toString均不返回原始值,则比较操作将会报错!
const a = {};
a.valueOf = () => 1;
a.toString = () => 2;
console.log(a == 1, a == 2); // true, false
const b = {};
b.valueOf = () => null; // 优先级高于toString,比较直接返回false
b.toString = () => '1';
console.log(b == 'null', b == 1, b == '1', b == 0); // false, false, false, false
const c = {};
c.valueOf = () => ([]); // 返回非基本值,将尝试取toString比较
c.toString = () => '1';
console.log(c == 'undefined', c == '1'); // false, true
const d = {};
d.valueOf = () => ([]); // 返回非基本值
d.toString = () => ([]);
console.log(d == 'undefined', d == '1'); // Cannot convert object to primitive value
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
很明显,根据valueOf大于toString的优先级可以看到,objA == 'abc'的比较并不同于简单地将objA显式转换为字符串进行比较,即:objA == 'abc'与String(objeA) == 'abc'结果并不一定相同(显式转换直接走toString):
const e = {};
e.valueOf = () => 1;
e.toString = () => '2';
// true, true, false, true
console.log(e == 1, e == '1', String(e) == '1', String(e) == '2');
2
3
4
5
总结:
在进行强转字符串类型时将优先调用
toString方法,强转为数字时优先调用valueOf。在有运算操作符的情况下,
valueOf的优先级高于toString。这两个方法一般是交由js去隐式调用,以满足不同的运算情况。
- 在数值运算里,会优先调用
valueOf,如a + b。 - 在字符串运算里,会优先调用
toString,如alert(c)。
- 在数值运算里,会优先调用
# Symbol.toPrimitive属性
除了valueOf、toString函数外,ES6规范提出了Symbol.toPrimitive作为对象属性名,其值是一个函数,函数定义了对象转为字符串、数字等原始值的返回值,其优先级要高于valueOf、toString。
Symbol.toPrimitive是一个内置的Symbol值,它是作为对象的函数值属性存在的,当一个对象转换为对应的原始值时,会调用此函数。该函数被调用时,会被传递一个字符串参数hint,表示要转换到的原始值的预期类型。hint参数的取值是number、string和default中的任意一个。对于==操作符,hint传递的值是default。
const a = {};
a[Symbol.toPrimitive] = (hint) => {
if (hint == 'number') return 1;
if (hint == 'string') return 2;
return 3;
};
a.valueOf = () => 4;
a.toString = () => 5;
// false, false, true, false, false
console.log(a == 1, a == 2, a == 3, a == 4, a == 5);
2
3
4
5
6
7
8
9
10
如果使用Number或者String强制转换a,则传入的hint会是number或者string。
console.log(Number(a)); // 1
console.log(String(a)); // 2
2
实现以下效果:
console.log(add[1][2] + 100); // 103
const proxy = (value = 0) => new Proxy({}, {
get(target, prop) {
console.log(target, prop, value);
// 加法时获取原始值
if (prop === Symbol.toPrimitive) {
return () => value;
}
return proxy(value + +prop);
},
});
const add = proxy();
2
3
4
5
6
7
8
9
10
11