作用域链、执行上下文、垃圾回收、闭包
1、作用域1.1 什么是作用域
1.2 全局作用域和函数作用域
1.3 作用域链
1.3.1 什么是自由变量
1.3.2 什么是作用域链
1.3.3 关于自由变量的取值
2、执行上下文栈
2.1 执行上下文(Execution Context)
2.2 栈(stack)
2.3 执行上下文栈(Execution Context Stack)
3、js内存泄露以及垃圾回收机制
3.1 什么是内存泄漏?
3.2 垃圾回收机制
3.2.1 可达性(Reachability)
3.2.2 " 标记-清除 "算法
3.2.3 引用计数算法
3.3 常见的内存泄露
3.3.1 意外的全局变量
3.3.2 被遗忘的计时器或回调函数
3.3.3 脱离 DOM 的引用
3.3.4 闭包
4、闭包
1、作用域
1.1 什么是作用域
作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。我们先来看个例子:
function outFun2() { var inVariable = "内层变量2"; } outFun2();//要先执行这个函数,否则根本不知道里面是啥 console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined
从上面的例子可以体会到作用域的概念,变量 inVariable 在全局作用域没有声明,所以在全局作用域下取值会报错。我们可以这样理解:作用域就是一个独立的地盘,让变量不会外泄、暴露出去。也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。
ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数作用域。ES6 的到来,为我们提供了‘块级作用域’,可通过新增命令 let 和 const 来体现。
1.2 全局作用域和函数作用域
在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下几种情形拥有全局作用域:
-
最外层函数 和在最外层函数外面定义的变量拥有全局作用域
var outVariable = "我是最外层变量"; //最外层变量 function outFun() { //最外层函数 var inVariable = "内层变量"; function innerFun() { //内层函数 console.log(inVariable); } innerFun(); } console.log(outVariable); //我是最外层变量 outFun(); //最外层函数 console.log(inVariable); //inVariable is not defined innerFun(); //innerFun is not defined
-
所有末定义直接赋值的变量自动声明为拥有全局作用域
function outFun2() { variable = "未定义直接赋值的变量"; var inVariable2 = "内层变量2"; } outFun2();//要先执行这个函数,否则根本不知道里面是啥 console.log(variable); //未定义直接赋值的变量 console.log(inVariable2); //inVariable2 is not defined
-
所有 window 对象的属性拥有全局作用域
一般情况下,window 对象的内置属性都拥有全局作用域,例如 window.name、window.location、window.top 等等。
全局作用域有个弊端:如果我们写了很多行 JS 代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。这样就会 污染全局命名空间, 容易引起命名冲突。
函数作用域,是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部。
作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。
1.3 作用域链
1.3.1 什么是自由变量
首先认识一下什么叫做 自由变量 。如下代码中,console.log(a)
要得到 a 变量,但是在当前的作用域中没有定义 a(可对比一下 b)。当前作用域没有定义的变量,这成为 自由变量 。自由变量的值如何得到 —— 向父级作用域寻找(注意:这种说法并不严谨,下文会重点解释)。
var a = 100 function fn() { var b = 200 console.log(a) // 这里的a在这里就是一个自由变量 console.log(b) } fn()
1.3.2 什么是作用域链
如果父级也没呢?再一层一层向上寻找,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就是 作用域链 。
var a = 100 function F1() { var b = 200 function F2() { var c = 300 console.log(a) // 自由变量,顺作用域链向父作用域找 console.log(b) // 自由变量,顺作用域链向父作用域找 console.log(c) // 本作用域的变量 } F2() } F1()
1.3.3 关于自由变量的取值
关于自由变量的值,上文提到要到父作用域中取,其实有时候这种解释会产生歧义。
var x = 10 function fn() { console.log(x) } function show(f) { var x = 20 (function() { f() //10,而不是20 })() } show(fn)
在 fn 函数中,取自由变量 x 的值时,要到哪个作用域中取?——要到创建 fn 函数的那个作用域中取,无论 fn 函数将在哪里调用。
所以,不要在用以上说法了。相比而言,用这句话描述会更加贴切:**要到创建这个函数的那个域”。
作用域中取值,这里强调的是“创建”,而不是“调用”**,切记切记。
2、执行上下文栈
2.1 执行上下文(Execution Context)
执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。
执行上下文(可执行代码段)总共有三种类型:
-
全局执行上下文(全局代码):不在任何函数中的代码都位于全局执行上下文中,只有一个,浏览器中的全局对象就是 window 对象,
this
指向这个全局对象。 -
函数执行上下文(函数体):只有调用函数时,才会为该函数创建一个新的执行上下文,可以存在无数个,每当一个新的执行上下文被创建,它都会按照特定的顺序执行一系列步骤。
-
Eval 函数执行上下文(eval代码): 指的是运行在
eval
函数中的代码,很少用而且不建议使用。
eval("alert(123)");
执行上下文又包括三个生命周期阶段:创建阶段→执行阶段→回收阶段
1.创建阶段
当函数被调用,但未执行任何其内部代码之前,会做以下三件事:
-
创建变量对象(Variable object,VO):首先初始化函数的参数arguments,提升函数声明和变量声明。
-
创建作用域链(Scope Chain):在执行上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。作用域链用于解析变量。当被要求解析变量时,JavaScript 始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量。
-
确定this指向:包括多种情况。
在一段 JS 脚本执行之前,要先解析代码(所以说 JS 是解释执行的脚本语言),解析的时候会先创建一个全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来。变量先暂时赋值为undefined,函数则先声明好可使用。这一步做完了,然后再开始正式执行程序。
另外,一个函数在被执行之前,也会创建一个函数执行上下文环境,跟全局上下文差不多,不过函数执行上下文中会多出this arguments和函数的参数。
全局代码的上下文环境数据内容为:
普通变量(包括函数表达式), 如: var a = 10; | 声明(默认赋值为undefined) |
---|---|
函数声明, 如: function fn() { } | 赋值 |
this | 赋值 |
如果代码段是函数体,那么在此基础上需要附加:
参数 | 赋值 |
---|---|
arguments | 赋值 |
自由变量的取值作用域 | 赋值 |
2.执行阶段
进入执行上下文、执行代码
3.回收阶段
执行完毕后执行上下文出栈等待垃圾回收
2.2 栈(stack)
栈是一种操作受限的线性表只允许从一端插入和删除数据。栈有两种存储方式,即线性存储和链接存储(链表)。栈的一个最重要的特征就是栈的插入和删除只能在栈顶进行,所以每次删除的元素都是最后进栈的元素,故栈也被称为先进后出表。每个栈都有一个栈顶指针,它初始值为-1,且总是指向最后一个入栈的元素,栈有两种处理方式,即进栈(push)和出栈(pop),因为在进栈只需要移动一个变量存储空间,所以它的时间复杂度为O(1),但是对于出栈分两种情况,栈未满时,时间复杂度也为O(1),但是当栈满时,需要重新分配内存,并移动栈内所有数据,所以此时的时间复杂度为O(n)。
2.3 执行上下文栈(Execution Context Stack)
假如我们写的函数多了,每次调用函数时都创建一个新的执行上下文,如何管理创建的那么多执行上下文呢?
所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文,具有 LIFO(后进先出)的栈结构,用于存储在代码执行期间创建的所有执行上下文。
首次运行JS代码时,会创建一个全局执行上下文并Push到当前的执行栈中。每当发生函数调用,引擎都会为该函数创建一个新的函数执行上下文并Push到当前执行栈的顶部,浏览器的JS执行引擎总是访问栈顶的执行上下文。
根据执行栈LIFO规则,当栈顶函数运行完成后,其对应的函数执行上下文将会从执行栈中Pop出,上下文控制权将移到当前执行栈的下一个执行上下文,最终移回到全局执行上下文,全局上下文只有唯一的一个,它在浏览器关闭时Pop出。
可根据以下代码来详细介绍上下文栈的压栈、出栈过程。
如上代码。
在执行代码之前,首先将创建全局上下文环境。
然后是代码执行。代码执行到第12行之前,上下文环境中的变量都在执行过程中被赋值。
执行到第13行,调用bar函数。
跳转到bar函数内部,执行函数体语句之前,会创建一个新的执行上下文环境。
并将这个执行上下文环境压栈,设置为活动状态。
执行到第5行,又调用了fn函数。进入fn函数,在执行函数体语句之前,会创建fn函数的执行上下文环境,并压栈,设置为活动状态。
待第5行执行完毕,即fn函数执行完毕后,此次调用fn所生成的上下文环境出栈,并且被销毁(已经用完了,就要及时销毁,释放内存)。
同理,待第13行执行完毕,即bar函数执行完毕后,调用bar函数所生成的上下文环境出栈,并且被销毁(已经用完了,就要及时销毁,释放内存)。
3、js内存泄露以及垃圾回收机制
3.1 什么是内存泄漏?
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
程序的运行需要内存,只要程序提出要求,操作系统或者运行是就必须供给内存。
对于持续运行的服务进程,必须及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。
不再用到的内存,没有及时释放,就叫做内存泄漏。
有些语言(比如c语言)必须手动释放内存,程序员负责内存管理。
这很麻烦,所以大多数语言提供自动内存管理,减轻程序员的负担,这被称为"垃圾回收机制"。
3.2 垃圾回收机制
JS自带一套内存管理引擎,负责创建对象、销毁对象,以及垃圾回收。
垃圾回收机制主要是由一个叫垃圾收集器(garbage collector,简称GC)的后台进程负责监控、清理对象,并及时回收空闲内存。
3.2.1 可达性(Reachability)
GC的最主要职责是监控数据的可达性(reachability);哪些数据是所谓的可达的呢?
-
所有显示调用,被称为
根
,包括-
全局对象
-
正被调用的函数的局部变量和参数
-
相关嵌套函数里的变量和参数
-
其他(引擎内部调用的一些变量)
-
-
所有从根引用或引用链访问的对象
举个简单的例子
let user = { name: 'Onion' }
这里全局变量user指向内存里的对象{name: 'Onion'}
,我们称其为引用。这时对象Onion(以下均以名字简称)是所谓可达的。
将user置为null后,引用丢失,Onion对象就变成不可达了。最终GC会将它从内存中清除。
user = null
再举一个复杂一点的例子:
function marry(man, woman) { woman.husband = man; man.wife = woman; return { father: man, mother: woman, } } let family = marry({ name: "Onion" }, { name: "Garlic" });
如图,现阶段所有对象都是可达的。
现在我们删除一些对Onion的引用
delete family.father delete family.mother.husband
如图,尽管Onion还有对Garlic的引用,但是它本身已不可达,所以很快就会被GC发现并回收。
3.2.2 " 标记-清除 "算法
基本的垃圾回收算法称为“标记-清除”,定期执行以下“垃圾回收”步骤:
-
垃圾回收器获取根并“标记”(记住)它们。
-
然后它访问并“标记”所有来自它们的引用。
-
然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
-
以此类推,直到有未访问的引用(可以从根访问)为止。
-
除标记的对象外,所有对象都被删除。
例如,对象结构如下:
我们可以清楚地看到右边有一个“不可到达的块”。现在让我们看看“标记并清除”垃圾回收器如何处理它。
第一步标记根
然后标记他们的引用
以及子孙代的引用:
现在进程中不能访问的对象被认为是不可访问的,将被删除:
这就是垃圾收集的工作原理。
到目前为止,IE、Firefox、Opera、Chrome、Safari的js实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同。
3.2.3 引用计数算法
另一种不太常见的垃圾收集策略叫做引用计数(reference counting)。引用计数的含义是跟踪记录每个值被引用的次数。
当声明了一个变量并将一个引用类型值赋值该变量时,则这个值的引用次数就是1.如果同一个值又被赋给另外一个变量,则该值得引用次数加1。相反,如果包含对这个值引用的变量又取 得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那 些引用次数为零的值所占用的内存。
语言引擎有一张"引用表",保存了内存里面所有资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。
上图中,左下角的两个值,没有任何引用,所以可以释放。
如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏。
const arr = [1,2,3,4]; //1 console.log("hello world");
上面的代码中,数组[1,2,3,4]是一个值,会占用内存。变量arr是仅有的对这个值的引用,因此引用次数为1。尽管后面的代码没有用到arr,它是会持续占用内存。
如果增加一行代码,解除arr对[1,2,3,4]引用,这块内存就可以被垃圾回收机制释放了。
let arr = [1,2,3,4]; console.log("hello world"); arr = null;
上面代码中,arr重置为null,就解除了对[1,2,3,4]的引用,引用次数变成了0,内存就可以释放出来了。
及时清除引用非常重要。但是,你不可能记得那么多,有时候一疏忽就忘了,所以才有那么多内存泄漏。
最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻程序员的负担,你只要清除主要引用就可以了。
ES6 考虑到了这一点,推出了两种新的数据结构:WeakSet和 WeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是弱引用。
下面以 WeakMap 为例,看看它是怎么解决内存泄漏的。
const wm = new WeakMap(); const element = document.getElementById('example'); wm.set(element, 'some information'); wm.get(element) // "some information"
上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element
的引用就是弱引用,不会被计入垃圾回收机制。
也就是说,DOM 节点对象的引用计数是1
,而不是2
。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。
3.3 常见的内存泄露
3.3.1 意外的全局变量
意外的全局变量
JavaScript 处理未定义变量的方式比较宽松:未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是 window
。
function foo(arg) { bar = "this is a hidden global variable"; }
真相是:
function foo(arg) { window.bar = "this is an explicit global variable"; }
函数 foo
内部忘记使用 var
,意外创建了一个全局变量。
解决办法: 在 JavaScript 文件头部加上 'use strict'
,可以避免此类错误发生。启用严格模式解析 JavaScript ,避免意外的全局变量。"use strict" 的目的是指定代码在严格条件下执行。严格模式下你不能使用未声明的变量。
3.3.2 被遗忘的计时器或回调函数
在 JavaScript 中使用 setInterval
非常平常。一段常见的代码:
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { node.innerHTML = JSON.stringify(someResource)); } }, 1000);
此例说明了什么:与节点或数据关联的计时器不再需要,node
对象可以删除,整个回调函数也不需要了。可是,计时器回调函数仍然没被回收(计时器停止才会被回收)。同时,someResource
如果存储了大量的数据,也是无法被回收的。
3.3.3 脱离 DOM 的引用
var elements = { button: document.getElementById('button'), image: document.getElementById('img'), text: document.getElementById('text') }; function removeButton() { // 按钮是 body 的后代元素 document.body.removeChild(document.getElementById('button')); // 此时,仍旧存在一个全局的 #button 的引用 // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。 }
3.3.4 闭包
var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log("hello"); } }; }; setInterval(replaceThing, 1000); 代码片段做了一件事情:每次调用 replaceThing ,theThing 得到一个包含一个大数组和一个新闭包(someMethod)的新对象。同时,变量 unused 是一个引用 originalThing 的闭包(先前的 replaceThing 又调用了 theThing )。思绪混乱了吗?最重要的事情是,闭包的作用域一旦创建,它们有同样的父级作用域,作用域是共享的。someMethod 可以通过 theThing 使用,someMethod 与 unused 分享闭包作用域,尽管 unused从未使用,它引用的 originalThing 迫使它保留在内存中(防止被回收)。当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄露。 在 replaceThing 的最后添加 originalThing = null 。
4、闭包
js的作用域分两种,全局和局部,基于我们所熟悉的作用域链相关知识,我们知道在js作用域环境中访问变量的权利是由内向外的,内部作用域可以获得当前作用域下的变量并且可以获得当前包含当前作用域的外层作用域下的变量,反之则不能,也就是说在外层作用域下无法获取内层作用域下的变量,同样在不同的函数作用域中也是不能相互访问彼此变量的,那么我们想在一个函数内部也有限权访问另一个函数内部的变量该怎么办呢?闭包就是用来解决这一需求的,闭包的本质就是在一个函数内部创建另一个函数。
闭包有3个特性:
①函数嵌套函数
②函数内部可以引用函数外部的参数和变量
③参数和变量不会被垃圾回收机制回收
闭包两种的主要形式
①函数作为返回值
在这段代码中,a()中的返回值是一个匿名函数,这个函数在a()作用域内部,所以它可以获取a()作用域下变量name的值,将这个值作为返回值赋给全局作用域下的变量b,实现了在全局变量下获取到局部变量中的变量的值
再来看一个闭包的经典例子
一般情况下,在函数fn执行完后,就应该连同它里面的变量一同被销毁,但是在这个例子中,匿名函数作为fn的返回值被赋值给了fn1,这时候相当于fn1=function(){var n = 0 ... },并且匿名函数内部引用着fn里的变量num,所以变量num无法被销毁,而变量n是每次被调用时新创建的,所以每次fn1执行完后它就把属于自己的变量连同自己一起销毁,于是乎最后就剩下孤零零的num,于是这里就产生了内存消耗的问题。
再来看一个经典例子-定时器与闭包
写一个for循环,让它按顺序打印出当前循环次数
按照预期它应该依次输出1 2 3 4 5,而结果它输出了五次5,这是为什么呢?原来由于js是单线程的,所以在执行for循环的时候定时器setTimeout被安排到任务队列中排队等待执行,而在等待过程中for循环就已经在执行,等到setTimeout可以执行的时候,for循环已经结束,i的值也已经编程5,所以打印出来五个5,那么我们为了实现预期结果应该怎么改这段代码呢?(ps:如果把for循环里面的var变成let,也能实现预期结果)
引入闭包来保存变量i,将setTimeout放入立即执行函数中,将for循环中的循环值i作为参数传递,100毫秒后同时打印出1 2 3 4 5
那如果我们想实现每隔100毫秒分别依次输出数字,又该怎么改呢?
在这段代码中,相当于同时启动3个定时器,i*100是为4个定时器分别设置了不同的时间,同时启动,但是执行时间不同,每个定时器间隔都是100毫秒,实现了每隔100毫秒就执行一次打印的效果。
②闭包作为参数传递
在这段代码中,函数fn1作为参数传入立即执行函数中,在执行到fn2(30)的时候,30作为参数传入fn1中,这时候if(x>num)中的num取的并不是立即执行函数中的num,而是取创建函数的作用域中的num这里函数创建的作用域是全局作用域下,所以num取的是全局作用域中的值15,即30>15,打印30
最后总结一下闭包的好处与坏处
好处
①保护函数内的变量安全 ,实现封装,防止变量流入其他环境发生命名冲突
②在内存中维持一个变量,可以做缓存(但使用多了同时也是一项缺点,消耗内存)
③匿名自执行函数可以减少内存消耗
坏处
①其中一点上面已经有体现了,就是被引用的私有变量不能被销毁,增大了内存消耗,造成内存泄漏,解决方法是可以在使用完变量后手动为它赋值为null;