再谈JS中的闭包

3,020 阅读8分钟

先说观点

1. 什么是闭包?

现象说: 如果在一个函数内定义了另一个函数,在外层函数执行完毕后,再执行内部函数,这个内部函数也能访问外层函数执行时的变量

函数说: 如果在一个函数内定义了另一个函数,且内部函数中访问了来自外层函数的变量,就把这个内部函数 称为闭包函数

2. 产生闭包的条件

从上面两种定义中,我们至少可以找出产生闭包的三个关键要素:

  • 要素1:两个函数有嵌套关系,即在 一个函数中,定义另一个函数
  • 要素2:内部函数是在外部函数执行结束后才执行。 表现形式可以是,内部函数做为返回值返回
  • 要素3:内部函数内访问了外部函数中的变量。 可以是实参或内部声明的变量

那我们来举个栗子🌰

function createFunc(a, b) {
    let a2 = a*a;
    let b2 = b*b;
    
    return function () {
        return a2+b2; // 访问外层函数的局部变量
    }
    
}

let fn  = createFunc(3, 4); // 我们执行了外层函数 
fn() // 输出 25

这个例子,满足了,我们刚才提到的三个要素 ,再看看这个!

function sumsqu(a, b) {
    
    let fn = function() {
        return a*a + b*b;
    }
    return fn();
}

sumsqu(3, 4) // 25

虽然这段代码有两个嵌套的函数 ,但内部函数 fn 定义 后就立即执行了,所以并不会形成闭包。

再深入一点

那有的小伙伴说,你说的这三个要素 我早就知道了!闭包了解这么多应该够了吧!曾经我也是这么理解的,直到有一天,面试官问我闭包的原理😂 !

要了解闭包的原理,我们要引入一些概念, 作用域, 静态作用域,执行上下文,有了这些概念,我们再分析代码的执行流程,就更加清晰了

1. 作用域

一句描述作用域:作用域是标识符的查找范围。 什么是标识符:变量名和函数名。 在JS中支持三种作用域 分别是 全局作用域函数作用域块级作用域

举个栗子🌰:

    let a = 100;  // 1
    function log() { // 2
        {
            let b = 10; // 3
        }  
        let c = 12;    // 4
        console.log(a+b+c); // 5
    }
    log(); 

我在代码中加上 数字的注释,方便说明执行的流程:

  1. 这段代码中我们 定义了a 和 函数 log 他们是位于全局作用域中的,可以把全局作用域 想象成一个大的盒子
  2. 最后一行我们调用了 log 函数 ,会进行一个标识符查找 ,因为这个log()位于代码最外层, 所以他的查找的范围是 全局作用域,可以很快的找到 函数 log
  3. 下面执行流程进入 log 函数内部 ,在 3 的地方 我们定义了一个 b = 10 ,它位于一对大括号中,这样会形成一个新的作用域(块级作用域),就是就是一个新的小盒子,这个盒子中,只有一个 b , 这个 b 对盒子外的代码 是不可见的
  4. 在 4 的地方, 我们定义了一个变量 c ,很明显,他定义在 函数log 作用域内, c的可访问范围就是这个函数
  5. 在 5 的地方,稍微有一点复杂,首先这是一个函数调用语句 ,js引擎要先查找 console 标识符,在 函数 log 中并没有出现过,于是 js 引擎会向上查找,进入全局作用域, 在全局作用域 console 是内置对象,直接返回,接着继续查找 log 属性,也成功找到。 a 的 查找过程与console 类似
  6. 注意 b 是无法找到的,这段代码最终是会报错,因为标识符查找,只会向上级查找

image.png

2. 静态作用域与作用域链

通过上面的分析,我们不难发现:通过分析代码中的函数和变量定义的位置,就可以清晰地知道代码中的各个作用域。不论我们何时调用log(), 也是遵循这些确定好的规则 ,所以: JS中作用域只和代码结构有关与何时运行没有关系 ,比如lodash这类库,这个工具函数是由使用者执行的。 且在代码中各个作用域是可以相互嵌套, 标识符的查找正是基于这些嵌套的作用域进行由内向外进行的,最后到达全局作用域, 这个全局作用域,一般是是由JS的运行环境 提供的,比如浏览器中的全局作用域中有, window, atob(), Promise(), fetch() 等!

