聊聊JavaScript中“任性”的类型和值

342 阅读16分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

JavaScript中的类型是相对其他传统语言更为“任性”的存在,这里的“任性”指的是我们经常明明一手操作的是某种字面量类型,一眨眼就被“神不知鬼不觉”地转换成了另一种字面量类型。

这里面涉及的就是JS引擎帮你“热心”操作的自动转换,也可以称之为强制类型转换,如何去明了其中的原理和过程,也是JS中不能回避的必修基础。

还有一些任性之处:JS的二进制浮点数之间的加减乘除运算所得,真的是数字字面量表面上看起来的那般“所见即所得”吗(0.1+0.2===0.3)?函数和对象的关系是什么?数组对象和普通对象的区别在哪里?函数怎么也有length属性?欢迎打开JavaScript的类型之门~

1. 类型

1.1 内置类型

JavaScript拥有七种内置类型:

  • 空值(null)
  • 未定义(undefined)
  • 布尔值(boolean)
  • 数字(number)
  • 字符串(string)
  • 符号(symbol ES6新增类型)
  • 对象(object 极其核心)

我们一般把除了对象以外的类型称之为基本数据类型,而对象内又包括了普通对象和数组对象。

console.log(typeof undefined === 'undefined'); //true
console.log(typeof null === 'object'); //true
console.log(typeof true === 'boolean'); //true
console.log(typeof 12 === 'number'); //true
console.log(typeof '12' === 'string'); //true
console.log(typeof { a: 2 } === 'object'); //true
console.log(typeof Symbol() === 'symbol'); //true

大家可能留意到了,typeof null的值是object,事实上很多初入门的同学会将null视为空对象,可是null真的是空对象吗?

并不是。

之所以typeof null会返回一个"object",是因为不同的对象在底层均表示为二进制,在JS中二进制前三位都为0的话,就会被判断为object类型,而null的二进制表示是全0,故会被判定为"object",这属于JS语言的"BUG"。

这个BUG已经出现太久了,由于其牵涉到了太多Web框架和系统的研发,属于彻头彻尾的历史遗留问题,估计很难再去解决了。

我们真正要检测一个属性的值是不是null,我们只能用以下方法判断才合理。

var a = null;
console.log(!a && typeof a === 'object');  //true

undefined和null类型的名称既是类型又是值,undefined表示从未赋值,null指的是空值。

null是一个特殊关键字,不是标识符,我们不能将其作为变量来使用和赋值,但是我们却可以把undefined当做变量来使用和赋值(但是强烈不推荐,某种意义上来说代码规范是严禁此种行为的)。

void运算符

我们可以使用void运算符来得到undefined的值,通过表达式void 变量 来实现。

void运算符无论配合什么类型的变量,得到的返回值都只会是undefined,前提是该变量必须声明。

var a = 15;
var b = null;
var c;
console.log(void a); //undefined
console.log(void b); //undefined
console.log(void c); //undefined

下面还有三种特殊情况:

var a = function () {};
var b = {};
var c = [];
console.log(typeof a === 'object'); //false
console.log(typeof a === 'function'); //true
console.log(typeof b === 'object'); //true
console.log(typeof c === 'object'); //true

我们在谈论函数的时候,如果用typeof去检查函数的类型,返回的值是function而非object,这让非常多的人感到疑惑:函数不是对象的子类型吗?

实际上,函数确实是对象的子类型,function并非是JS中的七种内置类型中的一种,只不过在typeof的检测会返回对应的详细类型,也就是对象的子类型——function

函数属于可调用对象,其存在一个内部属性[[Call]],通过该属性可以使得函数被调用。

同时函数还可以像对象一般拥有属性,属性是以传参的参数形式存在。

var a = function (a, b) {};
console.log(a.length); //2

函数对象的length属性是指的是声明参数的个数。

同时我们可以看到声明的数组c,其也属于对象的一个子类型,数组内部的元素按照数字顺序来索引,其length属性是元素的个数。

1.2 类型和值

JavaScript中的变量是自由的,其不存在类型,JavaScript的类型在被执行各类操作时(typeof),得到的结果只是该变量的值的类型,而非变量的类型,JS引擎不强制要求变量保持初始类型的值不变。

1.2.1 undefined和undeclared

已经在作用域中生命但还未进行赋值的变量,它的值是"undefined",且undefined可以作为一种普通的值进行复制。

var a;
console.log(typeof a); //"undefined"
var b = 42;
b = a;
console.log(typeof b); //"undefined"

而从未在作用域中声明过的变量的值为"undeclared"。

一般情况下,浏览器对"undeclared"值的类型会抛出一个ReferenceError: xxx is not defined

但其实更麻烦的在于,typeof处理"undeclared"的方法也是照常返回undefined。

console.log(typeof a); //"undefined"
console.log(a); //Uncaught ReferenceError: a is not defined

实际上,typeof针对值为undeclared的返回结果,是由于其自身的安全防范机制所产生的结果。

我们假设一个场景,在多模块引入的工作环境中,如果我们需要检测一个变量是否存在才能进行工作,如果我们直接用if()来判断,那么必然会导致报错,最理想的方法即为使用typeof来判断变量是否存在。

if (a) { //这里会抛出 Uncaught ReferenceError: a is not defined
  console.log('这样是不行的');
}
if (typeof a !== 'undefined') {
  console.log('我有这个变量了,执行下面的操作...');
}

另外一种方法则是通过window来检查所有全局变量是否是全局对象的属性,这也是一种安全防范操作。

if (window.a) {
     console.log('我有这个变量了,执行下面的操作...');
}

在全局对象window上访问不存在的对象属性,不会抛出报错。

我们可以利用typeof合成一个检查变量(这里是fn)存在与否的函数。

function checkIterior() {
  var iterior =
    typeof fn !== 'undefined'
      ? fn
      : function () {
          console.log('1');
        };
  var val = iterior(); 
}
checkIterior(); //1

2. 值

数组、字符串、数字是一个程序最基本的组成部分。

2.1 数组

在JS之中,数组可以容纳任何类型的值,可谓是海纳百川,不但可以容纳数字、字符串,甚至可以容纳对象。

var a = [];
console.log(a.length); //0
a[0] = 1;
a[1] = '2';
a[2] = [3, 4, 5];
console.log(a.length); //3
console.log(a[0], a[2][0]); //1 3

我们使用delete运算符可以删除数组中的元素,但是该元素被删除后,数组的长度也不会发生变化,你可以理解为删除的仅仅是元素的值,元素依然占位。

delete a[0];
console.log(a.length); //3
console.log(a[0], a[2][0]); //undefined 3

如果我们在数组中利用字符串键值对进行添加,数组的长度不会变化。

var a = [];
a[0] = 1;
a['getU'] = '2';
console.log(a.length); //1

打印结果如下:

11.png

但是存在一种特殊情况,如果字符串键值可以被强制类型转换为十进制数字,那么该值就会被当做数组索引来处理,与此同时中间若有空位,则默认被填充为“空白单元”,值为undefined,length也会同步变化。

var a = [];
a[0] = 1;
a['6'] = '2';
console.log(a); 

22.png

一般不要在对象中存入字符串键值对,数组常规拿来存放数字索引值,而对象用于存放常规键值对。

类数组

类数组是形似数组的一类通过数字索引的值,一般能通过indexOf(),concat(),forEach()将其转换为真正的数组,也可以通过slice()和Array.from()对其进行转换,并返回一个数组。

function apple() {
  var newArr = Array.prototype.slice.call(arguments);
  var newArr2=Array.from(arguments)
  newArr.push(2);
  console.log(newArr);
  console.log(newArr2);
}
apple(1, 3); //[1,3,2]  [1,3]

2.2 字符串

字符串属于类数组,拥有length属性,并且可以通过数组方法,或者借助数组的非变更方法来返回一个新的字符串。

但是字符串本身是不可变的,唯有数组可变,indexOf()只是查询,而非改变数组。我们经常对字符串进行数组方法的操作,是借助返回新字符串来实现的。

  1. 可以直接在字符串上使用的数组方法:toUpperCase()、indexOf()、concat()
var a = 'apple';
console.log(a.indexOf('l')); //3
var c = a.concat('pine');
console.log(c); //applepine
console.log(a === c); //false
a[3] = 'b';
console.log(a); //apple 原字符串不可变

2.通过借助数组函数来处理字符串的方法: map()、join()

我们可以通过call()来在字符串上利用数组函数的非变更方法,来处理字符串。

var a = 'apple';
var b = Array.prototype.join.call(a, '+');
var d = Array.prototype.map
  .call(a, function (item) {
    return item.toUpperCase() + '-';
  })
  .join('');
