认真学JavaScript系列之:变量与内存

前言

在我刚学习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、StringSymbol。这些值是保存在栈内存中,是按值访问的,我们平时操作的值就是存储在变量中的实际值。

引用类型可以统称为对象(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 }作为对象存在堆内存中

image.png

如上图,当我们访问a,b,c等基本变量时,会直接从栈内存中读取到具体值。

而访问堆内存中d,e等引用类型的值时,要分2步走:

  1. 从栈中获取该对象值的访问地址
  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的值复制给它。

然后输出xy的值,分别是25和26,修改y的值并不会影响x(这不废话嘛)。其过程如下图所示:

image.png

在复制基本类型的数据时,系统会在栈内存中开辟一个新的空间,为新的变量分配一个新的值,这个新的值就是从原来的变量复制过来的,之后原有的值和新的值保持相互独立,互不影响。

举个例子大家就会明白了,有一天你在写文档,你的同事想要一份你写的文档,然后你复制了一份你的文档,通过钉钉发给他了,他接收后修改文档,你这边的文档不会被修改(肯定改不了啊)因为你把文档本身发给他了啊

image.png

1.4.2 引用类型复制

引用类型的复制就截然不同了,我们把x定义为对象,并将其复制给y,接着修改yage属性,然后输出一下。发现,xage属性也变了,但是明明没有修改的x的值

let x = {
    name: 'Tom',
    age: 25
}

let y = x

y.age = 26

console.log(x.age) //>> 26
console.log(y.age) //>> 26

  • 引用类型的值发生复制时,同样会为新的值在栈内存中开辟一个新的空间,不同的是,这个新的值,是我们上面说的“值的访问地址”,是一个指针(C语言中的概念)
  • 这个地址(指针)指向堆内存中的对象,复制完成后,xy都指向同一个对象
  • 因此修改一个值,会影响另一个值

其过程如下图所示:

image.png

image.png

完成复制后,两个变量引用的是同一个值,所以,修改其中一个值就会影响另外一个,因为本质上二者在内存中访问的是同一份数据

还是举个例子,有一天你在写文档,你的同事想要一份你写的文档,你说我的文档太大,存存到网盘上了,我把网盘地址发你你自己看吧,你把地址发给他后,他进到网盘没有下载文档,而是在线编辑了一下,然后文档就被修改了,你们俩访问的文档是同一份

image.png

这样解释是不是就很容易理解,基本类型是复制的值,类似于你把文件直接发给他。而引用类型复制是传递的地址,你把文件地址发给他,让他自己去看。

但为什么这么设计呢,这就不得说一下JavaScript的历史这门语言的历史了:

image.png

简单来说:

  • JavaScript是国外公司推出的编程语言产品,用于快速抢占浏览器市场
  • JavaScript之父Brendan Eich (布兰登·艾奇),是公司雇佣的程序员,用来设计一门新的语言
  • 布兰登·艾奇 只用了10天就完成了设计
  • JavaScript 借鉴(抄袭)了C、Java、Python等语言

如此看起来,编程语言也是程序员为了完成KPI设计出来的,正如我们上面提的借鉴了C语言的指针、Java的JVM(虚拟机)的内存管理模型 所以,变量的复制问题,是一个历史遗留问题。这样像极了我们在日常开发中到处去搜功能代码一样。

image.png


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传递进去,接着输出personage属性,发现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行代码,把参数objage属性设置为25后,接着obj被设置为一个新的对象,并且age属性被设置为100。

如果按照之前我们的总结:

如果参数是引用类型的,那么在函数内部修改了参数,会反映到函数外部的变量,这就意味着参数是按引用传递的 那么personage属性应该是100,但是当调用函数后,输出person.age后,发现其值是25 这说明,“在函数内部修改参数,又不会反映到外部”。和之前的总结刚好相反,至于为什么,原因就在新增的2行代码

obj = new Object()
obj.age = 100

这两行代码其实就是一个重新赋值的操作,定义了一个新的对象,并将其赋值给obj,既然是新对象,那么参数obj的指针就指向那个new Object()对象,就和外部的person没关系了,所以修改obj不会影响到外部的person对象

image.png

image.png

所以,我们可以看出来,引用类型作为参数传递时,具体还得看函数内部是怎么处理的。 这里就涉及到函数里面的知识点:形参实参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

这里我们不深究原型链,毕竟不是本篇的重点,后面会有单独的文章去详细说。

回到上面的代码, 变量oa一个是对象,一个数组,但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的nameage属性逐个复制给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(), 到这变量里xy都指向各自的对象,二者互不影响。 接着将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.addressx.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种:全局作用域局部作用域局部作用域可以访问全局作用域的变量,而全局作用域无法访问局部作用域的变量

而局部作用域根据表现形式又分为函数作用域和块级作用域 用张图来解释一下:

image.png

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

任意代码片段外面用函数包装起来,就好像加了一层防护罩似的,可以将内部的变量和函数隐蔽起来,形参函数的局部作用域,外部无法访问到内部的内容。

用图来解释就是:

image.png

举个例子,就像蒸包子一样,全局变量是最底部那一笼的蒸汽,自下而上往上冒,上面的笼子就像局部作用域,都可以享受到下面笼子的蒸汽,而不能反过来

image.png

关于作用域,大家先了解这么多,至于为什么全局作用域不能访问局部作用域等细节,放到后面的《作用域、执行上下文、作用域链》中讲解


但块级作用域又是什么呢?这个要结合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、StringSymbol

基本类型保存在栈内存上 引用类型保存在堆内存上

基本类型复制是直接创建一个新的副本 引用类型复制是复制的指针,而不是对象本身

函数参数的复制由函数体内部决定,是直接修改还是重新赋值

typeof 可以确定基本类型的数据类型,null除外 instanceof 用于判断变量是不是给定引用类型的实例 Object.prototype.toString.call 可以精准判断所有的数据类型

浅拷贝只能拷贝基本类型的值,对于嵌套的引用类型,拷贝的依然是地址 深拷贝无论是基本类型还是引用类型,拷贝的都是具体值

作用域分为全局作用域和局部作用域 局部作用域可以访问全局作用域的变量,反过来不行 局部作用域又分为函数作用域和块级作用域 块级作用域只有在使用let和const时才有效

var声明的变量会提升到当前作用域的代码顶部 var声明的变量没有块级作用域的限制

let声明的变量不会提升 let不能重复声明变量 let声明的变量有块级作用域的限制

const用来声明常量 const声明常量时必须赋值 const声明的常量值无法更改,但如果是引用类型的常量,可以更改值的属性 const遵循let的所有规则

另外,关于堆和栈的区别总结如下:

栈(stack)中主要存放一些基本类型的变量和对象的引用, 其优势是存取速度比堆要快,并且栈内的数据可以共享,但缺点是存在栈中的数据大小与生存期必须是确定的,缺乏灵活性;

栈内存中为这个变量分配内存空间,当超过变量的作用域后,JS 会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

堆(heap)用于复杂数据类型(引用类型)分配空间,例如数组对象、object 对象;它们的大小可能随时改变,是不确定的,运行时动态分配内存空间的,因此存取速度较慢。

堆内存中分配的内存需要程序员手动释放,如果不释放,而系统内存管理器又不自动回收这些堆内存的话动态分配堆内存,那就一直被占用。

1.9 参考

猜你喜欢

转载自juejin.im/post/7111927919819587598