JavaScript 中常见类型判断的坑

JavaScript 语言弱类型的特性,使得它灵活而容易上手。但凡事都有两面性,要想写出健壮的 JavaScript 程序,对变量进行类型判断是必不可少的一个环节。而如何能够快速、准确的判断变量的类型却不是每个 JavaScript 程序员都能牢固掌握的。最近的项目里就遇到了因为类型判断不准确而带来的坑。相信这些问题不只有我会遇到,因此在这里分享一下。

使用 if (obj.prop) 判断 obj 是否包含 prop 属性

JavaScript 里 this.func && this.func()foo = foo || 'default' 这种「奇技淫巧」型的写法似乎已经司空见惯。第一个表达式是在判断 this.func 存在后,才进行调用,防止调用一个不存在的方法而产生异常;第二种方法常用作给参数赋缺省值。

之所以上面表达式/语句能够运行,是因为 JavsScript 在需要布尔值时会自动将其他类型的值转换为布尔值,转换的具体规则是:所有值都会被转换为 true,除了 undefinednull0NaN''(空字符串)。

根据这个规则再来看分析上面表达式/语句是否足够健壮。

首先是 this.func && this.func() 这个表达式,当 func 方法不存在,即 this.func===undefined,这时 this.func 将被转换为 false,后面一条表达式将不会执行。当然,如果 this.func 被赋值为一个可转换为 true 的非函数值,那么 this.func() 会被执行并抛出 this.func is not a function 这样的 TypeError。然而考虑到函数的特殊性,一般只有声明和未声明两个状态,不会改变做其他数据类型,更不会在运行时动态生成或修改。所以我们认为这条语句是健壮的。

反观第二条语句 foo= = foo || 'default' 就没有那么健壮了。这条语句在很多情况下被用于生成函数参数或用户输入的缺省值。当 foo 确实为 undefined 的时候,语句会得到我们预想的结果。然而当函数参数或用户输入为 0'' 时,就会有问题了:很多时候 0 值或 '' 值是可以被接受的正常值,然而这时他们却转换得到 false 的结果,从而导致缺省值错误的覆盖了真实值。所以在使用这条语句时一定要充分明确可能的输入值和代码的能够接受的正常值,如果 0''null 这些值也能够被接受的正常值,那么还是老老实实用 if (foo === undefined) 来赋缺省值吧。

说到这里,你也就知道标题中的做法坑在哪里了,若 obj.prop === 0obj.prop === '',这个原本存在的属性就这样被忽略掉了,在遇到这样的属性值时就会出现 Bug。正确的做法是用 if (obj.prop !== undefined) 或者 if ('prop' in obj) 来进行判断。

另外不得不单独说一下 nullobj.prop === null 时应当如何处理呢?这里我的建议是除非明确不需要 null,其他情况均把 null 当作一个可接受的正常值来处理,比如在拷贝对象时,null 也应该被拷贝。

使用 typeof foo === 'object' 判断是否为对象

typeof 是在类型判断中常用的运算符,然而这个在用这个运算符进行类型判断的时候也要特别注意。这其中最大的一个坑就是 typeof null === 'object'

关于 typeof null 返回 'object',有人认为这是一个错误,但我并不这么认为。ECMAScript 已经经历了二十余年(1995 - 2016)数个大小版本的迭代,如果是一个错误那么早就应该被修正。然而在目前主流的 ES 5.1 规范及下一代 ES 6 规范关于 typeof 运算符的定义中,都明确指出 typeof null 的返回值就是 'object'。所以说,绝不能简单的用错误来解释这个的结果。

从另一个角度来理解,对象变量的本质是一个指针,而 null 也是一个指针。这样就比较容易理解 typeof null === 'object' 了。

所以,判断一个变量是不是对象,一般情况下要排除 null 值:typeof foo === 'object' && foo !== null。根据上面提到的各种值转换为布尔值的规则,对象判断可以更简单的写做 foo && typeof foo === 'object'

可以预见的坑

上面两个类型判断的坑是我在项目当中真实遇到的,但在总结的过程中我发现还有坑是很有可能遇到的,一起这在里分享出来:

使用 typeof foo === 'number' 判断是否为数字

