- Published on
JavaScript 作用域与闭包详解
- Authors
- Name
- 青雲
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 引入了 let
和 const
,使得在块级(大括号 {}
)中定义的变量有块级作用域。
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
构造函数的私有变量,只能通过 increment
和 decrement
方法进行访问和修改。
模块化
闭包可以用来实现模块模式,封装变量和函数,防止全局命名污染。
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]();
});
在这种情况下,我们为每个数据元素创建了一个闭包,因此:
- 内存占用:我们为
dataSet
中的每个元素创建了一个闭包,每个闭包都有自己的count
变量。如果dataSet
非常大,我们最终将为每个数据元素保留大量的闭包和变量,导致内存使用量急剧上升。 - 垃圾收集频繁:随着闭包数量和频繁的创建和销毁(假设闭包在一次操作后不再需要),垃圾收集器需要工作得更加频繁来清理不再使用的闭包和私有变量。这可以增加CPU的工作负载,并可能导致性能下降。
- 作用域链查找:每当闭包内的
count
变量被访问时,JavaScript引擎必须遍历闭包的作用域链,直到找到count
变量。在数据处理量大且闭包使用频繁的情况下,这种作用域链的查找会导致可观察的性能瓶颈。
为了解决上述的闭包性能问题,可以使用以下策略:
- 减少闭包数量: 评估是否真的需要为每个数据元素创建一个闭包。对于计数器,可能只需要一个计数器就足够了,而不是一个数据元素一个闭包。
- 使用其他数据结构: 使用普通对象或数组来保持计数可能更加高效,即使这意味着无法使用闭包来创建真正的私有变量。
- 优化算法: 如果数据处理算法可以优化,那么对闭包的依赖可能会减少。
面试实战
题目一:解释 JavaScript 中的作用域?
答案: 在 JavaScript 中,作用域是变量和函数的可访问范围。主要有以下几种:
- 全局作用域:在最外层定义的变量和函数可以在任何地方访问。
- 函数作用域:在函数内部定义的变量和函数只能在该函数内部访问。
- 块级作用域:ES6 引入的
let
和const
创建的变量存在于块级作用域中,只有在块内部可以访问。
题目二:解释 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
变量,因此 counter1
和 counter2
维护各自的状态。
题目四:函数作用域
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);
}