林大妈的JavaScript进阶知识(一):对象与内存
JavaScript中的基本数据类型
在JS中,有6种基本数据类型:
- string
- number
- boolean
- null
- undefined
- Symbol(ES6)
除去这六种基本数据类型以外,其他的所有变量数据类型都是Object。基本类型的操作在JS底层中是这样实现的:
// 1. 申请一块内存,存储foo变量的内容为1
let foo = 1
// 2. 定义foo为1时,foo的数据类型是number
typeof foo // "number"
// 3. 我们知道,const的意思是constant(常量,无法改变的)
const bar = foo
// 4. 修改值时,新申请了一块内存存储foo的内容为2
foo = 2
// 4. 则会发现,foo已经是2了,bar仍然是1
console.log(foo) // 2
console.log(bar) // 1
由此可见,我们定义的变量实际上都是指针。基本数据类型的修改实际上是新申请一块内存地址,将这个指针指向新的内存地址。使用const定义变量,实际上相当于定义了一个指针常量,指向固定的地址不能被修改。
JavaScript中的对象
定义和修改对象
我们来试着从变量定义的执行结果看出它在底层的执行方式:
// 1. 定义一个对象
const obj = {
foo: 1
}
// 2. 定义一个新变量与其相等
const anotherObj = obj
// 3. 修改这个对象
obj.foo = 2
// 4. 发现两个对象都修改了
console.log(obj)
console.log(anotherObj)
由此可见,JS中对象的赋值是一种浅拷贝。
熟悉了对象的本质以后,我们要逐步了解对象有哪些特性。
对象的属性与方法
实际上,在学习一般高级语言的时候,应该先介绍类的属性与方法(共性),才介绍实例化类产生的对象如何使用(特性)。但由于JS是以原型、对象为主的语言,类只能在ES6中以语法糖的形式存活,我们只能先从对象入手,反推类的性质。
对象其实就是一些属性和一些方法的集合。而对象的属性和方法要深究,其实也是非常复杂的问题(光看内置对象Object以及Object.prototype上有多少方法处理对象的属性就知道不简单):
属性
描述符
每个属性上有描述符号。所谓的描述符号,是一些键值对,它们描述了对于这个属性是否能操作、是否能枚举等等的所有特性。
描述符号只能是数据描述符和存取描述符两个里面的一个(在一般的声明中,属性默认含有的是数据描述符)。
首先,这两种描述符公有的两个属性是:configurable(这个属性的描述符是否能被修改、以及这个属性是否能被delete运算符删除)和enumerable(是否能被枚举)。
然后是数据描述符,顾名思义,它定义了value(值)和writable(值是否能被赋值语句修改)。
最后是存取描述符,同样的顾名思义,它定义了这个属性的get(读取时执行的函数)和set(修改时执行的函数)。
定义和修改属性
我们可以通过Object.defineProperty来具体地配置一个属性的描述符:
const foo = {}
// 1. 数据描述符
Object.defineProperty(foo, 'bar', {
// 1.1. 固定了是数据描述符不能被修改
configurable: false,
// 1.2. 设置该属性可以枚举
enumerable: true,
// 1.3. 值为3
value: 3,
// 1.4. 无论怎么赋值更新foo.bar,它的值仍然是3
writable: false
})
// 2. 存取描述符
let baz = 3
Object.defineProperty(foo, 'baz', {
// 2.1. 固定了是存取描述符不能被修改
configurable: false,
// 2.2. 设置该属性可以枚举
enumerable: true,
// 2.3. 使用foo.baz读取时,会顺带输出这句话
get: function () {
console.log('The getter is called.')
return baz
},
// 2.4. 使用赋值语句为foo.baz赋值时,会顺带输出这句话
set: function (value) {
console.log('The setter is called.')
baz = value
}
})
由此可见,定义属性时可以根据自己的需求修改默认的描述符。
了解到这里,我们不难联想到,著名的前端框架Vue实现数据的双向绑定,实际上就是利用了这个存取描述符。我们在编写Vue代码时,定义Vue对象中data属性的值。Vue在编译过程中,首先收集了这个值的所有依赖(也就是它在我们代码中出现的各种地方),然后利用Object.defineProperty,把属性的描述符改成存取描述,并在setter中修改所有的依赖,通知视图更新。这样就有了我们觉得非常神奇的数据双向绑定。
遍历属性
对属性的常用操作除了定义与修改,还有遍历。最常用的遍历方法是:
// 两种方法,都只能遍历enumerable的属性
const obj = {
foo: 1,
bar: 2,
baz: 3
}
// 1. Object.keys
let objAttrs = Object.keys(obj)
// 2. for...in...
let objAttrs = []
for (let key in obj) {
objAttrs.push(key)
}
// 以上两种遍历的方法数量和顺序均一致
// 如果不希望遍历原型上的属性,还可以使用Object.hasOwnProperty进行过滤
遍历时需要考虑到属性是否能被枚举以及原型上的属性是否需要被遍历到。
方法
函数调用
函数有总共四种调用模式,这四种调用模式其实都是围绕着this指向的不同而定的(下面的全局在浏览器环境中表示window,在node环境中表示global):
- 普通函数调用 —— this指向全局
- 方法调用 —— this指向方法所定义的对象
- 构造器调用 ——(使用new关键字时)this指向当前函数对象(函数本身就是对象)
- (call、apply和bind)调用 —— this指向(call、apply和bind)函数的第一个参数
方法是什么
方法就是定义在类或者对象上,用来处理对象有关数据的函数。简而言之,方法就是函数的子集。方法特别于其他函数的点在于,它的this是指向当前对象的。
从方法到this
由此可见,我们通过不同的方式调用函数,最终为的还是根据自己的需求定义this的指向。我们试着来区分几个例子,从而最终总结出JS中this的指向情况:
一般情况
// 定义一个对象,里面有一个输出对象自身的方法
const obj = {
foo: function () {
return this
}
}
// 直接执行obj.foo方法,正常得到obj对象
console.log(obj.foo())
// 用一个外部变量接收obj.foo方法
const fakeFoo = obj.foo
// 执行这个接收回来的方法,获得this为全局对象
console.log(fakeFoo())
上述例子说明,一般情况下,this指向的是函数被调用时所在的上下文环境。
(所谓函数调用时的上下文环境,实际上也等同于JS中的词法作用域(lexical scope),即函数作用域)
内部函数
下面再来看看内部函数的this指向:
// 定义一个对象,里面有一个方法,方法里面有一个返回this的内部函数
// 以此测试内部函数中this指向
const obj = {
foo: function () {
return function () {
return this
}
}
}
// 执行这个内部的函数,发现this指向的是全局对象
console.log(obj.foo()())
上述例子说明,内部函数中,this没有指向当前对象,而是指向的是全局。
箭头函数
当然,ES6中箭头函数的出现修复了这些问题,内部函数的this也能正确指向当前对象了:
// 仅仅把上述对象的内部函数换为箭头函数
const obj = {
foo: function () {
return () => {
return this
}
}
}
// 正确得到this为当前对象
console.log(obj.foo()())
上述例子说明,箭头函数把this绑定回了词法作用域。
但是,由于JS的词法作用域为函数作用域,以下的写法又会发生错误:
const obj = {
foo: () => {
return this
}
}
// 得到的this为全局对象
console.log(obj.foo())
上述例子说明了,由于JS词法作用域为函数作用域,箭头函数没有外部函数包着,因此是全局作用域。
但是,箭头函数强制将this绑定到函数执行的上下文环境。这导致了bind、call与apply的失效。
// 定义一个对象,里面有一个方法返回当前对象的foo属性
// 并将这个方法应用到foo为2的新对象上
const obj = {
foo: 1,
bar: function () {
const foo = this.foo
const baz = function () {
return foo
}
return baz.call({ foo: 2 })
}
}
// 得到新对象的值为2
console.log(obj.bar())
正常情况下,call方法正常地将这个方法应用到另一个对象上。
// 仅将内部返回foo的函数改为箭头函数
const obj = {
foo: 1,
bar: function () {
const foo = this.foo
const baz = () => {
return foo
}
return baz.call({ foo: 2 })
}
}
// 得到的还是旧的1,说明call方法并没有成功将this绑定到新对象上
console.log(obj.bar())
而箭头函数的this则被紧锁在了旧对象上。
总结:
- JS的6种基本数据类型:string、number、boolean、null、undefined、Symbol
- JS中,除了6种基本数据类型以外,其他变量都是对象,我们通过操作指针对这些对象进行处理
- 对象的属性有两种描述符的其中一种:数据描述符(默认)和存取描述符
- 对象的方法中,this默认指向这个对象,而方法的内部函数this默认指向全局
拓展:
- 通过Object.defineProperty可以定义和修改某个属性的描述符
- 普通函数中的this默认指向词法作用域,使用new定义对象、call、apply、bind等内建方法,可以修改this的指向
- 箭头函数将this锁在了词法作用域,没办法使用call、apply、bind进行修改