闭包与作用域
闭包(Closure)是 JavaScript 中最基础也是最重要的概念之一。理解闭包不仅能帮助你写出更优雅的代码,更是深入理解 JavaScript 作用域和内存管理的关键。
什么是闭包?
闭包是指一个函数与其词法环境(Lexical Environment)的组合。简单来说,当一个函数能够"记住"并访问它被创建时所在的作用域,即使该函数在其原始作用域之外执行,就形成了闭包。
这个定义可能听起来有些抽象,让我们通过一个简单的例子来理解:
function createCounter() {
let count = 0; // 局部变量
return function() { // 返回一个内部函数
count++; // 访问外部函数的变量
return count;
};
}
const counter = createCounter(); // createCounter 执行完毕
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
在这个例子中,createCounter 函数执行完毕后,按理说 count 变量应该被销毁。但由于返回的内部函数引用了 count,这个变量被"保留"了下来,形成了闭包。
闭包的本质
要真正理解闭包,需要先理解 JavaScript 的作用域机制。
词法作用域
JavaScript 使用词法作用域(也叫静态作用域),意味着函数的作用域在函数定义时就确定了,而不是在调用时确定:
const x = 10;
function outer() {
const x = 20;
function inner() {
console.log(x); // 访问哪个 x?
}
return inner;
}
const fn = outer();
fn(); // 20,不是 10
inner 函数在 outer 内部定义,所以它访问的是 outer 中的 x(值为 20),即使 inner 是在全局作用域中调用的。这就是词法作用域——函数"记住"了它被定义时的环境。
执行上下文和作用域链
每次函数调用都会创建一个新的执行上下文。执行上下文包含:
- 变量环境:存储
var声明的变量和函数声明 - 词法环境:存储
let、const声明的变量 - 外部引用:指向外部词法环境(形成作用域链)
const global = 'global';
function outer() {
const outerVar = 'outer';
function inner() {
const innerVar = 'inner';
console.log(innerVar); // 在当前环境找到
console.log(outerVar); // 在外部环境找到
console.log(global); // 在全局环境找到
}
return inner;
}
// 作用域链:inner → outer → global
const fn = outer();
fn();
当查找变量时,JavaScript 引擎会沿着作用域链从内向外查找,直到找到变量或到达全局作用域。
闭包的工作原理
闭包的核心机制是:内部函数保持了对外部词法环境的引用。
function makeGreeter(greeting) {
return function(name) {
return `${greeting}, ${name}!`;
};
}
const sayHello = makeGreeter('你好');
const sayGoodbye = makeGreeter('再见');
console.log(sayHello('张三')); // 你好, 张三!
console.log(sayGoodbye('李四')); // 再见, 李四!
让我们分析这段代码的执行过程:
- 调用
makeGreeter('你好')时,创建一个执行上下文,其中greeting = '你好' - 返回的匿名函数保持对这个词法环境的引用
makeGreeter执行完毕,但词法环境不会被垃圾回收,因为sayHello引用了它- 调用
sayHello('张三')时,它可以访问greeting变量
每次调用 makeGreeter 都会创建一个新的词法环境,所以 sayHello 和 sayGoodbye 有各自独立的 greeting。
闭包与垃圾回收
这是理解闭包的关键:只要闭包存在,它引用的变量就不会被垃圾回收。
function createHeavyObject() {
const largeArray = new Array(1000000).fill('data');
return {
getLength: () => largeArray.length,
getItem: (index) => largeArray[index]
};
}
const obj = createHeavyObject();
// largeArray 不会被回收,因为闭包引用了它
console.log(obj.getLength()); // 1000000
// 如果不再需要
obj = null; // 现在 largeArray 可以被回收了
闭包的经典应用场景
1. 数据封装和私有变量
JavaScript 没有原生的私有变量(ES2022 之前),闭包可以实现类似的效果:
function createBankAccount(initialBalance) {
let balance = initialBalance; // 私有变量
let transactions = []; // 私有变量
return {
deposit(amount) {
balance += amount;
transactions.push({ type: 'deposit', amount, balance });
return balance;
},
withdraw(amount) {
if (amount > balance) {
throw new Error('余额不足');
}
balance -= amount;
transactions.push({ type: 'withdraw', amount, balance });
return balance;
},
getBalance() {
return balance;
},
getTransactions() {
return [...transactions]; // 返回副本,保护原数组
}
};
}
const account = createBankAccount(100);
console.log(account.deposit(50)); // 150
console.log(account.withdraw(30)); // 120
console.log(account.getBalance()); // 120
// 无法直接访问 balance
console.log(account.balance); // undefined
// account.balance = 0; // 这不会影响真正的 balance
2. 函数工厂
闭包是创建一系列相关函数的理想方式:
// 创建单位转换器
function createConverter(unit, ratio) {
return function(value) {
return {
original: value,
converted: value * ratio,
unit: unit
};
};
}
const kmToMiles = createConverter('miles', 0.621371);
const celsiusToFahrenheit = createConverter('°F', (c) => c * 9/5 + 32);
console.log(kmToMiles(10));
// { original: 10, converted: 6.21371, unit: 'miles' }
// 创建 API 请求函数
function createApiClient(baseUrl) {
const defaultHeaders = { 'Content-Type': 'application/json' };
return {
get(endpoint) {
return fetch(`${baseUrl}${endpoint}`, {
method: 'GET',
headers: defaultHeaders
}).then(r => r.json());
},
post(endpoint, data) {
return fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: defaultHeaders,
body: JSON.stringify(data)
}).then(r => r.json());
}
};
}
const api = createApiClient('https://api.example.com');
// api.get('/users')
// api.post('/users', { name: '张三' })
3. 回调函数与事件处理
闭包在异步编程中无处不在:
// 为多个按钮绑定不同的事件处理
function setupButtons() {
const buttons = document.querySelectorAll('button');
buttons.forEach((button, index) => {
button.addEventListener('click', function() {
console.log(`按钮 ${index + 1} 被点击`);
});
});
}
// 保留状态的回调
function createDebounce(fn, delay) {
let timer = null; // 闭包保存 timer 状态
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const debouncedSearch = createDebounce(query => {
console.log('搜索:', query);
}, 300);
// 用户快速输入时,只会在停止输入 300ms 后执行搜索
input.addEventListener('input', e => debouncedSearch(e.target.value));
4. 柯里化和偏应用
闭包是实现柯里化的基础:
// 柯里化函数
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function(...moreArgs) {
return curried.apply(this, args.concat(moreArgs));
};
};
}
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
const add1 = curriedAdd(1); // 闭包保存了 a = 1
const add1And2 = add1(2); // 闭包保存了 a = 1, b = 2
console.log(add1And2(3)); // 6
5. 模块模式
在 ES6 模块普及之前,闭包是实现模块化的主要方式:
const Calculator = (function() {
// 私有变量和方法
const precision = 10;
const round = (num) => Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision);
// 公共 API
return {
add(a, b) {
return round(a + b);
},
subtract(a, b) {
return round(a - b);
},
multiply(a, b) {
return round(a * b);
},
divide(a, b) {
if (b === 0) throw new Error('除数不能为零');
return round(a / b);
}
};
})();
console.log(Calculator.add(0.1, 0.2)); // 0.3(而不是 0.30000000000000004)
console.log(Calculator.precision); // undefined(私有)
闭包的陷阱与注意事项
1. 循环中的闭包
这是最经典的闭包陷阱:
// 问题代码
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// 输出: 4, 4, 4(不是 1, 2, 3)
// 原因分析:
// 1. var 声明的 i 是函数作用域,只有一个 i 变量
// 2. 三个 setTimeout 的回调共享同一个 i
// 3. 当回调执行时,循环已结束,i 的值是 4
// 解决方案 1:使用 let(块级作用域)
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// 输出: 1, 2, 3
// 解决方案 2:使用 IIFE 创建新的作用域
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 100);
})(i);
}
// 输出: 1, 2, 3
// 解决方案 3:使用 forEach
[1, 2, 3].forEach(function(i) {
setTimeout(function() {
console.log(i);
}, 100);
});
// 输出: 1, 2, 3
2. 内存泄漏
闭包可能导致意外的内存占用:
// 问题代码
function attachEvent(element) {
const largeData = new Array(10000).fill('x');
element.addEventListener('click', function() {
console.log(largeData.length); // 闭包引用了 largeData
});
// largeData 会一直存在于内存中,直到元素被移除
// 更好的做法:如果不需要 largeData,设为 null
// largeData = null;
}
// 另一个例子:遗忘的定时器
function setupPolling() {
const data = fetchData();
setInterval(function() {
updateUI(data); // 闭包引用了 data
}, 1000);
// 如果组件卸载,需要清除定时器
// 返回清除函数
return function cleanup() {
clearInterval(timer);
};
}
3. this 绑定问题
闭包不会绑定 this:
const obj = {
name: '对象',
greet: function() {
setTimeout(function() {
console.log(this.name); // undefined(this 指向全局或 undefined)
}, 100);
}
};
obj.greet();
// 解决方案 1:箭头函数(继承外层 this)
const obj2 = {
name: '对象',
greet: function() {
setTimeout(() => {
console.log(this.name); // '对象'
}, 100);
}
};
// 解决方案 2:保存 this 引用
const obj3 = {
name: '对象',
greet: function() {
const self = this;
setTimeout(function() {
console.log(self.name); // '对象'
}, 100);
}
};
// 解决方案 3:bind
const obj4 = {
name: '对象',
greet: function() {
setTimeout(function() {
console.log(this.name);
}.bind(this), 100);
}
};
闭包的性能考虑
闭包是 JavaScript 的基本特性,合理使用不会有性能问题。但需要注意:
1. 避免不必要的闭包
// 不必要的闭包
function processItems(items) {
return items.map(function(item) {
// 这个闭包是不必要的
return process(item);
});
}
// 更简洁的写法
function processItems(items) {
return items.map(process);
}
2. 注意闭包捕获的数据量
// 可能的内存问题
function createHandlers() {
const bigData = loadHugeDataSet();
return bigData.map(item => ({
id: item.id,
handler: function() {
// 如果只需要 item.id,闭包却捕获了整个 item 和 bigData
console.log(item.id);
}
}));
}
// 优化版本
function createHandlers() {
const bigData = loadHugeDataSet();
return bigData.map(item => {
const id = item.id; // 只提取需要的数据
return {
id,
handler: function() {
console.log(id); // 闭包只捕获 id
}
};
});
}
实践建议
1. 适度使用
闭包很强大,但不要滥用。如果不需要访问外部变量,就不需要闭包:
// 不需要闭包
function createLogger() {
return function(message) {
console.log(message);
};
}
// 需要闭包
function createLogger(prefix) {
return function(message) {
console.log(`[${prefix}] ${message}`);
};
}
2. 及时清理
如果闭包引用了大对象,在不需要时及时释放:
function processLargeFile(file) {
const data = parseFile(file);
const result = compute(data);
// 不再需要原始数据
data = null;
return function getResult() {
return result;
};
}
3. 命名清晰
闭包的用途应该一目了然:
// 不够清晰
const fn = createFn();
// 清晰的命名
const counter = createCounter();
const validator = createValidator();
const apiClient = createApiClient();
小结
闭包是 JavaScript 中一个核心而强大的特性:
- 本质:函数与其词法环境的组合,使函数能够访问定义时的外部变量
- 原理:内部函数保持对外部词法环境的引用,阻止垃圾回收
- 应用:数据封装、函数工厂、回调和异步处理、模块化
理解闭包需要注意:
- 词法作用域决定了函数能访问哪些变量
- 闭包会保持对外部变量的引用,影响内存回收
- 循环中使用闭包要特别小心
var的作用域问题 - 闭包不绑定
this,需要用箭头函数或bind解决
掌握闭包是成为 JavaScript 高级开发者的必经之路,它不仅是理解框架和库源码的基础,也是编写高质量代码的重要工具。