likes
comments
collection
share

「重学JS」带你一文吃透作用域与闭包

作者站长头像
站长
· 阅读数 12

前言

学习了这么久前端,发现自己对于基础知识的掌握并没有那么通透,于是打算重新学一遍JS,借用经济学的一句话:JS基础决定能力高度🤦🏻

基础很重要,只有基础好才会很少出 bug,大多数的 bug 都是基础不扎实造成的

今天我们来看一下闭包与作用域

闭包与作用域可以说是JS基础中的基础,工作的时间久了发现自己连这两个知识点也差不多快忘了,于是打算重新学习下

从一道题说起

JS函数结果缓存

写一个函数memorize,实现以下需求

前提: 可以保证被缓存函数一定有返回值(非undifined) 1.缓存函数执行结果 例子:

 function getRandom(params) {
   return parseInt(Math.random() * 10 + params)
 }
 const getMemeResult = memorize(getRandom)
 getMemeResult(1) // 3 假设getResult2第一次执行结果为3
 getMemeResult(2) // 5 重新执行getResult2得到结果为5
 getMemeResult(1) // 3
 getMemeResult(2) // 5
 ​
 function memorize(fn){
    /* coding here */
 }

大家看到这道题有什么想法?

好了先按下解题,先跟着我一起来学下闭包,当你学完闭包后你就会发现思路打开了,会发现题目原来轻而易举。

什么是闭包?

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

其实在JavaScript中闭包无处不在,你只需要能够识别并拥抱它,很多时候你的代码里写了很多的闭包,但是你自己也没有意识到,比如

 function wait(message, time) {
   setTimeout(() => {
     console.log(message)
   }, time * 1000)
 }
 wait('hello, closure', 1)

这就是我们日常使用中的闭包,或许你会觉得很奇怪,为啥这也是闭包啊?

我们知道闭包就是可以从内部函数访问外部函数的作用域,所以这里我们得先学习一下什么是作用域以及作用域的功能是什么。

作用域

作用域是根据名称查找变量的一套规则,通俗来讲就是在执行代码阶段如何找到这个变量的一套法则。

举个例子🌰

 function add(b) {
   return a + b;
 }
 const a = 2;
 add(3);
  1. 这段代码执行时会在全局作用域内声明函数add函数、变量a,然后在执行add函数
  2. add函数内,引擎会问add作用域,是否存在a
  3. add作用域说没听过,你去问问别人吧
  4. 于是引擎继续从add的作用域往上查找,发现找到全局作用域,于是问全局作用域是否有见过a,我需要使用它
  5. 全局作用域说,在我这里,给你吧,于是把a变量给到引擎,引擎继续执行代码

作用域在使用的过程中通常都是嵌套的,比如上面的全局作用域中嵌套了add作用域

什么时候发生作用域嵌套呢?

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。

嵌套作用域的查找规则是什么呢?

引擎在执行时需要查找某个变量时,如果在当前的作用域内无法找到这个变量的时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或者抵达全局作用域为止(如果还没找到就抛出ReferenceError异常)

简单画一个作用域查找规则

「重学JS」带你一文吃透作用域与闭包

JS中使用的作用域是词法作用域,也就是编译器在词法解析时生成的作用域。

接下来我们看看词法作用域是怎么生成的。

词法作用域

词法作用域就是定义在词法阶段的作用域,也就是词法作用域是由你写代码时将变量和块作用域写在哪里来决定的

当然也会有一些欺骗词法作用域的方法(比如evalwith),我们这里不做重点介绍。

 function foo(a) {
   const b = a * 2;
   function bar(c) {
     console.log(a, b, c);
   }
   bar(b * 2);
 }
 foo(3);
  1. 首先解析的时候包含着整个的全局作用域,其中包含一个标识符:foo
  2. 解析 foo 函数的时候包含着 foo 所创建的作用域,其中包含三个标识符:abbar
  3. 解析 bar 函数的时候包含着 bar 所创建的作用域,其中包含一个标识符:c

「重学JS」带你一文吃透作用域与闭包

