前言
在我刚学习JavaScript时,我一直对其中的变量很迷惑,为什么分了种类之后,还有类型?为什么变量的初始化有那么多方式?为什么变量复制的时候有那么多条条框框?
待着这些迷惑去网上找答案,有些文章确实能帮助我理解,但多数都是“结论型”的,仿佛都在告诉你:“这个就是这样,记住就行了”。但这样会很迷惑,我无法了解它的原理,以至于在面试和工作中,依然停留在“死记硬背”的阶段,这一度让我很难受。
于是便采用了最朴实的方法:读书。在看了大量的书和文章之后,我带着自己的理解,这里用最简单、直白的方式,一步一步地写了这篇关于变量的文章,希望能帮助和我一样困惑的人,解决面试中的问题
此外,该文章参考了一些前辈的优秀文章,后面会把链接附上,供大家欣赏
最后,文章难免有出错之处,希望大家能谅解。
1 变量
对于学过前端的人,对js的变量类型再熟悉不过了,该篇文章不会去讲每种类型的用法,而更侧重于阐明变量的本质区别 相比于其他类型的语言,比如C、Java等,JavaScript中的变量可谓是独树一帜。 我们都知道JavaScript里面的变量是松散类型的,这就意味着,一个变量的值和类型可以随时随地改变。这样一来JavaScript就有了很强的灵活性,不过也会带来不少问题。
1.1 变量的类型:包装对象与属性
在js中,定义变量有2种方式:字面量和构造函数
let str = 'hello' //使用字面量定义
let str1 = String('hello') //使用字面量定义
let str2 = new String('hello') //使用构造函数定义
但是这2者有什么不同呢,尝试着比较一下str和str1、str2是否相等
console.log(str === str1) //>> true
console.log(str === str2) //>> false
可以发现,str与str2不相等,但明明值都是hello,这里就要提一下JavaScript中的包装对象了
JavaScript中规定规定,万物皆对象,该语言在设计时提供了String、Boolean、Number三个包装对象(构造函数),这三个包装对象的作用是为了能够创建这三个基本数据类型对象
也就是说,使用new String创建出来的字符串是个对象,我们可以检测一下它的类型,发现类型是对象
console.log(typeof str2) //>> object
既然是对象,那么就可以添加自定义属性,我们给str和str2都添加一个name属性,输出发现str是undefined,str2可以正常输出
str.name = '我是字符串'
str2.name = '我是字符串'
console.log(str.name) // undefined
console.log(str2.name) //>> 我是字符串
所以,看到这里,我们大致可以了解到,对于Number、String、Boolean这3类数据类型:
- 使用字面量的声明方式,得到的变量就是简单的数据类型
- 使用
new 包装类的形式声明出来的是对象 - 即使值一样,2种声明方式得到的结果也不同
所以,为了在写代码的时候避免歧义,一般规定:创建字符串,数字,布尔值时,必须使用字面量的方式,而不要使用构造函数,因为基本数据类型是不该有自定义属性的
//推荐
let num = 123
let str = 'hello'
let bool = false
//不推荐
let num = new Number(123)
let str = new String('hello')
let bool = new Boolean(false)
为什么?因为这是这门语言的设计缺陷,借鉴了Java的设计,将数据类型分为原始值和引用值,大家只好尽量避免了
1.2 变量的类型:基本类型和引用类型
JavaScript的变量值可以包含2种不同类型:基本类型和引用类型。
基本类型就是我们常见的简单数据类型,如Undefined、Null、Boolean、Number、String和Symbol。这些值是保存在栈内存中,是按值访问的,我们平时操作的值就是存储在变量中的实际值。
引用类型可以统称为对象(Object)类型,比如常见的Array、Object、Function等。
这些值保存在堆内存中,与其他语言不同,JavaScript不允许直接访问对象所在的内存空间。
在操作对象时,实际操作的是对该对象的引用,而非对象本身。因此保存引用类型值的变量是按引用访问的。
上面引出了几个概念 基本类型、引用类型、栈内存、堆内存、按值访问、按引用访问
下面将会详细对这些概念进行解释
1.3 栈内存和堆内存
在说变量类型的区别之前,先了解一下前置知识:栈内存和堆内存
JavaScript之父布莱登·艾奇,在设计JavaScript之时,借鉴了Java虚拟机(JVM)的内存处理机制。
JavaScript代码在执行时,会在我们电脑的内存里开辟内存空间(其他语言的代码也是如此),用于存储代码运行时的变量的值,这里的内存就是我们通常说的8G、16G、32G的电脑内存条。
虽说开辟的内存空间用来存储变量值,但是对于不同的变量值类型,其处理方式也不同,因此在JavaScript中,开辟的内存空间分为2类:栈内存和堆内存
简单来说,在JavaScript中: 栈内存用于存储基本类型的变量值 堆内存用于存储引用类型的变量值
我们定义的基本类型的变量,其变量值都会被如实放到栈内存中。 而引定义的用类型的变量,其值的存储方式就不同了, js会将值的访问地址存储到栈内存中,而将引用类型的值存储到堆内存中。
至于栈内存和堆内存的详情区别,大家可以参考这篇《内存中堆和栈的区别》
这可能难以理解,我们结合代码和示意图来理解
let a = 25 //保存在栈内存中
let b = 'hello' //保存在栈内存中
let c = false //保存在栈内存中
let d = [1,2,3] //变量d存在栈内存中,[1,2,3]作为对象存在堆内存中
let e = { x:1, y:2 } //变量e存在栈内存中,{ x:1, y:2 }作为对象存在堆内存中
如上图,当我们访问a,b,c等基本变量时,会直接从栈内存中读取到具体值。
而访问堆内存中d,e等引用类型的值时,要分2步走:
- 从栈中获取该对象值的访问地址
- 再从堆内存中取得我们需要的对象数据
看到这不仅有人会疑问,这么抽象的概念和我平时开发有什么关系呢?不仅有,而且关系可大了,接着来看一下变量复制的区别
1.4 变量复制
JavaScript中变量的复制是很有意思的事,涉及到变量的浅拷贝和深拷贝,这也是为什么把变量分类和内存分类放在前面来说,因为二者是紧密关联的
1.4.1 基本类型复制
在我们平时开发中,经常遇到变量复制的时候,比如:
let x = 25
let y = x
y = 26
console.log(x) // >> 25
console.log(y) // >> 26
上面先定义了变量x = 25,接着定义变量y,并将x的值复制给它。
然后输出x和y的值,分别是25和26,修改y的值并不会影响x(这不废话嘛)。其过程如下图所示:
在复制基本类型的数据时,系统会在栈内存中开辟一个新的空间,为新的变量分配一个新的值,这个新的值就是从原来的变量复制过来的,之后原有的值和新的值保持相互独立,互不影响。
举个例子大家就会明白了,有一天你在写文档,你的同事想要一份你写的文档,然后你复制了一份你的文档,通过钉钉发给他了,他接收后修改文档,你这边的文档不会被修改(肯定改不了啊)因为你把文档本身发给他了啊
1.4.2 引用类型复制
引用类型的复制就截然不同了,我们把x定义为对象,并将其复制给y,接着修改y的age属性,然后输出一下。发现,x的age属性也变了,但是明明没有修改的x的值
let x = {
name: 'Tom',
age: 25
}
let y = x
y.age = 26
console.log(x.age) //>> 26
console.log(y.age) //>> 26
- 引用类型的值发生复制时,同样会为新的值在栈内存中开辟一个新的空间,不同的是,这个新的值,是我们上面说的“值的访问地址”,是一个指针(C语言中的概念)
- 这个地址(指针)指向堆内存中的对象,复制完成后,
x和y都指向同一个对象 - 因此修改一个值,会影响另一个值
其过程如下图所示:
完成复制后,两个变量引用的是同一个值,所以,修改其中一个值就会影响另外一个,因为本质上二者在内存中访问的是同一份数据。
还是举个例子,有一天你在写文档,你的同事想要一份你写的文档,你说我的文档太大,存存到网盘上了,我把网盘地址发你你自己看吧,你把地址发给他后,他进到网盘没有下载文档,而是在线编辑了一下,然后文档就被修改了,你们俩访问的文档是同一份
这样解释是不是就很容易理解,基本类型是复制的值,类似于你把文件直接发给他。而引用类型复制是传递的地址,你把文件地址发给他,让他自己去看。
但为什么这么设计呢,这就不得说一下JavaScript的历史这门语言的历史了:
简单来说:
- JavaScript是国外公司推出的编程语言产品,用于快速抢占浏览器市场
- JavaScript之父Brendan Eich (布兰登·艾奇),是公司雇佣的程序员,用来设计一门新的语言
- 布兰登·艾奇 只用了10天就完成了设计
- JavaScript 借鉴(抄袭)了C、Java、Python等语言
如此看起来,编程语言也是程序员为了完成KPI设计出来的,正如我们上面提的借鉴了C语言的指针、Java的JVM(虚拟机)的内存管理模型 所以,变量的复制问题,是一个历史遗留问题。这样像极了我们在日常开发中到处去搜功能代码一样。
1.4.3 变量作为函数参数时的复制
这部分在在第二篇文章《函数》会做详细讲解,不过其和值的复制也有很大关系,这里就先提一下
JavaScript中规定:所有函数的参数都是按值传递的。这意味着函数外部的值会被复制到函数内部的参数中,就像一个变量复制到另一个变,其中:
如果参数是基本类型的,就和我们上面说的基本类型的复制一样。 如果参数是引用类型的,就和我们上面说的引用类型的复制一样。
看起来说了,但又好像没说,直接看代码示意图:
function add(num) {
num += 10
return num
}
let count = 20
let result = add(count)
console.log(result) //>> 30
console.log(count) //>> 20 没有变化
定义了一个函数,接受一个参数num,在函数内部将其加上10,返回结果,用变量result保存。
接着定义一个变量count = 20 ,调用函数,将count作为参数传递进去。然后输出一下,发现原有的count的值没有发生变化,到这里都是正常的。但如果函数的参数传递的是对象,那就没那么清楚了,接着再看下面的示例
function setAge(obj) {
obj.age = 25
}
let person = new Object()
person.age = 1
console.log(person.age) //>> 1
setAge(person)
console.log(person.age) //>> 25
这次,创建了一个对象,并把它保存在变量person中,然后将其age属性设置为1。同样定义一个setAge函数,接收一个对象作为参数,修改对象的age属性。
然后调用函数,将person传递进去,接着输出person的age属性,发现age变成了25
到这里大家可能就有了结论,“如果参数是引用类型的,那么在函数内部修改了参数,会反映到函数外部的变量,这就意味着参数是按引用传递的”
等等,这句话真的对吗?和前面说的JavaScript中规定有点不同。 先别着急下定义,接着看下面的代码:
function setAge(obj) {
obj.age = 25
obj = new Object()
obj.age = 100
}
let person = new Object()
person.age = 1
console.log(person.age) //>> 1
setAge(person)
console.log(person.age) //>> 25
代码唯一的变化是,setAge函数多了2行代码,把参数obj的age属性设置为25后,接着obj被设置为一个新的对象,并且age属性被设置为100。
如果按照之前我们的总结:
“如果参数是引用类型的,那么在函数内部修改了参数,会反映到函数外部的变量,这就意味着参数是按引用传递的”
那么person的age属性应该是100,但是当调用函数后,输出person.age后,发现其值是25
这说明,“在函数内部修改参数,又不会反映到外部”。和之前的总结刚好相反,至于为什么,原因就在新增的2行代码
obj = new Object()
obj.age = 100
这两行代码其实就是一个重新赋值的操作,定义了一个新的对象,并将其赋值给obj,既然是新对象,那么参数obj的指针就指向那个new Object()对象,就和外部的person没关系了,所以修改obj不会影响到外部的person对象
所以,我们可以看出来,引用类型作为参数传递时,具体还得看函数内部是怎么处理的。 这里就涉及到函数里面的知识点:形参、实参、arguments对象了,由于篇幅较长,这里就不再叙述,大家可以去看我的另一篇文章《函数》。
1.5 如何确定变量类型
1.5.1 typeof
大家都知道,使用typeof操作符,最适合用来判断一个基本类型的变量的类型是否为字符串、数值、布尔或者undefined。
但是如果值是对象或者null,那么typeof都会返回object,如下代码:
let s = 'str'
let b = true
let i = 22
let u
let n = null
let o = {}
let a = []
console.log(typeof s) //>> string
console.log(typeof b) //>> boolean
console.log(typeof i) //>> number
console.log(typeof u) //>> undefined
console.log(typeof n) //>> object
console.log(typeof o) //>> object
console.log(typeof a) //>> object
奇怪的事情出现了,为什么 typeof null的值是object?
简单来说这还是JavaScript语言设计的一个缺陷,之前说过,布兰登·艾奇10天就把JavaScript设计出来了,难免有考虑不周的地方。
如果非要深究原理,就涉及到JavaScript的原型和原型链了,对象原型链的尽头就是null
大家可以试着输出一下下面这行代码。
let o = {}
let a = []
console.log(typeof o) //>> object
console.log(typeof a) //>> object
console.log(Object.__proto__.__proto__.__proto__) //>> null
console.log(Object.prototype.__proto__) //>> null
这里我们不深究原型链,毕竟不是本篇的重点,后面会有单独的文章去详细说。
回到上面的代码, 变量o和a一个是对象,一个数组,但typeof返的却都是object。那个该如何判断一个变量到底是什么类型的对象呢?
1.5.2 instanceof
为此,JavaScript提供了一个新的操作符instanceof,查看变量是不是给定构造函数的实例。如果是则返回true,否则返回false
let o = {}
let a = []
// o 是 Object的实例吗?
console.log(o instanceof Object) //>> true
// o 是 Array的实例吗?
console.log(o instanceof Array) //>> false
// a 是 Array的实例吗?
console.log(a instanceof Array) //>> true
// a 是 Object的实例吗?
console.log(a instanceof Object) //>> false
用法
变量 instanceof 构造函数
用instanceof操作符可以用来确定对象的具体类型
1.5.3 Object.prototype.toString.call
除了typeof和instanceof,使用该方法也能判断一个变量的具体类型,而且无论是基本类型还是引用类型,都可以精准判断
不同的是,这里使用了call借调了Object原型链上的方法,至于原型链、call,我们暂且先不管心,后续有文章会单独介绍,这里主要看一下该方法如何使用
let s = 'str'
let n = 123
let o = {}
let a = []
console.log(Object.prototype.toString.call(s)) // >> [object String]
console.log(Object.prototype.toString.call(n)) // >> [object Number]
console.log(Object.prototype.toString.call(o)) // >> [object Object]
console.log(Object.prototype.toString.call(o)) // >> [object Array]
Object.prototype.toString.call是一个函数,将变量传递进去,就返回一个字符串
字符串后面包含了该变量的具体类型,如Number Boolean Array Map。这样一来,我们可以做一个简单的封装,用来判断变量是不是给定的数据类型
function isType(data, type) {
const typeObj = {
"[object String]": "string",
"[object Number]": "number",
"[object Boolean]": "boolean",
"[object Null]": "null",
"[object Undefined]": "undefined",
"[object Object]": "object",
"[object Array]": "array",
"[object Function]": "function",
"[object Date]": "date", // Object.prototype.toString.call(new Date())
"[object RegExp]": "regExp",
"[object Map]": "map",
"[object Set]": "set",
"[object HTMLDivElement]": "dom", // document.querySelector('#app')
"[object WeakMap]": "weakMap",
"[object Window]": "window", // Object.prototype.toString.call(window)
"[object Error]": "error", // new Error('1')
"[object Arguments]": "arguments"
};
let name = Object.prototype.toString.call(data); // 借用Object.prototype.toString()获取数据类型
let typeName = typeObj[name] || "未知类型"; // 匹配数据类型
return typeName === type; // 判断该数据类型是否为传入的类型
}
console.log(
isType({}, "object"), //>> true
isType([], "array"), //>> true
isType(new Date(), "object"), //>> false
isType(new Date(), "date") //>> true
);
1.6 深拷贝与浅拷贝
我们再次回到引用类型复制的那段代码
let x = {
name: 'Tom',
age: 25
}
let y = x
y.age = 26
console.log(x.age) //>> 26
console.log(y.age) //>> 26
如何才能修改y的值,而又不影响x呢?
其实可以参考之前函数传值的时候,我们可以新建一个空对象,重新赋值赋值给y,然后x的name和age属性逐个复制给y
let x = {
name: 'Tom',
age: 25
}
let y = new Object()
y.name = x.name
y.age = x.age
y.age = 12 //将y的age修改为12
console.log(y.age) //>> 12
console.log(x.age) //>> 25 x.age不受影响
正如上面的代码,变量y重新指向一个新的空对象new Object(), 到这变量里x,y都指向各自的对象,二者互不影响。
接着将x的属性逐个复制给y,这样就完成了最基本的浅拷贝
1.6.1 浅拷贝
为什么说是浅拷贝,这个“浅”字该如何理解?,我们看接着看代码
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
}
}
let y = new Object()
y.name = x.name
y.age = x.age
y.address = x.address
y.address.city = '苏州'
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 苏州 x.address.city受影响
这次,给变量x增加了一个引用类型的属性address,同样把它复制给y,但不同的是,当修改了y.address.city后,x.address.city也变成了苏州。
参考我们前面说过引用类型的复制,这不难理解,因为address是个对象,对象复制是复制的地址,所以y.address和x.address指向堆内存的同一个对象。
看到这里,上面的“浅”其实就是无法复制引用类型的属性值
这里小结一下:
- 浅拷贝中,原始值和副本共享同样的属性。
- 浅拷贝会完整拷贝基本类型的值。
- 浅拷贝只拷贝了引用类型的地址。
- 浅拷贝中如果修改了拷贝对象会影响到原始对象,反之亦然。
- js中,数组和对象的赋值默认为浅拷贝。
使用上面的属性逐个赋值的方式,也可以完成浅拷贝,但是当对象属性比较多的时候就比较麻烦了,通常我们使用以下方式实现浅拷贝
1.6.1.1 for循环
定义个拷贝函数,接收一个要拷贝的原始对象,然后在函数体内,根据原始对象的类型,创建一个新数组或者对象,然后将原始对象的属性逐个复制到新对象上,接着返回新对象
function simpleCopy(originObj) {
let copyObj
if(Object.prototype.toString.call(originObj) === '[object Array]' ) {
copyObj = []
}
if(Object.prototype.toString.call(originObj) === '[object Object]') {
copyObj = {}
}
for (let i in originObj) {
copyObj[i] = originObj[i];
}
return copyObj;
}
使用
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
}
}
let y = simpleCopy(x)
y.address.city = '苏州'
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 苏州
1.6.1.2 Object.assign()
ES6新增了一个对象方法Object.assign,用于合并对象,可以借助该方法实现浅拷贝,用法如下:
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
}
}
let y = {}
Object.assign(y, x) //把x浅拷贝给y
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 苏州
1.6.2 深拷贝
与浅拷不同的是,深拷贝是指递归复制原对象的属性给新对象。 深拷贝结束后,新对象在堆内存中新开辟一块存储空间,二者没有任何关联。
- 深拷贝中,新对象和原对象不共享属性
- 深拷贝递归的复制属性
- 深拷贝得到的新对象不会影响到原对象,反之亦然
- 深拷贝中,所有的基本类型数据默认执行深拷贝,比如Boolean, null, Undefined, Number,String等
1.6.2.1 JSON方法
使用js自带的JSON方法,先将原始对象转为字符串再转为对象,然后赋值给新对象。
因为字符串是基本类型,所以独立存储在栈区,再转为对象,会重新在堆内存开辟空间,所以就完成了一个深拷贝。
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
}
}
let y = JSON.parse(JSON.stringify(x))
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 上海
优点:简单明了,方便记忆 缺点:当对象里面出现函数的时候就不适用了,看下面代码。
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
},
say: function() { //新增了一个函数
console.log('你好')
}
}
let y = JSON.parse(JSON.stringify(x))
console.log(y.say) //>> undefined 提示函数未定义
1.6.2.2 使用递归
使用递归深拷贝的本质就是:如果原对象的属性依然是引用类型,那就继续调用拷贝函数,每次函数执行都会新建一个空对象,作为newObj的属性;如果原对象的属性是基本类型,那就直接复制。
function deepCopy(obj) {
let newobj = obj.constructor === Array ? [] : {};
if (typeof obj !== 'object') {
return obj;
} else {
for (var i in obj) {
if (typeof obj[i] === 'object'){ //判断对象的这条属性是否为引用类型
newobj[i] = deepCopy(obj[i]); //若是,进行嵌套调用
}else{
newobj[i] = obj[i]; //若不是,直接复制
}
}
}
return newobj; //返回深度克隆后的对象
}
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
},
say: function() {
console.log('你好')
}
}
let y = deepClone(x)
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 上海
console.log(y.say()) //>> 你好
优点:能够实现对象和数组的深拷贝 缺点:如果拷贝的对象嵌套过深的话,会对性能有一定的消耗
1.6.2.3 ES6的解构运算符 ...
ES6新增了解构运算符...,可以更简洁地完成深拷贝
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
},
say: function() {
console.log('你好')
}
}
let y = { ...x }
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 上海
console.log(y.say()) //>> 你好
1.6.2.4 使用第三方库
在工作中,经常使用第三方库实现深拷贝,比如lodash或者Underscore
npm i --save lodash
const _ = require('lodash');
let x = {
name: 'Tom',
age: 25,
address: {
city: '上海',
},
say: function() {
console.log('你好')
}
}
let y = _.cloneDeep(x) // 使用lodash内置的深度克隆方法
console.log(y.address.city) //>> 苏州
console.log(x.address.city) //>> 上海
console.log(y.say()) //>> 你好
1.7 变量声明与作用域
本来作用域这部分内容是想和执行上下文、作用域、作用域链一起写的,但是变量声明也涉及到了块级作用域,就先带着变量浅说一下
放到最后说变量的声明是因为这部分内容大家都很熟悉,而且和前面的内容关系不大,这里算是老生常谈了
1.7.1 变量作用域
变量作用域就:在这个区域内的定义变量,出了这个区域就无法访问到,这个区域,就是变量起作用的地方。在JavaScript中,变量作用域分为2种:全局作用域和局部作用域。局部作用域可以访问全局作用域的变量,而全局作用域无法访问局部作用域的变量
而局部作用域根据表现形式又分为函数作用域和块级作用域 用张图来解释一下:
1.7.1.1 局部作用域:函数作用域
用代码来解释全局作用域和函数作用域就是:
<script>
var a = '皮卡丘' //在全局作用域中
function add() {
var sum = 0 //在函数作用域中
consoe.log(a)
}
console.log(a) //>> 皮卡丘
console.log(sum) //>> Uncaught ReferenceError: sum is not defined
add() //>> 皮卡丘
</script>
如上,a定义在全局作用域内,任何地方都可见,所以函数add内能访问到a;而sum定义在函数add内,属于局部作用域,后面的打印命令console.log(sum)在函数add之外执行的,访问不到函数add内的sum,因此输出Uncaught ReferenceError: sum is not defined。
任意代码片段外面用函数包装起来,就好像加了一层防护罩似的,可以将内部的变量和函数隐蔽起来,形参函数的局部作用域,外部无法访问到内部的内容。
用图来解释就是:
举个例子,就像蒸包子一样,全局变量是最底部那一笼的蒸汽,自下而上往上冒,上面的笼子就像局部作用域,都可以享受到下面笼子的蒸汽,而不能反过来。
关于作用域,大家先了解这么多,至于为什么全局作用域不能访问局部作用域等细节,放到后面的《作用域、执行上下文、作用域链》中讲解
但块级作用域又是什么呢?这个要结合var和let关键字一起说,继续往下看
1.7.1.2 局部作用域:块级作用域
块级作用域:属于局部作用域的一种,由距离最近的一对花括号构成。换句话说,if块、while块、switch块都可以构成块级作用域。 块级作用域中,使用let声明的变量,以及使用const声明的常量,在块外部是无法访问的
if(true){
let a = 0
var b = 0
}
console.log(a) //>> a未定义
console.log(b) //>> 0 可以访问
while(1) {
let c = 0
var d = 0
}
console.log(c) //>> d未定义
console.log(d) //>> 0 可以访问
1.7.2 使用var声明变量
在ES6之前,声明变量是使用var关键字,如:
var a = 1
var s = 'hello'
var f = false
1.7.2.1 var存在变量提升
使用var声明的变量,会被提升到当前变量所在的作用域顶部,位于所有的代码之前。这个现象叫做“提升”(hoisting)。这样的作用是让代码不用考虑变量是否声明就可以直接使用。
因此,下面的代码是等价的
var age = 12
//等价于
name = 12
var name
下面的函数也是等价的
function bar() {
var age = 12
}
//等价于
function bar() {
var age
age = 12
}
通过在变量声明之前使用变量,可以验证变量确实被提升了。这样一来,提前使用变量意味着会输出undefined,而不是Reference Error
console.log(age) //undefined
var age = 12
function bar() {
console.log(age) //undefined
var age = 12
}
1.7.2.2 不使用var会声明全局变量
==另外,如果不使用var声明变量,那么变量就会变成全局变量,任何地方都可以访问到,前面说过,全局变量在任何作用域都能访问,所以就会有如下现象:==
function bar() {
name = 12 //name此时是全局变量
console.log(name)
}
function foo() {
console.log('这是全局下的变量:' + name) //可以访问到name
}
console.log(name) //>> 12
bar() //>> 12
foo() // >> 这是全局下的变量:12
!!!切记,任何时候都不应该在函数内部声明全局变量,这样会造成不可预估的错误,如果需要全局变量,请使用var关键字在所有代码之前声明
1.7.2.3 var不遵循块级作用域
在块级作用域里使用var声明的变量,在块外面可以访问
if(true){
let a = 0
var b = 0
}
console.log(a) //>> a未定义
console.log(b) //>> 0 可以访问
while(1) {
let c = 0
var d = 0
}
console.log(c) //>> d未定义
console.log(d) //>> 0 可以访问
1.7.3 使用let声明变量
ES6新增的let关键字跟var很相似,都可以用来声明变量,大部分时候,它们的作用都是相同的,但也存在着一些差异。
let a = 1
var b = 1
var add = function(){}
let sum = function(){}
1.7.3.1 let遵循块级作用域
在块级作用域里使用let声明的变量,在块外面不可以访问
if(true){
let a = 0
}
console.log(a) //>> a未定义
while(true){
let b = 0
}
console.log(b) //>> b未定义
//函数的花括号也是块级作用域的一种,但一般我们称之为函数作用域,因为在函数作用域中使用var声明的变量,外界也是无法访问
function foo(){
let c = 0
}
console.log(c) //>> c未定义,没什么奇怪的,使用var声明也会报错,因为c属于函数作用域
//这不是声明的对象,而是一个对立的块,ES6新增的语法
// JavaScript引擎会根据里面的内容识别解析它
{
let d = 0
}
console.log(d) //>> d未定义
1.7.3.2 let没有变量提升
var不同的是,let声明的变量不会“提升”:
console.log(a) //>> undefined
console.log(b) //>> Uncaught ReferenceError: b is not defined
var a = 10
let b = 10
1.7.3.3 let不能重复声明变量
另一个不同的地方是,var可以重复声明变量,重复的var声明会被忽略,而let不可以,重复声明会报错:
var a
var a
{
let b
let b //>> Uncaught SyntaxError: Identifier 'b' has already been declared
}
1.7.4 使用const声明常量
ES6还增加了关键字const,用来声明常量。 const声明常量的同时必须赋值,此外,一旦声明,其值就无法更改。 除了这些,const有let的所有特性,比如块级作用域、没有变量提升、无法重复声明
1.7.4.1 const声明常量时必须赋值
const a //>> Uncaught SyntaxError: Missing initializer in const declaration
console.log(a)
定义了常量a,但没有赋值,报错
1.7.4.2 const声明的常量无法重新赋值
const b = 1
b = 2 //>> Uncaught TypeError: Assignment to constant variable.
定义了常量b = 1,修改为2,报错,因为常量是无法重新赋值。 但是对于引用类型的常量,是可以更改属性的值,但不能重新赋值,因为重新赋值就是在堆内存中新开辟存储空间:
const c = {
name: '皮卡丘'
}
c.name = '猪猪'
console.log(c.name) //>> 猪猪
//重新赋值(覆盖)会报错
c = { //>> Uncaught TypeError: Assignment to constant variable.
name: '猪猪'
}
如果想让对象的属性都不能修改,可以使用Object.freeze方法,来冻结对象,这样再修改属性值时,不会报错,但会默认失败:
const c = Object.freeze({ name: '皮卡丘' })
c.name = '猪猪'
console.log(c.name) //>> 皮卡丘
1.8 总结
JavaScript的变量可以保存2中数据类型的值:基本类型和引用类型 基本类型包括
Undefined、Null、Boolean、Number、String和Symbol
基本类型保存在栈内存上 引用类型保存在堆内存上
基本类型复制是直接创建一个新的副本 引用类型复制是复制的指针,而不是对象本身
函数参数的复制由函数体内部决定,是直接修改还是重新赋值
typeof 可以确定基本类型的数据类型,null除外 instanceof 用于判断变量是不是给定引用类型的实例 Object.prototype.toString.call 可以精准判断所有的数据类型
浅拷贝只能拷贝基本类型的值,对于嵌套的引用类型,拷贝的依然是地址 深拷贝无论是基本类型还是引用类型,拷贝的都是具体值
作用域分为全局作用域和局部作用域 局部作用域可以访问全局作用域的变量,反过来不行 局部作用域又分为函数作用域和块级作用域 块级作用域只有在使用let和const时才有效
var声明的变量会提升到当前作用域的代码顶部 var声明的变量没有块级作用域的限制
let声明的变量不会提升 let不能重复声明变量 let声明的变量有块级作用域的限制
const用来声明常量 const声明常量时必须赋值 const声明的常量值无法更改,但如果是引用类型的常量,可以更改值的属性 const遵循let的所有规则
另外,关于堆和栈的区别总结如下:
栈(stack)中主要存放一些基本类型的变量和对象的引用, 其优势是存取速度比堆要快,并且栈内的数据可以共享,但缺点是存在栈中的数据大小与生存期必须是确定的,缺乏灵活性;
栈内存中为这个变量分配内存空间,当超过变量的作用域后,JS 会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。
堆(heap)用于复杂数据类型(引用类型)分配空间,例如数组对象、object 对象;它们的大小可能随时改变,是不确定的,运行时动态分配内存空间的,因此存取速度较慢。
堆内存中分配的内存需要程序员手动释放,如果不释放,而系统内存管理器又不自动回收这些堆内存的话动态分配堆内存,那就一直被占用。