JavaScript内存、作用域和闭包

本文深入探讨了JavaScript中的执行上下文(作用域)概念,包括全局作用域、函数作用域和块级作用域。介绍了作用域链的工作原理,以及变量对象和垃圾回收机制,特别强调了如何管理内存以防止内存泄漏。此外,文章还讲解了闭包的特性和可能导致的内存问题,以及如何通过优化减少闭包的内存占用。最后,提到了防抖和节流函数在闭包应用中的实践。
摘要由CSDN通过智能技术生成

执行上下文(作用域)

任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称作用域)。

以上是红宝书中的原话。(之前一直搞不懂上下文和作用域有什么区别。。。其实就是同一个东西)

作用域决定了变量的生命周期,以及他们可以访问代码的哪些部分。

JavaScript有3种作用域:

  • 全局作用域:最外层作用域,即window对象的作用域
  • 函数作用域:函数内的作用域,也是最复杂的一个作用域。
    • var的声明范围即为函数作用域
  • 块级作用域{}包裹的作用域,如forwhileif等块。单独的{}块也算
    • 函数作用域也算块级作用域
    • letconst的声明范围是块级作用域

每个作用域都有一个关联的变量对象,保存该作用域下定义的所有变量和函数。

如果是函数作用域,其一开始就有个定义对象arguments作为变量对象

函数作用域

每个函数调用都有一个专属的函数作用域,其所有代码都执行完毕后会销毁。

当代码执行流进入函数,函数的作用域被推到一个作用域栈(上下文栈)上(此时原本的作用域比如全局作用域就被压在了下面),执行完毕后弹出,继续执行原来的作用域的代码。

如下代码:

let color = 'blue';

function changeColor () {
    let a = 1;
    color = 'red';
    console.log(a);
}

