类型判断

537 阅读12分钟

基础知识回顾

当前版本(ECMAScript 2021)ECMAScript 标准定义了 8 种类型:

简单数据类型(原始类型)

共 7 种:StringNumberBooleanUndefinedNullSymbolBigInt

原始类型的值特点:值不可变,无属性无方法,保存在栈内存中、值比较。

无属性无方法可能会让人迷惑:

let s1 = "some text";
let s2 = s1.substring(2);

原始值本身不是对象,因此逻辑上不应该有方法,实际上这个例子确实没有报错,且得到了结果。原因是第二行访问 s1 时,是以读模式访问的,也就是要从内存中读取变量保存的值。

当以读模式访问原始值的时候,后台会执行以下三步(以上面代码为例):

  1. 创建一个 String 类型的实例
  2. 调用实例上的特定方法
  3. 销毁实例

等同于后台自动执行了下面的代码

let s1 = new String("some text");
let s2 = s1.substring(2);
s1 = null;

因此原始值拥有了对象的行为。

复杂数据类型(引用类型)

除了上面的 7 种基本数据类型外,剩下的就是引用类型,统称为 Object 类型

  • 基本引用类型

    • DataRegExp 等ECMAScript 提供的原 生引用类型

    • 原始值包装类型,ECMAScript 提供了 3 种特殊的引用类型:BooleanNumberString (只有这三种)

      let stringObject = new String('hello');
      // 如果在使用包装对象时,忘了在前面加 “new” 操作符,会等被当做普通函数执行,作用是把任何类型的数据转换为对应的类型
      
    • 函数

    • 单例内置对象:GlobalMath

      ECMA-262中定义:任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象

      • Global 对象在浏览器中被实现为 window 对象。所有全局变量和函数都是 Global 对象的属性
      • Math 对象包含辅助完成复杂计算的属性和方法
  • 集合引用类型

    • 最常见的 ObjectArray

    • MapWeakMapSet 以及 WeakSet 类型

    • 定型数组:例如 Int32ArrayFloat64Array 等等

      定型数组(typed array)是 ECMAScript 新增的结构,目的是提升向原生库传输数据的效率。实际上, JavaScript 并没有“TypedArray”类型,它所指的其实是一种特殊的包含数值类型的数组。设计定型数组的目的就是提高与 WebGL 等原生库交换二进制数据的效率。

引用类型的值特点:可变、引用比较、值保存在堆(Heap)内存

类型判断

typeof

typeof 对判断原始值的类型简单有效。

typeof 37 === 'number';
typeof NaN === 'number';
typeof 42n === 'bigint';
typeof 'bla' === 'string';
typeof '1' === 'string';
typeof undefined === 'undefined';
typeof Symbol('foo') === 'symbol';
typeof false === 'boolean';

但是对引用值的作用不大,因为无论什么类型的对象(除了 Function 类型),使用typeof 都会返回 "object"。但是我们往往关心的不是这个值是不是对象,而是这个值是什么类型的对象。

typeof {a: 1} === 'object';
typeof [1, 2, 4] === 'object';
typeof new Date() === 'object';
typeof /regex/ === 'object';

除了正常的功能外,经常还会提起 typeof 的一些其它特性和例外:

  • null 是简单类型的值,它有自己的类型 Null,但是 typeof null 返回的却是 object

在 JavaScript 最初的实现中,值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0typeof null 也因此返回 "object"

之后也是为了不得罪人,也就一直保留了这个问题特性。

  • 判断函数返回的是 "function"

    typeof function() {} // "function"
    typeof class C {}    // "function"
    typeof Math.sin      // "function"
    typeof (() => {})    // "function"
    

    这里不好理解的是为什么 class C {} 返回的也是 "function"。其实 ECMAScript 中的 class 只不过是构造函数的语法糖而已。

  • 括号?

    typeof 是操作符,语法上后面直接跟操作数,不需要括号。括号本身也是操作符,并且括号的优先级要高于 typeof。所以当 typeof 后面跟上括号时,其实优先执行括号里的表达式并返回操作数,typeof 判断的是这个返回的操作数的类型。

    typeof 42 + ' hello'
    // "number hello"
    typeof (42 + ' hello')
    // "string"
    (42 + ' hello')
    // "42 hello"
    
  • 暂时性死区导致的报错

    • 在 ES6 之前,typeof 总能保证对任何所给的操作数返回一个字符串。使用 typeof 永远不会抛出错误。

    • ES6 新增了 letconst 用来声明变量,使用它们会形成块级作用域,那么在变量声明之前对块中的 letconst 变量使用 typeof 会抛出一个 ReferenceError(暂时性死区的特性)。

      typeof foo;
      let foo = 'bar';
      // Uncaught ReferenceError: foo is not defined
      
  • 一个“挑衅意味”的例外,对,就是各大浏览厂商故意这么做的(不过 document.all 在HTML5 中已经被废弃,但是现在所有浏览器依然支持)

    console.log(document.all)
    // HTMLAllCollection(1713) [html,... 页面上的所有元素
    
    typeof document.all
    // "undefined"
    

因为 typeof 无法很好的区分引用值是什么类型的对象,所以ECMAScript 提供了 instanceof 操作符来解决这个问题。

instanceof

如果变量是给定引用类型的实例,则 instanceof 操作符返回 trueinstanceof 检测的是构造函数的 prototype (原型)是否存在于给定的对象实例的原型链上。

使用 instanceof 来判断原生对象类型,像 ObjectArray 这样的原生构造函数,运行时可以直接在执行环境中使用:

var arr = [];
var arr1 = new Array(10);
console.log(arr instanceof Array); // true
console.log(arr1 instanceof Array); // true

ECMAScript 中可以使用自定义构造函数创建自定义类型的对象,那么依然可以使用 instanceof 判断自定义引用类型:

function Foo() {}
let f = new Foo();
console.log(f instanceof Foo); // true

class Bar {}
let b = new Bar();
console.log(b instanceof Bar); // true

我们知道,每个对象实例都有一个 constructor 属性,指向用于创建当前对象的函数(constructor 属性是在构造函数的 prototype 上定义的)

Array.prototype.constructor === Array; // true
[].constructor === Array; // true

// 为什么原始值也有 constructor ? 可以看 typeof 那一节有解释
''.constructor === String // true

// null 和 undefined 不存在 constructor 的

constructor 本来是用于标识对象类型的。

function Person(name, age, job){
  // ...
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person);  // true

但是对于自定义构造函数一旦重写 prototype,可能会造成原有的 constructor 引用丢失,从而导致 constaructor 会变成默认的 Object

function F() {}
// { foo: 'bar' } 是对象字面量,本身是一个 `Object` 实例,它自身的 constructor 指向 Object
F.prototype = { foo: 'bar' };
f.constructor === F
// false
f.constructor
// ƒ Object() { [native code] }

所以认为 instanceof 操作符是确定对象类型更可靠的方式。但是 instanceof 真的可靠吗?

并不可靠,就像上面说的,因为构造函数的 prototype 是可以改变的,所以在创建实例之后,修改构造函数的 prototype 属性,如果修改后的原型不在实例对象的原型链上了,那就会返回 false

function F() {}
var f = new F;
F.prototype = {}
f instanceof F  // false

还有一种方法是借助非标准的 __proto__ 伪属性来修改实例的原型链,使实例的原型链上不再出现创建它的构造函数的原型:

function F() {}
var f = new F;
f.__proto__ = {};
f instanceof F  // false

使用 instanceof 还有一些其他问题需要注意:

  • 不适用于原始值

    var simpleStr = "This is a simple string";
    var newStr    = new String("String created with constructor");
    
    simpleStr instanceof String; // 返回 false, 非对象实例,因此返回 false
    newStr    instanceof String; // 返回 true
    
  • Object.create(null) instanceof Object 返回 false。这是一种创建非 Object 实例的对象的方法

  • 如果一个对象实例的原型链上不止一种构造函数的原型,那么使用 instanceof 操作符检查这两种类型都会返回 true

function foo() {}
foo instanceof Function  // true
foo instanceof Object    // true

上面的 Person 自定义构造函数创建的对象实例 person1person2,既属于 Person 类型,又属于 Object 类型

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true
  • 多全局对象引起的问题

    在浏览器中,我们的脚本可能需要在多个窗口之间进行交互。多个窗口意味着多个全局环境,不同的全局环境拥有不同的全局对象,从而拥有不同的内置类型构造函数

    这可能会引发一些问题。例如:

    [] instanceof window.frames[0].Array // false
    

    因为 Array.prototype !== window.frames[0].Array.prototype,意思就是 window.frames[0].Array 的原型不在前者 [] 的原型链上。

  • instanceof 是操作符,所以要注意操作符的优先级,例如检测对象不是某个构造函数的实例时:

    if (!(mycar instanceof Car)) {
      // ...
    }
    
    // 而不是使用 if (!mycar instanceof Car),逻辑非的优先级高于 instanceof
    

