懂王系列(七)之彻底搞懂JavaScript数据类型

290 阅读16分钟

作为一名前端小白,不知道大家是否遇到和我一样的问题。看了一道面试题的解析,当时觉得会了,可是过两天以后再看又不会了;盲目追求各种新技术,感觉什么都会点,但是一上手就不行了... 痛定思痛后,我终于认识到了问题所在,开始专注于基本功的修炼。近半年来通读了(其实是囫囵吞枣)《JavaScript高级程序设计》、《你不知道的JavaScript上、中、下》等书籍,本系列文章是我读书过程中对知识点的一些总结。喜欢的同学记得帮我点个赞😁。

懂王系列(一)之彻底搞懂JavaScript函数执行机制
懂王系列(二)之彻底搞懂JavaScript作用域
懂王系列(三)之彻底搞懂JavaScript对象
懂王系列(四)之彻底搞懂JavaScript类
懂王系列(五)之彻底搞懂JavaScript原型
懂王系列(六)之彻底搞懂JavaScript中的this
懂王系列(七)之彻底搞懂JavaScript数据类型
懂王系列(八)之彻底搞懂JavaScript语句
懂王系列(九)之彻底搞定JavaScript类型转换

1. 值

JavaScript 中的变量是没有类型的,只有值才有。变量可以随时持有任何类型的值

1.1 数组

1.1.1 delete

使用 delete 运算符可以将单元从数组中删除,但是请注意,单元删除后,数组的 length 属性并不会发生变化

1.1.2 稀疏数组

在创建“稀疏”数组(sparse array,即含有空白或空缺单元的数组)时要特别注意:

var a = [];
a[0] = 1;
// 此处没有设置a[1]单元
a[2] = [3];
a[1]; // undefined
a.length; // 3

上面的代码可以正常运行,但其中的“空白单元”(empty slot)可能会导致出人意料的结果。a[1]的值虽然为 undefined,但这与将其显式赋值为 undefined(a[1] = undefined) 是有区别的

1.1.3 数组键值

数组通过数字进行索引,但有趣的是它们也是对象,所以也可以包含字符串键值和属性(但这些并不计算在数组长度内)

var a = [];
a[0] = 1;
a["foobar"] = 2;
a.length; // 1
a["foobar"]; // 2
a.foobar; // 2

特别注意,如果字符串键值能够被强制类型转换为十进制数字的话,它就会被当作数字索引来处理。

var a = [];
a["13"] = 42;
a.length; // 14

在数组中加入字符串键值/属性并不是一个好主意。建议使用对象来存放键值/属性值,用数组来存放数字索引值。

1.1.4 类数组转为数组

有时需要将类数组(一组通过数字索引的值)转换为真正的数组,这一般通过数组工具函数(如indexOf(..)、concat(..)、forEach(..) 等)来实现。

工具函数 slice(..) 经常被用于这类转换:

function foo() {
  var arr = Array.prototype.slice.call(arguments);
  arr.push( "bam" );
  console.log( arr );
}
foo( "bar", "baz" ); // ["bar","baz","bam"]

用 ES6 中的内置工具函数 Array.from(..) 也能实现同样的功能:

var arr = Array.from(arguments);

1.1.5 字符串

字符串经常被当成字符数组。字符串的内部实现究竟有没有使用数组并不好说,但 JavaScript 中的字符串和字符数组并不是一回事,最多只是看上去相似而已

var a = "foo";
var b = ["f","o","o"];

字符串和数组的确很相似,它们都是类数组,都有 length 属性以及 indexOf(..)(从 ES5开始数组支持此方法)和 concat(..) 方法:

a.length; // 3
b.length; // 3
a.indexOf( "o" ); // 1
b.indexOf( "o" ); // 1
var c = a.concat( "bar" ); // "foobar"
var d = b.concat( ["b","a","r"] ); // ["f","o","o","b","a","r"]
a === c; // false
b === d; // false
a; // "foo" 
b; // ["f","o","o"]

但这并不意味着它们都是“字符数组”,比如:

a[1] = "O";
b[1] = "O";
a; // "foo"
b; // ["f","O","o"]

