看见这个标题有没有感到神奇?究竟是连小学生都不如,还是要搞什么哥德巴赫猜想;究竟是人性的扭曲,还是道德的沦丧,敬请收看……咳哄,跑题了。这不是篇技术博客嘛,怎么搞起数学来了!
这确实还是一篇技术博客,在我之前的博客中有提到,JS 除 undifined 和 null 以外的数据类型都是有对应的原型对象的,这一篇,想讲讲 JS 的类中一些可能很难注意到的有趣的小东西。
先来做个测试。
let a1 = 10, a2 = Number(10), a3 = new Number(10);
console.log(a1, typeof a1);
console.log(a2, typeof a2);
console.log(a3, typeof a3);
这个细节可能很多人都没注意到,用原型对象 new 出来的变量,用 typeof 无法正确判断类型,但想到 JS 中 class 定义的类也会出现类似的问题,这个现象就显得合理了。另外,判断 class 定义的类时,通常使用 instanceof 关键字,那么这里能否使用呢?
let a1 = 10, a2 = Number(10), a3 = new Number(10);
console.log(a1, a1 instanceof Number);
console.log(a2, a2 instanceof Number);
console.log(a3, a3 instanceof Number);
顺便再加一下判断……
是不是有一种感觉结果确应如此却又感觉哪里怪怪的,毕竟明明都是数,只是定义方式不同罢了。
首先 JS 为什么要有原型对象,很简单,JS 在原型对象上绑定了诸多原生方法,在使用基本数据类型时调用的方法(例如 number 类型的 toFixed 方法,string 类型的 trim 方法),其实就是向对应的原生对象借来的。
利用这一点,就能来玩点好 van♂ 的了,比如如何实现 1+1=3。那么先来扯两个看似毫无关联的东西。
- JS 类与实例中的继承关系,也就是原型链(就不用 ES5 实现了想想就麻烦)。
简单定义两个类,类 C 继承于类 P。
class P {
constructor() {
this.p = "P";
}
Pfunc() {
console.log(this.p);
}
}
class C extends P {
constructor() {
super();
this.c = "C";
}
Cfunc() {
console.log(this.c);
}
}
输出后,首先可以看到类 C 和 P 的静态属性和方法 length、name、arguments,caller 等,如果有 ES5 开发经验就能明白, ES6 的 class 不过是语法糖,JS 的类本质上还是函数。另外,如果想要自定义类的静态属性和方法,只需在类外如 C.x = x、C.y = () => {}形式定义就可以,静态顾名思义通过 new 创建的实例是无法访问的,而实例可访问的方法都存在 prototype 里,更多的 ES5 相关知识就不再赘述了。
输出 C 和 P 主要想看的是__proto__,可以看到 C 的__proto__指向 P,这就是继承关系的最直接的表现,如果 C 的实例调用的方法在 C 的 prototype 中没有找到,就会找到 C 的__proto__的 prototype,也就是 P 的 prototype。那如果还没有呢,可以看到,虽然这里只定义了两个类,但 P 的__proto__还是指向了一个函数,也就是原生构造函数,这说明 P 是继承于这个函数的,这也是类的本质是函数的原因,而许多数据类型对应的原生对象也同样继承于这个函数,同时,这个函数的__proto__又指向了一个对象,而这个对象不存在__proto__属性,也就是说这个对象是最顶层的对象,JS 中一切对象、函数无论是否是由开发者定义的,最终都会指向这个对象,其中有的会指向原生构造函数。因此,只要是在__proto__这条链上所有对象的 prototype 里的方法,最底层创建的实例都可以访问。
注:建议多输出几个不同类型的例子观察__proto__指向,这里就不一一展示了,如:
console.log([], {});
const a = () => {};
console.log({ a });
console.log({ a: Math.random });
console.log({ Number, Array });
特别说明,[]的__proto__指向的 Array(0),并不是 Array 类,而是 Array 的 prototype,这是因为[]是 Array 的实例,而实例指向类的 prototype。
- 类使用运算符的方法。
比如在 C++中,可以针对定义的类重载运算符,但其实 JS 中有更方便的方法。在《JavaScript の大数运算のこと》中提到了 Big 框架,假设现在以及导入框架,来做两个运算。
诶 new Big 不是一个实例嘛,为啥可以做运算,以及为啥我自己定义的类不能正确做运算?这是因为 Big 这个类里,实现了 valueOf 和 toString 两个方法,做运算时,JS 会自动调用这两个方法的一个取返回值,优先级是 valueOf 高于 toString。
class P {
constructor() {
this.p = "P";
}
valueOf() {
return 1;
}
toString() {
return 2;
}
}
class P {
constructor() {
this.p = "P";
}
toString() {
return 2;
}
}
class P {
constructor() {
this.p = "P";
}
}
而在没有实现 valueOf 或 toSting 的情况下,会沿着__proto__链找到第一个有 valueOf 或 toString 的父类调用(这一点在面试里爱出各种奇奇怪怪的考题,懂原理就很简单了),也就是原生构造函数的 toString 方法。
进入正题,如文章开头所说,对于原生对象,使用 new 操作符和直接定义对应的数据类型,有着非常微妙的区别,具体原因可以看 ECMAScript 规范和 JavaScript 引擎相关知识,由于实在过于晦涩,我就不介绍了(其实我也没看懂)。
那么对于 let n = new Number(10)定义的变量而言,它既然是个实例,在做运算时候,就必然会调用自己或某个父类的 valueOf 方法,那么如果我们把 valueOf 重写掉,那么从理论上讲,我们就可以让四则运算得到任意的值。那么来找找 Number 的 valueOf 或 toString 方法。
可以看到在 Number 的 prototype 上,valueOf 和 toString 都有,那么先试试能不能拿到 value。
Number.prototype.valueOf = function (value) {
console.log(value);
return 1;
};
用 function 函数不用箭头函数的原因后续再说。可见我们虽然自定义了返回,但 value 并不会传到函数里,那如果希望根据 value 值操作返回值该怎么办呢,可否先将 valueOf 函数存下来,再调用获取 value 呢。
const valueOf = Number.prototype.valueOf;
Number.prototype.valueOf = function () {
let value = valueOf();
console.log(value);
return 1;
};
诶!报错了,说是 this 找不到,这是因为 valueOf 取值时,需要从 Number 对象实例上拿,而拿到实例需要通过 this,这就是这里为什么用 function 函数,因为 function 函数的 this 指向调用函数的对象,比如 new Number(10) + 1 触发了 valueOf,this 就指向 new Number(10),如果用箭头函数的话,由于箭头函数没有 this,this 就会从父作用域里找,这里就会指向 Window 而无法指向 new Number(10),另外箭头函数同样因为没有 this,也无法通过 bind、call、apply 改变 this 指向。那么既然 function 里的 this 指向正确,就可以修改变量 valueOf 的 this 指向了。
修改 this 指向可以通过 bind、call、apply,其中 bind 会返回一个修改完 this 指向的函数,不会立即执行,而 call 和 apply 会在修改完 this 指向以后立即执行,而 call 和 apply 的区别就是传参形式不同,比如修改 this 指向的函数需要传入 a、b、c 三个参数,传参方式分别为 call(this, a, b, c)、apply(this, [a, b, c])。
const valueOf = Number.prototype.valueOf;
Number.prototype.valueOf = function () {
let value = valueOf.call(this);
return value + 1;
};
至此,本文主题 1+1=3,实现。めでたしめでたし!