一文搞懂到底什么是闭包[Javascript]

85 阅读6分钟

首先,开门见山,一个函数返回函数则会形成闭包。


接下来,我们一步一步介绍 Javascript 中闭包的原理及形成。

词法环境(Lexical Environment)

在 JavaScript 中的每个函数、代码块 {...} 及整个脚本,都存在一个被称为 词法环境 的内部关联对象。词法环境对象由两部分组成:

  1. 环境记录(Environment Record) : 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
  2. 外部词法环境 的引用,与外部代码相关联。

变量本质上是环境记录对象的属性。当我们访问或修改变量时,实际上是在操作词法环境的属性。

当函数被调用时,会自动创建一个新的词法环境,用于存储该调用的局部变量和参数。

在访问变量时,JavaScript 会按照从内到外的顺序依次搜索词法环境,直到找到目标变量或到达全局词法环境。

图1

返回函数

举个例子,我们做一个计时器,如下:

function makeCounter() {
    let count = 0;
​
    return function() {
        return count++;
    };
}
​
let counter = makeCounter();

所有函数都带有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用。函数在创建时会记住其所属的词法环境,从而能够访问外部变量。

当调用 makeCounter() 时,会创建一个新的词法环境,用于存储 makeCounter 函数运行时的变量。在这个例子中,makeCounter() 内部存在两层嵌套的词法环境:一层是记录了 count 变量的词法环境,另一层则是外部的全局词法环境。

counter 被赋值了返回的函数。因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。

当调用 counter() 时,会为该调用创建一个新的词法环境。之后,counter 在查找 count 变量时,它会先搜索自己的词法环境(此时为空),然后搜索外部的 makeCounter() 的词法环境,这时则可找到 count 变量,对其进行加法操作。

通过多次调用 counter()count 变量的值会不断递增,这正是闭包的典型表现。

闭包

闭包 是指一个函数可以记住其外部变量并可以访问这些变量。

换句话说,闭包是一个 “函数 + 词法作用域” 的组合。

闭包的基本特性

  1. 访问外部变量:闭包允许内部函数访问外部函数的局部变量。
  2. 变量的持久性:即使外部函数已经执行完毕,闭包中的内部函数仍然可以访问外部函数中的变量。

JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。

闭包的应用

定时器与延迟执行

在 JavaScript 中,setTimeoutsetInterval 是实现延迟操作的常用方法。通过闭包,可以轻松地让这些定时器记住当前的变量或状态。

例如,以下是一个简单的倒计时函数:

function countdown(seconds) {
  let timeLeft = seconds;
  const timer = setInterval(() => {
    console.log(timeLeft);
    timeLeft--;
    if (timeLeft <= 0) {
      clearInterval(timer);
      console.log("Time's up!");
    }
  }, 1000);
}
​
countdown(5);

在这个例子中,setInterval 的回调函数是一个闭包,它能够访问外部函数 countdown 中的 timeLeft 变量,从而实现倒计时功能。

防抖与节流(Debouncing & Throttling)

在处理高频事件(如滚动、窗口调整大小或按键事件)时,防抖和节流是优化性能的关键技术。闭包在这里发挥了重要作用,能够有效避免函数的频繁执行。

防抖示例

function debounce(func, delay) {
  let timer;
  return function() {
    if(timer) clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, arguments);
    }, delay);
  };
}
​
const log = debounce(() => console.log('Function executed!'), 1000);
​
window.addEventListener('resize', log);

在这个例子中,debounce 函数返回一个闭包。它确保只有在用户停止触发事件后的延迟时间内才会执行实际的回调函数,从而避免了不必要的重复调用。

回调函数与异步编程

在异步编程中,闭包可以保持对外部变量的访问,确保在异步操作完成时,能够正确地使用这些变量。

function fetchData(url) {
  let dataLoaded = false;
  fetch(url)
    .then(response => response.json())
    .then(data => {
      dataLoaded = true;
      console.log('Data fetched:', data);
    });
​
  return function() {
    if (dataLoaded) {
      console.log('Data has been loaded');
    } else {
      console.log('Loading data...');
    }
  };
}
​
const checkDataStatus = fetchData('https://api.example.com/data');
checkDataStatus(); // 可能输出: 'Loading data...'

在这个例子中,checkDataStatus 是一个闭包,它能够访问 fetchData 中的 dataLoaded 变量,从而在需要时检查数据是否加载完成。

装饰器模式

问题思考

假设有一个 CPU 重负载的函数 (运行时间十分长),但它的结果是稳定的。(换句话说,对于相同的输入,它总是返回相同的结果。)

如果经常调用该函数,我们可能希望将结果缓存(记住)下来,以避免在重新计算上花费额外的时间。

透明缓存

我们不将这个缓存功能添加到 同一个函数 中,而是创建一个包装器(wrapper) 函数,该函数增加了缓存功能。这样做有很多好处。

function slow(x) {
  // 这里可能会有重负载的 CPU 密集型工作
  alert(`Called with ${x}`);
  return x;
}
​
function cachingDecorator(func) {
  let cache = new Map();
​
  return function(x) {
    if (cache.has(x)) {    // 如果缓存中有对应的结果
      return cache.get(x); // 从缓存中读取结果
    }
​
    let result = func(x);  // 否则就调用 func
​
    cache.set(x, result);  // 然后将结果缓存(记住)下来
    return result;
  };
}
​
slow = cachingDecorator(slow);
​
alert( slow(1) ); // slow(1) 被缓存下来了,并返回结果
alert( "Again: " + slow(1) ); // 返回缓存中的 slow(1) 的结果alert( slow(2) ); // slow(2) 被缓存下来了,并返回结果
alert( "Again: " + slow(2) ); // 返回缓存中的 slow(2) 的结果

在上面的代码中,cachingDecorator 是一个 装饰器(decorator) :一个特殊的函数,它接受另一个函数并改变它的行为。

cachingDecorator(func) 的结果是一个“包装器”:function(x)func(x) 的调用“包装”到缓存逻辑中。

其思想是,我们可以为任何函数调用 cachingDecorator,它将返回缓存包装器。

通过将缓存与主函数代码分开,我们还可以使主函数代码变得更简单。

几个好处:

  • cachingDecorator 是可重用的。我们可以将它应用于另一个函数。
  • 缓存逻辑是独立的,它没有增加 slow 本身的复杂性(如果有的话)。
  • 如果需要,我们可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。

模块模式(Module Pattern)

闭包是实现 JavaScript 模块化开发的重要工具。通过闭包,可以将变量和方法封装在函数作用域内,避免污染全局作用域。

const module = (function() {
  let privateVar = 'I am private';
​
  function privateMethod() {
    console.log(privateVar);
  }
​
  return {
    publicMethod: function() {
      privateMethod();
    }
  };
})();
​
module.publicMethod(); // 输出: I am private

在这个例子中,privateVarprivateMethod 是私有的,外部代码无法直接访问它们。只有通过公开的 publicMethod 方法,才能调用 privateMethod,从而实现了模块的封装。

参考文章: