JavaScript:作用域&变量回收
- 局部作用域
- 函数作用域
- 块作用域
- 全局作用域
- 作用域链
- 变量在浏览器模型中的位置
- 浏览器模型
- 全局变量的产生情况
- 直接赋值全局对象与var全局对象的区别
- 垃圾回收机制
- 引用计数法
- 标记清除法
- 闭包
- 变量提升&函数提升
作用域规定了变量能够被访问的范围,离开这个范围后,变量就无法访问。
在JavaScript中,作用域被分为局部作用域与全局作用域,本博客会简单为大家讲解作用域的基本规则后,深入拓展作用域的机制以及技巧。
局部作用域
函数作用域
函数作用域就是指:只能在函数内部访问的变量,外部无法直接访问的变量。
比如:
function counter(x, y) {const a = x + yconsole.log(a) // 18}counter(10, 8)console.log(a)// 报错
在以上代码中,a
变量在函数内部声明,等到出了函数再访问这个a
,那么a
就无法被找到了,最后会发生报错。
这是因为:
当一个函数被调用,其会创建自己的变量,当这个函数执行结束,其会销毁内部产生的变量,避免浪费内存。
那么在函数内部声明的变量,出了函数就一定无法访问吗?
答案是不一定的,首先变量有三种定义形式:let
/const
/var
,前两者是局部变量,var则是全局变量。:但是在函数中,let
/const
/var
三者声明的变量在外部都不能访问。
在函数中,还有一种声明变量的方式,就是直接使用没有声明的过的变量:
function counter(x, y) {a = x + yconsole.log(a) // 18}counter(10, 8)console.log(a)// 正常
在上述代码中,a
变量没有使用let
/const
/var
三者之一声明,而且外部也没有a
这个变量。此时,a变量不仅可以正常使用,而且可以被函数的外部访问,不会被销毁。
其根本原因是:如果一个变量不声明直接赋值(外部作用域也没有声明过),那么这个变量就会被直接挂在window
对象下面,作为window
对象的属性之一,根据标记清除法
,这个变量不会被回收。如果你看不懂这段话也没关系,我们后面会说明标记清除法
的机制。
到此,我们应该知晓以下重点:
- 函数内部声明的变量,在函数外部无法被访问
- 函数内部没有声明直接使用的变量,可以在外部访问
- 函数的参数也是函数内部的局部变量
- 不同函数内部声明的变量无法互相访问
- 函数执行完毕后,函数内部的变量实际被清空了
块作用域
JavaScript中使用{}
包裹的代码称为代码块,代码内部声明的变量外部有可能
无法访问。
- 直接创建块级作用域
{let age = 18;console.log(age); // 正常}// 超出了 age 的作用域console.log(age) // 报错
- if 内部的块级作用域
let flag = true;if(flag) {let str = 'hello world!'console.log(str); // 正常}// 超出了 age 的作用域console.log(str); // 报错
- for内部的块级作用域
for(let t = 1; t <= 6; t++) {console.log(t); // 正常}// 超出了 t 的作用域console.log(t); // 报错
还有非常多种方式会创建块级作用域,这里就不一一列举了。
那么我刚刚标红了有可能
两个字,什么情况下在块级作用域声明的变量在外部可以访问呢?
其中一种情况函数作用域是一样的:没有声明就直接赋值的变量。
这里不再做展示了,本博客后续会深入了解这个问题。
还有一种情况就是,在块级作用域内用var
声明的变量。
在早期的JavaScript中,并不存在
let
与const
,也不存在块级作用域。当时的变量统一用var
声明,所以后续推出的块作用域对var
并没有影响。但是函数作用域是JavaScript早期就存在的概念,所以在上述函数作用域中,var
定义的变量在函数外部也不能访问。
这也是此处要将块级作用域和函数作用域区分讲解的重要原因。有不少人认为函数也有{}
,所以其和块级作用域是一致的,但实际上并不是。由于历史问题,与var
同一时期的函数作用域会限制var
,而后续出现的块级作用域并不会限制var
。
至此,块级作用域我们应知晓以下重点:
let
/const
声明的变量会产生块作用域,var
不会产生块作用域- 不同块级作用域之间的变量无法互相访问
- 在块级作用域中没有声明直接赋值的变量,外部可以访问
全局作用域
<script>
标签和 .js
文件的【最外层】就是所谓的全局作用域,在此声明的变量在函数内部也可以被访问。
<script>// 此处是全局作用域function fn() {// 此处为局部作用域}// 此处为全局作用域
</script>
全局作用域中声明的变量,任何其它作用域都可以被访问,如下代码所示:
<script>// 全局变量 nameconst name = '小明'// 函数作用域中访问全局function sayHi() {// 此处为局部console.log('你好' + name)}// 全局变量 flag 和 xconst flag = truelet x = 10// 块作用域中访问全局if(flag) {let y = 5console.log(x + y) // x 是全局的}
</script>
那么什么情况会产生全局作用域呢?
- 在全局作用域用
let
/const
/var
三者之一声明的变量- 在块级作用域用
var
声明的变量- 未使用任何关键字声明的变量
注意标红的作用域范围:(超级大总结)
在第一条中:在函数作用域与块级作用域用
let
/const
/var
三者之一声明的变量不一定是全局变量,只有在全局作用域声明的才是。
在第二条中:块级作用域用
var
声明的变量是全局变量,注意不包括函数作用域
在第三条中:未使用任何关键字声明的变量,在任何地方都是全局变量。即在全局/块级/函数作用域中,未声明直接赋值的变量也是全局变量。
作用域链
在解释什么是作用域链前先来看一段代码:
<script>// 全局作用域let a = 1let b = 2function f() {let c// 局部作用域function g() {let d = 'yo'//局部作用域}}
</script>
函数内部允许创建新的函数,f
函数内部创建的新函数 g
,会产生新的函数作用域,由此可知作用域产生了嵌套的关系。
如下图所示,父子关系的作用域关联在一起形成了链状的结构,作用域链的名字也由此而来。
作用域链本质上是底层的变量查找机制,在函数被执行时,会优先查找当前函数作用域中查找变量,如果当前作用域查找不到则会依次逐级查找父级作用域直到全局作用域,如下代码所示:
<script>// 全局作用域let a = 1;let b = 2;// 局部作用域function f() {let c = 9;let a = 10;console.log(a); // 10console.log(d); // 报错// 局部作用域function g() {let d = 6;console.log(b); // 2 或 20}// 调用 g 函数g();}f();
</script>
上述代码中有三个输出语句,解析:
console.log(a)
在输出a
时,处于函数f()
环境下,此时函数f()作用域中存在a = 10
,于是输出了10
console.log(d)
在输出d
时,处于函数f()
环境下,此时函数f()
作用域中不存在d
,于是其向上一级作用域找,即全局作用域;全局作用域中只存在a
和b
,不存在d
,所以最后找不到d
,发生报错
console.log(b)
在输出b
时,处于函数g()
环境下,此时函数g()
作用域中不存在b
,于是其向上一级作用域找,即f()
函数作用域;f()
函数作用域中不存在b = 2
,于是其向上一级作用域找,即全局作用域;全局作用域中只存在b
,于是最后输出2。
总结:
- 嵌套关系的作用域串联起来形成了作用域链
- 相同作用域链中按着从小到大的规则查找变量
- 子作用域能够访问父作用域,父级作用域无法访问子级作用域
变量在浏览器模型中的位置
现在就是揭晓前面留下的疑惑的时间了,理解这一块后,你就可以理解前面的全局变量的产生原因了。
浏览器模型
浏览器对象模型 (BrowserObjectModel) BOM (Browser Object Model) 是指浏览器对象模型,是用于描述这种对象与对象之间层次关系的模型,浏览器对象模型提供了独立于内容的、可以与浏览器窗口进行互动的对象结构。 BOM由多个对象组成,其中代表浏览器窗口的Window对象是BOM的顶层对象,其他对象都是该对象的子对象。
在JavaScript中,一切都是对象,包括你创建的变量,都会被包装为对象。而在JavaScript中,window是最顶级的对象,它处于对象的顶点。
document
是JavaScript
中最常用的对象,因为其是文档树的根节点,其分支包含着所有HTML的元素。但是document
并不是JavaScript中最高级的对象,而是window
。window
有多高级呢?
由于window
是浏览器对象模型(BOM)的根节点,所以所有的对象都是window
的后代节点,甚至你创建的变量,也会放在window
对象里。
所以window
作为一切的根,那么就可以默认存在,于是它就省略了。就好比你在填写地址的时候,绝对不会从地球开始:地球,中国,xx省…,因为人类都是默认处于地球环境中,所以我们会把地球省略。
也许你知道,var
创建的变量是全局变量,let
与const
创建的变量是局部变量,这又是为什么?
这是因为,
var
创建的对象,会被直接放在window
下面,在任何页面都能通过window对象拿到该变量。
证明:
可以看到,我们用var
创建的变量直接处于window
对象下了,而let
和const
创建的变量没有。
全局变量的产生情况
还记得我之前总结的产生全局变量的情况吗:
- 在全局作用域用
let
/const
/var
三者之一声明的变量- 在块级作用域用
var
声明的变量- 未使用任何关键字声明的变量
接下来我我们分别检测以下哪一些情况下,变量会被放在window
下方,作为window
的亲儿子。
检测方法:一个变量a
,如果a === window.a
那么说明这个变量是直接处于window
下方作为window
的亲儿子。
测试代码:
let a = 1;//在全局范围用let声明的全局变量const b = 2;//在全局范围用const声明的全局变量var c = 3;//在全局范围用var声明的全局变量{var d = 4;//块作用域内通过var声明的全局变量e = 5//块作用域内不声明的全局变量}function fn(){f = 6;//函数作用域内不声明的全局变量var g = 7;///函数作用域内通过var声明的局部变量console.log("函数作用域内通过var声明的局部变量",g === window.g);}fn();console.log("在全局范围用let声明的全局变量",a === window.a);console.log("在全局范围用const声明的全局变量",b === window.b);console.log("在全局范围用var声明的全局变量",c === window.c);console.log("块作用域内通过var声明的全局变量",d === window.d);console.log("块作用域内不声明的全局变量",e === window.e);console.log("函数作用域内不声明的全局变量",f === window.f);
测试结果:
注意,我在此额外塞入了一个局部变量,来说明在函数作用域内部,为什么var定义的变量不是全局变量。
首先看到三条var定义的变量:
其中,在块作用域内和在全局范围内,其声明的变量都被直接放在了window下方,即输出结果为true。而当var声明的变量处于函数作用域中时,变量就不处于window下方了,所以此时的var在函数内部声明的变量不是全局变量。
再看到最后两条,即不声明的变量
当我们不声明一个变量直接使用,不论其处于何处,都会挂在window底下,由于window可以省略,所以你在写出
a = 1
这样的语句的时候,相当于写的是:window.a = 1
。这样一来,没有声明的变量的赋值过程,就相当于是window对象添加了一个属性的过程,所以是符合语法的,而window是顶级对象,所以最后得到的变量也是全局变量。
最后看到由let
和const
创建的全局变量:
它们创建的变量不会作为
window
的对象和属性,在MDN解释中:let
和const
会将作用域限制在当前变量所处的作用域中。
也就是说let
和const
定义变量时就在全局环境中定义,那么此时这些变量也就是全局变量了。为什么呢?
还记得我们之前讲的作用域链吗,通过var
定义的全局变量,或者是不声明直接定义的变量,它们的访问都是通过window
这个顶级对象。
而通过let
和cosnt
定义的全局对象,它们由于本身就处于全局作用域中,当其他的块级/函数作用域找不到变量时,就会一层层往上查找,直到找到全局作用域。通过作用域链,在全局定义的变量也可以被任何地方查找到,所以此时let
和const
创建的也是全局变量了。
直接赋值全局对象与var全局对象的区别
- 区别一
全局变量不能通过delete删除,而window属性上定义的变量可以通过delete删除:
var num1=123;
window.num2=456;
//删除num2可以,但是不可以删除第一个
delete num1;
delete num2;
console.log(num1); //123
console.log(num2); //num2 is not defined
全局变量num1之所以不能通过delete删除,是因为通过var语句添加的全局变量有一个configurable属性,其默认值为false,如下,所以这样定义的属性不可以通过delete删除。
var num1=123;
window.num2=456;
//打印对象中属性的详情
var t1 = Object.getOwnPropertyDescriptor(window, "num1");
console.log(t1)
//Object {value: 123, writable: true, enumerable: true, configurable: false}
var t2 = Object.getOwnPropertyDescriptor(window, 'num2');
console.log(t2)
//Object {value: 456, writable: true, enumerable: true, configurable: true}
- 区别二
尝试访问未声明的变量会报错,xxx is not defined。 但是通过查询window查询,可以知道某个可能未声明的变量是否存在,不会报错,只会显示undefined。
console.log(num1); // undefined ,因为var会提升声明
var num1=123;
console.log(num2); // ReferenceError: a is not defined 因为无这个变量存在
window.num2=456;
这一点可以用预解析解释,var声明的变量会提升声明到顶部。(本博客后面细讲)
- 区别三
在函数中使用var定义的变量是局部变量。 有时想要在外部也访问到函数里面的变量,就需要定义window对象属性。
function () {var num1 = 123;window.num2 = 456;
}
console.log(num1); //num1 is not defined
console.log(num2); //456
垃圾回收机制
什么是垃圾回收机制
JavaScript中内存的分配和回收都是自动完成的,内存在不使用时会被垃圾回收器自动回收,正因为垃圾回收器的存在,许多人并不关心JavaScript的内存问题。但是如果不了解这个问题,就容易导致内存泄漏,不使用的内存无法立即释放。
内存生命周期
JavaScript环境中的分配的内存,有如下生命周期:
- 内存分配: 当我们声明变量,函数,对象的时候,系统会自动为它们分配内存
- 内存使用: 即读写内存,比如调用函数,读取数据,变量赋值等等
- 内存回收: 当JavaScript检测到某一个变量不再被使用,垃圾回收器就会自动回收掉这一块内存
全局变量:在关闭网页之前不会被回收掉的内存。
局部变量:在关闭网页前,有可能被垃圾回收器自动回收的内存。
那么JavaScript是如何检测一块内存是否有用,从而判断是否要将其回收的呢?
在这方面,不同的浏览器有不同的处理方式,主要为以下两种:
引用计数法
这是IE浏览器采用的方法,其会对对象检测是否有指向它的引用,没有引用就回收对象。
这里的引用是指:有几个变量指向这个对象。
比如以下代码:
let arr1 = [1, 3, 5, 7, 9];
let arr2 = arr1;
arr1 = null;
arr2 = null;
当我们写出
arr1 = [1, 3, 5, 7, 9]
时,此时数组[1, 3, 5, 7, 9]
就被一个名为arr1
的变量引用了,此时数组[1, 3, 5, 7, 9]
引用次数为1。
当执行到第二条语句:
let arr2 = arr1
相当于数组[1, 3, 5, 7, 9]
同时被arr1
和arr2
引用了,此时数组[1, 3, 5, 7, 9]
引用次数为2。
当执行到第三条语句:
arr1 = null
,此时数组[1, 3, 5, 7, 9]
的引用次数减少了一个,但是引用次数不为0,JavaScript不会将其回收。
当执行到第四条语句:
arr2 = null
,此时数组[1, 3, 5, 7, 9]
的引用次数减少了一个,引用次数不为0,JavaScript将其回收。
可以看出,这是一个简单有效的方法,但是其有一个非常大的缺陷,那就是对象的相互引用:
分析代码:
let person1 = {name: "小明",gender: "man"
}let person2 = {name: "张三",gender: "man"
}person1.friend = person2;
person2.friend = person1;person1 = null;
person2 = null;
请问通过引用计数法,以上person1
和person2
被回收了吗?
答案是没有的,解析一下:
创建了
person1
和person2
后{name: "小明",gender: "man"}
被person1
引用了,引用次数为1;{name: "张三",gender: "man"}
被person2
引用了,引用次数为1。
当执行
person1.friend = person2; person2.friend = person1;
语句后,{name: "小明",gender: "man"}
被person1
和person2.friend
引用了,引用次数为2;{name: "张三",gender: "man"}
被person2
和person1.friend
引用了,引用次数为2;
当执行
person1 = null; person2 = null;
语句后,{name: "小明",gender: "man"}
被person2.friend
引用了,引用次数为1;{name: "张三",gender: "man"}
被person1.friend
引用了,引用次数为1;
可以发现,此时他们两个对象互相保留了一个引用,导致引用次数不为0。而此时person1
只能通过person2
访问,而person2
只能通过person1
访问,形成一个死循环,用户已经无法使用这两块内存了,按理来说应该被释放掉,但是这个方法任务其被引用了,就是还有用,所以没有将其释放。
所以现代浏览器已经不采用这种方法了,下面一种方法解决了这个问题:
标记清除法
标记清除法从全局出发,即JavaScript的顶级对象window
向下查找节点,只要这个对象可以被查找到,那么它就是可以访问的;如果这个对象不能从window
出发找到,那么它就是不再使用的内存,JavaScript会将其释放掉。(如果你不知道什么是window
对象,可以参考这个博客:[JavaScript:BOM操作])
如下图所示:
此处的gobal
(全局对象)即window
对象。它从根节点出发查找,寻找所有节点,只要某个对象不在这个window
下方,就将其回收。
我们分析同一段代码:
let person1 = {name: "小明",gender: "man"
}let person2 = {name: "张三",gender: "man"
}person1.friend = person2;
person2.friend = person1;person1 = null;
person2 = null;
在创建person1
和person2
其是window下的全局变量(因为直接在全局作用域中使用let
定义),它会被挂在window底下,在将两个person置空之前,其指向如下:
在上图中,由于两个对象被person指向了,而person在window内部,所以从window出发可以查找到两个对象的内存空间,不会清除这两块内存。
当执行
person1 = null; person2 = null;
语句后,其标记图如下:
此时虽然两个内存相互指向了,但是从window出发无法找到,两块空间就会被销毁。
更形象的如下图:
闭包
定义:
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
重点:闭包让你可以在一个内层函数中访问到其外层函数的作用域。
如果你无法理解以上语句也没有关系,接下来我为大家解析一段代码:
function outer(){let a = 0;function inner() {a++;console.log(a);}return inner;
}let fun = outer();
fun();//输出1
fun();//输出2
以上代码中,出现了三个函数,分别是:outer()
, inner()
,fun()
接下来我为大家解析闭包的详细机制:
首先,我们需要一个外层函数:
function outer()
然后创建一个内层函数:
function inner()
内层函数需要调用外层函数的变量:
a++;
给外层函数设置返回值,返回值为内层函数的函数名:
return inner;
调用外层函数,并用一个变量接收这个外层函数的返回值:
let fun = outer();
闭包完成!
我们调用这个函数试试:
可以看到,我们居然可以在没有调用outer
函数的情况下使用到outer
函数内部的a
变量。而且从每次a的值都增加1可以看出,a从始至终没有被销毁过,按理来说一个变量出了函数就应该被销毁了,这是为什么?
这就是闭包的效果。
我们来分析一下闭包的结构:
为什么外层函数的返回值是内层函数名:
外层函数的返回值是内层函数的函数名inner,那么此时fun接收到的是什么呢?
此时fun === inner
,也就是说fun() === inner()
,所以我们调用fun()
其实就是在调用inner()
。因为inner
在outer函数的内部,被限制在了函数作用域中,此时我们无法通过外部调用inner
函数,所以我们讲inner
作为返回值返回给了外部,让外部可以调用inner
这个函数。
为什么要在内层函数调用外层函数的变量:
这就涉及到刚才讲的垃圾回收机制了。
按理来说,a这个变量会在outer函数调用完毕时销毁,但是它没有。根据标记清除法,说明此时window
对象是可以找到a变量的,我带大家还原一下路线:
fun
是指向inner
函数的,而inner
函数内部有a变量,那么此时a变量就会指向外层函数outer
的a变量。即:
window
->...
->fun
->inner
->a(innre的a)
->a(outer的a)
就是这一条线路,保证了a不会被销毁,每次调用,a都会保留上一次的值。
为什么不能在内层函数定义这个变量:
如果我们讲a这个变量定义在inner
内部,那么当每次inner
调用结束时,没有inner
以外的区域调用a这个值,a就会被销毁。
而当我们利用内层函数调用外层函数的变量,当外层函数outer
想要销毁a这个变量,就会发现a变量居然被另外一个函数使用了,也就是被自己以外的区域调用了,此时a就不满足被销毁的要求。
此处我们只通过作用域讲解闭包的原理,不讲解具体使用方法了。
关于闭包的使用,可以参考这一篇博客:[闭包的作用]
变量提升&函数提升
变量提升是JavaScript中比较奇怪的现象,它允许变量在声明之前访问(仅限于var定义的变量)。
请问以下代码输出结果是什么:
console.log(str + " world!");var str = "hello";
此代码的输出结果为:undefined world!
。
那么第一个问题就是:为什么没有报错???
这就涉及到了变量提升:
JavaScript在编译代码时,会把var定义的变量提到当前作用域的最顶端。但是其只提升声明过程,不提提升赋值过程。
相当于刚刚的代码应该是这样运行的:
var str;
console.log(str + " world!");str = "hello";
而当定义一个变量但不给他赋值,其值就为undefined
,所以最后输出结果为undefined world!
。
这就是变量提升。
此外,函数也有提升,函数在编译阶段,也会被提到当前作用域的最顶端。
在变量提升时,只有声明会被提升,而函数也是如此,函数也只提升声明过程,但是函数在声明过程就已经决定了其内部的语句,所以函数可以在作用域内的任何地方调用!!。
比如以下代码:
fn(2, 3);function fn(x, y){return x + y;}
由于函数提升,其相当于如下代码:
function fn(x, y){return x + y;}
fn(2, 3);