整理了一下,那些 JavaScript 里不清不楚的知识点。
截止发文日期,ECMAScript 标准的数据类型仅有 8 种(ECMAScript Language Types)。可以分为两类:
关于使用 typeof 判断以上数据类型的话题,老生常谈了。例如,为什么 typeof null === 'object'、typeof(() => {}) === 'function' 呢?这里不展开赘述了,请移步:JavaScript 的迷惑行为大赏。
原始类型的比较的是值,只有两者的值相等,那么它们被认为是相等的,否则不相等。而引用类型比较的是地址,当两者的标识符同时指向内存的同一个地址,则被认为是相等的,否则不相等。
- console.log({} == {}) // false
- console.log([] == []) // false
-
所有基本类型的值(即原始值,Primitive Values)都是不可改变(immutable)的,而且不含任何属性和方法的。
到这里可能会有小伙伴打问号了???
Q1:原始类型与原始值有什么区别?
原始类型的值称为原始值。例如原始类型 Boolean 有两个(原始)值 true 和 false。同样的原始类型 Undefined(Null),只有一个原始值 undefined(null)。其他的就有很多个了...
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 中除了 null 和 undefined 之外,所有的基本类型都有其对应的包装对象(Wrapper Object)。因此,访问 null 或 undefined 的任何属性和方法都会抛出错误。
这些包装对象的 valueOf方法返回其对应的原始值。
再次明确一点,原始值是没有任何属性和方法的。
不是说好的,原始值不含任何的属性和方法吗?那 foo.length 和 foo.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)的。
我们可以将对象划分为普通对象(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"
-
Object 和 Function 本身就是 JavaScript 中自带的函数对象。其中 obj1、obj2、obj3 为普通对象(均为 Object 的实例),而 fn1、fn2、fn3 为函数对象(均是 Function 的实例)。
记住以下这句话:
所有 Function 的实例都是函数对象,而其他的都是普通对象。
接着,引入两个很容易让人抓狂、混淆的两兄弟 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)。
关于继承内容,可看另外一篇文章:深入 JavaScript 继承原理。
在规范中,对象的内部方法和内部插槽使用双方括号 [[]] 中包含的名称标识,且首字母为大写。例如 [[Prototype]]、[[Class]]、[[Extensible]]、[[Call]]、[[Scopes]]、[[FunctionLocation]] 等等。
下面挑几个来讲一下:
4.1 [[Class]]
[[Class]] 是对象的一个内部属性,其值为以下字符串之一:
我们都知道 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 详解。