JavaScript 中字符串是不可变的,而数组是可变的。并且 a[1] 在 JavaScript 中并非总是合法语法。正确的方法应该是 a.charAt(1)
字符串不可变是指字符串的成员函数不会改变其原始值,而是创建并返回一个新的字符串。而数组的成员函数都是在其原始值上进行操作。

c = a.toUpperCase();
a === c; // false
a; // "foo"
c; // "FOO"
b.push( "!" );
b; // ["f","O","o","!"]

许多数组函数用来处理字符串很方便。虽然字符串没有这些函数,但可以通过“借用”数组的非变更方法来处理字符串

a.join; // undefined
a.map; // undefined
var c = Array.prototype.join.call(a, "-");
var d = Array.prototype.map.call(a, function(v){
 return v.toUpperCase() + ".";
}).join( "" );
c; // "f-o-o"
d; // "F.O.O."

另一个不同点在于字符串反转(JavaScript 面试常见问题)。数组有一个字符串没有的可变更成员函数 reverse()

a.reverse; // undefined
b.reverse(); // ["!","o","O","f"]
b; // ["f","O","o","!"]

可惜我们无法“借用”数组的可变更成员函数,因为字符串是不可变的:

Array.prototype.reverse.call(a);
// 返回值仍然是字符串"foo"的一个封装对象

一个变通(破解)的办法是先将字符串转换为数组,待处理完后再将结果转换回字符串:

var c = a
 // 将a的值转换为字符数组
 .split("")
 // 将数组中的字符进行倒转
 .reverse()
 // 将数组中的字符拼接回字符串
 .join("");
c; // "oof"

这种方法的确简单粗暴,但对简单的字符串却完全适用。

上述方法对于包含复杂字符(Unicode,如星号、多字节字符等)的字符串并不适用。这时则需要功能更加完备、能够处理 Unicode 的工具库。

如果需要经常以字符数组的方式来处理字符串的话,倒不如直接使用数组。这样就不用在字符串和数组之间来回折腾。可以在需要时使用 join("") 将字符数组转换为字符串。

1.2 数字

1.2.1 数字语法

JavaScript 中的“整数”就是没有小数的十进制数。所以 42.0 即等同于“整数”42
JavaScript 中的数字字面量一般用十进制表示。例如:

var a = 42; 
var b = 42.3;

小数点前面的 0 可以省略:

var a = 0.42; 
var b = .42;

小数点后小数部分最后面的 0 也可以省略:

var a = 42.0; 
var b = 42.;

tofixed(..) 方法可指定小数部分的显示位数, toPrecision(..) 方法用来指定有效数位的显示位数

对于 . 运算符需要给予特别注意,因为它是一个有效的数字字符,会被优先识别为数字字面量的一部分,然后才是对象属性访问运算符。

// 无效语法:
42.toFixed( 3 ); // SyntaxError 
// 下面的语法都有效:
(42).toFixed( 3 ); // "42.000" 
0.42.toFixed( 3 ); // "0.420" 
42..toFixed( 3 ); // "42.000"

42.toFixed(3) 是无效语法,因为 . 被视为常量 42. 的一部分,所以没有 . 属性访问运算符来调用 toFixed 方法。
42..toFixed(3) 则没有问题,因为第一个 . 被视为 number 的一部分,第二个 . 是属性访问运算符。只是这样看着奇怪,实际情况中也很少见。在基本类型值上直接调用的方法并不多见,不过这并不代表不好或不对。
下面的语法也是有效的(请注意其中的空格):

42 .toFixed(3); // "42.000"

然而对数字字面量而言,这样的语法很容易引起误会,不建议使用。

1.2.2 0.1+0.2问题

最常见的方法是设置一个误差范围值,通常称为“机器精度”(machine epsilon),对 JavaScript 的数字来说,这个值通常是 2^-52 (2.220446049250313e-16)。 从 ES6 开始,该值定义在 Number.EPSILON 中,我们可以直接拿来用,也可以为 ES6 之前的版本写 polyfill:

if (!Number.EPSILON) {
 Number.EPSILON = Math.pow(2,-52);
}