关于作用域的嵌套请看下图:

image.png

来个小结:

  1. 我们把这种基于代码结构分析 得到作用域的机制,叫 静态作用域也叫 词法作用域, 它是由JS引擎,按ECMAScript-262 标准来实现的
  2. 我们把 作用域嵌套 和 标识符由内向外的查找规则 ,叫 作用域链, 也就是标识符的每次查找都会由内向外在作用域链上查找

3. 静态作用域与闭包的关系

那你讲了半天的作用域和我闭包有什么关系🙄!

在我看来:闭包,是用来实现静态作用域的一种手段! JavaScript 的标准TC39委员会那波人制定的,而具体实现是写 JavaScript 引擎的那波人! 我仿佛听到他们的对话,“标准我们已经制定出来了,怎么实现就看你们的了!”

我们回到文章开头,闭包的例子!

function createFunc(a, b) {
    let a2 = a*a;
    let b2 = b*b;
    
    function getSum() {
        return a2+b2; // 访问外层函数的局部变量
    }
    
    console.dir(getSum) // 打印 函数  getSum
    
    return getSum;
    
}


let fn  = createFunc(3, 4); // 我们执行了外层函数 
fn() // 输出 25

基于我们刚提到的静态作用域的知识 ,简单分析一下

  1. 这块代码中有两个作用域 createFunc 函数 作用域,和 getSum 函数作用域,两个作用域相互嵌套
  2. getSum 函数中访问了 外层作用域中的 a2b2 两个变量
  3. 依据 作用域链的规则 ,在函数getSum 中 就应该可以访问 a2b2
  4. createFunc 执行结束后,getSum 并没有执行
  5. 一般来讲,函数中的变量,会在函数执行完成后,释放掉!但有一种情况例外
  6. 为了实现第三点,我们必需找一个地方把 a2b2 存在起来,在函数 getSum 调用时使用,只有这样才符合 JavaScript 静态作用域的规则

那到底存在哪儿呢?其实在函数 getSum 存在一个内部属性 [[Scopes]],在Chrome 中运行运行上面这段代码 瞅瞅:

image.png

可以看到图片的 Scopes 是一个类似数组的东西, 第一项是 Closure(createFunc) 里边两个值 a2=9 b2=16, 看这名字难道是由 createFunc() 函数创建的闭包,挂在了 getSum 函数上。

对没错!是就是这样

image.png

感觉越来越接近原理了呢!

那我们给闭包重新 下一个定义 吧:

闭包是实际上是 挂在函数上的一组没有释放的变量(或内存区域),在这个函数执行时使用!

还有几点要说:

  1. 闭包的是在对 函数进行 词法分析 时创建的,为了节约内存,闭包中只保留必要的值,也就是在这个函数执行时要用到的变量!

闭包的运用

1. 防抖和节流

贴一代码 ,大家感受一下

// 防抖
function debounce(fn, delay = 300) {
  let timer;
  return function () {
    const args = arguments;
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// 节流
function throttle(fn, delay) {
  let flag = true;
  return function () {
    let args = arguments;
    if (!flag) return;
    flag = false;
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

2. 生成连续ID

let uniqueId = function() {
    let start = 0;
    return function (prefix = 'id_') {
        let id = ++start;
        if(prefix === 'id_') {
            return  `${id}`;
        }
        return `${prefix}${id}`
    }
}();

闭包的使用场景 还有很多,要注意的是如一个闭包函数 ,就用不了就及时释放掉,以免过多消耗内存, 将闭包函数 赋值为 null 可以释放!

总结

  1. 闭包的本质是一组没有释放的变量(或内存区域),并且在函数被执行时,加到入执行上下文中!
  2. 闭包产生原因:因为 JavaScript 遵循静态作用域规则, 为了保证函数在执行时可以访问外层作用域的变量,而形成的一种实现机制

参考

  1. 《你不知道的 JavaScript 上》
  2. Closures - JavaScript | MDN
  3. 面试官:说说作用域和闭包吧
  4. JavaScript 的静态作用域链与“动态”闭包链

最后

感谢你读到这里, 希望你读完这篇文章对闭包有更进一步的了解 !

如果觉得有所收获 ,点个赞,再走吧~~