console.log(b); //a+p+p+l+e
console.log(d); //A-P-P-L-E-

像是其他类似于reverse()(反转数组)的方法,是不能在字符串上应用的,无论是直接应用还是借助数组函数应用,如果希望在字符串上实现,通用的方法是将字符串转换为数组来处理,再转换为字符串。

var a = 'apple';
var b = a.split('').reverse().join('');
console.log(b); //elppa

注意,上述字符串如包括复杂字符,那将无法完成转换。

2.3 数字

JS中的数字并没有真正意义上的整数,JS使用的是双精度格式(64位二进制)的数组,所谓的整数只不过是带小数的十进制数。JS中的数字字面量支持二进制、八进制和十六进制的输入和读取。

基于JS里面所谓的“整数”实际上都是带小数的十进制数这一规则,很多对数字的表达可以基于此原理进行变换。

var a=11.000;  //11
var b=.11 //0.11
var c=15.3000 //15.3 后面的0可被忽略

数字在使用Number对象提供的方法时,会被自动转换(封装)为Number对象,接着调用Number.prototype里面的内置函数,下面介绍常用的三种Number对象的内置方法:

1.toExponential()

toExponential()方法用于以省略的方式概括数字后面的10的倍数,e代表当前数字的个位数定位,+号后面跟着的是10的倍数。

var a = 20000000000000;
console.log(a.toExponential()); //2e+13
var b = a * a*0.3;
console.log(b); //1.2e+26

2.toPrecision()

该方法用来指定有效数位的显示位数,默认执行四舍五入规则,输出给定数字的字符串形式,指定显示的小数部分位数多与实际位数,默认采用0补齐。

var b = 12.54;
console.log(b.toPrecision(1)); //1e+1
console.log(b.toPrecision(2)); //13
console.log(b.toPrecision(3)); //13.5
console.log(b.toPrecision(4)); //13.54
console.log(b.toPrecision(5)); //13.540

3.toFixed() toFixed()类似于toPrecision(),不同的是toFixed()用来指定的是小数部分的显示位数,默认执行四舍五入规则,输出给定数字的字符串形式,指定显示的小数部分位数多与实际位数,默认采用0补齐。

var b = 12.54;
console.log(b.toFixed(0)); //13 小数点显示零位
console.log(b.toFixed(1)); //12.5
console.log(b.toFixed(2)); //12.54
console.log(b.toFixed(3)); //12.540
console.log(b.toFixed(4)); //12.5400
console.log(b.toFixed(5)); //12.54000

toFixed()有一个重要的注意点,那就是直接使用数字字面量也可以用toFixed(),但是对于.运算符一定要注意,在toFixed()里面.是一个有效数字字符,属于数字字面量的一部分,在调用toFixed()的时候,会自动把数字字面量内的.识别为有效数字符号,进而容易引发报错。而数字字面量整体是一个对象属性访问运算符。

 55.toFixed(2) //Uncaught SyntaxError: Invalid or unexpected token
 console.log(55..toFixed(2)); //55.00

我们当然还可以使用指数形式来描述数字字面量:

var num = 1e5; //1*10^5
console.log(num); //100000

2.3 浮点精度问题

我们经常会见到以下问题:

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

为什么会出现以上情况?因为二进制浮点数并非精确的数值,就好比0.2并非0.2的整精度,在JS里面它的精确值实际上是0.2000000000000004。

为了彻底解决上述代码的问题,我们提出了Number.ESPILON属性,这个属性的值即为误差范围值,可以称之为类似“机器精度”一般的数值。只要小于这个数值,即可证明两者已经无限接近于“相等”。

function numCheckEqual(x1, x2) {
  return Math.abs(x1 - x2) < Number.EPSILON;
}
var a = 0.1 + 0.2;
var b = 0.3;
console.log(numCheckEqual(a, b)); //true

通过借助Number.EPSILON,我们封装了一个numCheckEqual()解决了数值之间的差异。

2.3.1 整数

整数的读取范围在JS中是有界限的,最大整数是2^53-1,即9007199254740991,同时最小的整数在JS中是负数,等同于正整数的读取范围:-9007199254740991。

检测整数可以利用ES6的Number.isInteger(),检测的整数可以带小数点以及后面的补位0,不影响对整数的判定。

console.log(Number.isInteger(15.0)); //true
console.log(Number.isInteger(33)); //true

