JavaScript 中的值分类

JavaScript 中的值分类

这篇文章将介绍 JavaScript 中的值分类的方法:通过隐藏属性[[Class]]、 typeof 操作符、 instanceof 操作符和函数 Array.isArray()。我们查看 JavaScript 对象内置构造方法的属性,这会产生意外的分类结果。

前置知识

为了深入讨论这个话题,让我们回顾一些前置知识。

基础类型与对象(object)

JavaScript 中的所有值都是基础类型或对象两者中的一个。

基础类型,下面这些就是基础类型:

  • undefined
  • null
  • Booleans
  • Numbers
  • Strings

基础类型都是不可变的,你不能向它们添加属性:

var str = 'abc';
str.foo = 123; // 试着添加属性 "foo"
// -> 123
str.foo; // 没有改变
// -> undefined

基础类型之间的比较是值比较,如果它们相等的话,那么它们的内容相同:

'abc' === 'abc'; // true

对象,所有的非基础类型的值都是对象。对象是可变的:

var obj = {};
obj.foo = 123; // 试着添加属性 "foo"
// -> 123
obj.foo; // 属性 "foo" 被添加
// -> 123

对象是引用比较。每一个对象都拥有自己的标识,如果两个对象是真实的同一个,那么它们相同:

{} === {}; // false

var obj = {};
obj === obj; // true

包装对象类型,基础类型布尔(boolean)、数值(number)和字符串(string) 有对应的包装对象类型 BooleanNumberString。后者的实例是对象,且基础类型不同于它们的包装对象:

typeof new String('abc'); // 'object'
typeof 'abc'; // 'string'
new String('abc') === 'abc'; // false

包装对象很难直接使用,但是它们的原型对象是基础类型的方法。举例来说,String.prototype 是包装对象 String 的原型对象,所有的方法对字符串也是有效的。比如包装方法 String.prototype.indexOf。基础字符串拥有相同的方法。不是字面上的相同方法,确实是相同的方法:

String.prototype.indexOf === ''.indexOf; // true

内部属性

内部属性是 JavaScript 中不能直接访问的属性,但会影响它如何工作。内部属性的名字开始大写字母且包含在双中括号内。举例来说,[[Extensible]] 表示一个布尔标签代表着确定是否可以添加属性到一个对象。它的值可以间接操作:通过 Object.isExtensible() 读取它的值,通过 Object.preventExtensions() 设置它的值为 false。一旦它的值为 false,就没有办法改变它的值为 true

术语:原型与原型对象

在 JavaScript 中,术语 prototype(原型) 有一点设计过重:

  1. 一方面,对象之间存在原型联系。每个对象都有隐含属性[[Prototype]]指向它的 prototype(原型) 或者 null。原型是对象的延续。如果引用对象的属性却没有找到,将继续搜索前者(即在原型上查找)。不同的对象可以有相同的原型。
  2. 另一方面,如果一个类型是构造方法 Foo 的实例,那么该构造方法拥有原型对象 Foo.prototype 的属性。

为了对比,我们称为“原型”和“原型对象”。有三个方法帮助我们处理原型:

  • 方法 Object.getPrototypeOf(obj) 返回对象 obj 的原型:
Object.getPrototypeOf({}) === Object.prototype; // true
  • 方法 Object.create(proto) 创建一个原型为 proto 的空对象。
Object.create(Object.prototype); // {}
  • 方法 proto.isPrototypeOf(obj) 是用来确定对象 proto 是否是对象 obj 的原型。如果 protoobj 的原型(或原型的原型),方法 proto.isPrototypeOf(obj) 返回 true
Object.prototype.isPrototypeOf({}); // true

属性“constructor”(构造方法)

给一个构造方法 Foo,原型对象 Foo.prototype 有一个指向 Foo 的属性 Foo.prototype.constructor ,它为每个功能函数默认设置该属性。

function Foo() {}
Foo.prototype.constructor === Foo; // true
RegExp.prototype.constructor === RegExp; // true

构造函数的所有实例继承了原型对象的属性。然后,我们可以使用它查明哪个构造函数创建了实例:

new Foo().constructor; // [Function: Foo]

/abc/.constructor; // [Function: RegExp]

值分类

让我们看看四种分类值的方法:

  • [[Class]] 是一个内部属性,表示将对象分类的一个字符串;
  • typeof 是一个对基础类型分类的操作符,并帮助我们区别对象;
  • instanceof 是对对象进行分类的操作符;
  • Array.isArray() 是用来判断值是否是数组的函数。

[[Class]] 属性

[[Class]] 是一个值为下面字符串的内部属性:

"Arguments", "Array", "Boolean", "Date", "Error", "Function", "JSON", "Math", "Number", "Object", "RegExp", "String"

通过默认方法 toString() 是 JavaScript 代码中唯一访问它的方式,方法 Object.prototype.toString() 是通用的且返回:

  • "[object Undefined]" 如果 thisundefined,
  • "[object Null]" 如果 thisnull,
  • "[object " + obj.[[Class]] + "]" 如果 this 是一个对象 obj.
  • 基础类型被转换为对象,就像之前的规则一样处理。

