怎样让 a == 1 && a == 2 && a == 3 成立?
== 一边是对象、一边是数字时,会先把对象转成原始值,转换会调用对象的 Symbol.toPrimitive、valueOf 或 toString。让这些方法每次返回一个递增的数,三次比较就分别得到 1、2、3。全局变量改成 getter 也行,每次读取 a 都会执行函数,这时连 === 都能成立。Proxy 拦截 Symbol.toPrimitive 是同一个思路的变形。
简答
这道题考的是
==遇到对象时会做什么。a == 1里一边是对象、一边是数字,规范要求先把对象转成原始值再比较,而转换过程会调用对象自己的方法,所以只要让这个方法每次返回的数加 1,三次比较就分别拿到 1、2、3。对象转原始值时,先看有没有
Symbol.toPrimitive方法,有就用它;没有就在valueOf和toString之间按顺序试,==这种场合先试valueOf。所以最常见的写法是给对象写一个valueOf,里面让计数器自增并返回。另一种思路是不让
a是普通变量。把a定义成全局对象上的 getter,每次读a都会执行函数,返回一个递增的数。这种写法连a === 1 && a === 2 && a === 3都能成立,因为严格相等虽然不做类型转换,但读取a本身就有副作用。要注意,这些写法利用的都是“比较时会调用你的代码”,实际项目里不应该这样写。它真正的启示是:
==和对象比较时行为可以被改写,所以业务代码里应该用===,并避免让对象和原始值直接比较。
用 valueOf 和 toString 实现
下面三种写法都成立,先看运行结果,再看各自依赖的规则。
const a = {
value: 0,
valueOf() {
this.value += 1;
return this.value;
},
};
console.log(a == 1 && a == 2 && a == 3 ? '成立' : '不成立'); // 成立
const list = [1, 2, 3];
list.toString = list.shift;
console.log(list == 1 && list == 2 && list == 3 ? '成立' : '不成立'); // 成立
let count = 0;
const b = {
[Symbol.toPrimitive](hint) {
count += 1;
return hint === 'string' ? '第' + count + '次' : count;
},
};
console.log(b == 1 && b == 2 && b == 3 ? '成立' : '不成立'); // 成立
console.log(`${b}`); // 第4次
console.log(b === 5 ? '成立' : '不成立'); // 不成立
第一种为什么成立?ECMAScript 的 IsLooselyEqual 规定,一边是对象、另一边是数字或字符串时,先对对象调用 ToPrimitive。对象没有 Symbol.toPrimitive,ToPrimitive 就按默认顺序先调 valueOf。每比较一次,valueOf 就执行一次,返回 1、2、3,正好和右边对上。
第二种是第一种的变形。数组自带的 valueOf 返回数组自己,还是对象,不是原始值,于是转换接着调 toString。把 toString 换成 shift,每次调用就从数组头部取出一个元素,依次是 1、2、3。
第三种用 Symbol.toPrimitive,它的优先级最高,有它就不再看 valueOf 和 toString。它还会收到一个 hint 参数,说明调用方想要什么类型:== 传的是 'default',模板字符串传的是 'string'。所以第 4 次在模板字符串里调用时,返回的是“第4次”。
最后一行说明了这种写法的边界:=== 不做类型转换,对象和数字直接判定为不相等,Symbol.toPrimitive 根本不会被调用。对象转原始值的完整规则见《隐式类型转换有哪些容易踩坑的规则?》。
用 getter 让 === 也成立
如果想让严格相等也成立,就不能靠类型转换了,要让“读取 a”这个动作本身返回不同的值。
let n = 0;
Object.defineProperty(globalThis, 'c', {
get() {
n += 1;
return n;
},
});
console.log(c === 1 && c === 2 && c === 3 ? '成立' : '不成立'); // 成立
在非严格的脚本里,直接写变量名 c,找不到局部变量时会去全局对象上找属性。这个属性是 getter,每读一次就执行一次函数,所以三次读到的分别是 1、2、3,比较的都是数字,=== 自然成立。属性描述符和 getter 的用法见《属性描述符、Object.freeze 和只读有什么区别?》。
用 with 语句把一个带 getter 的对象放进作用域链,效果也一样,不过 with 在严格模式下不能用。
用 Proxy 拦截转换
Proxy 可以拦截对属性的读取,ToPrimitive 在内部会读对象的 Symbol.toPrimitive 属性,所以可以在 get 里拦住它,返回一个自增的函数:
const counter = new Proxy({ n: 0 }, {
get(target, key) {
if (key === Symbol.toPrimitive) return () => ++target.n;
return Reflect.get(target, key);
},
});
console.log(counter == 1 && counter == 2 && counter == 3 ? '成立' : '不成立'); // 成立
这和第三种写法本质相同,只是方法不是写在对象上,而是读取时临时给出来的。Proxy 的其他用法见《Proxy 与 Reflect 能实现什么,存在哪些限制?》。
常见追问
为什么 valueOf 写法里,=== 不成立?
因为
===不做类型转换。两边类型不同,一个是对象一个是数字,直接返回false,不会调用valueOf。想让===成立,只能让读取变量本身返回不同的数字,也就是 getter 的写法。
a 同时有 valueOf 和 toString,a == '1' 用哪个?
没有
Symbol.toPrimitive时,==用的是'default'提示,普通对象按数字的顺序处理,先调valueOf,它返回原始值就不再调toString。Date是例外,它在'default'提示下先调toString。