如果我们遇到面试官要求我们手写判定一个数是否是整数,我们可以使用ES6之前版本polyfill方法:

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

上面我们说过,如果要检测一个整数是否超过了读取范围,那么我们就需要利用Number.isSafeInteger()方法来进行判定。

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

2.3.2 特殊的数字

NaN->不是数字的数字

如果数学运算的操作数不是数字类型,这种情况的返回值为NaN。

NaN本身也是数字(number)类型,它用于指出数字类型中的错误情况,特指执行数学运算后失败的返回结果。

NaN是所有基本数据类型中唯一和自身不想等的值,这是JS基本数据类型中最特殊的值。

var apple = 10 / 'apple';
console.log(apple); //NaN
console.log(apple === NaN); //false

这里有一种不被提倡的检测NaN的方法——isNaN()

之所以称其不值得提倡,是因为isNaN()检测返回为真(true)的条件为“被检查参数只要是NaN,无论是不是数字的情况下都成立。”

var apple = 10 / 'apple';
var orange = 'orange';
console.log(window.isNaN(apple)); //true
console.log(window.isNaN(orange)); //true

这就导致了我们根本没法判断我们检测的参数到底是NaN还是其他类型的参数,我们需要一个独特的仅对NaN生效的“宝具”->Number.isNaN()

var apple = 10 / 'apple';
var orange = "orange";
console.log(Number.isNaN(apple)); //true
console.log(Number.isNaN(orange)); //false

话不多说,立刻手写这个方法:

if (!Number.isNaN) {
  Number.isNaN = function (num) {
    return typeof num === 'number' && window.isNaN(num);
  };
}

实际上还能利用不对等的特性,来检测NaN。

if (!Number.isNaN) {
  Number.isNaN = function (num) {
    return num !== num;
  };
}

Infinity->无穷数

在JavaScript中,有一种操作不会像其他编译语言被执行为编译错误:

var a= 1 / 0;

最后所得的a是一个无穷数值Infinity,并且操作数若为负数,则返回-Infinity(var a= -1 / 0;)。

Infinity主要应用于判断JavaScript的数学运算结果是否溢出,常见的判断如下。

var a = Number.MAX_VALUE;
console.log(a); //1.7976931348623157e+308
console.log(a + Math.pow(2, 969)); //1.7976931348623157e+308
console.log(a + Math.pow(2, 970)); //Infinity

计算结果一旦是无穷数,就无法再回到有穷数(加减乘除都无法实现),可以从有穷走向无穷,而不能从无穷走向有穷。

Infinity/Infinity的返回结果并非是1或者无穷,而是NaN,而Infinity*Infinity的返回结果是Infinity本身,有穷正数/Infinity的结果是0。

零值->0/-0

我们可能听说过0,但是对-0却知之甚少。

var a = 0 / -1;
var b = 0 * -1;
console.log(a, b); //-0 -0

加法和减法运算不会得到-0,只有乘除会。

var a = 0;
var b = 0 / -1;
console.log(a == b); //true
console.log(-0 == 0); //true
console.log(a === b); //true
console.log(-0 === 0); //true

将-0的数字转换为字符串只会得到0

var a = 0 / -1; console.log(a.toString()); //0 console.log(a + ''); //0 console.log(String(a)); //0

将-0的字符串转换为数字会得到-0


console.log(+'-0'); //-0
console.log(Number('-0')); //-0

JSON.parse()JSON.Stringify()的结果也不尽相同。

var a = 0 / -1;
console.log(JSON.parse('-0')); //-0
console.log(JSON.stringify(a)); //0

实际上,我们完全可以手写一个检测-0的方法,无论是字符串或者数字均可适用。

function isNegZero(n) {
  n = Number(n);
  return n === 0 && 1 / n === -Infinity;
}

2.4 “通用”特殊等式

前面我们介绍了我们检测NaN要用的Number.NaN()方法和检测-0要用的isNegZero()方法。

那么有没有一种通用的等式函数能够检测上面说的两种参数呢?

有的,那就是Object.is()

在介绍它之前,我们看一下它的polyfill代码:

if (!Object.is) {
  Object.is = function (n1, n2) {
    /* 判断-0 */
    if (n1 === 0 && n2 === 0) {
      return 1 / v1 === 1 / v2;
    }
    if (n1 !== n1) {
      return n2 !== n2;
    }
    /* 若n1并非NaN */
    return n1 === n2;
  };
}
var a = 0 / -1;
var b = -2 / 'apple';
var c = 0 / 1;
console.log(Object.is(a, -0)); //true
console.log(Object.is(b, NaN)); //true
console.log(Object.is(c, -0)); //false