可以使用 Number.EPSILON 来比较两个数字是否相等(在指定的误差范围内):

function numbersCloseEnoughToEqual(n1,n2) {
	// abs(), 绝对值
	return Math.abs(n1 - n2) < Number.EPSILON;
}
var a = 0.1 + 0.2;
var b = 0.3;
numbersCloseEnoughToEqual(a, b); // true
numbersCloseEnoughToEqual(0.0000001, 0.0000002); // false

1.2.3 整数检测

要检测一个值是否是整数,可以使用 ES6 中的 Number.isInteger(..) 方法:

Number.isInteger(42); // true 
Number.isInteger(42.000); // true 
Number.isInteger(42.3); // false

也可以为 ES6 之前的版本 polyfill Number.isInteger(..) 方法:

if (!Number.isInteger) { 
  Number.isInteger = function(num) { 
  	return typeof num == "number" && num % 1 == 0; 
  }; 
}

要检测一个值是否是安全的整数,可以使用 ES6 中的 Number.isSafeInteger(..) 方法:

Number.isSafeInteger(Number.MAX_SAFE_INTEGER); // true 
Number.isSafeInteger(Math.pow( 2, 53 )); // false 
Number.isSafeInteger(Math.pow( 2, 53 ) - 1); // true

可以为 ES6 之前的版本 polyfill Number.isSafeInteger(..) 方法:

if (!Number.isSafeInteger) { 
  Number.isSafeInteger = function(num) { 
    return Number.isInteger(num) && 
    Math.abs(num) <= Number.MAX_SAFE_INTEGER; 
  }; 
}

1.3 特殊值

1.3.1 null与undefined

• null 指空值(empty value)
• undefined 指没有值(missing value)

或者:

• undefined 指从未赋值
• null 指曾赋过值,但是目前没有值

已在作用域中声明但还没有赋值的变量,是 undefined 的。相反,还没有在作用域中声明过的变量,是 undeclared(未定义) 的

null 是基本类型中唯一的一个“假值”,typeof对它的返回值为 "object",所以检测null类型需要通过如下方法:

var a = null;
(!a && typeof a === "object"); // true

1.3.2 void

表达式 void ___ 没有返回值,因此返回结果是 undefinedvoid 并不改变表达式的结果,只是让表达式不返回值:

var a = 42;
console.log(void a, a); // undefined 42

按惯例我们用 void 0 来获得 undefined(这主要源自 C 语言,当然使用 void true 或其他 void 表达式也是可以的)。void 0void 1undefined 之间并没有实质上的区别。

void 运算符在其他地方也能派上用场,比如不让表达式返回任何结果(即使其有副作用)。例如:

function doSomething() {
  // 注: APP.ready 由程序自己定义
  if (!APP.ready) {
    // 稍后再试
    return void setTimeout(doSomething, 100);
  }
  var result;
  // 其他
  return result;
}
// 现在可以了吗?
if (doSomething()) {
	// 立即执行下一个任务
}

这里 setTimeout(..) 函数返回一个数值(计时器间隔的唯一标识符,用来取消计时),但是为了确保 if 语句不产生误报(false positive),我们要 void 掉它。 很多开发人员喜欢分开操作,效果都一样,只是没有使用 void 运算符:

if (!APP.ready) {
  // 稍后再试
  setTimeout( doSomething,100 );
  return;
}

总之,如果要将代码中的值(如表达式的返回值)设为 undefined,就可以使用 void。这种做法并不多见,但在某些情况下却很有用。

1.3.3 特殊数字

  1. NaN
var a = 2 / "foo"; // NaN
typeof a === "number"; // true

换句话说,“不是数字的数字”仍然是数字类型。
NaN 是一个特殊值,它和自身不相等,是唯一一个非自反(自反,reflexive,即 x === x 不成立)的值

var b = "foo";
a; // NaN
b; "foo"
window.isNaN(a); // true
window.isNaN(b); // true——晕!

从 ES6 开始我们可以使用工具函数 Number.isNaN(..)。ES6 之前的浏览器的 polyfill 如下:

if (!Number.isNaN) {
  Number.isNaN = function(n) {
    return (
      typeof n === "number" &&
      window.isNaN(n)
    ); 
  };
}
var a = 2 / "foo";
var b = "foo";
Number.isNaN(a); // true
Number.isNaN(b); // false——好!

实际上还有一个更简单的方法,即利用 NaN 不等于自身这个特点。NaN 是 JavaScript 中唯一一个不等于自身的值。一一个不等于自身的值。于是我们可以这样:

if (!Number.isNaN) {
  Number.isNaN = function(n) {
  	return n !== n;
  };
}
  1. 零值

有些应用程序中的数据需要以级数形式来表示(比如动画帧的移动速度),数字的符号位(sign)用来代表其他信息(比如移动的方向)。此时如果一个值为 0 的变量失去了它的符号位,它的方向信息就会丢失。所以保留 0 值的符号位可以防止这类情况发生。

var a = 0 / -3; // -0
var b = 0 * -3; // -0

乘除会得到负零,加法和减法运算不会得到负零(negative zero)。 对负零进行字符串化会返回 "0",JSON也如此

JSON.stringify(a); // "0"

但是其从字符串转换为数字,得到的结果是准确的:

+"-0"; // -0
Number("-0"); // -0
JSON.parse("-0"); // -0

1.3.4 特殊等式

Object.is(..) 来判断两个值是否绝对相等,可以用来处理上述所有的特殊情况:

var a = 2 / "foo";
var b = -3 * 0;
Object.is(a, NaN); // true
Object.is(b, -0); // true
Object.is(b, 0); // false

对于 ES6 之前的版本,Object.is(..) 有一个简单的 polyfill:

if (!Object.is) {
   Object.is = function(v1, v2) {
     // 判断是否是-0
     if (v1 === 0 && v2 === 0) {
     	return 1 / v1 === 1 / v2;
     }
     // 判断是否是NaN
     if (v1 !== v1) {
    	return v2 !== v2;
     }
     // 其他情况
     return v1 === v2;
   };
}

能使用 =====时就尽量不要使用 Object.is(..),因为前者效率更高

2. 原生函数

常用的原生函数:

• String()
• Number()
• Boolean()
• Array()
• Object()
• Function()
• RegExp()
• Date()
• Error()
• Symbol()——ES6 中新加入的!

var a = new String("abc");
typeof a; // 是"object",不是"String"
a instanceof String; // true
Object.prototype.toString.call(a); // "[object String]"

通过构造函数(如 new String("abc"))创建出来的是封装了基本类型值(如 "abc")的封装对象。

2.1 内部属性[[Class]]

所有 typeof 返回值为 "object" 的对象(如数组)都包含一个内部属性 [[Class]](我们可以把它看作一个内部的分类,而非传统的面向对象意义上的类)。这个属性无法直接访问,一般通过 Object.prototype.toString(..) 来查看

注意:函数是“可调用对象”,它有一个内部属性 [[Call]],该属性使其可以被调用

多数情况下,对象的内部 [[Class]] 属性和创建该对象的内建原生构造函数相对应(如下),但并非总是如此。 那么基本类型值呢?下面先来看看 nullundefined

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

其他基本类型值(如字符串、数字和布尔)的情况有所不同,通常称为“包装”

Object.prototype.toString.call("abc");
// "[object String]"
Object.prototype.toString.call(42);
// "[object Number]"
Object.prototype.toString.call(true);
// "[object Boolean]"

以上基本类型值被各自的封装对象自动包装,所以它们的内部 [[Class]] 属性值分别为"String"、"Number" 和 "Boolean"

2.2 封装

如果想要自行封装基本类型值,可以使用 Object(..) 函数(不带 new 关键字):

var a = "abc";
var b = new String(a);
var c = Object(a);
typeof a; // "string"
typeof b; // "object"
typeof c; // "object"
b instanceof String; // true
c instanceof String; // true
Object.prototype.toString.call(b); // "[object String]"
Object.prototype.toString.call(c); // "[object String]"

再次强调,一般不推荐直接使用封装对象(如上例中的 b 和 c),但它们偶尔也会派上用场。

