Published on

JavaScript 作用域与闭包详解

Authors
  • avatar
    Name
    青雲
    Twitter

JavaScript 作为一种动态语言,其作用域和闭包不仅在编码实践中至关重要,更是很多面试考察的常见内容。本文将详细解释 JavaScript 中的作用域和闭包,从基础概念到实际应用,帮助你深入理解这两个关键概念。

什么是作用域?

作用域(Scope)是指代码中变量和函数的可访问范围。在 JavaScript 中,主要有三种作用域:

全局作用域

在最外层定义的变量和函数属于全局作用域。这些变量和函数在任何地方都可以访问。

var globalVar = "I am global";

function globalFunction() {
  console.log(globalVar);
}

globalFunction(); // 输出:I am global

函数作用域

在函数内部定义的变量和函数,只能在该函数内部访问,这就是函数作用域。JavaScript 的函数作用域在函数定义时就已经确定,与后面的运行无关。

function myFunction() {
  var localVar = "I am local";
  console.log(localVar); // 输出: I am local
}

myFunction();
console.log(localVar); // 报错: localVar is not defined

块级作用域

在 ES6 之前,JavaScript 只有全局作用域和函数作用域。而 ES6 引入了 letconst,使得在块级(大括号 {})中定义的变量有块级作用域。

if (true) {
  let blockVar = "I am block scoped";
  console.log(blockVar); // 输出: I am block scoped
}

console.log(blockVar); // 报错: blockVar is not defined

blockVar 只在块级作用域内部是可访问的,而在全局作用域中访问会报错。

作用域链

当执行一个变量时,JavaScript 引擎会从当前的作用域开始查找变量,如果没有找到,则继续向上查找,直到找到该变量或达到全局作用域。这种查找过程形成的链条称为作用域链。

var globalVar = "global";

function outerFunction() {
  var outerVar = "outer";

  function innerFunction() {
    var innerVar = "inner";
    console.log(innerVar); // 输出: inner
    console.log(outerVar); // 输出: outer
    console.log(globalVar); // 输出: global
  }

  innerFunction();
}

outerFunction();

下图显示了innerFunction 如何通过作用域链访问外部函数变量(outerVar)和全局变量(globalVar)。

什么是闭包

闭包(Closure)是指在函数内部定义的函数可以访问其外部作用域的变量。即使在外部函数执行完毕后,内部函数依然可以访问外部函数的变量。

基本概念

function outerFunction() {
  var outerVar = "I am outside!";

  function innerFunction() {
    console.log(outerVar); // 输出: I am outside!
  }

  return innerFunction;
}

const closureFunction = outerFunction();
closureFunction(); // 调用内部函数

在这个例子中,innerFunction 作为 outerFunction 的内嵌函数,形成了一个闭包。即使 outerFunction 已经返回,innerFunction 依然可以访问到 outerVar

闭包的作用

闭包在 JavaScript 中有很多实际应用,包括模块化和数据隐藏。

数据隐藏

闭包可以用来创建私有变量,从而实现数据隐藏。

function Counter() {
  var count = 0;

  this.increment = function() {
    count++;
    console.log(count);
  };

  this.decrement = function() {
    count--;
    console.log(count);
  };
}

const myCounter = new Counter();
myCounter.increment(); // 输出: 1
myCounter.increment(); // 输出: 2
myCounter.decrement(); // 输出: 1

在这个例子中,count 变量是 Counter 构造函数的私有变量,只能通过 incrementdecrement 方法进行访问和修改。

模块化

闭包可以用来实现模块模式,封装变量和函数,防止全局命名污染。

const Module = (function() {
  var privateVar = "I am private";

  return {
    getPrivateVar: function() {
      return privateVar;
    },
    setPrivateVar: function(value) {
      privateVar = value;
    }
  };
})();

console.log(Module.getPrivateVar()); // 输出: I am private
Module.setPrivateVar("I am new private");
console.log(Module.getPrivateVar()); // 输出: I am new private

常见误区和陷阱

循环和闭包

在使用 for 循环生成闭包时,常常会遇到意想不到的结果,这是由于所有闭包共享同一个词法环境造成的。

for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// 输出: 3, 3, 3

上述代码的输出是 3, 3, 3,因为每个闭包共享同一个 i 变量。可以通过 let 或创建一个立即执行函数表达式(IIFE)来解决。

for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// 输出: 0, 1, 2

// or 使用IIFE
for (var i = 0; i < 3; i++) {
  (function(i) {
    setTimeout(function() {
      console.log(i);
    }, 1000);
  })(i);
}
// 输出: 0, 1, 2

使用 let 定义的 i 在每次循环迭代时形成了新的作用域,因此在 setTimeout 中能够正确输出迭代变量的值。

闭包导致内存泄露

闭包引用的外部变量会被保存在内存中,可能导致内存不被释放,最终引发内存泄漏。

function createFunction() {
  var largeData = new Array(1000000).join('*');
  
  return function() {
    console.log(largeData);
  };
}

const myFunc = createFunction();
// 即使不再使用 largeData,内存也不会被释放

所以要确保在不再需要闭包时,正确地释放引用的外部变量。

function createFunction() {
  var largeData = new Array(1000000).join('*');
  
  return function() {
    console.log(largeData);
    largeData = null; // 释放内存
  };
}

const myFunc = createFunction();
myFunc();

在循环中迭代 DOM 事件处理器失效

在循环中为 DOM 元素绑定事件处理器时,由于闭包的存在,常常会导致所有处理器都引用的是同一个变量。

const buttons = document.querySelectorAll('button');

for (var i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log('Button ' + i + ' clicked');
  });
}

// 无论点击哪个按钮,输出的都是: 'Button 3 clicked'