3. JS中变量的值和引用的关系

JavaScript中没有指针,在JavaScript中变量不会成为指向另一个变量的引用。

JavaScript中引用永远指向的是值,就算这个值有若干个引用,这些引用指向的都是同一个值,引用之间没有任何关系。

先确定一个简单的原则,简单的基本数据类型是通过值的复制来进行赋值,以此达到值的传递。

var a = 1;
var b = a; //b是a的值的一个复制传递的变量
b++;
console.log(a); //1
console.log(b); //2
var c = [1, 5, 7, 9];
var d = c; //d通过浅拷贝成为了[1,5,7,9]的引用,这里是数组对象
d.push(2);
console.log(c); //[1, 5, 7, 9, 2]
console.log(d); //[1, 5, 7, 9, 2]

你可以理解为简单基本数据类型的值都是通过复制传递过来的“本体”,每个变量都有独一无二的“本体”,它们之间虽然通过复制获取了值,但是变量本身都是独立的。

3.1 对象的引用

而对象通过浅拷贝所复制的不过是值的引用,这些引用并非真正拥有值,只是单纯的指向关系。

一旦其中一个以浅拷贝复制的引用修改了值,其他指向值的引用也会因为指向同一个值同步获取“更新”,复合值——对象一般通过引用复制的方式来传递值。

因为引用指向的是值本身而非变量,所以一个引用无法更改另一个引用的指向。

var a = [1, 2, 3];
var b = a;
console.log(a, b); //[1, 2, 3]  [1, 2, 3]
b = [4, 5, 6];
console.log(a); //[1, 2, 3]
console.log(b); //[4, 5, 6]

上述例子中的b被变更了具体对象的引用,但不会影响到a的指向。

3.2 函数对象的引用

function apple(newArr) {
  newArr.push(1);
  console.log(newArr); //[2, 3, 1]
  newArr = [4, 5, 6];
  newArr.push(7);
  console.log(newArr); //[4, 5, 6, 7]
}
var a = [2, 3];
apple(a);
console.log(a); //[2, 3, 1]

我们可以从上述代码学习到,我们在向函数apple传递数组对象a时,只是将引用a以浅拷贝的形式复制给了newArr,a依然指向[2,3],而后续newArr对数组本身进行了修改,导致a引用也“同步”了更新,但是后续newArr改变了指向,成为了数组[4,5,6]的引用,但其不影响引用a的指向。

如果想将a的值变成[4,5,6,7],那么我们必须要在引用指向值的本体上进行修改,而不是“另立门户”去指向另一个数组对象。

function apple(newArr) {
  newArr.push(1);
  console.log(newArr); //[2, 3, 1]
  newArr.length = 0; //清空数组
  newArr.push(4, 5, 6, 7);
  console.log(newArr); //[4, 5, 6, 7]
}
var a = [2, 3];
apple(a);
console.log(a); //[4, 5, 6, 7]

我们一般无法决定对值使用的是复制还是引用,这是由值的类型自行决定的(object类型和非object类型)。

遇到一些对象操作的结果为返回新对象的方法时,导致返回值也是一个浅拷贝所生成的复本(也就是这个新生成的对象),所以也不会影响到原引用指向的数组对象。 function apple(newArr.slice())...

3.3 函数基本数据类型的修改

如果我们想通过函数,对基本数据类型的值进行修改,而不是通过复制的原理新生成一个变量和值,那么我们必须要借助对象,将值封装到这个对象之中,然后通过引用复制的方式传递修改后的值。

function apple(newObj) {
  newObj.a = 5;
}
var obj1 = {
  a: 3,
};
apple(obj1);
console.log(obj1.a); //5

如果我们传递的是指向数字对象的引用复本,那我们不能通过它来更改对应的基本类型值。

function apple(newObj) {
  newObj =newObj+ 1;
  console.log(newObj); //2
}
var a = 1;
var b = new Number(a); //new Object(a)也是一样的
apple(b);
console.log(b); // {1}

我们更改的只是newObj引用的值,newObj在处理传递过来的数字对象b时,从引用对象自动转换成了数字值,和数字对象b是没有任何关系的。