跳到主要内容

闭包与作用域

闭包(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 是在全局作用域中调用的。这就是词法作用域——函数"记住"了它被定义时的环境。

执行上下文和作用域链

每次函数调用都会创建一个新的执行上下文。执行上下文包含:

  1. 变量环境:存储 var 声明的变量和函数声明
  2. 词法环境:存储 letconst 声明的变量
  3. 外部引用:指向外部词法环境(形成作用域链)
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('李四')); // 再见, 李四!

让我们分析这段代码的执行过程:

  1. 调用 makeGreeter('你好') 时,创建一个执行上下文,其中 greeting = '你好'
  2. 返回的匿名函数保持对这个词法环境的引用
  3. makeGreeter 执行完毕,但词法环境不会被垃圾回收,因为 sayHello 引用了它
  4. 调用 sayHello('张三') 时,它可以访问 greeting 变量

每次调用 makeGreeter 都会创建一个新的词法环境,所以 sayHellosayGoodbye 有各自独立的 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 中一个核心而强大的特性:

  • 本质:函数与其词法环境的组合,使函数能够访问定义时的外部变量
  • 原理:内部函数保持对外部词法环境的引用,阻止垃圾回收
  • 应用:数据封装、函数工厂、回调和异步处理、模块化

理解闭包需要注意:

  1. 词法作用域决定了函数能访问哪些变量
  2. 闭包会保持对外部变量的引用,影响内存回收
  3. 循环中使用闭包要特别小心 var 的作用域问题
  4. 闭包不绑定 this,需要用箭头函数或 bind 解决

掌握闭包是成为 JavaScript 高级开发者的必经之路,它不仅是理解框架和库源码的基础,也是编写高质量代码的重要工具。