JavaScript 的类型系统

259 阅读10分钟

类型介绍

JavaScript 具有七种内置数据类型:

  • null
  • undefined
  • boolean
  • number
  • string
  • object
  • Symbol (ES6新增)

其中前五种数据类型为基本类型,第六种 object 类型又包含了 functionarraydateRegExp 等, symbol 也是基本类型。

Undefined

Undefined 类型表示未定义 ,它只有一个值,就是 undefined

任何变量在赋值前都是 Undefined 类型,值为 undefined。因为 undefined 并不是一个被受保护关键字,可以被声明为变量和赋值,JavaScript 语言公认的设计失误之一。为避免程序中被无意篡改,所以一般程序中会声明一个 undefined 的全局变量 或 用 **void **运算来把任意一个表达式变成 undefined 值。

let undefiend = 1;
console.log(undefined); // 1

let a = void 0;
console.log(a); // undefined

Null

Null 类型表示空值(定义了但是为空),它也只有一个值,就是 null是一个关键字。所以任何代码中可以放心使用 null 关键字来获取 null 值。

String

String 用户表示文本数据,最大长度是 2^53 - 1

这个所谓的最大长度,并不是完全是你理解中的字符数。因为 String 的意义并非“字符串”,而是字符串的 UTF16 编码(一组无符号的16位值组成的不可变的有序序列),字符串的长度(length)是其所含16位值的个数,所以,字符串的最大长度,实际上是受字符串的编码长度影响的1

✍️ Note:

JavaScript 采用 UTF-16 编码的 Unicode 字符集,最常用的 Unicode 字符都是通过 16 位的内码表示,并代表字符串中的单个字符,那些不能表示为 16 位的 Unicode 字符则遵循 UTF-16编码规则——用两个16位值组成一个序列表示,意味着一个长度为2的 JavaScriipt 字符串(2个 16位码值表示一个 Unicode 字符),如 “𠮷”

绝大多数字符属于第 0 号平面,即 BMP 平面(码值范围在 U+0000 - U+FFFF,每个平面有 2^16 = 65536 个码值)。除了 BMP平面之外,其它平面都被称为补充平面

表情符号(emoji) 中很多表情不在 BMP 平面内,码值超过了 U+FFFF。使用 UTF-8编码时,普通的 ASCII 是一个字节,中文是 3个字节,而有一些表情需要 4个字节来编码(还有一些支持颜色修饰的表情,其实是 8个字节),所以在处理表情符号时需要特别注意,最好采用专门的库来处理这种问题。

Number

Number 类型表示的就是“数字”,JavaScript 采用 IEEE 754 标准2 定义的64位浮点格式(双精度浮点数)表示数字,整数范围是 -9007199254740992~9007199254740992 (-2^53 ~ 2^53), 包含边界值。如果使用了超过此范围的整数,则无法保证低位数字的精度。然而需要注意的是,JavaScript中实际的操作则是基于32位整数。

除此之外,JavaScript 为表达几个额外的语言场景,还引入了无穷大的概念:

  • NaN,占用了 9007199254740990,这原本是符合 IEEE 规则的数字;
  • Infinity, 无穷大
  • -Infinity, 负无穷大

JavaScript采用了IEEE-754浮点数表示法(几乎所有现代编程语言所采用),这是一种二进制表示法,可以精确地表示分数,比如1/2、1/8和1/1024。遗憾的是,我们常用的分数(特别是在金融计算方面)都是十进制分数1/10、1/100等。二进制浮点数表示法并不能精确表示类似 0.1 这样简单的数字。

根据双精度浮点数的定义,Number 类型中有效的整数范围是 -0x1fffffffffffff 至 0x1fffffffffffff,所以 Number 无法精确表示此范围外的整数。

同样根据浮点数的定义,非整数的 Number 类型无法用 ==(=== 也不行) 来比较,一段著名的代码,为什么在 JavaScript 中,0.1+0.2 不能 =0.3:

console.log( 0.1 + 0.2 == 0.3); // false

正确的比较方法是使用 JavaScript 提供的最小精度值:

console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON); // true

类型判断

在 JavaScript 语言里常用的类型判断有:

  • typeof 关键字(常用)
  • instanceof
  • Object.prototype.toString (最准确,常用)
  • cunstructor

typeof

// 基础数据类型
typeof 1; // "number"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof 'junting'; // "string"
typeof Symbol(); // "symbol"

// 复杂数据类型
typeof function(){}; // "function"
typeof [1, 2, 3]; // "object"
typeof new Date(); // "object"
typeof new RegExp(); // "object"

// 基本类型里的唯一意外
typeof null; // "object"

// 检测 null 值
let a = null;
(!a && typeof a === "object");// true

结论:

使用 typeof 关键字可以准确的判断出除 null 以外的基本类型,以及 function 类型、symbol 类型;

null 会被判断为 object 类型。

instanceof

使用 **a instanceof B ** 判断类型是根据: a 是否为 B 的实例,即 a 的原型链是否存在 B 构造函数

function Dog(name) {
  this.name = name;
}

const pipi = new Dog('pipi');

pipi instanceof Dog; // true

这里 pipiDog 构造出来的实例。同时,顺着 pipi 的原型链,也能找到 Object 构造函数。

pipi.__proto__.proto__ === Object.prototype; // true

因此:

pipi instanceof Object; // true

但是 instanceof 只对 object 类型有用,对基本类型就不起作用了,因为其原理是根据其值是否是该类型对象构造函数造出来的实例对象。

1 instanceof Number; // false
'junting' instanceof String; // false
Symbol() instanceof Symbol; //false

模拟一个 instanceof 功能的函数:

/**
 * L 左表达式,R 右表达式
 */
function _instanceof(L, R) {
  if (typeof L !== "object") {
    return false;
  }
  
  while(true) {
    // 最顶层为 null
    if(L === null) {
      return false;
    }
    if(L.__proto__ === R.prototype) {
      return true;
    }
    // 一层层向上查找
    L = L.__proto__;
  }
}

_instanceof('junting', String); // false
_instanceof({}, Object); // true

结论:

instanceof 不支持基本类型的类型判断。

constructor

使用 constructor 查看目标的构造函数来进行类型判断:

let variate = 1;
variate.constructor; // ƒ Number() { [native code] }

variate = true; 
variate.constructor; // ƒ Boolean() { [native code] }

variate = 'string'; 
variate.constructor; // ƒ String() { [native code] }

variate = [1, 2, 3];
variate.constructor; // ƒ Array() { [native code] }

variate = { a: 1};
variate.constructor; // ƒ Object() { [native code] }

variate = new RegExp();
variate.constructor; // ƒ RegExp() { [native code] }

variate = new Date();
variate.constructor; // ƒ Date() { [native code] }

variate = null;
variate.constructor;
// VM1546:2 Uncaught TypeError: Cannot read property 'constructor' of null
//    at <anonymous>:2:9

variate = undefined;
variate.constructor;
// VM1670:2 Uncaught TypeError: Cannot read property 'constructor' of undefined
//    at <anonymous>:2:9

结论:

对于 undefinednull,如果尝试读取其 constructor 属性,将会进行报错。并且 constructor 返回的是构造函数本身,一般使用它来判断类型的情况并不多见。

Object.prototype.toString (终极方案)

使用 Object.prototype.toString 判断类型,是最为准确且无什么缺陷:

Object.prototype.toString.call(1); // "[object Number]"

Object.prototype.toString.call('junting'); // "[object String]"

Object.prototype.toString.call(undefined); // "[object Undefined]"

Object.prototype.toString.call(true)) // "[object Boolean]"

Object.prototype.toString.call({}) // "[object Object]"

Object.prototype.toString.call([]) // "[object Array]"

Object.prototype.toString.call(function(){}) // "[object Function]"

Object.prototype.toString.call(null)) // "[object Null]"

Object.prototype.toString.call(Symbol('junting')) // "[object Symbol]"

JavaScript 类型及其转换

JavaScript 中的几个基本类型,都在对象类型中有一个“亲戚”。它们是:

  • Number
  • String
  • Boolean
  • Symbol

3 和 new Number(3) 是完全不同的值,他们一个是 Number 类型,一个是对象类型。

Number、String 和 Boolean,三个构造器是两用的,当跟 new 搭配时,它们产生对象,当直接调用时,它们表示强制类型转换。

Symbol 函数比较特殊,用 new 调用它会抛出错误,但它仍然是 Symbol 对象的构造器。

日常开发中,我们可以把对象上的方法用在基本类型上,如:

'Junting'.slice(0, 1)

甚至在原型上添加方法,都可以应用于基本类型上,如:

Symbol.prototype.hello = () => console.log('Hello');

let a = Symbol('a');
typeof a; // symbol
a.hello(); // Hello

这是因为 "." 运算符提供了封箱操作[^装箱转换],它会根据基础类型构造一个临时对象,使得我们能在基础类型上调用对应的方法。

装箱转换

每一种基本类型 Number、String、Boolean、Symbol 在对象中都有对应的类,所谓装箱转换,正是把基本类型转换为对应的对象,它是类型转换中一种相当重要的种类。装箱机制会频繁产生临时对象,在一些对性能要求较高的场景下,我们应该尽量避免对基本类型做装箱转换。

