前端知库

怎样让 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 实现

下面三种写法都成立,先看运行结果,再看各自依赖的规则。

JavaScript
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”这个动作本身返回不同的值。

JavaScript
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 里拦住它,返回一个自增的函数:

JavaScript
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。