仍然是 typeof 运算符,不过这次要判断的是数字。乍一看这样的判断没什么问题,但却忽略了一个特殊的数字类型值 —— NaNNaN 的全名是「Not a number」,顾名思义,它表示一个不是数字的值。然而比 null 还要诡异的是:typeof NaN 的运算结果竟然是 'number'

其实,NaN 本身就属于 Number 类型,这里的「Not a number」并不代表这个值不属于 Number 类型,而是不能表示为数学量的 Number 类型值。这一类型的值其实是弱类型语言的一大特性:要保证所有类型之间能够在大多数情况下灵活的互相转换,就必须在较小表意集合中定义一些特殊的值,用来表示那些无法按照常理从更大表意集合类型转换来的值。上面的说法听起来很绕,但理解起来其实很简单,比如:String 相对于 Number 就是一个更大的表意集合,即 String 相对于 Number 能够表示更多有意义的值。要将 String 中的一些值按照常理转换为 Number 是不现实的(如:'Hello' 是无法按照常理转换为一个数字的),因此 Number 类型中就人为定义了 NaN 来表示这些值。

注意到 NaN 这个值之后,我们必须要对 NaN 进行判断。然而除了 typeof NaN === 'number' 这个诡异的特性,对两个 NaN 进行相等判断的结果也被规定为 false,即 NaN != NaNNaN !== NaN。所以通过 typeof foo === 'number' && foo !== NaN 来保证 foo 不是 NaN 是行不通的。所以 ES 中定义了一个 isNaN 函数,专门用来解决这个问题。

isNaN 函数接收一个参数,如果这个参数值是 NaN,则返回 true,如果是其他 Number 类型的值,则返回 false。利用这一特性,我们就可以对 NaN 进行判断了:使用 typeof foo === 'number' && !isNaN(foo) 就可以保证 foo 是一个「纯粹的」Number 类型。

上面说了很多关于 NaN 的诡异行为,但你以为到这里就结束了么?并没有!用来判断一个值是否是 NaNisNaN 函数并不可靠!

上面说到 isNaN 的参数如果是 NaN,那么将会返回 true,如果是其他 Number 类型的值,则会返回 false。到这里都没有问题,那么如果我们传递一个非 Number 类型的值给 isNaN 函数呢?这时 isNaN 的表现会非常怪异:它会首先尝试将这个值转换为 Number 类型。如果我们向 isNaN 函数传递一个 String 类型的参数,那么这个 String 会首先转换为 Number,然后判断转换后的值是否是 NaN。这样的行为将会导致 isNaN('Hello') 这样的判断返回结果为 true,这是非常有违直觉的。

isNaN 这一怪异的行为使得我们不能单靠这个函数来判断一个值是否就是 NaN,而必须结合 typeof 进行类型判断才能精确锁定 NaN 值:typeof foo === 'number' && isNaN(foo)。这么冗长的表达式必然会导致一些奇技淫巧的出现,利用 NaN 自身不相等的特性,foo !== foo 也常常用作 NaN 的判断,但这种判断方法是不推荐的,因为没人能保证在后续的 ES 版本中不会出现另一个自身不相等的「奇葩」值。另外,在 ES6 中也提供了一个新的判断途径:Number.isNaN。这个方法没有 isNaN 的怪异表现,能够对 NaN 做出精确的判断。

转换为布尔值

JavaScript 在大多数需要布尔值的时候会对传入的值进行自动转换。但当我们如何显式的将一个值转换为布尔值呢?

如果你想到了 Boolean 构造函数,那么恭喜你成功入坑。Boolean 构造函数并不能将其他值转换为布尔值,使用 Boolean 构造函数会始终得到一个对象类型的值。而对象类型的值会始终转换为布尔类型的 true

那么如何显式转换呢?答案是使用 !!foo。其中的原理也很简单,!foo 将会把 foo 强制转化为布尔类型并取反,再次取反,就可以得到 foo 的对应布尔类型值。

总结

类型判断是一个健壮的程序不可缺少的部分,上面列举了一些在进行常见的类型判断过程中可能遇到的坑。总结一下不难发现,这些坑都是围绕 undefinednull0''NaN 这几个很特殊的值出现的。因此在进行类型判断及操作的时候多考虑一下这几个值是否会对代码产生影响,就可以基本避免 JavaScript 中类型相关的坑。