每个作用域创建由其对应的作用域块代码写在哪里决定的,它们是逐级包含的

每个作用域查找时会由当前作用域开始查找,一直往上级作用域去查找

这里你可能会问如果同一个变量存在两个作用域内怎么查找?

我们知道,作用域查找会在找到第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。

但是核心就是一点作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止

当然还有一个问题那就是如果在内层作用域访问 window 会怎么查找呢?

如果直接访问 window 上的变量就会直接从全局作用上找到该变量,而不会通过嵌套作用域查找,可以访问那些被同名变量被遮蔽的全局变量。

 const a = 13;
 function foo() {
   const a = 12;
   console.log(a);
   console.log(window.a);
 }
 foo();
 // 12 undefined

我们上面所看的都是函数产生的函数作用域。

函数作用域是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用),外部作用域无法访问包装函数内部的任何内容。

块作用域

块级作用域通常在一个代码块里面,比如一个for循环代码块,一个 if 代码块,一个 try catch代码块等。

块级作用域的作用是让变量的声明应该距离使用的地方越近越好,并最大限度地本地化。简单来说需要的变量需要在块作用域内声明并且只能在块作用域内使用

当然块级作用域内查找变量的规则也同作用域的查找规则

块级作用域搭配letconst,它们可以将可以将变量绑定到所在的任意作用域中(通常是{ .. }内部)。通俗易懂的说就是为其声明的变量隐式地劫持了所在的块作用域

 var foo = 1;
 if (foo) {
   let bar = foo * 2;
   console.log(bar); // 2
 }
 console.log(bar); // ReferenceError: bar is not defined

所以这里推荐大家都要使用letconst,而不要去使用var,因为var会进行变量提升,在块级作用域声明的变量会提升到全局作用域内。

 var foo = 1;
 if (foo) {
   var bar = foo * 2;
   console.log(bar); // 2
 }
 console.log(bar); // 2

闭包

闭包的定义以及使用我们在上面已经讲过了。

那么为什么会产生闭包?

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

来看一个最简单的闭包

 function foo() {
   let a = 2;
   function bar() {
     console.log(a);
   }
   return bar;
 }
 const baz = foo();
 baz(); // 2

在上面的例子里 bar的作用内访问了 foo作用域内的变量,并在 foo函数内返回了bar函数,并在foo函数执行完后赋值给baz变量,并调用了baz,实际上就是通过不同的标识符引用以及调用了内部函数bar

这里的baz变量显然是能够执行的,并且执行的bar函数已经脱离的其所在的foo作用域

通常在foo函数执行完后,foo函数的整个内部作用域都被销毁(因为引擎的垃圾回收机制,将不需要使用的内存空间回收),但是闭包的作用就是会阻止垃圾回收 foo 的整个作用域,因为bar本身还在使用foo作用域。

因此bar保留了foo作用域,使得这个作用域能够一直存活,以供bar()函数在后续任何时间进行引用

bar()依然持有对foo内部作用域的的引用,就叫做闭包

因此在后来的baz的函数调用(实际上调用的是 bar()),它可以访问foo的完整作用域,因此也能访问到a

因此我们可以知道不仅仅是返回一个函数就是闭包。也有其他使用的方法。

 // 比如这样放到全局作用域上
 let fn;
 function foo() {
   let a = 2;
   function bar() {
     console.log(a);
   }
    fn = bar;
 }
 foo();
 ​
 fn();

好了,这里我们回到上面那个例子

 function wait(message, time) {
   setTimeout(() => {
     console.log(message)
   }, time * 1000)
 }
 wait('hello, closure', 1)
  1. 首先wait函数内部作用域内有messagetime两个变量
  2. 当执行wait函数时,将一个匿名箭头函数传递给setTimeout, 这个匿名箭头函数保留了wait()作用域的闭包,因此还保留着对message的引用
  3. wait函数执行的 1000 ms 后,它的内部作用域并不会消失,匿名箭头函数依然保持了对wait()作用域的闭包。

这就是闭包。