举例:

Object.prototype.toString.call(undefined);
// -> '[object Undefined]'
Object.prototype.toString.call(Math);
// -> '[object Math]'
Object.prototype.toString.call({});
// -> '[object Object]'

于是,下面的函数返回值 x 的 [[Class]] 属性:

function getClass(x) {
  const str = Object.prototype.toString.call(x);
  return /^\[object (.*)\]$/.exec(str)[1];
}

下面是该函数的应用:

getClass(null); // 'Null'

getClass({}); // 'Object'

getClass([]); // 'Array'

getClass(JSON); // 'JSON'

(function () {
  return getClass(arguments);
})(); // 'Arguments'

function Foo() {}
getClass(new Foo()); // 'Object'

typeof 操作符

操作符 typeof 可以分类基础类型,并且帮助我们来区别基础类型和对象。

typeof value;

起决于操作值 value,操作返回下面字符串中的一个: | 操作值 | 结果 | | ----- | ----- | | undefined | "undefined" | | null | "object" | | Boolean 值 | "boolean" | | Number 值 | "number" | | String 值 | "string" | | Function 值 | "function" | | 所有其它值 | "object" |

typeof 操作 null 返回 "object" 是一个 bug。这无法被修复,因为修复意味着破话现有代码。注意,函数也是对象,但是 typeof 区分了它们。另一方面,数组却被它视为对象。

instanceof 操作符

instanceof 检查值是否是一个类型的实例:

value instanceof Type;

该操作符查看 Type.prototype 并且检查谁是 value 的原型。如果我们自己实现接口 instanceof,它看起来就像这样(去除一些错误检查,例如类型是 null ):

function myInstanceof(value, Type) {
  return Type.prototype.isPrototypeOf(value);
}

对基础类型使用 instanceof 总是返回 false:

'' instanceof String; // false
'' instanceof Object; // false

Array.isArray()

Array.isArray() 的出现是因为浏览器的一个特殊问题:每一个 frame 都有自己的全局环境。举一个例子:给定 frame A 和 frame B(它们在同一个页面)。frame A 可以添加一个值到 frame B 的代码中。然后 B 的代码不能使用 instanceof Array 来检查这个值是否是一个数组,因为它是 B 的数组不同于 A 的数组。例子:

<html>
  <head>
    <script>
      // test() is called from the iframe
      function test(arr) {
        const iframeWin = frames[0];
        console.log(arr instanceof Array); // false
        console.log(arr instanceof iframeWin.Array); // true
        console.log(Array.isArray(arr)); // true
      }
    </script>
  </head>
  <body>
    <iframe></iframe>
    <script>
      // Fill the iframe
      const iframeWin = frames[0];
      iframeWin.document.write('<script>window.parent.test([])</' + 'script>');
    </script>
  </body>
</html>

因此,ECMAScript 5 推出了使用 [[Class]] 来确定值是否是数组的 Array.isArray() 函数。意图使 JSON.stringify() 变得安全。但是所有的类型与 instanceof 结合使用时依然存在相同的问题。

内置的原型对象

内置类型中的原型对象是特殊值:它们是类型的原始成员,却不是它的实例。这使得分类变得古怪。通过了解这些怪异,我们能够更深了解类型分类。

Object.prototype

Object.prototype 就像一个空对象:他没有任何可枚举的自有属性(它的方法都是不可枚举的)。

Object.prototype; // {}
Object.keys(Object.prototype); // []

出乎意料的是Object.prototype 是一个对象,但却不是 Object 的实例。一方面,typeof 和 [[Class]] 都分出它是一个对象:

getClass(Object.prototype); // 'Object'
typeof Object.prototype; // 'object'

另一方面,instanceof 不认为它是 Object 的实例:

Object.prototype instanceof Object; // false

如果为了使上面的结果为 trueObject.prototype 将必须在它自己的原型链上。但是那将造成链的循环,这就是为什么 Object.prototype 没有原型。它是唯一没有原型的内置对象。

Object.getPrototypeOf(Object.prototype); // null

这种悖论适用于所有内置原型对象:除了 instanceof 之外,它们被所有机制视为其类型的实例。

预期,[[Class]],typeofinstanceof 在大多数其它对象上达成一致:

getClass({}); // 'Object'
typeof {}; // object'
{} instanceof Object; // true

Function.prototype

Function.prototype 本身就是一个函数。它接受任何参数并返回 undefined

Function.prototype('a', 'b', 1, 2); // undefined

出乎意料Function.prototype 是一个函数,但不是 Function 的实例:一方面,typeof 检查内置方法 [[Call]] 是否存在,说明 Function.prototype 是一个函数:

typeof Function.prototype; // 'function'

[[Class]] 属性也说明相同的事:

getClass(Function.prototype); // 'Function'

