一文搞懂JavaScript类型和语法(二)

82 阅读14分钟

1. 概念普及

1.1 内部属性[[Class]]

所有typeof 返回值为'object'的值都有一个内部属性[[Class]],这个属性一般通过Object.prototype.toString(...)来查看。

1.2 封装对象

由于基本类型值没有.length和.toString()这样的属性和方法, 所以需要通过封装对象才能访问。

一般情况下,不需要使用分装对象,因为JS引擎会自动决定和什么时候应该使用封装对象。

1.3 拆封

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

var a = new String('abc')
var b = new Number(20)

a.valueOf() // 'abc'
b.valueOf() // 20

在需要用到封装对象中的基本类型值时,会发生隐式拆封(强制类型转换)

1.4 原生函数

原生函数就是JS中的一些内建函数,如String和Number. 常见的原生函数有:

  • String()
  • Number()
  • Boolean()
  • Array()
  • Object()
  • Function()
  • RegExp()
  • Date()
  • Error()
  • Symbol()

2.原生函数作为构造函数

2.1 Array(..)

var a = new Array(1,2,3)
a // [1,2,3]

var b = [1,2,3]
b // [1,2,3]

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

Array构造函数只带一个参数是,代表的是数组长度,而非只充当一个元素。

2.2 Date(..)和Error(..)

Date()

相较于原生构造函数,Date(..)和Error(..)用处要大得多,因为没有对应的常量形式来作为他们的替代品。

创建日期对象用new Date()。Date(..)可以带参数,用来指定日期和时间,不带参数则使用当前的日期和时间。

获取当前时间戳的方式:

  • (new Date()).getTime()
  • Date.now()
  • +(new Date()) -------- '+'会将时间对象强转为时间戳

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

Error()

构造函数Error()和Array()类似(带不带new关键字都行)

通常错误对象都包含一个message属性,表示错误内容;同时还有.stack用于获取当前调用栈信息;通过toString()来获取经过格式化的便于阅读的错误信息等。

2.3 Symbol(..)

ES6中新加入了一个基本数据类型,symbol是具有唯一性的特殊值(并非绝对),用它来命名对象属性不容易导致重名。

符号可以用作属性名,但无论是在代码还是开发控制台中都无法查看和访问它的值,只会显示为诸如Symbol(Symbol.create)这样的值。

ES6中有一些预定义符号,以Symbol的静态属性形式出现,如Symbol.create、Symbol. iterator等,可以这样来使用:

obj[Symbol.iterator] = function(){ /*..*/ };

可以使用Symbol(..)原生构造函数来自定义符号。但它比较特殊,不能带new关键字,否则会出错。

虽然符号实际上并非私有属性(通过Object.getOwnPropertySymbols(..)便可以公开获得对象中的所有符号),但它却主要用于私有或特殊属性。很多开发人员喜欢用它来替代有下划线()前缀的属性,而下划线前缀通常用于命名私有或特殊属性。

符号并非对象,而是一种简单标量基本类型。

2.4 原生原型

原生构造函数有自己的.prototype对象,如Array.prototype、String.prototype等。

这些对象包含其对应子类型所特有的行为特征。

看下面的例子:

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

RegExp.prototype.toString();        // "/(? :)/"——空正则表达式

Array.isArray( Array.prototype );   // true
Array.prototype.length == 0 // true

可以看到函数的原型对象是一个空函数,正则的原型对象是一个空正则,数组的原型对象是一个空数组.

所以,对未赋值的变量来说,它们是很好的默认值,如下:

// ES5
function(fn, reg, arr) {
    fn = fn || Function.prototype
    reg = reg || RegExp.prototype
    arr = arr || Array.prototype
}

//ES6的默认值写法
function (fn = Function.prototype, reg = RegExp.prototype, arr = Array.prototype) {
    // ...
}

根据文档约定,我们可以将String.prototype.XYZ简写为String#XYZ,对其他.prototype也同样如此。

3.强制类型转换

关于强制类型转换是一个设计上的缺陷还是有用的特性,这一争论从JavaScript诞生之日起就开始了。下面介绍强制类型转换的优缺点,让你能够在开发中合理地运用它。