changeColor();
console.log(color);
  1. 一开始代码执行流在全局作用域,作用域栈内只有一个全局作用域(既是堆顶,也是堆底)
    1. 在第1行创建了一个color变量并赋值blue
    2. 在第3行定义了一个changeColorFunction对象
  2. 在第7行执行changeColor(),代码执行流进入函数changeColor()的作用域
    1. 将**changeColor作用域**压入作用域栈,成为堆顶
    2. 全局作用域被压在changeColor作用域下
  3. changeColor()的代码执行完毕,弹出栈顶,所有都被销毁(如临时创建的a以及arguments
  4. 代码执行流重新进入全局作用域,继续往下执行

作用域链

作用域链:决定各级上下文访问变量和函数的顺序

作用域中的代码被执行的时候,会创建变量对象的一个作用域链正在执行的作用域的变量对象始终位于作用域链的最前端。

作用域链中的下一个变量对象来自包含上下文(外面一层),再下一个对象来自再下一个包含上下文,以此类推至全局上下文。

上面例子中的changeColor()的作用域链包含2个变量对象,一个是他本身的变量对象(包含argumentsa),另一个是全局作用域的变量对象(包含color)。因为能在作用域链中找到color,因此在changeColor()中能访问color

在作用域链上能找到的变量,均能被访问或修改。(一般来说,外层作用域的变量对象均在作用域链上)


看一个例子:

var color = 'blue';
function changeColor() {
    let anotherColor = 'red';
    function swap(){
        let temp = anotherColor;
        anotherColor = color;
        color = temp;
    }
    swap();
}
changeColor();

红宝书上的作用域链图是下侧为最前端。不过我感觉有些别扭,下面画了些以顶部为最前端的简图

  • 代码流执行到第1行的作用域链如下:

    • 全局作用域链:
      在这里插入图片描述
  • 执行到第2行:

    • 全局作用域链:
      在这里插入图片描述
  • 执行到第11行,进入changeColor作用域:红色代表changeColor函数作用域

    • 执行到changeColor作用域内第1行的代码(第3行的位置let anotherColor = 'red';

      • 全局作用域链:
        在这里插入图片描述

      • anotherColor作用域链:
        在这里插入图片描述

    • 执行到changeColor作用域内第2行的代码(第4行的位置function swap()

      • 全局作用域链:
        在这里插入图片描述

      • anotherColor作用域链:
        在这里插入图片描述

  • 执行到changeColor作用域内第9行的代码,进入swap作用域:这个新的颜色代表swap函数作用域

    • 执行到swap作用域内第1行的代码(第5行的位置let temp = anotherColor;
      • 全局作用域链:
        在这里插入图片描述

      • anotherColor作用域链:
        在这里插入图片描述

      • swap作用域链:
        在这里插入图片描述

  • changeColor作用域内第9行的代码执行完毕,释放swap作用域

  • 第11行执行完毕,释放changeColor的作用域

JavaScript引擎在查找变量时顺着作用域链找的,先找最前端的正在执行的作用域的变量对象,没找到再一层层往下找,直到全局作用域。因此,访问局部变量比访问全局变量要快得多。

内存

垃圾回收

JavaScript会自动释放内存,其基本思路是:

  • 确定哪些变量不会再被使用,然后释放他的内存
  • 周期性:垃圾回收程序是每隔一段时间运行一次的

但是单靠算法也无法完美避免内存的泄露,因此需要理解垃圾回收机制,注意避免。

回收算法

前面说过,回收程序在确定变量不会再被使用时,释放他的内存,那么怎么确认呢,就需要跟踪记录哪些变量还有可能使用。

一般来说,局部变量在其作用域执行时,不会被删除。此时,内存会给他分配空间以保存相应的值。当作用域中的代码执行完后,里面的变量基本就不会被使用了,此时就会释放该作用域占用的所有内存。但有一种情况例外,就是当局部变量被其他作用域的变量引用(在其他作用域被使用)时,回收程序会认为他扔需要使用,就不会释放内存。

如何标记未使用的变量主要有以下两种方式:标记清理引用计数

标记清理(常用)

标记清理是针对变量(变量名)的

  1. 当变量进入上下文时(如函数内声明一个变量),这个变量会被加上一个存在于上下文的标记。

    标记方法有很多(这都不重要)

    • 如反转某一位
    • 或维护“上下文中”和“不在上下文中”的变量列表
  2. 当变量离开上下文时(上下文内的代码执行完),也会被加上一个离开上下文的标记。

  3. 垃圾回收程序运行时,会标记(待删除)内存中存储的所有变量。然后将

    • 所有上下文中的变量
    • 被在上下文中的变量引用的变量

    的标记去掉,剩下的还带着(待删除)标记的变量就是待删除的了。(因为不会再被访问了)

    let a = [1, 2, 3];
    let b = a; 
    // 此时,a和b均在上下文中,且a被b引用
    
    let method = function () {
        let a = [1, 2, 3];
        return function () {
            let b = a;
            return b;
        }
    }
    let method1 = method();
    // 此时,a已经离开上下文,但a仍在被b引用
    
  4. 到了回收周期进行清理待删除标记的变量,并回收他们的内存。

引用计数

引用计数是针对(变量值)的

  • 当声明一个变量并为它赋予一个引用值时,这个的引用数为1

    • 如果这个又被赋给了另一个变量,那么引用数+1

      let a = [1, 2, 3];// [1, 2, 3]的引用数为1
      let b = a; // [1, 2, 3]的引用数为2
      
  • 如果保存该的变量被覆盖了,则引用数-1

    a = []; // [1, 2, 3]的引用数为1
    

引用计数有一个严重的缺陷,就是当a引用b,而b又引用a时,会造成循环引用,导致这两个值一直不被释放

总结
  • 标记清理是针对变量(变量名)的
  • 引用计数是针对(变量值)的
  • 标记清理比引用计数更常用,也更好

回收周期

垃圾回收程序周期运行,当垃圾累积到一定数量会影响性能,因此回收周期(时间调度)很重要。

回收频率太低会导致垃圾积压,频率太高会到处小内存片泛滥

IE7后,JavaScript引擎的垃圾回收程序为动态改变分配变量、字面量或数组槽位等阈值。

  • 设一个初始阈值。

  • 如果有一次回收内存不到已分配的15%,则阈值会翻倍。

  • 如果有一次回收内存达到85%,则阈值重置为默认值。

(有点像网络拥塞控制的慢开始和快重传)

内存管理

手动释放内存

JavaScript是自动回收内存的,但也可以通过解除引用的方式手动释放内存。

如果数据不再必要,那么把他设置成null,从而释放引用

这种方法最适合全局变量,因为局部变量在超出作用域会被自动接触引用,而全局变量会一直存在直到浏览器关闭

注:解除引用不是立即回收相关内存,而是在下一次垃圾回收的时候释放

多用let和const

let和const以块级作用域为单位,比var更有助于被回收

善用隐藏类

能够共享相同隐藏类的对象性能更好。

算是属于V8JavaScript引擎(Chrome使用这个引擎)的特性。

通过下面这个例子来解释:

function Article(){
    this.title="title";
}
let a1=new Article();
let a2=new Article();

这时候a1a2同属于一个类(Article

a2.author='jake';

这时候a1a2还是同属于一个类(Article),但他们不属于同一个隐藏类。其中,a2的隐藏类比a1多了一个author属性

再看下例:

function Article(author){
    this.title="title";
    this.author=author;
}
let a1=new Article();
let a2=new Article("jake");

同样的,此时2者属于同一个类,也属于同一个隐藏类

delete a1.author;

现在,a1a2又不属于同一个隐藏类了。


能够共享隐藏类的对象性能更好,即尽可能少的制造隐藏类

因此,我们可以把delete a1.author改成下面这样

a1.author=null;//用这个代替delete

对象池

开发者无法控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件

  • **标准1:**对象更替速度。

    如果很多对象初始化,然后一下子又都超出了作用域(不再用到),那么浏览器更频繁地垃圾回收

    function addVector(){
        let result=new Vector();
        result.x=1;
        result.y=1;
        return result;
    }
    

    如上面这个例子,如果多次调用addVector(),那么Vector的实例对象被频繁的创建,又很快超出作用域,生命周期很短。垃圾回收程序会发现此处对象更替速度快,则会更频繁地安排垃圾回收。

    解决:不要频繁的创建对象,可以通过对象池来实现。

  • **对象池:**创建一个对象池,用来管理一组可回收的对象

    //vectorPool:假设这是一个已经创建好的对象池对象,先不管是怎么实现的
    let v1=vectorPool.allocate();//从对象池中取一个vector对象
    let v2=vectorPool.allocate();
    console.log(addVector(v1));
    console.log(addVector(v2));
    vectorPool.free(v1);
    vectorPool.free(v2);//用完把result对象还给对象池
    v1=null;
    v2=null;
    function addVector(result){//函数改成这样
        result.x=1;
        result.y=1;
        return result;
    }
    
    

    这样垃圾回收检测就不会发现有对象更替,也就不会频繁的垃圾回收。


一般对象池都是用数组实现的,JavaScript的数组大小是动态可变的,Array.push()操作有可能会导致不必要的垃圾回收,因此最好一开始就初始化创建一个大小够用的数组作为对象池

原话是这样的,但不是很理解。

let list=new Array(100);
let vector=new Vector();
list.push(vector);

引擎会删除大小为100的数组,再创建一个大小为200的数组。垃圾回收程序看到这个删除操作,可能会跑过来进行一次垃圾回收。

不知道这个是不是和C++的capacity一样,当数组长度超过capacity时,加长capacity,然后之后的push就不需要再花大力气分配新的内存了。但我看JavaScript好像并不是这样,所以不是很理解,以下是CSDN中说的

JavaScript据元素的增加和删除来动态调整存储空间大小,内部是通过扩容和收缩机制实现

内存泄漏

JavaScript的内存泄漏大部分是由不合理的引用导致的。

  • 意外声明全局变量是最常见也是最容易修复的内存泄漏

    function setName(){
        name='jake';
    }
    

    这里name一不小心定义在了全局作用域window),即使setName的上下文执行完毕不再需要,也不会释放

    解决:只要在name前面加上varletconst即可。(最好不要用var

  • 定时器可能会导致内存泄漏。(本质上是闭包引起的)

    let name = 'Jack';
    setInterval(() => {
        console.log(name);
    },100);
    

    只要定时器一直运行,回调函数中引用的name就会一直占内存。因而就不会清理外部变量(外面那个name

  • 关于闭包的泄漏,在下面会详细讲。其实节流和防抖函数也是利用这个,使计时器一直不被回收

闭包

闭包指的是那些引用了另一个函数作用域中变量的函数

解析一下上面这句话:

  1. 引用:这个引用很关键,闭包不仅仅是函数内的函数,还有个重要条件是引用了另一个函数作用域的变量

    function outer() {
        let name = 'jack'
        return function() {// 这是一个闭包
            return name;
        }
    }
    
    function outer2() {
        return function() {// 这不是
            let name = 'joker';
            return name;
        }
    }
    
  2. 另一个函数域:指的一般是外部域。因为只有外层的作用域的变量是可以被引用的(变量对象在作用域链后端可以被找到)

  3. 的函数:指的是里面那层函数,上面例子中被return的函数是闭包,而不是外层的outer

闭包的内存泄漏

看以下例子

function outer() {
    let name = 'jack'
    return function() {// 这是一个闭包
        return name;
    }
}
let method = outer();// 通过引用outer的返回值定义一个method函数
method(); // 执行method函数

在定义let method = outer();这段代码,代码执行流进入outer的函数作用域,执行完毕后本来应该释放局部变量name,但因为返回了一个匿名函数(闭包),而这个匿名函数对name有所引用(需要用到),因此name实际上与这个匿名函数共生死

而这个匿名函数(闭包)作为返回值被method引用,又导致匿名函数与method绑定在了一起,此时method→闭包→name,只要箭头左侧的变量/值存在,右侧的就不会被释放(反之不会)。

在这个例子中,method因为是全局变量,因此直到浏览器窗口被关闭前都不会被销毁,因此只要method不被别的值覆盖,name也会一直在内存中。

注:匿名函数≠闭包,只是这个例子中是同一个


不过我们可以减少闭包的内存泄漏,如下例

function outer(obj){
    return function(){
        return obj.a;
    }
}
let temp=outer({'a': 1});

这里闭包引用了obj.a,从而使得obj无法释放。当obj大到一定程度的时候,会严重影响性能,而我们仅仅是需要obj.a的属性,得不偿失。

可以改成下面这样:

function outer(obj){
    let a = obj.a;
    obj = null;
    return function(){
        return a;
    }
}
let temp=outer({'a': 1});

这样一来,当执行到obj = null时,obj会被解除引用(本质上是解除了obj对其他变量的引用,而obj.a由于还存在被a的引用,不会被释放),在下一次垃圾回收时会被释放。而a则会一直存在在内存中,但大大降低了内存的泄漏。

闭包的this指向

如果闭包不是使用箭头函数定义,那么其this会在运行(被调用)时绑定到执行函数的上下文(调用闭包函数的对象的作用域)。也就是说,这种情况下,闭包的this指向调用该闭包函数的对象。即:

  • 如果在全局函数调用:
    • 非严格模式下:this等于(指向)window
    • 严格模式下:thisundefined
  • 如果作为某个对象的方法调用:this等于(指向)这个对象

如下例:

window.color = 'red';
let obj = {
    color: 'blue',
    getColor() {
        return function() {
            return this.color;
        }
    }
}
console.log(obj.getColor()());// red
// 相当于
// let c = obj.getColor();
// c();

这里解析一下:

  1. 闭包是函数声明式定义,非箭头函数,因此与就看谁调用的了

  2. obj.getColor()()这块,我们可以把他看成是以下2行代码

    let c = obj.getColor(); // 先对返回的闭包函数进行一个赋值
    c(); // c()实际上是window.c()
    

    这样就很容易看出,闭包是在全局对象上被调用的


那么该如何通过闭包调用到obj.color呢?

每个函数在被调用时会自动创建2个变量:thisarguments但内部函数永远无法直接访问外部函数的这2个变量。因此我们只能通过创建一个临时变量引用来保存这2个变量的值,如下:

window.color = 'red';
let obj = {
    color: 'blue',
    getColor() {
        let self = this;
        return function() {
            return self.color;
        }
    }
}
console.log(obj.getColor()());// blue

防抖、节流

防抖和节流是闭包的一个很经典的例子,具体的防抖节流介绍这里就不详细讲了,就列举几个例子

// 节流函数(计时器版)
function throttle(func, wait) {
    var timeout;

    return function() {// 这是一个闭包
        var context = this;
        var args = arguments;
        if (!timeout) {
            timeout = setTimeout(function(){
                timeout = null;
                func.apply(context, args)
            }, wait)
        }

    }
}

这里在debounce函数域中定义了计时变量timeout

  • 为了阻止timeout自动释放,使用闭包来对其引用。

计时器版也是如此

function throttle(func, wait) {
    var previous = 0;
    return function () {
        var now = Date.now();
        var context = this;
        var args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}

主要参考《JavaScript高级程序设计》,由于本人水平有限,可能会有理解不到位的地方,欢迎各位指正

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值