闭包的作用

  1. for 循环延迟函数输出问题

     for (var i = 1; i <=5; i++) {
       setTimeout(() => {
         console.log(i); // 6 6 6 6 6
       }, i * 1000)
     }
    

    这个问题我们一秒就知道,是因为在块级作用域内声明了变量i,而setTimeout函数内的匿名箭头函数因为闭包保留了块级作用域内i的引用,所以当过了对应的 time 后去输出i,但是因为是延迟输出,此时块级作用域内的i已经变成了 6 ,而输出所以都变成了 6。

    解决方法:

    • 可以通过一个 IIFE 函数去创建一个函数作用域,并将对应的 i 传给这个作用域,让匿名箭头函数保留这个函数作用域内的 i的引用即可。

    • 直接使用let声明,劫持块级作用域,并且在块级作用域内声明一个变量。

       for (let i = 1; i <=5; i++) {
         setTimeout(() => {
           console.log(i); // 1 2 3 4 5
         }, i * 1000)
       }
      

      for 循环的 let 声明会在每一次循环的时候都被重新声明,并且每个迭代都会使用上一个迭代结束时的值来初始化这个变量

  2. 模块

    我们要提供一个模块供别人使用,要解决命名空间污染的问题。

    命名空间污染:模块要用多个变量,我们希望变量不影响全局,全局也不影响我们的变量。

     // 闭包实现模块化。
     const moduleA = (function (global) {
       const methodA = function() {};
       const dataA = {};
       return {
         methodA,
         dataA
       };
     })(window);
    
  3. 模拟私有属性

     // 模拟_name的私有属性
     function Test(name) {
       let _name = name;
       const getName = () => _name;
       return {
         getName
       }
     }
     let obj = new Test('test');
     console.log(obj.getName()); // test
     console.log(obj._name); // undefined
    
  4. 高阶函数的使用

     // 节流
     function throttle(fn, delay) {
       let timer = null
       return function (...arg) {
         if(timer) return
         timer = setTimeout(() => {
           fn.apply(this, arg)
           timer = null
         }, delay)
       }
     }
     // 防抖
     function debounce(fn, delay) {
       let timer = null;
       return function (...arg) {
         if (timer) clearTimeout(timer);
         timer = setTimeout(() => {
           fn.apply(this, arg)
         }, delay)
       }
     }
    

    使用闭包的注意点

    1. 由于闭包不会销毁作用域,使得作用域内的变量都还保存在内存中,增加内存的使用,可能会造成内存泄漏,所以不要滥用闭包。
    2. 闭包会在父函数外部,可能会改变父函数内部变量的值。因此要谨慎操作父函数内的值。

好了,回到开头,或许你现在已经知道了memorize的实现了。不妨现在自己就先试试。

 function getRandom(params) {
   return parseInt(Math.random() * 10 + params ?? 0)
 }
 function memorize(fn){
   let cache = {};
   // 闭包 缓存数据
   return (...args) => {
     let key = JSON.stringify(args);
     if (cache[key]) {
       return cache[key];
     }
     return (cache[key] = fn(args));
   }
 }
 const getMemeResult = memorize(getRandom)
 console.log(getMemeResult(1)); // 8
 console.log(getMemeResult(2)); // 7
 console.log(getMemeResult(1)); // 8
 console.log(getMemeResult(2)); // 7
 console.log(getMemeResult()); // 2
 console.log(getMemeResult()); // 2

总结

  1. 从一闭包题目切入,介绍了闭包的以及日常工作中可能用到的闭包场景。
  2. 介绍了作用域嵌套作用域以及作用域内的查找规则。
  3. 讲解了词法作用域的创建以及如何查找。
  4. 讲解了块级作用域的产生以及用法。
  5. 重点讲解了 闭包以及 闭包的作用闭包的使用注意点

最后,如果本篇文章对大家有帮助的话,希望大家能够点个赞点个关注,鼓励下作者,感谢。

如果你想了解 JS 的数据类型,可以看这篇文章「重学JS」你真的懂数据类型吗?