Symbol.hasInstance(ES6)

上一节说到 instanceof 操作符可以用来确定一个对象实例的原型链上是否有原型。在 ES6 中,instanceof 操作符会使用 Symbol.hasInstance 函数来确定关系。

function Foo() {}
let f = new Foo();
// 使用 instanceof
console.log(f instanceof Foo); // true
// 使用 Symbol. hasInstance
console.log(Foo[Symbol.hasInstance](f)); // true

这个属性定义在 Function 的原型上,因此默认在所有函数和类上都可以调用。由于 instanceof 操作符会在原型链上寻找这个属性定义,因此可以在继承的类上通过静态方法重新定义这个函数,从而可以用它自定义 instanceof 操作符在某个类上的行为:

class Array1 {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}

console.log([] instanceof Array1); // true

Object.prototype.toString()

每个对象都有一个 toString() 方法,默认情况下,toString() 方法被每个 Object 对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回一个字符串 "[object type]",其中 type 是对象的类型。

var o = new Object();
o.toString(); // [object Object]

为了获取准确的数据类型,我们最常用的方法也是目前最全最有效的方法就是使用 Object.prototype.toString() 来检测。需要注意的是:

  • 不是直接使用 toString 方法,而是使用 Object 构造函数原型上的方法,因为其它内置对象或者自定义类型的对象可能会实现自己的 toString 方法或者根本没有定义该方法,导致返回的不再是 “[object type]” 或者直接报错

    var str = new String('hello')
    str.toString()  // "hello"
    123..toString() // "123"
    null.toString() //  Uncaught TypeError: Cannot read property 'toString' of null
    undefined.toString() // Uncaught TypeError: Cannot read property 'toString' of undefined
    
  • 需要以 Function.prototype.call() 或者 Function.prototype.apply() 的形式来调用(不陌生吧,改变 this 指向)

    var toString = Object.prototype.toString;
    toString.call(new String); // "[object String]"
    toString.call('hello'); // "[object String]"
    toString.call(Math); // "[object Math]"
    toString.call(123); // "[object Number]"
    toString.call(true); // "[object Boolean]"
    toString.call(123n); // "[object BigInt]"
    toString.call(new Date); // "[object Date]"
    toString.call(Symbol()) // "[object Symbol]"
    toString.call(() => {}) // "[object Function]"
    toString.call([1, 2]);    // "[object Array]"
    toString.call(new Map) // "[object Map]"
    toString.call(function* () {}); // "[object GeneratorFunction]"
    toString.call(Promise.resolve()); // "[object Promise]"
    // ES5 中才实现下面的功能
    toString.call(undefined); // "[object Undefined]"
    toString.call(null); // "[object Null]"
    

这种方法对于日常开发中的大部分开发场景都适用了,但是对于开发者创建的类,默认情况下 toString() 只会返回默认的 Object 标签:

class ValidatorClass {}
Object.prototype.toString.call(new ValidatorClass()); // "[object Object]"

那有没有方法让自定义的类实例返回我们想要的结果呢?在 ES2015 标准中有一个内置 SymbolSymbol.toStringTag

Symbol.toStringTag(ES6)

Symbol.toStringTag 的值是一个字符串,这个字符串表示该对象的自定义类型标签。上面说到 Object.prototype.toString() 默认会返回返回一个字符串 "[object type]",其中的“type”有时候就是读取的 Symbol.toStringTag 的值。

不是所有的内置 JavaScript 对象类型都有 Symbol.toStringTag 属性,比如 ES6 之前使用 Object.prototype.toString() 也能返回类型:

Object.prototype.toString.call('foo');     // "[object String]"
Object.prototype.toString.call([1, 2]);    // "[object Array]"
Object.prototype.toString.call(3);         // "[object Number]"
Object.prototype.toString.call(true);      // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null);      // "[object Null]"
// ...

var o = new Object;
console.log(o[Symbol.toStringTag]); // undefined

JavaScript 引擎会为一些对象类型设置好 toStringTag 标签:

Object.prototype.toString.call(new Map());       // "[object Map]"
Object.prototype.toString.call(function* () {}); // "[object GeneratorFunction]"
Object.prototype.toString.call(Promise.resolve()); // "[object Promise]"
// ...

var m = new Map;
console.log(Map.prototype[Symbol.toStringTag]); // 'Map'
console.log(m[Symbol.toStringTag]); // 'Map'

但是开发者自己创建的类需要手动添加 toStringTag 属性,否则引擎无法识别只能返回默认的 [object Object]

