细读 JS | 数据类型详解

今天又又又...又整理了一下,那些 JavaScript 里不清不楚的知识点。

一、数据类型的分类

截止发文日期,ECMAScript 标准的数据类型仅有 8 种(ECMAScript Language Types)。可以分为两类:

  • 原始类型(Primitives),我们也称作基本数据类型
    • Undefined
    • Null(一种特殊的原始类型,typeof(instance) === 'object'
    • Boolean
    • String
    • Symbol(typeof(instance) === 'symbol'
    • Number
    • BigInt(typeof(instance) === 'bigint'
  • 引用类型(Objects)
    • Object(包括从 Object 派生出来的结构类型,如 Object、Array、Map、Set、Date 等)

关于使用 typeof 判断以上数据类型的话题,老生常谈了。例如,为什么 typeof null === 'object'typeof(() => {}) === 'function' 呢?这里不展开赘述了,请移步:JavaScript 的迷惑行为大赏。

原始类型的比较的是值,只有两者的值相等,那么它们被认为是相等的,否则不相等。而引用类型比较的是地址,当两者的标识符同时指向内存的同一个地址,则被认为是相等的,否则不相等。

console.log({} == {}) // false
console.log([] == []) // false

二、原始类型与原始值

所有基本类型的值(即原始值,Primitive Values)都是不可改变(immutable)的,而且不含任何属性和方法的。

到这里可能会有小伙伴打问号了???

Q1:原始类型与原始值有什么区别?

原始类型的值称为原始值。例如原始类型 Boolean 有两个(原始)值 truefalse。同样的原始类型 Undefined(Null),只有一个原始值 undefinednull)。其他的就有很多个了...

Q2:原始值不可改变?这样不是改变了吗?

var foo = true
foo = false
console.log(foo) // false

其实不然,以上示例是原始类型和一个赋值为原始类型的变量的区别。变量会被赋予一个新值,而原值不能像数组、对象以及函数那样被改变。

基本类型值可以被替换,但不能被改变。

// 使用字符串方法不会改变一个字符串
var foo = 'foo'
foo.toUpperCase()
console.log(foo) // "foo"

// 赋值行为可以给基本类型一个新值,而不是改变它
foo = foo.toUpperCase() // "FOO"

再有示例:

var num = 1

function add(num) {
  num += 1
  console.log(num)
}

add(num) // 2
console.log(num) // 1

// ************************** 华丽的分割线 **************************

// 如果没有看上面的一些概念,单纯地看上面的例子,我相信百分百都能得到正确答案。
// 但看完上面一些的概念之后,再结合例子,不知道会不会有人对 “原始类型的值不可改变” 这句话产生怀疑?
// 如果有怀疑就继续往下看 ,否则可直接跳到 Q3 了。

// ************************** 华丽的分割线 **************************

// JS 运行的三个步骤:词法分析、预编译、解析执行。
// 其中预编译,不仅仅发生在 script 代码块执行之前,还发生在函数执行之前。
// 
// 函数预编译的过程大致是这样的:
// 1. 首先查找形参和变量声明(此时并赋予值 undefined)
// 2. 接着将实参赋值给形参
// 3. 接着查找函数体内的函数声明(赋予函数本身)。
//
// 函数 add 在实参赋值给形参的过程,会将传递进来的参数(基本类型的值)复制一份,
// 创建一个本地副本,该副本只存在于该函数的作用域中。(原本的值与副本是完全独立,互不干扰的)

Q3:原始值没有任何属性和方法?那这个是怎么回事?

var foo = 'foo'
console.log(foo.length) // 3
console.log(foo.toUpperCase()) // "FOO"

// 试图改变 length 属性
foo.length = 4
console.log(foo.length) // 3

其实这是 JavaScript 包装类的内容了。

在 JavaScript 中除了 nullundefined 之外,所有的基本类型都有其对应的包装对象(Wrapper Object)。因此,访问 nullundefined 的任何属性和方法都会抛出错误。

  • String 为字符串基本类型。
  • Number 为数值基本类型。
  • BigInt 为大整数基本类型。
  • Boolean 为布尔基本类型。
  • Symbol 为字面量基本类型。

这些包装对象的 valueOf方法返回其对应的原始值。

再次明确一点,原始值是没有任何属性和方法的。

不是说好的,原始值不含任何的属性和方法吗?那 foo.lengthfoo.toUpperCase() 是咋回事啊???

其实它内部是这样实现的:当字符串字面量调用一个字符串对象才有的方法或属性时,JavaScript 会自动将基本字符串转化为字符串对象并且调用相应的方法或属性。(Boolean 和 Number 也同样如此)。

我们尝试在控制台上打印一下 new String('foo'),可以看到该实例对象有一个 length 属性,其值为 3,实例对象本身没有 toUpperCase() 方法,所以接着往原型上查找,果然找到了。(由于原型上方法太多,截图里没有展开,否则影响文章篇幅)

因此

var foo = 'foo'
console.log(foo.length) // 3
console.log(foo.toUpperCase()) // "FOO"

// 相当于
var foo = 'foo'
console.log(new String(foo).length) // 3
console.log(new String(foo).toUpperCase()) // "FOO"

可下面为什么 length 还是 3 呢?

foo.length = 4
console.log(foo.length) // 3

// 怎样理解呢?
//
//
// 执行第一行代码
// foo.length = 4 可以拆分成两部分去理解:
var temp = new String(foo) // 在内存中创建了一个对象,只是没有一个标识符(变量)指向它而已(为了便于理解,我这里假装有一个 temp 变量指向它)
temp.length = 4 // 修改包装对象的 length 属性,其实是修改成功的
// 由于该对象并没有被引用,所以在执行下一句代码之前就被回收销毁了
//
//
// 2. 执行第二行代码
// console.log(foo.length) 相当于
console.log(new String(foo).length) // foo 还是 "foo",自然结果就是 3 了。

三、对象

在 JavaScript 中,除了以上的原始值,其余都属于对象。

与原始类型不同的是,对象是可变(mutable)的。

1. 对象的分类

我们可以将对象划分为普通对象(ordinary object)和函数对象(function object)。

那怎样区分呢?我们先定义一些 Function 实例和 Object 实例:

// Function 实例
function fn1() {}
var fn2 = function() {}
var fn3 = new Function('console.log("Hi, everyone")') // 一般不使用 Function 构造器去生成 Function 对象,相比函数声明或者函数表达式,它表现更为低效。

// Object 实例
var obj1 = {}
var obj2 = new Object()
var obj3 = new fn1()

我们来打印一下结果:

typeof Object     // "function"
typeof Function   // "function"

typeof fn1        // "function"
typeof fn2        // "function"
typeof fn3        // "function"

typeof obj1       // "object"
typeof obj2       // "object"
typeof obj3       // "object"

ObjectFunction 本身就是 JavaScript 中自带的函数对象。其中 obj1obj2obj3 为普通对象(均为 Object 的实例),而 fn1fn2fn3 为函数对象(均是 Function 的实例)。

记住以下这句话:

所有 Function 的实例都是函数对象,而其他的都是普通对象

2. 对象的原型

接着,引入两个很容易让人抓狂、混淆的两兄弟 prototype (原型对象)和 __proto__(原型)。这俩兄弟的主要是为了构造原型链而存在的。

对象类型 prototype __proto__
普通对象
函数对象

因此有以下结论:

所有对象都有 __proto__ 属性,而只有函数对象才具有 prototype 属性。

再上几个菜,请慢慢品尝:

// 每个对象都有一个 constructor 属性,该属性指向实例对象的构造函数
Object.prototype.constructor === Object // true
Function.prototype.constructor === Function // true


// (全局对象)Object 是 (构造器)Function 的实例
// (全局对象)Function 也是 (构造器)Function 的实例
Object.__proto__ === Function.prototype // true
Function.__proto__ === Function.prototype // true


// (构造器)Function 也是(构造器)Object 的实例
Function.prototype.__proto__ === Object.prototype // true


// 从原型上查找属性,不可能无终止地查找下去,那原型的尽头在哪呢?
// 站在原型顶端的男人,是它。
// 假设我们访问一个对象的属性或者方法,如若前面的原型上均无法查找到,最终会止步于此,并返回 undefined。
Object.prototype.__proto__ // null

在 JavaScript 中访问一个对象属性,它在原型上是怎样查找的呢?

function Person() {} // 构造函数
var person = new Person() // 实例化对象
console.log(person.name);  // undefined

// 过程如下:
person // 是对象,可以继续
person['name'] // 不存在属性 name,继续查找
person.__proto__ // 是对象,可以继续
person.__proto__['name'] // 不存在属性 name,继续查找
person.__proto__.__proto__ // 是对象,可以继续
person.__proto__.__proto__['name'] // 不存在属性 name,继续查找
person.__proto__.__proto__.__proto__ // 不是对象,是 null 值。停止查找,返回 undefined

需要注意的是,Object.prototype.__proto__ 从未被包括在 ECMAScript 语言规范中标准化,但它被大多数浏览器厂商所支持。该特性已从 Web 标准中删除,详情可看 Object.prototype.__proto__。

在标准中,几乎(例外是 Object.create(null) ,下面有说明)每个实例对象内部都有一个 [[Prototype]] 属性,该属性指向对象的原型,而且该属性值只会是对象或者 null

在非标准下,可以通过 Object.prototype.__proto__ 访问(或设置)实例对象内部的 [[Prototype]],这种方式其实是不被推荐使用的。现在更被推荐使用的方式是 Objec.getPrototypeOf()/Object.setPrototypeOf()

请注意,以上(包括下文)所指对象均不是通过 Object.create(null) 实例化的(除特意说明外)。Object.create(null) 实例化的对象比较特殊,它内部没有 [[Prototype]] 属性,也没有任何其他内部属性。(Object.create())

var obj = Object.create(null)

var obj1 = Object.create(null)
var obj2 = {}

obj.__proto__ === undefined // true
obj.getPrototypeOf() // 抛出错误 TypeError: obj.getPrototypeOf is not a function

我们可以在控制台打印一下,看下两者的区别。

JavaScript 常被描述为一种基于原型的语言 —— 每个对象拥有一个原型([[Prototype]]),对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链(prototype chain)。

3. 继承

关于继承内容,可看另外一篇文章:深入 JavaScript 继承原理。

4. 对象的内部属性(Internal properties)

在规范中,对象的内部方法和内部插槽使用双方括号 [[]] 中包含的名称标识,且首字母为大写。例如 [[Prototype]][[Class]][[Extensible]][[Call]][[Scopes]][[FunctionLocation]] 等等。

下面挑几个来讲一下:

4.1 [[Class]]

[[Class]] 是对象的一个内部属性,其值为以下字符串之一:

  • 常见的有:FunctionObjectArrayBooleanNumberStringSymbolRegExpJSONDateMathErrorArguments 等。
  • 比较少用的有:BigIntSetWeakSetMapWeakMapReflectPromiseGeneratorFunctionAsyncFunctionWindowIntlWebAssembly,以及派生于 HTMLElement 的(如 HTMLScriptElement )等等。
  • 几乎所有标准内置对象,都有特定的类型。实在太多了...

我们都知道 typeof 无法判断对象的具体类型,无论是 typeof {}typeof []、还是 typeof Math 都返回 "object"。但有了 [[Class]] 属性之后,我们就可以利用它来判断对象的类型了。访问 [[Class]] 的唯一方法是通过默认的 toString() 方法(该方法是通用的):

Object.prototye.toString()

  • 如果参数 undefined,则返回 [object Undefined] 字符串;
  • 如果参数 null,则返回 [object Null] 字符串;
  • 如果参数是一个对象,则返回 "[object " + obj.[[Class]] + "]" 字符串,例如 [object Array]
  • 如果参数是一个原始值,则会先将其转换为相应的对象,然后按照以上的规则输出。

以下封装了获取对象类型的方法:

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

getClass(null) // "Null"
getClass(undefined) // "Undefined"
getClass({}) // "Object"
getClass([]) // "Array"
getClass(JSON) // "JSON"
getClass(() => {}) // "Function"
;(function() { return getClass(arguments) })() // "Arguments"

4.2 [[Construct]]

一个对象里,如若没有 [[construct]] 属性,是无法使用 new 关键字进行构造的。

四、类型转换

在 JavaScript 中,我们会经常使用相等运算符(==)去比较两个操作数是否相等。当两个操作数一个是引用类型,另一个是原始类型的时候,前者会先转换为原始类型,再比较。

那么,引用类型是如何转换为原始类型的呢?

关于 JavaScript 类型转换的内容,已经单独写了一篇文章详细地介绍了,请看 Type Conversion 详解。

未完待续...

参考

  • JavaScript’s type system
  • Categorizing values in JavaScript
  • Primitive MDN
  • ToPrimitive Abstract Operation
  • OrdinaryToPrimitive Abstract Operation

你可能感兴趣的:(细读 JS | 数据类型详解)