拆箱转换

在 JavaScript 标准中,规定了 ToPrimitive 函数,它是对象类型到基本类型的转换(即,拆箱转换)。

对象到 String 和 Number 的转换都遵循“先拆箱再转换”的规则。通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String 或者 Number。

JavaScript 是一种弱类型或者说动态语言。这意味着你不用提前声明变量的类型,在程序运行过程中,类型会被自动确定。

console.log(1 + '1')
// "11"

console.log(1 + true)
// 2

console.log(1 + false)
// 1

console.log(1 + undefined)
// NaN

console.log('Junting ' + true)
// "Junting true"

**从上面的例子,可以得出:**当使用 + 运算符计算 string 和其他类型相加时,都会转换为 string 类型;其他情况,都会转换为 number 类型,但是 undefined 会转换为 NaN,相加结果也是 NaN

console.log('Junting ' + {})
// "Junting [object Object]"

console.log('Junting ' + [])
// "Junting "

+ 号两侧,如果存在复杂类型,比如对象,那么这到底是怎样的一套转换规则呢?

答: 当使用 + 运算符计算时,如果存在复杂类型,那么复杂类型将会转换为基本类型,再进行运算。

这里就涉及到对象类型转换基本类型的这个过程了。

对象类型转换基本类型 具体规则:

答: 对象在转换基本类型时,会调用该对象上 valueOftoString 这两个方法,该方法的返回值是转换为基本类型的结果。

那具体调用 valueOf 还是 toString 呢?

这是 ES 规范所决定的,实际上这取决于内置的 toPrimitive 调用结果。主观上说,这个对象倾向于转换成什么,就会优先调用哪个方法。如果倾向于转换为 Number 类型,就优先调用 valueOf;如果倾向于转换为 String 类型,就只调用 toString

Symbol.toPrimitive 方法被调用时会默认传递一个 **类型提示(hint)**参数,判断采取哪种模式进行转换。(这里可以参考看下 Symbol.toPrimitive 的介绍)

valueOftoString 这两个方法是可以被开发者重写的:

const person = {
  toString() {
    return "Junting";
  },
  valueOf() {
    return 666;
  }
};

alert(person); // 'Junting', 调用 alert 打印输出时,倾向于使用 person 对象的 toString 方法将 person 转为基本类型。这里跟属性方法先后顺序无关。

console.log(222 + person); // 888

对于加法操作的转换规则

  • 加号两边都是 Number 类型
    • 如果 + 号两边存在 NaN,则结果为 NaNtypeof NaN 是 ’number‘)。
    • 如果是 Infinity + Infinity, 结果 Infinity
    • 如果是 -Infinity + (-Infinity), 结果是 -Infinity
    • 如果是 Infinity + (-Infinity), 结果是 NaN
  • 加号两边有至少一个是字符串
    • 如果 + 号两边都是字符串,则执行字符串拼接
    • 如果 + 号两边只有一个值是字符串,则将另一边的值转换为字符串,再执行字符串拼接
    • 如果 + 号两边有一个对象,则调用 valueOf() 或者 toString() 方法取得值,转换为基本类型再进行字符串拼接。

对于其他操作符也是类似的。

分析一道网红题

Can (a == 1 && a == 2 && a == 3) ever evaluate to true? 
// a == 1 && a == 2 && a == 3 可能为 true 吗?

直观上分析,如果变量 a 是一个基本 Number 类型,这是不可能为 true 的,因此解题思路也需要从变量 a 的类型及(对象)转换(基本类型)上来考虑。

const a = {
    value: 1,
    toString: function () {
        return a.value++
    }
}
console.log(a == 1 && a == 2 && a == 3) // true

这个方案中,我们将 a 定义为一个对象,并重写了其 toString 方法。因此在每次进行判断时,按照规则,== 号两边出现了对象类型,另一边是 Number 类型,需要调用 a 对象 toString 方法,toString 方法的返回值会作为对象转为基本类型的值,我们每次将 value 属性加 1。

同样,如果按照相同的方式重写 valueOf 方法,也是可以达到同样目的的。

let value = 0
Object.defineProperty(window, 'a', {
    get: function() {
        return ++value
    }
})

console.log(a == 1 && a == 2 && a == 3) // true

这里我们将 a 作为属性,挂载在 window 对象当中,重写其 getter 方法。

这道题考察的是你对 JavaScript 隐式转换其原理了解多少。

Footnotes

  1. weread.qq.com/web/reader/…

  2. Java程序员应该很熟悉这种格式,就像他们熟悉双精度(double)类型一样。在C和C++的所有现代实现中也都用到了双精度类型。