class ValidatorClass {
  get [Symbol.toStringTag]() {
    return "Validator";
  }
}
Object.prototype.toString.call(new ValidatorClass()); // "[object Validator]"

Symbol.toStringTag 的属性特性:是【不可写】、【不可枚举】并且【不可配置】

Map.prototype[Symbol.toStringTag];  // "Map"
Map.prototype[Symbol.toStringTag] = 'map';
var m = new Map;
m[Symbol.toStringTag]; // "Map"
m[Symbol.toStringTag] = 'map';

m[Symbol.toStringTag]  // "Map"
Map.prototype[Symbol.toStringTag] // "Map"

// ---
var va = new ValidatorClass
va[Symbol.toStringTag] = 'va'
va[Symbol.toStringTag] // "Validator"

但是在测试的时候发现,内置的 JavaScript 对象类型,如果本身没有设置 Symbol.toStringTag 属性的话,我们是可以赋值并改写它的值的

Object.prototype[Symbol.toStringTag] = 'o'
var o = new Object
o[Symbol.toStringTag]  // "o"
o[Symbol.toStringTag] = "object"
o[Symbol.toStringTag] // "object"
Object.prototype[Symbol.toStringTag] // "o"
Object.prototype.toString.call(o);   // "[object object]"

目前不知原因。

ECMAScript 内置的针对某个类型的判断方法

  • Array.isArray() 用于确定传递的值是否是一个 Array

    Array.isArray([1]); // true
    Array.isArray(new Array('a', 'b', 'c', 'd'));  // true
    

    有个非常细节的知识点,Array.prototype 是一个数组

    Array.isArray(Array.prototype);  // true
    
    console.log(Array.prototype);
    // [constructor: ƒ, concat: ƒ, copyWithin: ƒ, fill: ƒ, find: ƒ, …]
    

    对于定型数组(TypedArray)实例,总是返回 false

    Array.isArray(new Uint8Array(32)); // false
    

    Array.isArray() 能检测iframes,所以针对数据的检查,应该使用它而不要使用instanceof

    // 偷懒,直接搬 MDN 上的
    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    xArray = window.frames[window.frames.length-1].Array;
    var arr = new xArray(1,2,3); // [1,2,3]
    
    Array.isArray(arr);  // true
    arr instanceof Array; // false
    
  • isNaN()Number.isNaN() 用来确定一个值是否为 NaN

    全局对象的 isNaN 方法会将不是 Number 类型的参数转换为数值(空字符串和布尔值分别会被强制转换为数值 0 和 1),然后才会对转换后的结果是否是 NaN 进行判断。这就产生了有趣的现象

    isNaN(NaN);       // true
    isNaN(undefined); // true
    isNaN({});        // true
    
    isNaN(true);      // false
    isNaN(null);      // false
    isNaN(37);        // false
    
    // strings
    isNaN("37");      // false: 可以被转换成数值37
    isNaN("37.37");   // false: 可以被转换成数值37.37
    isNaN("37,5");    // true
    isNaN('123ABC');  // true:  parseInt("123ABC")的结果是 123, 但是Number("123ABC")结果是 NaN
    isNaN("");        // false: 空字符串被转换成0
    isNaN(" ");       // false: 包含空格的字符串被转换成0
    
    // dates
    isNaN(new Date());                // false
    isNaN(new Date().toString());     // true
    
    isNaN("blabla")   // true: "blabla"不能转换成数值
                      // 转换成数值失败, 返回NaN
    

    所以建议使用 Number.isNaN() 方法来确定传递的值是否为 NaN,它是比原来的全局 isNaN() 的更安全的版本。它不会将

    Number.isNaN("NaN");      // false,字符串 "NaN" 不会被隐式转换成数字 NaN。
    Number.isNaN(undefined);  // false
    Number.isNaN({});         // false
    Number.isNaN("blabla");   // false
    

总结

  • ECMAScript 标准定义了 8 种类型,包括7种基本数据类型和1种复杂(引用)类型
  • 有明确期望的类型判断,可以使用内置方法,例如 Array.isArray()isNaN
  • 对于原始值可以使用 typeof 操作符,对于引用类型的值可以使用 instanceof 操作符确定对象类型。对于ES6的代码,可以通过重写类的 Symbol.hasInstance 静态方法来自定义 instanceof 的行为
  • 更全更有效的检查方法 Object.prototype.toString(),调用的时候需要使用 .call() 或者 .apply()
  • 自定义类型的实例对象如何通过 Object.prototype.toString() 返回期望的类型字符串,可以通过添加 Symbol.toStringTag 属性。

参考