02 - js的作用域以及作用域链、闭包

62 阅读12分钟

作用域

  • 是一个变量或函数的可访问范围,作用域控制着变量或函数的可见性和生命周期

全局作用域

可以全局访问

  1. 最外层函数和最外层定义的变量拥有全局作用域
  2. Window 上的对象属性方法拥有全局作用域
  3. 为定义直接复制的变量自动声明也有全局作用域
  4. 过多的全局作用域会导致全局污染,命名冲突

函数作用域

只能在函数中访问

  1. 在函数中定义的变量,都只能在内部使用,外部无法访问
  2. 内层 作用域 可以访问外层,外层不能访问 内存 作用域
  3. 注意函数的参数也在函数作用域中

ES6中的块级作用域

只在代码块中访问使用

  1. 使用ES6新增的 let、const变量,具备块级作用域,可以在函数中创建(由{}包裹的代码都是)

  2. let、const声明的变量不会变量提升,const也不能重复声明

  3. 块级作用域主要用来解决由变量提升导致的变量覆盖问题

       for (let i = 0; i < 5; i++) {
         console.log(i);
       }
    
       console.log(i); // ReferenceError
       ```
    

词法作用域

函数被定义的时候,它的 作用域 就已经确定了,和它在哪里执行没有关系,因为词法作用域也称为‘静态作用域

  1. 举个例子

        var value = 1;
        function fn() {
            console.log(value);
        }
    
        function outFn() {
            var value = 2;
            fn()
        }
        outFn()   // 这里输出的是1而不是2
        ```
    
  2. 分析以上代码

    a. 共有3个作用域

    1.  全局作用域
    1.  fn的函数作用域
    1.  outFn的函数作用域
    

    b. fn的上层作用域是调用时所在的outFn作用域还是定义时所在的全局作用域

    1.  答案是 定义时的,词法作用域为静态作用域
    

作用域链

  • 当可执行代码内部访问变量时,会先查找本地作用域,如果找到目标即返回,否则会去父级作用域继续查找,直到找到全局作用域,把这种作用域的嵌套机制,称为作用域链
  1. 举个例子

    1. function foo(a) {
        var b = a * 2;
      
        function bar(c) {
          console.log( a, b, c );
        }
      
        bar(b * 3);
      }
      
      foo(2); // 2 4 12
      
  2. 在以上代码中

    1. bar内部作用域只能获取到变量c,而 a b 都是从外部foo函数的作用域中获取到的
    2. 三层作用域嵌套:全局、foo作用域、bar作用域

作用域应用场景

模块化(最常见)

  • 通过作用域,可以创建模块化的代码结构,防止变量污染全局命名空间
// 用函数隔离变量  
// 外部作用域无法访问到函数内部的a变量 但是module1和2函数本身已经对全局作用域造成了污染
function module1 () {
  var a = 1;
  console.log(a);
}
function module2 () {
  var a = 2;
  console.log(a);
}
module1(); // => 1
module2(); // => 2

// 优化: 立即调用函数表达式 解决模块名污染全局作用域的问题
(function () {   // module1.js
  var a = 1;
  console.log(a);
})();
(function () {   // module2.js
  var a = 2;
  console.log(a);
})();


// 继续优化: 赋予能够判断外部环境的权力
(function (global) {
  if (global...) {
    // is browser
  } else if (global...) {
    // is nodejs
  }
})(window);
  • 再举一个例子
// 模块1
var module1 = (function() {
  var privateVar = "I'm private";

  return {
    getPrivateVar: function() {
      return privateVar;
    }
  };
})();

// 模块2
var module2 = (function() {
  var privateVar = "I'm another private";

  return {
    getPrivateVar: function() {
      return privateVar;
    }
  };
})();
console.log(module1.getPrivateVar());  // 输出 "I'm private"
console.log(module2.getPrivateVar());  // 输出 "I'm another private"

避免命名冲突

  • 作用域帮助避免变量之间的命名冲突。不同作用域中可以使用相同的变量名而不会发生冲突
// 全局作用域
var count = 0;

