欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

与作用域相关的闭包、内存泄漏、this

程序员文章站 2022-04-19 15:37:02
...

作用域是 JavaScript 里的一个非常重要和基础的概念. 很多人认为自己理解了作用域, 但是在遇到闭包时却说不出个所以然, 甚至不能识别出来.

闭包也是个非常重要, 且经常被误解的概念. 然而闭包就是基于作用域书写代码时所产生的自然结果. 倘若抛开作用域讲闭包, 那都是耍流氓. 闭包可以说在平时的代码里随处可见, 但真正让闭包发挥积极作用的做法是隔离作用域、模块函数等.

作用域机制是不能直接查看的, 我们首先模拟一个场景来尽可能的说明作用域这套规则, 然后通过代码片段和开发者工具进行验证.

游戏存档

想必大家都有玩过游戏的经验. 刚开始的时候, 也就是第一关, 难度比较简单. 到了第二关的时候, 就在第一关的基础上加些难缠的角色, 难度相应地加大了. 关卡越是往后, 难缠的角色也就会越来越多.

可在游戏的时候, 由于各种原因, 往往我们不可能一下子通过所有的关卡, 所以游戏提供了存档的功能. 下次再玩的时候可以从存档里续上. 如果不想这样, 完全可以从头玩起.

为什么我们能从存档里直接跳到上次的关卡, 很显然, 这里是有记录存储的. 比如第一关有个场景食人花和海王, 第二关又多了个邪恶人等等. 每个关卡都会记录该关卡新增的角色或场景同时也会存储之前关卡的记录. 这样就保证了不同的存档的独立性, 无论在哪个关卡存档, 下次也定会续上之前的地方. 当然了, 我们也可以回到上一个关卡.

(海王之雄风&敌人之邪恶)

几个知识点

结合上面的场景, 我们再回头看看以下几个知识点.

  1. 标识符: 变量、函数、属性的名字, 或者函数的参数.

  2. 每个函数都有自己的执行环境. 当执行流进入一个函数时, 函数的环境就会被推入一个环境栈中. 而在函数执行后, 栈将其环境弹出, 把控制权返回之前的执行环境.

  3. 执行环境定义了变量或函数有权访问的其它数据. 每个执行环境都有一个与之关联的变量对象, 环境中定义的所有变量和函数都保存在这个对象中. 某个执行环境中的所有代码执行完毕后, 该环境被销毁, 保存在其中的所有变量和函数定义也随之销毁.

  4. 当代码在一个环境中执行时, 会创建变量对象的一个作用域链.

  5. 作用域链是保证对执行环境有权访问的所有变量和函数的有序访问. 作用域的前端始终都是当前执行的代码所在的变量对象. 如果这个环境是函数, 则将其活动对象作为变量对象. 活动对象在最开始只包含一个变量, 即 arguments 对象. 作用域链中的下一个变量对象来自包含(外部)环境. 全局执行环境的变量对象始终都是作用域链的最后一个对象.

  6. 当某个环境中为了读取或写入而引入一个标识符时, 必须通过搜索来确定该标识符来确定该标识符实际代表什么. 搜索过程从作用域链的前端开始, 向上逐级查询与给定名字匹配的标识符. 如果在局部环境中找到了该标识符, 搜索过程停止, 变量就绪. 如果在局部环境中没有找到该变量名, 则继续沿作用域链向上搜索. 搜索过程将一直追溯到全局环境的变量对象. 如果在全局环境中也没有找到这个标识符, 则意味着该变量尚未声明.

  7. 作用域链本质上时一个指向变量对象的指针列表, 它只引用但实际不包含变量对象.

如果我们把以上的几个知识点串起来, 这就是所谓的作用域链规则了. 上图解释一波.( arguments 应该加到变量对象里的, 图中没体现, 疏忽)

图解作用域

现在我们从最后两行说起,

var outer = outerFn(10);
var inner = outer(10);
复制代码

执行 outer = outerFn(10) 后, outer 拥有了返回函数的引用. outer(10) 在执行的时候它会创建 属于它自己 的作用域链, 这里包含函数所处外部环境的变量对象.

在读取 initial 变量时, 在 Inner 变量对象中没有检索到, 它会沿着作用域链向上搜索, 在 outer 变量对象里找到了该标识符, 搜索过程停止, 变量就绪.

函数在定义的时候就已经决定了之后执行时, 作用域里将包含什么. 这也解释了, 即使我们把定义在函数内部的函数扔在外边执行也能访问到函数内部的变量. 这和内部函数在哪执行没有半毛钱关系.

为什么强调 属于它自己 的呢?

function outer() {
    var num = 0;
    return function inner() {
        return num++;
    }
}
let innerFn_1 = outer();
let a_1 = innerFn_1()
let innerFn_2 = outer();
let a_2 = innerFn_2();

let a_1_1 = innerFn_1();
let a_2_2 = innerFn_2();
复制代码

innerFn_1 和 innerFn_2 都属于自己的作用域链, 而 a_1 和 a_2 则分别在 innerFn_1 和 innerFn_2 上创建了属于自己的作用域链. 所以它们函数里的 num 是属于不同作用域链里的变量. 但对于 a_1 和 a_1_1 来说它们都是基于 innerFn_1, 拥有同一 outer 变量对象, num 自然也是同一个, 所以会累加. 同理 a_2 和 a_2_2.

如果理解了这个, 那么面试常考的一题就小菜一碟了.

for(var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i*1000)
}
复制代码

重点是执行的时候才会创建变量对象的一个作用域链.

闭包是什么?

当函数可以记住并访问所在的作用域, 即使函数是在当前作用域之外执行, 这时就产生了闭包. 这就和之前提到的游戏存档差不多.

好了, 扔几个闭包出来巩固一下.

function outer_1() {
    var a = 'hello world';
    function inner() {
        console.log(a)
    }
    outer_2(inner)
}
function outer_2(fn) {
    fn()
}
复制代码

这里也有闭包.

var a = new array(99999999);
function b() {
    console.log(b)
}
b()
window.addEventListener('click', function() {
    console.log('hello world')
})
复制代码

DevTools里直观看闭包

还有开头所说的可以结合开发者工具直观地看一下, 一张动态图解释一切.

内存泄漏

闭包之所以能成为闭包, 是因为它记录了函数所在的作用域. 现主流的自动垃圾收集机制正因为闭包的这个特点而不能释放内存. 闭包的滥用会导致导致内存能分配的空间变少, 最终崩溃.

正常来说, 函数在执行的过程中, 局部变量会被分配相应的内存空间, 以便存储它们的值, 直至函数执行结束. 此时局部变量占有的空间会被释放以供将来使用.

常说的回收机制之一, 标记清除, 它的工作原理是, 当变量进入执行环境时, 储存在内存中的所有变量都会被加上标记(至于什么标记我们不关心), 然后找到 环境中的变量 以及 被环境中引用的变量, 把它们之前加的标记给去掉. 而剩下的被标记的变量将被视为 准备 删除的变量. 最后, 垃圾收集器找出不再继续使用的变量, 释放其占用的内存. 所以, 一旦数据不再被需要, 应解除引用, 将其值设置为null.

outer = null;
inner = null;
复制代码

内部函数的执行环境会保存着外部环境活动对象的引用, 内部函数被扔出去后, 就意味着外部环境中的变量不能被销毁了.

this对象

执行环境里记录的不只是这些, 它也记录了函数调用栈、函数调用方式等. this 和作用域有关系, 但不是你们想象的那种关系. 每个函数在被调用时都会自动取得两个特殊变量: this 和 arguments. 内部函数在搜索这两个变量时, 只会搜索到其活动对象为止(即当前变量对象). 因此永远不可能直接访问到外部函数中的这两个变量. 除非我们把外部作用域中的 this 对象保存在一个闭包能够访问到的变量里.

// 很常见是不是????
let obj = {
    a: function() {
        var self = this;
        return function() {
            console.log(self)
        }
    }
}
复制代码

函数内部的 this 在函数执行时才正式被赋予相应的值, 所以说函数的调用位置很关键. 可以这么说, 谁 直接 调用了这个函数, this 就指向了谁. 如果不是对象在直接调用这个函数, 我们可统统认为是 undefined, 非严格模式下浏览器环境就是 window. 如果真想知道为什么, 可以直接看规范(神烦).

'use strict'
function a() {
    console.log(this)
}
var b = {
    a: function() {
        console.log(this);
    },
    b: function() {
        return a;
    }
}
let b_a = b.a;
a();    //1. undefined;
b_a();  //2. undefined;
b.a();  //3. {a: f, b: f};
b.b()();    //4. undefined;
(true && b.a)() //5. undefined;
new a();    //6. {}
b.call(b);  //7. {a: f, b: f};
复制代码

从 1 ~ 6, 我们看看哪个对象直接调用了该函数.

第 1 个没找到调用对象, 就是个普通函数调用. 第 2 个经过 b_a = b.a 赋值操作后, 返回的就是那个普通函数, 就是一普通的函数调用. 第 3 个很直接, 就是 b 这个对象了. 第 4 个是个闭包, 首先 this 只在当前活动对象里找 this 对象, 不知道是哪个对象, 但肯定不会是 b. 第 5 个和第 2 个是一个道理. 第 6 个吧, 貌似不算是函数调用了吧, 不过我们知道, this 是指向新创建的空对象. 第 7个就更直接了, 人家都指名道姓就差喊出来了.

this 绑定对象的几条准则貌似在我这里就剩一条了????.