2.3 拆封

如果想要得到封装对象中的基本类型值,可以使用 valueOf() 函数:

var a = new String("abc");
var b = new Number(42);
var c = new Boolean(true);
a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true

2.4 原生函数作为构造函数

2.4.1 Array

构造函数 Array(..) 不要求必须带 new 关键字。不带时,它会被自动补上。因此 Array(1,2,3)new Array(1,2,3) 的效果是一样的。

Array 构造函数只带一个数字参数的时候,该参数会被作为数组的预设长度(length),而非只充当数组中的一个元素。

var a = new Array( 3 );
a.length; // 3
a;

a 在 Chrome 中显示为 [ empty x 3 ],稀疏数组

var a = new Array(3);
var b = [undefined, undefined, undefined];
var c = [];
c.length = 3;
a.join("-"); // "--"
b.join("-"); // "--"
a.map(function(v,i){ return i; }); // [ empty x 3 ]
b.map(function(v,i){ return i; }); // [ 0, 1, 2 ]

a.map(..) 之所以执行失败,是因为数组中并不存在任何单元,所以 map(..) 无从遍历。join(..) 却不一样,它的具体实现可参考下面的代码:

function fakeJoin(arr,connector) {
  var str = "";
  for (var i = 0; i < arr.length; i++) {
    if (i > 0) {
    	str += connector;
    }
    if (arr[i] !== undefined) {
    	str += arr[i];
    } 
  }
  return str; 
}
var a = new Array(3);
fakeJoin(a, "-"); // "--"

从中可以看出,join(..) 首先假定数组不为空,然后通过 length 属性值来遍历其中的元素。而 map(..) 并不做这样的假定,因此结果也往往在预期之外,并可能导致失败。

我们可以通过下述方式来创建包含 undefined 单元(而非“空单元”)的数组:

var a = Array.apply(null, { length: 3 });
a; // [ undefined, undefined, undefined ]

虽然 Array.apply(null, { length: 3 }) 在创建 undefined 值的数组时有些奇怪和繁琐,但是其结果远比 Array(3) 更准确可靠。总之,永远不要创建和使用空单元数组。

2.4.2 Date

创建日期对象必须使用 new Date()Date(..) 可以带参数,用来指定日期和时间,而不带参数的话则使用当前的日期和时间。 Date(..) 主要用来获得当前的 Unix 时间戳。该值可以通过日期对象中的 getTime() 来获得。从 ES5 开始引入了一个更简单的方法,即静态函数 Date.now()。对 ES5 之前的版本我们可以使用下面的 polyfill:

if (!Date.now) {
  Date.now = function(){
  	return (new Date()).getTime();
  };
}

如果调用 Date() 时不带 new 关键字,则会得到当前日期的字符串值。

2.4.3 Error

构造函数 Error(..)(与前面的 Array() 类似)带不带 new 关键字都可,错误对象通常与 throw 一起使用:

通常错误对象至少包含一个 message 属性,有时也不乏其他属性(必须作为只读属性访问),如 type。除了访问 stack 属性以外,最好的办法是调用(显式调用或者通过强制类型转换隐式调用)toString() 来获得经过格式化的便于阅读的错误信息。

typeof Function.prototype; // "function"
Function.prototype(); // 空函数!

2.4.4 原生函数

Function.prototype 是一个空函数,而 Array.prototype 是一个空数组,对未赋值的变量来说,它们是很好的默认值。

function isThisCool(vals,fn,rx) {
  vals = vals || Array.prototype;
  fn = fn || Function.prototype;
  rx = rx || RegExp.prototype;
  return rx.test(
  	vals.map( fn ).join( "" )
  ); 
}
isThisCool(); // true
isThisCool(
  ["a","b","c"],
  function(v){ return v.toUpperCase(); },
  /D/
); // false

这种方法的一个好处是 .prototype 已被创建并且仅创建一次。 相反, 如果将 []function(){}作为默认值,则每次调用 isThisCool(..) 时它们都会被创建一次(具体创建与否取决于 JavaScript 引擎,稍后它们可能会被垃圾回收),这样无疑会造成内存和 CPU 资源的浪费。 另外需要注意的一点是,如果默认值随后会被更改,那就不要使用 Array.prototype。上例中的 vals 是作为只读变量来使用,更改 vals 实际上就是更改 Array.prototype

