JavaScript的秘密生活:理解闭包
Source: Dev.to
函数回顾
Timothy 湿漉漉地来到图书馆,雨水从他的外套上滴落。他刚在伦敦的街道上奔跑,急于向 Margaret 说明一件让他对函数工作原理产生怀疑的事。
“这不可能,”他一边甩掉头发上的水说,“我写的代码违背了我对内存和作用域的所有认识。”
Margaret 抬起头,放下手中的茶。
“给我看看。”
Timothy 拿出笔记本电脑并敲下:
function createCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
“我不明白,”Timothy 说,“当
createCounter执行完毕后,它的局部变量应该被垃圾回收。count变量应该不存在了。但返回的函数仍然能访问它。这是在访问一个已经不该存在的变量。”
Margaret 微笑道。
“你发现了闭包。你感到困惑是对的——这看起来像是对编程语言工作方式的违背。不过在 JavaScript 中,这不是 bug,而是特性。”
词法作用域
Margaret 拿出笔记本。
“在解释发生了什么之前,我们需要先了解词法作用域。你已经在第 1 章学过作用域——变量的存放位置。但还有更深层的东西。”
她写下:
function outer() {
let outerVar = "I'm in outer";
function inner() {
console.log(outerVar); // Can I see this?
}
inner();
}
outer(); // "I'm in outer"
“当
inner去寻找outerVar时,它会去哪里找?” Margaret 问。
“它会去变量声明的地方,”Timothy 回答,“在外层函数里。”
“正是如此。
inner被调用的地点并不重要,重要的是它被写在哪里。这就是词法作用域:函数在它们被定义的作用域中寻找变量,而不是在它们被执行的地方寻找。”
返回内部函数
function outer() {
let outerVar = "I'm in outer";
function inner() {
console.log(outerVar);
}
return inner; // Return the function itself
}
const myFunction = outer(); // `outer()` finishes here
myFunction(); // "I'm in outer" - but how?!
“当
outer执行完毕后,它的局部变量应该消失,”Timothy 缓慢地说,“但inner仍然在访问outerVar。这个变量怎么还能活着?”
“因为 JavaScript 不让它死去,”Margaret 回答,“当你返回
inner时,JavaScript 会说:*‘这个函数引用了outerVar。我还不能回收这个变量,因为函数以后可能会被调用,需要它。’*于是 JavaScript 为inner保持了outerVar在内存中的存在。”
“所以变量会持久化?”
“变量会持久化。函数记住了它的出生地。这种组合——函数加上它需要的作用域变量——被称为 闭包。”
背包类比
Margaret 向后靠了靠。
“想象每个函数在创建时都会得到一个背包。函数被定义的那一刻,JavaScript 环顾四周并说:*‘这个函数会需要哪些变量?’*然后把这些变量装进背包。”
function createGreeter(greeting) {
// createGreeter's scope
return function(name) {
// This inner function needs `greeting` from createGreeter's scope
console.log(greeting + ", " + name);
};
}
const sayHello = createGreeter("Hello");
const sayHi = createGreeter("Hi");
sayHello("Alice"); // "Hello, Alice"
sayHi("Bob"); // "Hi, Bob"
“当你定义内部函数时,它会环顾四周,看到
greeting,于是把greeting装进自己的背包。以后,无论这个函数在哪里被调用,它都会带着 `greeting”。”
Timothy 指着代码说。
“所以
sayHello和sayHi各自有不同的背包?不同的greeting值?”
“没错。每次
createGreeter运行时,都会创建一个新作用域,生成一个新的greeting变量,返回的函数就把对应的greeting放进自己的背包。它们完全互相独立。”
用闭包实现数据隐私
function createAccount() {
let balance = 100;
return {
deposit: function(amount) {
balance += amount;
console.log("Balance: " + balance);
},
withdraw: function(amount) {
balance -= amount;
console.log("Balance: " + balance);
},
getBalance: function() {
return balance;
}
};
}
const account = createAccount();
account.deposit(50); // Balance: 150
account.withdraw(20); // Balance: 130
account.getBalance(); // 130
“这三个方法都是对
balance的闭包。它们看到的是同一个balance值,对吗?”
“是的。而且关键是,它们持有的是 实时引用,而不是副本。”
进一步演示:
account.deposit(50); // Balance: 150
account.withdraw(20); // Balance: 130
account.deposit(100); // Balance: 230
// All three methods access the SAME variable.
// If one changes it, all others see the change.
直接访问私有变量会失败:
console.log(account.balance); // undefined
account.balance = 9999; // Creates a new property; does not affect the closure variable
console.log(account.getBalance()); // Still 230
“
balance变量被完全隐藏。唯一能与之交互的方式就是通过你公开的这三个方法。这才是真正的数据隐私。”
模块模式
const calculator = (function() {
let lastResult = 0;
return {
add: function(a, b) {
lastResult = a + b;
return lastResult;
},
subtract: function(a, b) {
lastResult = a - b;
return lastResult;
},
getLastResult: function() {
return lastResult;
}
};
})();
calculator.add(10, 5); // 15
calculator.subtract(20, 8); // 12
calculator.getLastResult(); // 12
// `lastResult` is private—no one can access or modify it except through these methods.
“这就是模块。你使用立即调用函数表达式(IIFE)创建一个私有作用域,然后返回一个包含公共方法的对象,这些方法可以访问私有变量。在 ES6 类出现之前,模块模式是 JavaScript 中创建带有私有状态对象的主要手段。”
经典闭包陷阱
function setupButtons() {
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log("Button " + i + " clicked");
}, 1000);
}
}
“这就是第 1 章里提到的
var问题,与闭包混在一起。使用var时,变量i属于整个setupButtons函数,而不是循环块。所有三个回调共享同一个i,循环结束后它的值是4”(循环在i变为4` 时退出)。”
修复方法: 使用 let 进行块级作用域,或为每次迭代创建新作用域。
// Using let (ES6)
function setupButtons() {
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log("Button " + i + " clicked");
}, 1000);
}
}
// Using an IIFE
function setupButtons() {
for (var i = 1; i <= 3; i++) {
(function(index) {
setTimeout(function() {
console.log("Button " + index + " clicked");
}, 1000);
})(i);
}
}