「JavaScript深入」彻底理解JS中的闭包

发布于:2024-10-11 ⋅ 阅读:(30) ⋅ 点赞:(0)


一、概念

闭包简单来说就是引用了另一个函数作用域中变量的函数

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


二、示例

//函数作为返回值
function test() {
  const a = 1;
  return function() {
    console.log('a: ',a);
  }
}

const fn = test();
const a = 2;
fn();// a: 1

通俗来说,a这个自由变量查找的规则,它会在函数定义的地方去向上一层查找它的值,而不会在函数执行的地方向上一层去查找

//函数作为参数
function test(fn) {
  const a = 1;
  fn();
}
const a = 2;
function fn() {
  console.log('a:',a);
}
test(fn);//a: 2

依然是上述通俗的定义,在函数定义处查找变量a的值


三、实用的闭包

🤔 假如,我们想在页面上添加一些可以调整字号的按钮。

function makeSizer(size){
  return function() {
    document.body.style.fontSize = size + 'px';
  }
}
var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;

从本质上将,makeSizer 是一个函数工厂,他创建了将字体调整至指定大小的函数,上面的示例中,我们使用函数工厂创建了三个新函数,分别将字体大小调制12、14、16px
size12size14size16 都是闭包,它们共享相同的函数定义,但是保存了不同的词法环境,在size12中,size为12,其他同理。


四、用闭包模拟私有方法

Java支持将方法声明为私有,即它们只能被同一个类中的其他方法调用,而JavaScript没有这种原生支持,但我们可以使用闭包来模拟私有方法。

我们定义了一个匿名函数,用于创建一个计数器,我们立即执行了这个匿名函数,并将他的值赋给了变量Counter。我们可以把这个函数储存在另一个变量makerCounter中,并用他来创建多个计数器

var Counter = (function () {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function () {
      changeBy(1);
    },
    decrement: function () {
      changeBy(-1);
    },
    value: function () {
      return privateCounter;
    }
  }
})();

console.log(Counter.value()); // 0
Counter.increment();
Counter.increment();
console.log(Counter.value()); // 2
Counter.decrement();
console.log(Counter.value()); // 1

在之前的示例中,每个闭包都有它自己的词法环境,而这次我们只创建了一个词法环境,为三个函数所共享:Counter.incrementCounter.decrementCounter.value

上面使用了立即执行函数表达式(IIFE)的相关内容,使用立即执行函数表达式好处有下

  • 避免变量污染(命名冲突),例如不同的第三方库恰好使用了同一个变量名称
  • 隔离作用域
  • 提高性能(减少对作用域的查找)

(在ES6前,JS原生并没有块级作用域的概念,所以IIFE可以用函数作用域来模拟块级作用域)

每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境。然而在一个闭包内对变量的修改,不会影响到另外一个闭包中的变量


五、一个常见错误:在循环中创建闭包

在ECMAScript 2015引入 let 关键字之前,在循环中有一个常见的闭包创建问题

function showHelp(help) {
  document.getElementById("help").innerHTML = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function () {
      showHelp(item.help);
    };
  }

}

setupHelp();

这里赋值给 onfocus 的是闭包,在循环中创建了三个闭包,但它们共享了一个词法作用域,在这个作用域中存在一个变量 item。因为变量 item 使用var进行声明,由于变量提升,所以具有函数作用域。当 onfocus 的回调执行时,由于循环早已执行完毕,item 已经指向了 helpText

有了 let 就不会有这样的问题(块级作用域)

🌰 另一个经典例子-定时器与闭包

for(var i = 1; i <= 5; i++){
  setTimeout(function(){
    console.log(i + '');
  },100)
}

按照预期它应该依次输出 1 2 3 4 5,而结果它输出了五次5,同上理,在 setTimeout 的回调函数开始在 Callback Queue 中依次执行时,循环早执行解释,i的值为5,而这回调函数的五个闭包共享一个词法作用域

使用 let 关键字,按照预期依次输出 1 2 3 4 5

for(let i = 1; i <= 5; i++){
  setTimeout(function(){
    console.log(i + '');
  },100)
}

不用 let 关键字修改如下

for(var i = 1; i <= 5; i++){
  (function(number) {
    setTimeout(function(){
      console.log(number);
    },100)
  })(i)
}

六、优劣

好处

  • 保护函数内的变量安全,实现封装,防止变量流入其他环境发生命名冲突,避免全局变量污染
  • 在内存中维持一个变量,可以做缓存,延长变量的生命周期
  • 匿名自执行函数可以减少内存消耗

坏处

  • 内存泄漏:由于闭包中的函数引用了外部函数的变量,而外部函数的作用域在函数执行结束后并不会被销毁,这就导致了闭包函数中的变量也无法被销毁,从而占用了内存空间。如果闭包被滥用,可能会导致内存泄漏的问题。
  • 性能问题:闭包中的函数访问外部函数的变量需要通过作用域链来查找,而作用域链的长度决定了查找的速度。如果闭包层数较深,作用域链就会很长,从而影响了函数的执行效率。

解决

  • 及时释放闭包:如果不再需要使用闭包,可以手动将其赋值为 null,从而释放闭包中占用的内存空间。
  • 减少闭包层数:尽量减少闭包层数,避免作用域链过长,从而提高函数的执行效率。
  • 使用立即执行函数:可以使用立即执行函数来避免闭包的内存泄漏问题。由于立即执行函数在执行结束后会被立即销毁,因此其中的变量也会被释放。
  • 使用模块化编程:可以使用模块化编程来避免闭包的性能问题。在模块化编程中,每个模块都是一个独立的作用域,不会对全局作用域造成影响,从而避免了作用域链过长的问题。

七、图解闭包

在这里插入图片描述


八、应用 💪

封装私有变量

👇 闭包可以用于创建具有私有成员的对象。通过将变量放在闭包中,使之对外不可见。

function createCounter() {
  let count = 0;

  return {
    increment: function() {
      count++;
    },
    getValue: function() {
      return count;
    }
  };
}

const counter = createCounter();
counter.increment();
console.log(counter.getValue()); // 输出 1

函数工厂

👇 如下,makeSizer是一个函数工厂,他创建了将字体调整至指定大小的函数。

function makeSizer(size){
  return function() {
    document.body.style.fontSize = size + 'px';
  }
}
var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;

异步操作中的回调函数

👇 在异步编程中,回调函数通常是闭包,因为它们可以访问其定义时的上下文,这对于保存状态和数据非常有用。

function fetchData(url, callback) {
  // 异步操作获取数据
  setTimeout(function() {
    const data = /* 获取的数据 */;
    callback(data);
  }, 1000);
}

fetchData('https://example.com/api', function(data) {
  console.log(data);
});

setTimeout 内部延迟执行的「匿名回调函数」可以访问外部函数fetchData的词法作用域,引用了其中变量callback,因此形成了闭包

柯里化(封装函数)

// 多参数柯里化
const curry = function(fn){
  return function curriedFn(...args){
    if(args.length<fn.length){
      return function(){
        return curriedFn(...args.concat([...arguments]));
      }
    }
    return fn(...args);
  }
}
const fn = (x,y,z,a)=>x+y+z+a;
const myfn = curry(fn);
console.log(myfn(1)(2)(3)(1));

引申

手写一个闭包?举个闭包的例子?