将值从一种类型转换为另一种类型通常称为类型转换,这是显式的情况;隐式的情况称为强制类型转换。

也可以这样来区分:类型转换发生在静态类型语言的编译阶段,而强制类型转换则发生在动态类型语言的运行时(runtime)。

3.1 抽象值操作

3.1.1 ToString

它用来处理非字符串到字符串的强制类型转换。基本类型值的字符串化规则为:null转换为"null", undefined转换为"undefined", true转换为"true"。数字的字符串化则遵循通用规则,

对普通对象来说,除非自行定义,否则toString()(Object.prototype.toString())返回内部属性[[Class]]的值,如"[object Object]"。

数组的默认toString()方法经过了重新定义,将所有单元字符串化以后再用", "连接起来:

var arr = [1,2,3]
arr.toString() // '1,2,3'

toString()可以被显式调用,或者在需要字符串化时自动调用。

3.1.2 ToNumber

有时我们需要将非数字值当作数字来使用,比如数学运算,因此需要用到抽象操作ToNumber。

其中true转换为1, false转换为0。undefined转换为NaN, null转换为0。

扩展:为了将值转换为相应的基本类型值,抽象操作ToPrimitive会首先检查该值是否有valueOf()方法。如果有并且返回基本类型值,就使用该值进行强制类型转换。如果没有就使用toString()的返回值(如果存在)来进行强制类型转换。 如果valueOf()和toString()均不返回基本类型值,会产生TypeError错误。

看下面这个例子,将对象a和b转成基本类型值Number的过程中,先尝试调用valueOf(),再尝试调用toString(),如果都不存在则报错TypeError

var a = {
    valueOf: function(){
      return "42";
    }
};

var b = {
    toString: function(){
      return "42";
    }
};

var c = [4,2];
c.toString = function(){
    return this.join( "" ); // "42"
};

var d = {
  valueOf() {
    return '456'
  },
  toString() {
    return '123'
  }
}

// 从ES5开始,使用Object.create(null)创建的对象[[Prototype]]属性为null,并且没有valueOf()和toString()方法,因此无法进行强制类型转换
var e = Object.create(null)
Number( a );                // 42
Number( b );                // 42
Number( c );                // 42
Number( d );                // 456
Number( e );    // TypeError: Cannot convert object to primitive value

以上案例可以解释一道面试题:如何让console.log(a)连续输出1 2 3 4 5

let i = 1
let a = {
    valueOf() {
        return i++
    }
}

3.1.3 ToBoolean

首先,也是最重要的一点是,JavaScript中有两个关键词true和false,分别代表布尔类型中的真和假。我们常误以为数值1和0分别等同于true和false。在有些语言中可能是这样,但在JavaScript中布尔值和数字是不一样的。虽然我们可以将1强制类型转换为true,将0强制类型转换为false,反之亦然,但它们并不是一回事。

假值

JS中的值可以分为两类:

  • 可以被强制转为false的值
  • 其他(被强制转为true的值)

JavaScript规范具体定义了一小撮可以被强制类型转换为false的值。 假值列表包含:

  • undefined
  • null
  • false
  • +0,-0和NaN
  • ''

从逻辑上说,假值列表以外的都应该是真值(truthy)。但JavaScript规范对此并没有明确定义,只是给出了一些示例,例如规定所有的对象都是真值,我们可以理解为假值列表以外的值都是真值。

真值

真值就是假值列表以外的任何值。

var a = "false";
var b = "0";
var c = "''";

var d = Boolean( a && b && c );

d;

上例的字符串看似假值,但所有字符串都是真值。不过""除外,因为它是假值列表中唯一的字符串。

var a = [];             // 空数组——是真值还是假值?
var b = {};             // 空对象——是真值还是假值?
var c = function(){};   // 空函数——是真值还是假值?

var d = Boolean( a && b && c );

d;

d依然是true。还是同样的道理,[]、{}和function(){}都不在假值列表中,因此它们都是真值。

3.2 显示强制类型转换

3.2.1 字符串和数字之间的显式转换

字符串和数字之间的转换是通过String(..)和Number(..)这两个内建函数来实现的,请注意它们前面没有new关键字,并不创建封装对象。