function increment() {
  // 函数作用域
  var count = 1;
  console.log(count);  // 访问的是函数内部的 count
}

increment();
console.log(count);  // 访问的是全局的 count

闭包

  • 通过函数嵌套创建的,它可以访问外部函数作用域中的变量。这种机制允许变量保持在内存中,不受外部函数执行完毕的影响
function outer() {
  var outerVar = "I'm from outer";

  function inner() {
    console.log(outerVar);  // 闭包:inner 函数可以访问 outer 函数的作用域
  }

  return inner;
}

var closureFunction = outer();
closureFunction();  // 输出 "I'm from outer"

闭包

能够访问到其他函数的私有变量的现象,称为闭包

  1. 可以简单理解为:函数内部定义的函数,被返回了出去并在外部调用,访问到了外部函数中的变量

  2. 如上面的例子中,分析下代码运行流程

    1. 编译阶段,变量和函数被声明,作用域确定
    2. 运行outer(),此时创建一个outer函数的执行上下文,执行上下文内部存储了 outer中声明的所有变量函数信息
    3. 函数outer运行完毕,将内部的 inner函数 的引用赋值给外部变量 closureFunction,此时closureFunction指针指向的还是inner,因此closureFunction位于outer作用域外,还是能够获取到outer的内部变量
    4. closureFunction在外部被执行,closureFunction的内部可执行代码 console.log向作用域请求获取 outerVar 变量,本地作用域没找到,继续请求父级作用域,找到了 outer 中的 outerVar 变量,返回给console.log ,打印出 "I'm from outer"
  3. 闭包的执行看起来像是开发者使用的一个小小的 “作弊手段” ——绕过了 作用域 的监管机制,从外部也能获取到内部作用域的信息。闭包的这一特性极大地丰富了开发人员的编码方式,也提供了很多有效的运用场景

闭包的应用场景

大多数应用在需要维护内部变量的场景下

实现单例模式

  • 单例模式保证了一个类只有一个实例,实现方法:先判断实例是否存在,若存在,直接返回,不存在,创建之后再返回。好处是:避免了重复实例化带来的内存开销
// 实现单例实例
        function Singleton() {
            this.data = 'aSingleton'
        }

        Singleton.getInstance = (function () {
            let instance;
            return {
                createInstance: function () {
                    if (!instance) return instance = new Singleton()
                    else return instance
                }
            }
        })()

        // Singleton.getInstance = (function () {
        //     let instance;
        //     return function () {
        //         if (!instance) return instance = new Singleton()
        //         else return instance
        //     }
        // })()

        const a1 = Singleton.getInstance.createInstance()
        const a2 = Singleton.getInstance.createInstance()
        console.log(Singleton.prototype)    // {constructor: ƒ}
        console.log(a1 === a2);   // true
        console.log(a1.data);     // 'aSingleton'

私有属性

  • 可以利用闭包创建私有变量和方法,从而实现数据封装和隐藏
function creatCounter() {
            let count = 0;
            return {
                increamCount: function () {
                    count++
                },
                getCount: function () {
                    return count
                }
            }
        }
        let privateCount = creatCounter()
        console.log(privateCount.getCount());   // 0
        privateCount.increamCount()
        console.log(privateCount.getCount());   // 1
        console.log(privateCount.count);        // undefined

柯里化