3. 数据类型检测

3.1 typeof

特点:
返回结果都是字符串,字符串中包含了对应的数据类型
"number""string"/"boolean"/"undefined"/"symbol"/"object"/"function"

局限:
typeof null => "object",null不是对象,它是空对象指针
检测数据或者正则等特殊的对象,返回结果都是"object",所以无法基于typeof判断是数据还是正则

console.log(typeof []); //=>"object"
console.log(typeof typeof []); //=>"string"

3.2 instanceof/constructor

特点:
检测某个实例是否属于这个类
他检测的底层机制:所有出现在其原型链上的类,检测结果都是TRUE

局限性:
由于可以基于__proto__或者prototype改动原型链的动向,所以基于instanceof检测出来的结果并不一定是准确的
基本数据类型的值,连对象都不是,更没有__proto__,虽说也是所属类的实例,在JS中也可以调取所属类原型上的方法,但是instanceof是不认的

console.log(12 instanceof Number); //=>false
console.log(new Number(12) instanceof Number); //=>true
console.log([] instanceof Array); //=>true
console.log([] instanceof Object); //=>true

function Fn() {}
Fn.prototype.__proto__ = Array.prototype;
let f = new Fn();
//=>原型链:f -> Fn.prototype -> Array.prototype -> Object.prototype

3.3 Object.prototype.toString.call([value])/({}).toString.call([value])

特点:
不是用来转换为字符串的,而是返回当前实例所属类的信息

格式:"[object 所属类信息]"

这种方式基本上没有什么局限性,是检测数据类型最准确的方式

3.4 完整版

var class2type = {};
var toString = class2type.toString; //=>Object.prototype.toString
var hasOwn = class2type.hasOwnProperty; //=>Object.prototype.hasOwnProperty
var fnToString = hasOwn.toString; //=>Function.prototype.toString
var ObjectFunctionString = fnToString.call(Object); //=>Object.toString() =>"function Object() { [native code] }"

"Boolean Number String Function Array Date RegExp Object Error Symbol".split(" ").forEach(function anonymous(item) {
    class2type["[object " + item + "]"] = item.toLowerCase();
});
console.log(class2type);

function toType(obj) {
  if (obj == null) {
	return obj + ""; //=>return "null"/"undefined"
  }

  return typeof obj === "object" || typeof obj === "function" ? class2type[toString.call(obj)] || "object" : typeof obj;
}
  
//=>是否为函数
var isFunction = function isFunction(obj) {
  return typeof obj === "function" && typeof obj.nodeType !== "number";
};

//=>检测是否为window对象
// window.window===window
var isWindow = function isWindow(obj) {
  return obj != null && obj === obj.window;
};

//=>是否为纯粹的对象{}(数组和正则等都不是纯粹的对象)
var isPlainObject = function isPlainObject(obj) {
  var proto, Ctor;
  if (!obj || toString.call(obj) !== "[object Object]") {
      return false;
  }
  //=>getPrototypeOf获取当前对象的原型
  proto = Object.getPrototypeOf(obj);
  // Objects with no prototype (`Object.create( null )`)
  if (!proto) {
      return true;
  }
  // Objects with prototype are plain iff they were constructed by a global Object function
  Ctor = hasOwn.call(proto, "constructor") && proto.constructor;
  return typeof Ctor === "function" && fnToString.call(Ctor) === ObjectFunctionString;
};

//=>是否为空对象
var isEmptyObject = function isEmptyObject(obj) {
  var name;
  for (name in obj) {
    return false;
  }
  return true;
};

//=>是否为数组或者类数组
var isArrayLike = function isArrayLike(obj) {
  var length = !!obj && "length" in obj && obj.length,
  	type = toType(obj);
  if (isFunction(obj) || isWindow(obj)) {
    return false;
  }
  return type === "array" || length === 0 || typeof length === "number" && length > 0 && (length - 1) in obj;
};