var a = 42;
var b = String( a );

var c = "3.14";
var d = Number( c );

b; // "42"
d; // 3.14

String(..)遵循前面讲过的ToString规则,将值转换为字符串基本类型。 Number(..)遵循前面讲过的ToNumber规则,将值转换为数字基本类型。

i. 一元运算符

一元运算符-和+一样,并且它还会反转数字的符号位。由于--会被当作递减运算符来处理,所以我们不能使用--来撤销反转,而应该像- -"3.14"这样,在中间加一个空格,才能得到正确结果3.14。

下面是一个疯狂的例子:

1 + - + + + - + 1;  // 2
ii. 日期显式转换为数字

一元运算符+的另一个常见用途是将日期(Date)对象强制类型转换为数字,返回结果为Unix时间戳,以毫秒为单位(从1970年1月1日00:00:00 UTC到当前时间):

var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );

+d; // 1408369986000

JavaScript有一处奇特的语法,即构造函数没有参数时可以不用带(),于是有了下面的写法:

var timestamp = +new Date

不过上述代码可读性方面可能有所欠缺,不建议对日期类型使用强制类型转换,应该使用Date.now()来获得当前的时间戳,使用new Date(..).getTime()来获得指定时间的时间戳。

iii.奇特的~运算符

一个常被人忽视的地方是~运算符(即字位操作“非”)相关的强制类型转换。

~x大致等同于-(x+1)。很奇怪,但相对更容易说明问题:

~42;    // -(42+1) ==> -43

在-(x+1)中唯一能够得到0(或者严格说是-0)的x值是-1。也就是说如果x为-1时,~和一些数字值在一起会返回假值0,其他情况则返回真值。

-1是一个“哨位值”,哨位值是那些在各个类型中(这里是数字)被赋予了特殊含义的值。JavaScript中字符串的indexOf(..)方法也遵循这一惯例,该方法在字符串中搜索指定的子字符串,如果找到就返回子字符串所在的位置(从0开始),否则返回-1。indexOf(..)不仅能够得到子字符串的位置,还可以用来检查字符串中是否包含指定的子字符串,相当于一个条件判断。例如:

var a = "Hello World";

if (a.indexOf( "lo" ) >= 0) {   // true
    // 找到匹配!
}
if (a.indexOf( "lo" ) ! = -1) {  // true
    // 找到匹配!
}

if (a.indexOf( "ol" ) < 0) {    // true
    // 没有找到匹配!
}
if (a.indexOf( "ol" ) == -1) {  // true
    // 没有找到匹配!
}

>= 0和== -1这样的写法不是很好,称为“抽象渗漏”,意思是在代码中暴露了底层的实现细节,这里是指用-1作为失败时的返回值,这些细节应该被屏蔽掉。

现在我们终于明白~有什么用处了!~和indexOf()一起可以将结果强制类型转换(实际上仅仅是转换)为真/假值:

var a = "Hello World";

~a.indexOf( "lo" );         // -4   <-- 真值!

if (~a.indexOf( "lo" )) {   // true
    // 找到匹配!
}

~a.indexOf( "ol" );         // 0    <-- 假值!
!~a.indexOf( "ol" );        // true

if (! ~a.indexOf( "ol" )) {  // true
    // 没有找到匹配!
}

如果indexOf(..)返回-1,~将其转换为假值0,其他情况一律转换为真值。

iiii. 字位截除

~~中的第一个执行ToInt32并反转字位,然后第二个再进行一次字位反转,即将所有字位反转回原值,最后得到的仍然是ToInt32的结果。

对~~我们要多加注意。首先它只适用于32位数字,更重要的是它对负数的处理与Math. floor(..)不同。

Math.floor( -49.6 );    // -50
~~-49.6;                // -49

~~x能将值截除为一个32位整数,x | 0也可以,而且看起来还更简洁。出于对运算符优先级(详见第5章)的考虑,我们可能更倾向于使用~~x。

3.2.2 显式解析数字字符串

解析字符串中的数字和将字符串强制类型转换为数字的返回结果都是数字。但解析和转换两者之间还是有明显的差别。

var a = '42'
var b = '42px'
Number( a );    // 42
parseInt( a );  // 42

Number( b );    // NaN
parseInt( b );  // 42

解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败并返回NaN。

解析字符串中的浮点数可以使用parseFloat(..)函数.

parseInt只能接受字符串作为参数,传入其它类型的参数是没有用的。非字符串参数会首先被强制类型转换为字符串,依赖这样的隐式强制类型转换并非上策,应该避免向parseInt(..)传递非字符串参数。

注意:

ES5之前的parseInt(..)有一个坑导致了很多bug。即如果没有第二个参数来指定转换的基数(又称为radix), parseInt(..)会根据字符串的第一个字符来自行决定基数。

var hour = parseInt( selectedHour.value );
var minute = parseInt( selectedMinute.value );

console.log(
  "The time you selected was: " + hour + ":" + minute
);

上面的代码看似没有问题,但是当小时为08、分钟为09时,结果是0:0,因为8和9都不是有效的八进制数。

将第二个参数设置为10,即可避免这个问题:

var hour = parseInt( selectedHour.value, 10 );
var minute = parseInt( selectedMiniute.value, 10 );

从ES5开始parseInt(..)默认转换为十进制数,除非另外指定。如果你的代码需要在ES5之前的环境运行,请记得将第二个参数设置为10。

i. 解析非字符串

尝试解释下面的代码:

parseInt( 1/0, 19 ); // 18

有人可能会觉得这不合理,parseInt(..)应该拒绝接受非字符串参数。但如果这样的话,它是否应该抛出一个错误?这是Java的做法。一想到JavaScript代码中到处是抛出的错误,要在每个地方加上try..catch,我整个人都不好了。

那是不是应该返回NaN?也许吧,但是下面的情况是否应该运行失败?

 parseInt( new String( "42") );

因为它的参数也是一个非字符串。如果你认为此时应该将String封装对象拆封(unbox)为"42",那么将42先转换为"42"再解析回42不也合情合理吗?

parseInt(..)先将参数强制类型转换为字符串再进行解析,这样做没有任何问题。因为传递错误的参数而得到错误的结果,并不能归咎于函数本身。

回到最开始的案例,1/0 = Infinity,JS将Infinity字符串化为'Infinity';19代表19进制,parseInt首先解析'Infinity'的首字母'I',恰巧'I'在十九进制中代表18,当解析第二个字符'n'的时候,发现十九禁止不包含n,所以解析结束(和"42px"中的"p"一样),最后返回18.

此外还有一些看起来奇怪但实际上解释得通的例子:

parseInt( 0.000008 );       // 0   ("0" 来自于 "0.000008")
parseInt( 0.0000008 );      // 8   ("8" 来自于 "8e-7")
parseInt( false, 16 );      // 250 ("fa" 来自于 "false")
parseInt( parseInt, 16 );   // 15  ("f" 来自于 "function..")

parseInt( "0x10" );         // 16
parseInt( "103", 2 );       // 2

其实parseInt(..)函数是十分靠谱的,只要使用得当就不会有问题。因为使用不当而导致一些莫名其妙的结果,并不能归咎于JavaScript本身。

3.2.3 显式转换为布尔值

现在我们来看看从非布尔值强制类型转换为布尔值的情况。

与前面的String(..)和Number(..)一样,Boolean(..)(不带new)是显式的ToBoolean强制类型转换。

此外还有一种显示强制转换成布尔值的方式:!!a

在if(..)..这样的布尔值上下文中,如果没有使用Boolean(..)和!!,就会自动隐式地进行ToBoolean转换。建议使用Boolean(..)和!!来进行显式转换以便让代码更清晰易读。


var a = 42;

var b = a ? true : false;

看上面的代码: 三元运算符?:判断a是否为真,如果是则将变量b赋值为true,否则赋值为false。

然而这里涉及隐式强制类型转换,因为a要首先被强制类型转换为布尔值才能进行条件判断。这种情况称为“显式的隐式”,有百害而无一益,我们应彻底杜绝。

建议使用Boolean(a)和!! a来进行显式强制类型转换。