什么是函数柯里化:一种将多个参数的函数转换为单个参数函数的技术

  1. 举例子理解柯里化

    1. 一个求两数之和的函数

          function add(a, b) {
              return a + b;
          }
          // 函数柯里化后
          function add(a) {
            return function(b) {
              return a + b;
            }
          }
          ```
      
      
    2. 求两数相乘并加上偏移量的函数

          function multiplyAndOffset(a, b, offset) {
            return a * b + offset;
          }
          // 柯里化后
          function multiply(a) {
            return function(b) {
              return function(offset) {
                return a * b + offset;
              }
            }
          }
          // 柯里化后可以这样子调用
          multiply(2)(3)(1); // 7
          ```
      
      
  2. 为什么需要函数柯里化

  • 有很多好处:

    • 函数复用:由于函数柯里化的特性,我们可以非常容易地为函数的一部分参数进行复用。例如,上面那个例子中的 multiply 函数,如果我们想要使用相同的 offset 参数,在调用时只需要传递两个参数就可以了

    • 延迟执行:柯里化使得函数的执行被延迟到最后一个参数被传递进来的时候再进行。这使得我们可以预先传递一些参数,并将剩余的参数留到稍后再决定,这在一些场景下非常有用,例如事件处理程序

      • 在这个例子中,curryEventHandler 是一个柯里化函数,它接受一个 DOM 元素和事件类型,返回一个新的函数。这个新函数接受两个参数:事件处理函数和事件选项。当调用 handleButtonClick 时,我们预先传递了事件处理函数 logClick 和事件选项 { message: 'Button clicked!' },而等到按钮被点击时,这些参数会被传递给事件处理函数,实现了延迟执行的效果
      • // 柯里化的事件处理程序
        function curryEventHandler(element, eventType) {
          return function(callback, options) {
            element.addEventListener(eventType, function(event) {
              callback(event, options);
            });
          };
        }
        
        // 创建一个柯里化的点击事件处理程序
        const handleButtonClick = curryEventHandler(document.getElementById('myButton'), 'click');
        
        // 预先传递事件处理函数,延迟执行
        const logClick = function(event, options) {
          console.log('Button Clicked!', event, options);
        };
        
        // 使用柯里化的事件处理程序
        handleButtonClick(logClick, { message: 'Button clicked!' });
        
    • 简单化函数:由于柯里化把多参数函数转化成了单参数函数,这使得函数接口更加简洁,更加容易使用

    • 函数组合:函数柯里化可以使得不同的函数更加容易组合起来使用,从而实现更加复杂的操作

  1. 如何实现函数柯里化

    本质就是通过闭包来返回一个函数,这个返回的函数会接收一个新的参数并返回一个新函数,这个新函数会再次接收一个参数并返回一个新的函数,以此类推直到最后一个参数被传递进来

            // 函数柯里化
                function add() {    // 存在一个问题 只能传递1个参数进行处理
                    let sum = 0;
                    return function innerSum(num) {
                        if (num !== undefined) {
                            sum += num
                            return innerSum
                        } else {
                            return sum
                        }
                    }
                }
                console.log(add()());
                console.log(add()(1)(2)());
                console.log(add()(3)(4)());
    
                // 优化函数柯里化
                function optimiseAdd(fn) {
                    // console.log(fn.length); 3
                    return function concatFn(...args) {
                        console.log(args);
                        if (args.length >= fn.length) {
                            // return fn.apply(this, args)
                            return fn(...args)
                        } else {
                            return function (...args2) {
                                console.log(this);  // window 在全局调用 隐式丢失
                                // return concatFn.apply(this, args.concat(args2))
                                return concatFn(...args.concat(args2))
                            }
                        }
                    }
                }
                const addThreeNum = (a, b, c) => a + b + c
                const addThreeNum = function (a, b, c) {
                    return a + b + c
                }  // 普通函数写法
                const addUnknowNum = optimiseAdd(addThreeNum)
                console.log(addUnknowNum(1)(2)(3));     // 6
                console.log(addUnknowNum(1, 2)(3));     // 6
                console.log(addUnknowNum(1, 2, 3));     // 6
                console.log(addUnknowNum(1, 2, 3, 4));  // 6
        ```
    
    

对一个函数取length

  • 普通函数:返回形参的数量
  • 箭头函数:(没有自己的arguents对象),因此没有length属性,箭头函数的length属性值实际上是其形参中不带默认值的参数的数量,带有默认值的参数不计入length

对以上代码分析

  1. 该函数的参数是一个函数 fn,它返回一个柯里化后的函数
  2. 在函数内部,定义了一个 curried 函数,作用是接受函数需要的所有参数
  3. 当传入的参数数量大于或等于原函数需要的参数数量时,就直接调用原函数并返回结果;否则,返回一个新函数,然后使用闭包将当前已经传入的参数保存下来。这个新函数再次接受一个参数,并将这个参数与之前已经保存的参数合并,然后递归调用 curried 函数

闭包的问题

  • 可能造成内存泄漏
  • 由于闭包使用过度而导致的内存占用无法释放的情况会造成内存泄漏

内存泄漏

在js中,内存泄漏通常指的是不再需要的对象仍然被引用,导致垃圾回收器无法释放这些对象,从而占用内存。内存泄露可能会导致应用程序卡顿或者崩溃

  • 原因有多种
  1. 闭包

    1.   举一个例子
        function foo() {
          var a = 2;
    
          function bar() {
            console.log( a );
          }
    
          return bar;
        }
    
        var baz = foo();
    
        baz(); // 这就形成了一个闭包
    
    
  • avascript 内部的垃圾回收机制用的是引用计数收集:即当内存中的一个变量被引用一次,计数就加一。垃圾回收机制会以固定的时间轮询这些变量,将计数为 0 的变量标记为失效变量并将之清除从而释放内存。
  • 上述代码中,理论上来说, foo 函数作用域隔绝了外部环境,所有变量引用都在函数内部完成,foo 运行完成以后,内部的变量就应该被销毁,内存被回收。然而闭包导致了全局作用域始终存在一个 baz 的变量在引用着 foo 内部的 bar 函数,这就意味着 foo 内部定义的 bar 函数引用数始终为 1,垃圾运行机制就无法把它销毁。更糟糕的是,bar 有可能还要使用到父作用域 foo 中的变量信息,那它们自然也不能被销毁... JS 引擎无法判断你什么时候还会调用闭包函数,只能一直让这些数据占用着内存
  1. 全局变量的无意创建

本来设计的局部变量,由于忘记写var、let等说明,导致变量被泄露到全局中

function foo() {
    b = 2;
    console.log(b);
}
foo(); // 2
console.log(b); // 2
  1. 未正确清理定时器

如果在调用 stopTimer 之前对象被销毁,定时器仍然在运行,可能导致内存泄漏。解决方法是确保在不再需要时清理定时器

function startTimer() {
  // 每秒执行一次操作
  this.timer = setInterval(() => {
    // 执行一些操作
  }, 1000);
}
function stopTimer() {
  clearInterval(this.timer);
}
  1. 事件监听未移除
     //如果在页面切换或组件销毁时未移除事件监听器,可能导致引用的对象无法被正确释放
     function addEventListener() {
       document.getElementById('someElement').addEventListener('click', () => {
         // 处理点击事件
       });
     }
     function removeEventListener() {  // 页面切换或组件销毁时,应该移除事件监听器
       document.getElementById('someElement').removeEventListener('click', /* handler */);
     }

     // 移除 DOM 元素前如果忘记了注销掉其中绑定的事件方法,也会造成内存泄露
     const wrapDOM = document.getElementById('wrap');
     wrapDOM.onclick = function (e) {console.log(e);};

     // some codes ...

     // remove wrapDOM
     wrapDOM.parentNode.removeChild(wrapDOM);
  1. 未释放 DOM 节点:

    操作 DOM 元素时,如果没有正确地释放对元素的引用,可能导致内存泄漏。确保在不需要时移除对 DOM 元素的引用

  2. 循环引用

    如果存在循环引用,即对象之间相互引用,而且这些对象都不再需要,可能导致内存泄漏。解决方法是在不再需要时断开引用关系

  • 如何排查

    • 最新的 JavaScript 引擎和浏览器通常具有先进的垃圾回收机制,可以自动处理大多数内存管理问题。然而,开发者仍然需要注意上述情况,确保及时释放不再需要的资源。使用工具如 Chrome DevTools 中的 Memory 面板可以帮助诊断和调试内存泄漏问题

参考文章

面试官:说说作用域和闭包吧 - 掘金

柯里化其实就是这么回事,别被高大上的名词骗了——拒绝“八股文” - 掘金