问题原因和陷阱一类似,可以使用 let 关键字或 IIFE 重写代码。

for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log('Button ' + i + ' clicked');
  });
}

闭包中的 this 绑定错误

闭包中的 this 是动态绑定的,容易在复杂回调中引用错误。

function Timer() {
  this.seconds = 0;
  
  setInterval(function() {
    this.seconds++;
    console.log(this.seconds);
  }, 1000);
}

const myTimer = new Timer();
// 输出: NaN, NaN, NaN...

可以使用箭头函数或 that 变量保持正确的 this 绑定,比如

function Timer() {
  this.seconds = 0;
  
  setInterval(() => {
    this.seconds++;
    console.log(this.seconds);
  }, 1000);
}

const myTimer = new Timer();
// 输出: 1, 2, 3...

使用闭包访问私有变量导致性能问题

在性能敏感的代码中,闭包中频繁访问私有变量可能会导致性能下降,因为闭包的上下文需要一直保存。 假设我们有一个函数,它用闭包来创建一个计数器。每次这个函数被调用时,它都会返回一个新的闭包,这个闭包会访问和增加一个私有变量的值。

function createCounter() {
  let count = 0;
  return function() {
    count += 1;
    return count;
  };
}

现在,如果我们在一个性能敏感的环境中(例如,一个大数据集的每个元素都要经过这个计数器函数处理),你可能会编写类似如下的代码:

// 假设我们有一个大数据集
const dataSet = new Array(1000000).fill(/* 大数据集的内容 */);

// 对每个数据项创建一个计数器闭包
const counters = dataSet.map((data) => createCounter());

// 每次处理数据,都会调用对应的计数器闭包
dataSet.forEach((data, index) => {
  // 做一些复杂的处理...
  // 然后增加计数器
  counters[index]();
});

在这种情况下,我们为每个数据元素创建了一个闭包,因此:

  1. 内存占用:我们为dataSet中的每个元素创建了一个闭包,每个闭包都有自己的count变量。如果dataSet非常大,我们最终将为每个数据元素保留大量的闭包和变量,导致内存使用量急剧上升。
  2. 垃圾收集频繁:随着闭包数量和频繁的创建和销毁(假设闭包在一次操作后不再需要),垃圾收集器需要工作得更加频繁来清理不再使用的闭包和私有变量。这可以增加CPU的工作负载,并可能导致性能下降。
  3. 作用域链查找:每当闭包内的count变量被访问时,JavaScript引擎必须遍历闭包的作用域链,直到找到count变量。在数据处理量大且闭包使用频繁的情况下,这种作用域链的查找会导致可观察的性能瓶颈。

为了解决上述的闭包性能问题,可以使用以下策略:

  1. 减少闭包数量: 评估是否真的需要为每个数据元素创建一个闭包。对于计数器,可能只需要一个计数器就足够了,而不是一个数据元素一个闭包。
  2. 使用其他数据结构: 使用普通对象或数组来保持计数可能更加高效,即使这意味着无法使用闭包来创建真正的私有变量。
  3. 优化算法: 如果数据处理算法可以优化,那么对闭包的依赖可能会减少。

面试实战

题目一:解释 JavaScript 中的作用域?

答案: 在 JavaScript 中,作用域是变量和函数的可访问范围。主要有以下几种:

  • 全局作用域:在最外层定义的变量和函数可以在任何地方访问。
  • 函数作用域:在函数内部定义的变量和函数只能在该函数内部访问。
  • 块级作用域:ES6 引入的 letconst 创建的变量存在于块级作用域中,只有在块内部可以访问。

题目二:解释 JavaScript 中的闭包?

答案: 闭包是指那些能够访问自由变量的函数。自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。闭包可以记忆并访问其被创建时所在的词法作用域,即使该函数在其词法作用域外执行。闭包的用途包括数据封装和隐私、在回调中维持状态、以及创建函数工厂和模块等。

题目三:解释下代码输出

function createCounter() {
  let count = 0;
  
  return {
    increment: function() {
      count++;
      console.log(count);
    },
    decrement: function() {
      count--;
      console.log(count);
    }
  };
}

const counter1 = createCounter();
counter1.increment(); // ?
counter1.increment(); // ?
counter1.decrement(); // ?

const counter2 = createCounter();
counter2.increment(); // ?

答案:

  • counter1.increment() 第一次调用时,count 变为 1,输出: 1
  • counter1.increment() 第二次调用时,count 变为 2,输出: 2
  • counter1.decrement() 调用时,count 变为 1,输出: 1
  • counter2.increment() 调用时,是新的计数器对象,count 变为 1,输出: 1

每次调用 createCounter 都会创建一个新的 count 变量,因此 counter1counter2 维护各自的状态。

题目四:函数作用域

function foo() {
  var bar = "bar";
  function baz() {
    console.log(bar);
  }
  return baz;
}

var qux = foo();
qux(); // ?

答案: 输出: bar qux 是函数 baz,当调用 qux 时,仍然可以访问原始作用域链中的变量 bar,形成闭包。

题目五:下面的代码输出什么

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, i * 1000);
}

答案: 代码会打印5次数字5,间隔1秒钟。因为setTimeout中的函数是在循环结束后执行的,而这时var声明的变量i已经变成了5。

题目六:如何修改上面的代码,使其打印0到4的数字?

答案: 可以通过立即执行函数表达式(IIFE)来创建一个新的作用域,保存每次迭代时的变量值。

for (var i = 0; i < 5; i++) {
  (function(i) {
    setTimeout(function() {
      console.log(i);
    }, i * 1000);
  })(i);
}

或者使用let声明变量让其具有块级作用域。

for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, i * 1000);
}