JavaScript系列 -- 作用域、执行上下文、作用域链、闭包、垃圾回收机制

724 阅读26分钟

前言

作用域的定义相信大家都已经非常清楚,但作用域链那块的底层你了解吗,本文对作用域链的底层原理进行深入解析。本文先从作用域和执行上下文说起,再到作用域链,层层深入,最后补充闭包相关内容。

(篇幅过长,可分多次食用)

作用域的分类

作用域分为三种:

  • 全局作用域
  • 函数作用域
  • 块级作用域(ES6新增)

其中A是全局作用域,B和C都是函数作用域。

全局作用域

我的理解是全局属性的作用域,也是全局方法(函数)的作用域。全局作用域没有用{ }包裹起来。

函数作用域

每创建一个函数,就相当于创建了一个对应的函数作用域。它的访问就是用{ }包裹起来的区域

值得注意的是:有 var 和 没有 var 会造成不一样的结果:

function fn(){
    var n = 999;
}
fn()
console.log(n); // Uncaught ReferenceError: n is not defined
---------------------------------------------------------------------------
function fn(){
    n = 999;
}
fn()
console.log(n); // 999

没有 var 的话实际上是声明了一个全局变量 n,即 n 会出现在 window 对象里面

原因是:没有加声明,n = 999其实是window.n = 999,不管它放在在哪都是这样。道理就跟访问document对象时不需要window.document而是document就可以。

因为浏览器会认为当前标签页的操作就是在引用当前标签页,就是在给这个标签页的 window 对象访问属性(window.document)/ 增加属性(window.a = 999),所以可以缩写成给 window 对象访问属性(document)/ 增加属性(a = 999)

块级作用域

【过渡】场景:代码本意是要实现:

  1. if 外部代码使用外层的 tmp 变量 new Date()
  2. if 内部代码使用内层的 tmp 变量"hello world"
var tmp = new Date()
function fn(){
    console.log(tmp)
    if(true){
        var tmp = "hello world"
        console.log(tmp)
    }
}
fn() // undefined ;hello world

结果调用fn()时却输出undefined,也就是说 内部变量会覆盖外部变量,原因是:

  1. var 声明的变量没有块级作用域的概念,使得 if 内层的变量覆盖了外部的变量
  2. var 声明的变量有变量提升的特性,所以打印出来的不是"hello world"而是undefined

这与代码原意是相违背的,所以 ES6 新增了块级作用域,同时新增 let、const 两种变量声明方式,对应着有块级作用域。对上述代码中只需把 if 语句中的var改成let即可:

var tmp = new Date()
function fn(){
    console.log(tmp)
    if(true){
        let tmp = "hello world"
        console.log(tmp)
    }
}
fn() // Thu Jun 17 2021 11:01:31 GMT+0800 (中国标准时间);hello world

块级作用域简单理解也可以是用{ }包裹起来的区域。在 if 和 for 语句里面比较常见

  • 在 ES6 之前没有块级作用域的概念,变量都是用 var 来声明。举个例子:
var a = 666
console.log(a) // 666
{
    var a = 666
}
console.log(a) // 666

变量 a 的声明和赋值放在{ }里面和外面是没有区别的。我的理解就是把{ }看成是透明的,所以两者是等价的。也就是说,无论你嵌套多少层,各种花式嵌套都一样,都视为无。所以var a = 666相当于window.a = 666

  • 在 ES6 之后有了块级作用域的概念,变量还可以用 let、const 来声明。举个例子:
{
    let a = 666
}
console.log(a) // 报错:Uncaught ReferenceError: a is not defined

但反过来是成立的:

let a = 666
{
    console.log(a) // 666
}

具体为什么这样是可以的,跟作用域链有关,见下方。

值得注意的是:

var obj = {
    a: 6
}

obj也有对应的{ },但它是对象,跟块级作用域是两个完全不同的概念,不要混淆。

块级作用域和函数作用域的区别

虽然都有花括号 { },但是两者是完全不同的概念:

  • 函数作用域里面 var 声明一个变量,该变量则是局部变量;(如果没有 var 声明才是全局变量)
  • 块级作用域里面 var 声明一个变量,该变量则是全局变量;(window.xxx)

执行上下文(看成一块集合)

上面说到执行上下文我们举个例子形象了解一下执行上下文环境,其实很多同学(包括我哈哈哈)都混淆了作用域和执行上下文的概念,参考 JS作用域和执行上下文的区别 我对两者的分界有了初步的认识:

  • 作用域:是静态的,在函数定义的时候就确定好的
  • 执行上下文环境:是动态的,是在被调用时才确定的,每调用一次函数就会创建函数执行时对应的执行上下文环境。看个简单例子:

即使是同一个函数的多次调用,也会对应创建多个执行上下文环境

1     let a = 10 // 1、进入全局上下文环境
2     let bar = function(x) {
3         let b = 5
4         fn(x + b) // 3、进入fn函数上下文环境
5     }
6     let fn = function(y) {
7         let c = 5
8         console.log(y + c)
9     }
10
11    bar(10) // 2、进入bar函数上下文环境

可以这么理解:执行上下文环境是一个键值对式的集合,各个变量和this组成执行上下文环境。我们细分代码执行的全过程:

(1)代码执行到第1行,创建了一个全局上下文环境,从第1行执行到第10行,将所有变量全都赋值(注意:此时变量a、函数bar、函数fn、this 4个就组成了“一家人”,绑定在一块了,无论bar函数、fn函数在哪个地方被调用,绑定的变量a的值都是10,绑定的this都是window(除非this值被改变))【可以参考下面一道面试题】

// 全局上下文环境
a: undefined —> 10
bar: undefined —> function
fn: undefined —> function
this: window —> window

(2)代码执行到11行时,调用bar函数。跳转到bar函数内部,执行函数体语句之前,会创建一个新的执行上下文环境

// bar函数执行上下文环境
b: undefined —> 5
x: 10 —> 10
arguments: [10] —> [10]
this: window —> window

然后将bar执行上下文环境压栈,并设置为活动状态(当前唯一)(栈顶)

(3)然后执行到第4行,调用fn函数。 调到fn函数内部,执行函数体语句之前,会创建一个新的执行上下文环境

// fn函数执行上下文环境
c: undefined —> 5
y: 15 —> 15
arguments: [15] —> [15]
this: window —> window

然后将fn执行上下文环境压栈,设置为活动状态(当前唯一)(栈顶)

(4)fn执行完毕后,调用fn函数生成的fn上下文环境出栈,被销毁。 然后bar执行完毕后,调用bar函数生成的上下文环境出栈,被销毁。然后剩下全局上下文环境,出栈销毁

当函数调用完成时,这个上下文环境以及其中的数据都会被消除(当然闭包并不会乖乖就范)

上面这样代码执行全过程对应的执行上下文环境的“入栈”、“出栈”对应的“栈”就是“执行上下文栈”:

从上面的例子可以看出:处于活跃状态的环境就是对应处于栈顶的执行上下文环境。而刚刚说作用域链的时候是说处于活跃状态的对象对应处于链头的对象,而一个执行上下文环境对应一个对象,这就对上了。

在这里我的理解是:一个栈的对应一条不同的作用域链,栈顶的活跃执行上下文环境对应着处于链头的活跃对象。往下走依次一一对应。

PS:this指向也一直没有变,从头到尾所有环境中都是window。

(注意:这里只是针对主线程的同步任务,其他线程的异步任务会通过队列的方式执行,详细见 JavaScript系列 -- event loop 事件轮询

作用域和上下文环境的区别

作用域好比是固定的房子,是实体化的概念;上下文环境好比是居住环境,是抽象化的概念。

二者关系就是:作用域从写代码那一刻起就决定好了,上下文环境随着代码的执行而变迁到不同的作用域处。

作用域链

在JavaScript中,函数也是对象,实际上,JavaScript里一切都是对象。函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性

其中一个内部属性是[[Scope]],根据官方定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问

当一个函数创建后,它的作用域链会被创建此函数的作用域中可访问的数据对象填充。例如定义下面这样一个函数:

function add(num1,num2) {
    var sum = num1 + num2;
    return sum;
}

在函数add创建时,它的作用域链中会填入一个全局对象,该全局对象包含了所有全局变量,如下图所示(注意:图片只例举了全部变量中的一部分):

对应控制台:

image.png

(这里看到其实上面所说的Global object其实就是window或者说是window.global(===window))

函数add的作用域将会在执行时用到。例如执行如下代码:

var total = add(5,10);

执行此函数时会创建一个称为“执行上下文”的内部对象,执行上下文定义了函数执行时的环境。每个执行上下文都有自己的作用域链,用于标识符解析,当执行上下文被创建时,而它的作用域链初始化为当前运行函数的[[Scope]]所包含的对象。

这些值按照它们出现在函数中的顺序被复制到执行上下文的作用域链中。它们共同组成了一个新的对象,叫“活动对象”,该对象包含了函数的所有局部变量、命名参数、参数集合以及this,然后此对象会被推入作用域链的链头当执行上下文被销毁,活动对象也随之销毁。新的作用域链如下图所示:

对应控制台:

image.png

作用域链上的每一个结点就是对应一个对象

过程

  • 在全局执行上下文中搜索变量:对应的作用域链[[Scopes]]是只有一个元素的Scopes数组,这个元素Scopes[0]就是Global object,在这个对象里面找想要的变量;
  • add函数的执行上下文环境对应的作用域链[[Scopes]]是有两个元素的Scopes数组,从链头出发,第一个元素Scopes[0]是当前的活跃对象Activation object,如果找不到,再继续沿着作用域链往下走,来到第二个元素Scopes[1]是Global object,在这个对象里面找想要的变量

执行上下文栈和作用域链:

  • 在前面执行上下文那块的例子中,我们把执行上下文环境的关系比喻为栈,而上面又说一个执行上下文环境对应一条作用域链。而处于活跃状态的环境对应于栈顶的执行上下文环境;处于活跃状态的对象对应处于链头的对象。前者是形象地描述,后者是底层原理上的描述。

而我对于这块的理解是这样的:不同状态的栈对应一条作用域链,把作用域链比作是“链表”,而处于栈顶的上下文环境就是指向这个“链表”的“指针”。

值得注意的是:作用域链在代码刚写好就已经确定下来长啥样了,跟代码执行顺序(怎么跳,跳来跳去)都没有关系;而执行上下文栈就跟代码执行顺序有关。所以执行上下文栈上面的环境跟作用域链上的对象并不一定是一一对应的

比如下面的一种情况:

var a = 10
function foo(){
    console.log(a)
}
function sum() {
    var a = 20
    foo()
}
sum() // 10

image.png

这里可以看到在第3阶段,我们的执行上下文栈有3个环境,而对应的作用域链只有1个结点,不是一一对应的。所以我们应该这样做:对应的作用域链是长啥样的只需看当前活跃的上下文环境,不用看整个执行上下文栈

总结

  • 每个函数有一个执行环境,一个执行环境关联一个变量对象,变量对象的集合叫做作用域链
  • 在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程以决定从哪里获取和存储数据。该过程从作用域链头部,也就是从活动对象开始搜索,查找同名的标识符,如果找到了就使用这个标识符对应的变量,如果没找到继续搜索作用域链中的下一个对象,如果搜索完所有对象都未找到,则认为该标识符未定义。函数执行过程中,每个标识符都要经历这样的搜索过程。
  • 这种机制保证了同级函数之间、上下级函数之间的变量/方法不会互相干扰。(如果需要想要突破这种机制就需要闭包)

注意

  • 上面的this指向不会由于执行上下文环境的切换而改变指向。this指向在函数被调用时就决定了,此后除非使用call/apply/bind方法否则不会改变
  • 代码执行完毕后,所在的环境会被销毁,web中全局执行环境是window对象,全局环境会在应用程序退出时被销毁

作用域链的代码性能优化

从作用域链的结构、原理可以看出,在执行上下文的作用域链中,标识符所在的位置越深,读写速度就会越慢。如上图所示,因为全局变量总是存在于执行上下文作用域链的最末端,因此在标识符解析的时候,查找全局变量是最慢的。所以,在编写代码的时候应尽量少使用全局变量,尽可能使用局部变量

一个好的经验法则是:如果一个跨作用域的对象被引用了一次以上,则先把它存储到局部变量里再使用。例如下面的代码:

function changeColor(){
    document.getElementById("btnChange").onclick=function(){
        document.getElementById("targetCanvas").style.backgroundColor="red";
    };
}
function changeColor(){
    var doc = document; // 先把下面要用到超过1次的document全局变量存到局部变量doc中,减少读取次数
    doc.getElementById("btnChange").onclick=function(){
        doc.getElementById("targetCanvas").style.backgroundColor="red";
    };
}

避免使用 with语句

function initUI(){
    with(document){ // 使用with语句
        var bd = body,
            links = getElementsByTagName("a"),
            i = 0,
            len = links.length;
        while(i < len){
            update(links[i++]);
        }
        getElementById("btnInit").onclick = function(){
            doSomething();
        };
    }
}

这里因为需要多次用到document,所以使用with语句看起来会比较简洁高效,但实际引发了性能问题。

当代码运行到with语句时,运行期上下文的作用域链临时被改变了。一个新的可变对象被创建,它包含了参数指定的对象的所有属性。这个对象将被推入作用域链的头部,这意味着函数的所有局部变量现在处于第二个作用域链对象中,因此访问代价更高了。如下图所示: 因此在程序中应避免使用with语句,在这个例子中,只要简单的把document存储在一个局部变量中就可以提升性能。

try...catch 语句

另外一个会改变作用域链的是try-catch语句中的catch语句。当try代码块中发生错误时,执行过程会跳转到catch语句,然后把异常对象推入一个可变对象并置于作用域链的头部。在catch代码块内部,函数的所有局部变量将会被放在第二个作用域链对象中。示例代码:

try{
    doSomething();
}catch(ex){
    alert(ex.message); //作用域链在此处改变
}

try-catch语句在代码调试和异常处理中非常有用,因此不建议完全避免。你可以通过优化代码来减少catch语句对性能的影响。一个很好的方法是将错误交给一个函数处理,例如:

try{
    doSomething();
}catch(ex){
    handleError(ex); // 交给handleError函数处理
}

优化后的代码,handleError方法是catch子句中唯一执行的代码。该函数接收异常对象作为参数,这样你可以更加灵活和统一的处理错误。由于只执行一条语句,且没有局部变量的访问,作用域链的临时改变就不会影响代码性能了

看几道常见的面试题

  1. 块级作用域相关:
for(var i=0;i<5;i++){
    console.log(window.i) // 0 1 2 3 4
}
for(let i=0;i<5;i++){
    console.log(window.i) // undefined * 5
}

这个输出不同的原因是:window里面没有{ }里面的i,for循环看成的5个{ }

  1. 执行上下文环境
var a = 10
function foo(){
    console.log(a)
}
function sum() {
    var a = 20
    foo() // 相当于 window.foo(),每次看到都要这样想
}
sum()

这里会输出 10,原因是:即使在 sum 函数里面调用 foo 函数,也相当于执行 window.foo 函数,而 foo 函数和全局变量 window.a = 10 是绑定在一个(全局)上下文环境中的,所以输出的是全局变量 a = 10,而不是局部变量 a = 20。

总的来说,foo 函数在哪个区域被调用无关,因为只要 foo 函数被调用就一定会进入全局执行上下文环境,就一定使用的是 a 变量

上面两个例子表明函数在哪个地方被调用无关紧要,我们只要认被调用函数所创建的对应的上下文环境,我们就进入哪个上下文环境中去找变量/方法。

  1. 块级作用域、执行上下文、this指向
var obj = {
    a: 5,
    get1: function(){
        console.log(this.a)
    },
    get2: function(){
        setTimeout(this.get1,0)
    }
}

obj.get1() // 5 ----------------- 1

var fn = obj.get1
fn() // undefined ----------------- 2

obj.get2() // undefined ----------------- 3

1 和 2 不一样的原因:obj.get1() 是o bj 对象调用get1()方法,所以 this 指向 obj 对象,所以this.a就是 5;fn() 虽然也是调用 get1() 方法,但是在全局中调用的,所以 this 默认指向 window,所以this.a 是 undefined。

3 输出 undefined 的原因是:同 1,this指向obj,但是在setTimeout里面this是指向window的,因为它是脱离主线程另外在全局中开启的线程(但也不是绝对,需要具体看情况,详见JavaScript系列 -- this关键字)。所以setTimeout里面的this.get1会把this(===window)传给get1,所以输出undefined。

闭包

我们看看上面的一个例子:

var n = 5
function fn(){
    var n = 6
    function f(){
        console.log(n)
    }
}
fn()
console.log(n) // 报错
f() // 报错

这里打印变量 n 和 直接 f() 都会报错:Uncaught ReferenceError: n/f is not defined的原因:从上下文环境的角度看 f() 函数不在全局上下文环境里、只在 fn() 函数的上下文环境里。所以执行 f() 时所处的全局上下文环境对应的作用域链上面的对象(只有一个:window)里面没有 f() 这个方法,所以报错

那它的本质原因是什么,从计算机原理的角度看应该是内存的原因,所以我们先从JavaScript的堆栈内存释放说起

堆栈内存释放机制

  • 堆内存:存储引用类型值,对象类型就是键值对,函数就是代码字符串
  • 堆内存释放:将引用类型的空间地址变量赋值成 null,或没有变量占用堆内存了浏览器就会释放掉这个地址
  • 栈内存:存储基本类型值和提供代码执行的环境(上下文环境)。
  • 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。

但栈内存的释放也有特殊情况:① 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。② 全局下的栈内存只有页面被关闭的时候才会被释放

所以当代码运行到第8行后,fn()函数已经执行完,所以其私有作用域里的变量和方法所占用的栈内存和堆内存会被释放掉,所以访问不到,报错。

所以上面的例子如何保证在fn()函数执行完还能用一下f()方法和变量n呢(闭包的意义所在),我们在 fn() 函数里面利用 return f 把整个 f() 函数返回到 fn() 函数外部(并赋值给一个新的变量 s),(调用 s 函数)就可以实现在 fn() 函数外面访问里面的 变量 n了:

function fn(){
    var n = 6
    function f(){
        console.log(n)
    }
    return f
}
var s = fn()
s() // 6

这就是闭包的一个典型的例子,也体现出了它的作用。由此我们引出了闭包的概念:

闭包的意义

闭包就是能够在某个函数外部读取这个函数内部变量或方法。在Javascript中,只有函数内部的子函数才能读取局部变量,所以闭包打破了这一界限。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。(同时也可能带来内存泄露,下面会讲

这里我认为闭包就是能够读取其他函数内部变量的函数这句话中最后的那个函数应该指的就是上面的那个 f() 函数;

这里我认为闭包是将函数内部和函数外部连接起来的桥梁里的桥梁就是s = fn()而其中fn()的值就是f,所以本质上等价于s = f就是这座桥梁,连接 fn 函数外部的那一头是 s,连接 fn 函数内部的那一头是 f。因为有 s 的存在,fn 函数外部才能访问 fn 函数内部的 f 方法和变量 n;因为有闭包函数 f 的存在

闭包的原理

我们再次研究一下上面那个例子:

function fn(){
    var n = 6
    function f(){
        console.log(n)
    }
    return f
}
var s = fn()

到这里我们先不着急s()(调用s函数),我们先把s打印出来

image.png

由于 s 是全局变量,所以 window.s === s,我们在控制台输出它的详细属性

image.png

我们发现了它指向的作用域链是

image.png

① s 对应的作用域链的第一个结点对应的对象就是 {n: 6},第二个结点对应的对象是 window 对象。所以执行 s() 时才能拿到 n 这个变量。这和 fn 函数里的 f 函数是一模一样的。

结论:闭包的原理是把闭包函数拷贝一份window 对象 作为其中的一个方法,所以调用闭包函数时是在全局上下文环境下调用的

② 因为 s 对应的作用域链中有包括 {n: 6} 这个对象,所以变量 n 仍然需要用到,所以会占用内存。①③但把 window.n 输出出来是 undefined,所以变量 n 不是全局变量,仍是局部变量。由此可引出闭包的作用:

闭包的作用:

  1. 保证函数内部的变量还是局部变量的前提下(对应 ③),在函数外部可以对这些变量进行读取(get)/ 改写(set)(对应上面的 ①)
  2. 让这些变量的值始终保持在内存中(对应上面的 ②)

但其实去掉 变量 s 也是可以的,只不过我们理解的时候可以借助这个 新变量 来思考,就是把fn()()的前半部分fn()看成是全局上下文环境(/window对象)中的一个函数 s(/方法)

function fn(){
    var n = 6
    function f(){
        console.log(n)
    }
    return f
}
fn()()

由此我们对闭包的概念作下更进:

闭包就是把一个函数放在这个函数的上下文环境以外的任何一个环境中去执行,而这个函数就是闭包函数

上面例子虽然我们借助的这个辅助变量 window.s 实际是不存在的,但是能辅助我们理解,比如下面两道题

闭包的题目

  • this 指向 + 闭包
var name = "The Window";
var object = {
    name : "My Object",
    getNameFunc : function(){
        return function(){
            return this.name;
        };
    }
};
console.log(object.getNameFunc()());
var name = "The Window";
var object = {
    name : "My Object",
    getNameFunc : function(){
        var that = this;
        return function(){
            return that.name;
        };
    }
};
console.log(object.getNameFunc()());

第一题输出 The Window,第二题输出 My Object,乍一看挺懵的,我们来慢慢研究一下:

我们把object.getNameFunc()()的前半部分object.getNameFunc()看作是一个整体,是全局上下文环境里的一个匿名函数f (){ return this.name },因为这个匿名函数在被调用时前面没有object.,所以 this 指向 window,所以输出The Window

第二题也是同样道理,只不过执行这个匿名函数时 this 不在是默认的 window,而是被指定的 object 了,至于为什么是 object,这是因为执行object.getNameFunc()让此时getNameFunc()的this指向 object,所以 that 也跟着指向 object,所以在全局上下文环境中执行f (){ return that.name }自然就输出My Object

所以第一题等价于:

var name = "The Window";
(function(){
    return this.name
})()

而第二题就等价于:

var object = {
    name : "My Object"
}
function fn(){
    return this.name
}
fn.call(object)

另外:下面这道题的 nAdd 的值是个匿名函数,它也是一个闭包

function f1(){
    var n = 999;
    nAdd = function(){ n++ } // 注意:这里是全局方法
    function f2(){
        console.log(n);
    }
    return f2;
}
var result = f1();
result(); // 999
nAdd();
result(); // 1000

上面讲函数作用域的时候说到:如果在函数内部声明变量直接赋值的话就相当于 window.n = 999,控制台输出证明:

image.png 所以执行nAdd()这行代码时就是在全局上下文环境中执行 f1 函数内部的方法。

  • for 循环 + 闭包
var data = [];
for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}
data[0]();
data[1]();
data[2]();

for 循环过后,数组 data 里面每一个元素都是一个匿名函数,由于 var 声明的变量 i 会互相“污染”,所以data[0]、data[1]、data[2]里面的 i 都是 window.i,在全局上下文环境中调用这些匿名函数,匿名函数找到了跟自己同在{ }里的 i,只不过这个 i 已经被“污染”了,所以是三个 3。除了改成用 let 声明变量,③ 我们还可以给每次循环都加上一层“保护层”,防止里面的变量被“污染”

var data = [];
for (var i = 0; i < 3; i++) {
  (function(j){ // 自执行函数的形参
      data[j] = function () {
          console.log(j);
      };
  })(i) // 自执行函数的实参
}
data[0]();
data[1]();
data[2]();
  • for 循环 + setTimeout + 闭包 我们知道:
for(var i = 0; i< 10; i++){
    setTimeout(function(){
        console.log(i)
    }, 200) 
}

会输出 10 个 10,是因为var声明的变量没有块级作用域的概念,换句话说 i 会忽视掉所有的{ },所以每一次循环 i 的赋值工作都会突破{ }这道“墙”篡改了全局变量 window.i,所以相当于最后的输出全是 window.i。也是除了改成用 let 声明变量,③ 我们还可以给每次循环都加上一层“保护层”,防止里面的变量被“污染”

for(var i = 0; i< 10; i++){
    (function(j){ // 注意:需要用形参j把实参i传递进去
        setTimeout(function(){
            console.log(j)
        }, 200) 
    })(i)
}

由此我们对闭包作用新增一条:

  1. 保证函数内部的变量还是局部变量的前提下(对应 ③),在函数外部可以对这些变量进行读取(get)/ 改写(set)(对应上面的 ①)
  2. 让这些变量的值始终保持在内存中(对应上面的 ②)
  3. 自执行函数创建私有作用域,保护函数的私有变量不受外部的干扰(对应上面的 ③)

上面例子等价于:

for(var i = 0; i< 10; i++){
    function fn(j){ // 注意:需要用形参j把实参i传递进去
        setTimeout(function(){
            console.log(j)
        }, 200) 
    }
    fn(i)
}

垃圾回收机制

上面说到堆栈内存释放,那么是怎么知道需要释放的呢,这就需要了解浏览器的垃圾回收机制。

垃圾回收机制的必要性

内存空间是有限的,而当一个文件的函数增多、变量增多时,会占用很多内存空间,如果其中存在一些不再被使用到的函数或变量,应及时清理掉,腾出更多的内存空间。

垃圾回收机制的方式

浏览器中的 JS 引擎会自动定时清理内存垃圾,可在新版本的chrome在 performance 中查看:

由图中观测到,每隔一段时间,内存空间占用率就会往下降,说明 JavaScript 是周期性地清理内存垃圾的,那它是怎么清理的呢?

引用计数法

1. 什么是引用?

引用可以简单的理解为我们日常的赋值操作:

var arr = [1,2,3,4]

上面代码中,数组[1, 2, 3]是一个值,会分配一定的内存空间,而该数组被赋值给了变量 arr,换句话说,变量 arr 对数组[1, 2, 3]进行了引用

2. 引用计数

变量 arr 对数组[1, 2, 3]进行了引用,所以数组[1, 2, 3]的引用次数加 1,当又给变量 arr 赋值新的数组(值)时:

var arr = [1,2,3]
arr = [4,5,6]

数组[1, 2, 3]的引用次数就会减 1,即变成 0,所以当 JS 进行垃圾回收的时候就会把数组[1, 2, 3]清除掉。

3. 循环引用
var obj1 = {}
var obj2 = {}
obj1.a = obj2
obj2.b = obj1

两个对象,各自的其中一个属性是各自本身,则会造成循环引用,也就是说,对象 obj1、obj2 的引用次数都是 1,但如果这两个对象不再使用时,引用次数也不会减为 0,所以使用引用计数法永远无法识别出该内存垃圾,也就永远都无法清除掉,这就是所谓的内存泄露。所以现代浏览器一般采用的是标记清除法

标记清除法

该算法假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。垃圾回收器将定期从根开始(在JS中就是全局对象)扫描内存中的对象。凡是能从根部到达的对象,都是还需要使用的。那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收

上上文说到 执行上下文环境,看个例子:

function fn(){
    let s = "ALKAOUA"
    console.log(s)
}
fn()

fn() 这一过程分为:进入函数 fn 上下文环境 —> 执行函数 fn 的代码 —> 离开函数 fn 上下文环境

内存泄露

内存泄露:本该被清理的内存垃圾,因为某些原因而没有被清理掉,就会造成内存泄露

还是上面那张图,当 JavaScript 隔周期性会回收内存垃圾后,会达到一个区间内的极小值,我们可以观测到这个极小值越来越大,说明就有了内存泄露的情况出现

1. “意外”的全局变量

为什么说意外呢?我们在函数里面声明变量是这样的:

function fn(){
    let s = "ALKAOUA"
}

当函数 fn 执行结束后,变量 s 自然会被垃圾回收机制清理掉,但如果写成:

function fn(){
    s = "ALKAOUA"
}
或
function fn(){
    this.s = "ALKAOUA"
}

那么其实变量 s 就成了全局变量(在 window 对象里面),所以我们以为变量 s 会被清理掉,但是实际上并没有

【注意事项】所以在函数里面创建变量的时候要注意,究竟该变量要不要持续性地使用,不是的话要声明

2. 闭包引起的内存泄露

function fn(){
    var s = "ALKAOUA"
    function f(){
        console.log(s)
    }
    return f
}
fn()

上个要点说到的闭包也会造成内存泄露,原因是:当我们使用闭包使得函数 fn 外部能够访问函数 fn 内部的函数 f 和变量 s 的时候,也就意味着我们能从根对象(window 对象)访问到函数 f 和变量 s,所以标记清除法不会把这两个清理掉,因而造成内存泄露(不过这其实也是我们的目的)

【注意事项】如果不需要再访问函数 fn 内部的函数 f 和变量 s 的时候,记得把闭包解决掉:

image.png

(这里把闭包函数直接置为 null 会报错,只能把函数 fn 置为 null,但总感觉有些不妥,掘友们给个意见)

3. DOM 元素的引用

有时,保存 DOM 节点内部数据结构很有用。假如你想快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或者数组很有意义。此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。将来你决定删除这些行时,需要把两个引用都清除

var elements = { // 把多个 DOM 元素存在一个 obj 里面
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};
function doStuff() { // 针对保存起来的 obj 里面的 DOM 节点进行 DOM 操作
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
}
function removeButton() {
    document.body.removeChild(document.getElementById('button')); // 删除某个 DOM 节点
    // 此时,仍旧存在一个全局的 #button 的引用 —— elements 对象。button 元素仍旧在内存中,不会被回收掉
}

上面例子中虽然把 #button 的 DOM 节点删除掉,但是全局中还是存在对它的引用,所以按照标记清除法的思路,从 window 对象出发会到达该 DOM 节点的引用,所以不会把该节点所占用的内存清除掉。

【注意事项】所以记得删除节点后,如果有对该节点进行引用的话,记得同时把引用清除掉,比如 elements.button = null

4. 引用 DOM 节点的定时器

上面说到,引用 DOM 节点后,删除该 DOM 节点之后由于全局中有对象引用该节点,而该引用还存在,GC 机制(按照标记清除法的思路)又不会清除掉该引用,变相来说该 DOM 节点被删除后还是占用内存且不会被清理掉

同理,如果有定时器引用该 DOM 节点,那么如果该 DOM 节点被删除,那么定时器不会被删除,会一直运行着直到页面卸载,而且如果定时器里面用到了其他的回调函数,同样回调函数也会占用内存,就像是一条链上的

我们模拟一下这个过程

var obj = {
    num: 666
}
var timer = setInterval(()=>{
    var n = obj.num
    console.log(n)
},1000) // 每 1 秒调用一次

1.gif

obj = null // 模拟删除 DOM 节点

2.gif

由此看到,执行obj = null后,定时器一直运行,由于 obj 已经变为 null,所以一直重复报错

【注意事项】如果定时器引用了 DOM 节点或对象,该 DOM 节点或对象被删除后如果定时器中没有其他引用了请务必记得删除:

clearInterval(timer) // 清除不再需要的定时器

针对垃圾回收的场景优化

数组置空

数组不再使用,置空数组,这样会造成:[1,2,3]变成内存垃圾,又重新申请了一个地址放置一个空数组

var arr = [1,2,3]
arr = []

优化方式:

var arr = [1,2,3]
arr.length = 0

将数组长度赋值为0(arr.length = 0也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生

循环中复用对象

每次循环都会创建一个新对象:

for (var i = 0; i < 10; i++) {
  var obj = {};// 每次循环都会创建一个新对象。
  obj.index = i
  console.log(obj)
}

优化方式:

var t = {}; 
for (var i = 0; i < 10; i++) {
  obj.index = i
  console.log(obj)
}

每次循环都是使用同一个对象,且不再使用该对象的时候记得置为 null,这一点在对象特别大的时候的优化效果显而易见:

obj = null

循环中复用函数

循环中复用函数也是同样的道理:

// 在循环中最好也别使用函数表达式。
for (var k = 0; k < 10; k++) {
  var fn = function(a) {
    // 创建了10次  函数对象。
    console.log(a)
  }
  fn(k)
}

优化方式:

// 推荐用法
function fn(a) {
  console.log(a)
}
for (var k = 0; k < 10; k++) {
  fn(k)
}
fn = null

参考文章