另一方面,instanceof 说明 Function.prototype 不是 Function 的实例:

Function.prototype instanceof Function; // false

那是因为它的原型链中没有 Function.prototype。相反,它的原型是 Object.prototype

Object.getPrototypeOf(Function.prototype) === Object.prototype;
// -> true

预期,使用其它方法,它们没有意外:

typeof function () {}; // 'function'
getClass(function () {}); // 'Function'
function () {} instanceof Function; // true

Function 也是一个函数:

typeof Function; // 'function'
getClass(Function); // 'Function'
Function instanceof Function; // true

Array.prototype

Array.prototype 本身是一个空数组:它以这种方法显示且长度为 0。

Array.prototype; // []
Array.prototype.length; // 0

[[Class]] 也证明它是数组:

getClass(Array.prototype); // 'Array'

也可以使用基于 [[Class]] 的函数 Array.isArray()

Array.isArray(Array.prototype); // true

当然,instanceof 不是如此:

Array.prototype instanceof Array; // false

在本章节的其余部分中,我们不再提及原型对象不是其类型的实例。

RegExp.prototype

RegExp.prototype 是一个匹配所有内容的正则表达式:

RegExp.prototype.test('abc'); // true
RegExp.prototype.test(''); // true

String.prototype.match 也接受 RegExp.prototype,它通过 [[Class]] 检查其参数是否是正则表达式。对于正则表达式和原型对象,该检查是令人信服的:

getClass(/abc/); // 'RegExp'
getClass(RegExp.prototype); // 'RegExp'

RegExp.prototype 等价于“空正则表达式”。这种表达式有两种创建方式:

new RegExp(''); // 通过构造方法方式
/(?:)/; // 字面量方式

你应该只用 RegExp 构造方法创建动态正则表达式。通过文字表达空的正则表达式很复杂,因为你不能使用 // 来描述它。空的非捕获组 (?:)的行为与空的正则表达式相同:它匹配所有内容,并且不会在匹配中创建捕获。

new RegExp('').exec('abc');
// -> [ '', index: 0, input: 'abc' ]
/(?:)/.exec('abc');
// -> [ '', index: 0, input: 'abc' ]

对比:一个空的组不仅匹配索引 0,而且捕获了位于索引 1 的第一组:

/()/.exec("abc")
// ->
[ '',  // index 0
  '',  // index 1
  index: 0,
  input: 'abc' ]

有趣的是,通过构造函数创建的空正则表达式和 RegExp.prototype 两者都显示为空的字面量:

new RegExp(''); // /(?:)/
RegExp.prototype; // /(?:)/

Date.prototype

Date.prototype 也是一个日期类型:

getClass(new Date()); // 'Date'
getClass(Date.prototype); // 'Date'

日期包装数字。这里引用ECMAScript 5.1 规范

  • Date 对象包含一个数字,表示一个毫秒内的特定时刻。这样的数字称为时间值。时间值也可以是 NaN,表示 Date 对象不表示特定的时刻。
  • ECMAScript 规定时间是自 UTC 时间 1970 年 1 月 1 日 00:00:00 起,以毫秒为单位。

访问时间值的两种常用方法是调用方法 valueOf 或通过将日期强制转换为数字:

const d = new Date(); // now

d.valueOf(); /// 1347035199049
Number(d); // 1347035199049

另外可以使用 getTime()Date.now() 函数来获取时间值,Date.now() 函数返回自 1970 年 1 月 1 日 00:00:00 UTC 到当前时间的毫秒数。

const now = new Date();
now.getTime(); // 1560500640506

Date.now(); // 1560500676490

Date.prototype 的时间值是 NaN

Date.prototype.valueOf(); // NaN
Number(Date.prototype); // NaN

Date.prototype 显示的是无效的时间,可以通过 NaN 创建相同的时间:

Date.prototype; // Invalid Date
new Date(NaN); // Invalid Date

Number.prototype

Number.prototypenew Number(0) 大约相同:

Number.prototype.valueOf(); // 0

当转换为数字将返回包装的原始值:

+Number.prototype; // 0

对比:

+new Number(0); // 0

String.prototype

String.prototypenew String('') 大约相同:

String.prototype.valueOf(); // ''

当转换为数字将返回包装的原始值:

'' + String.prototype; // ''

对比:

'' + new String(0); // ''

Boolean.prototype

Boolean.prototypenew Boolean(false)大约相同:

Boolean.prototype.valueOf(); // false

布尔对象(Boolean)可以强制转换为布尔值,但是结果总是 true,那是因为任何对象转换为布尔值都是 true

!!Boolean.prototype; //true
!!new Boolean(false); // true
!!new Boolean(true); // true

这不同于对象转换为数字或字符串:如果对象包装这些基础值,那么转换的结果就是包装的原始值。

本文作者 Axel Rauschmayer,转载请注明来源链接:

原文链接:https://2ality.com/2013/01/categorizing-values.html

本文链接:https://tie.pub/2019/06/categorizing-values-in-javascript/