07 章で、関数は「書かれた場所の外側の変数が見える」ことを見ました。クロージャは、この決まりの続きです。関数は、外側の変数が見えるだけでなく、外側の関数が終わった後も、その変数を覚え続けます。
function makeCounter() {
let count = 0;
return () => {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter());
console.log(counter());
console.log(counter());
1
2
3
makeCounter() は 1 回呼ばれて、もう終わっています。ふつうなら、中の変数 count はそこで消えるはずです。ところが、返されたアロー関数を呼ぶたびに、count は 1 ずつ増えていきます。
関数は、作られたときに周りの変数の箱への道を一緒に持ちます。その道がある限り、箱は消えません。「関数」と「生まれた場所の変数」の組をクロージャと呼びます。
覚えているのは変数そのもので、作ったときの値の写しではありません。後から変数を変えると、関数から見える値も変わります。
let greeting = "こんにちは";
const greet = () => `${greeting}、チーム`;
console.log(greet());
greeting = "こんばんは";
console.log(greet());
こんにちは、チーム
こんばんは、チーム
makeCounter() を 2 回呼ぶと、変数の箱も 2 つできます。それぞれの関数は、自分が生まれたときの箱だけを持っているので、互いに影響しません。
function makeCounter() {
let count = 0;
return () => ++count;
}
const a = makeCounter();
const b = makeCounter();
console.log(a(), a(), a());
console.log(b());
1 2 3
1
08 章のクラスで new するたびにインスタンスができたのと、よく似ています。クロージャは、データと、それを扱う関数をひとまとめにするもう 1 つの方法です。
箱の中の変数には、返した関数を通してしか触れません。この性質を使うと、外から勝手に書き換えられない状態を作れます。
function createAccount(initial) {
let balance = initial;
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) {
return "残高が足りません";
}
balance -= amount;
return balance;
},
get balance() {
return balance;
},
};
}
const account = createAccount(1000);
console.log(account.deposit(500));
console.log(account.withdraw(2000));
console.log(account.withdraw(300));
console.log(account.balance);
account.balance = 999999;
console.log(account.balance);
console.log(account);
1500
残高が足りません
1200
1200
1200
{
deposit: [Function: deposit],
withdraw: [Function: withdraw],
balance: [Getter]
}
account.balance = 999999 は無視されました。balance は読むための getter しか用意していないからです。中の変数 balance は、どこからも直接は見えません。08 章の #balance(private フィールド)と同じことを、クラスを使わずに実現しています。
クロージャで有名な落とし穴です。ループの中で関数を作り、後で呼ぶと、思った値になりません。
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 0);
}
var: 3
var: 3
var: 3
let: 0
let: 1
let: 2
setTimeout に渡した関数は、ループが全部終わった後で呼ばれます。そのとき何が見えるかは、変数の箱がいくつあるかで決まります。
var i は関数全体で 1 つの変数なので、3 つの関数はすべて同じ箱を見ます。呼ばれた時点で、その箱には最後の値 3 が入っています。let j は、ループが 1 周するたびに新しい箱が作られるので、それぞれの関数は自分の周の値を見ます。ループでも let を使えば、この落とし穴は起きません。
引数の一部を先に決めた関数を作れます。(n) => (x) => … は「関数を返す関数」を短く書いたものです。
const multiplier = (n) => (x) => x * n;
const double = multiplier(2);
const withTax = multiplier(1.1);
console.log(double(21));
console.log(Math.floor(withTax(1000)));
console.log([1, 2, 3].map(multiplier(10)));
42
1100
[ 10, 20, 30 ]
「もう実行したか」を箱に覚えておけば、何度呼ばれても 1 回しか動かない関数を作れます。初期化の処理などに使います。
function once(fn) {
let done = false;
let result;
return (...args) => {
if (!done) {
done = true;
result = fn(...args);
}
return result;
};
}
const init = once(() => {
console.log("初期化します");
return "準備完了";
});
console.log(init());
console.log(init());
初期化します
準備完了
準備完了
一度計算した結果を箱に記録しておき、同じ引数ならそれを返します(メモ化)。Map は、キーと値の組を持つ入れ物です。
function memoize(fn) {
const cache = new Map();
return (n) => {
if (cache.has(n)) {
return `${cache.get(n)}(記録から)`;
}
const value = fn(n);
cache.set(n, value);
return value;
};
}
const square = memoize((n) => n * n);
console.log(square(9));
console.log(square(9));
81
81(記録から)
タイマーやイベントに渡すコールバックは、何度も呼ばれます。呼ばれるたびに変わる状態を、クロージャの箱に持たせます。第2部のイベント処理では、この形をたくさん書きます。
function startCountdown(from) {
let rest = from;
const id = setInterval(() => {
console.log(rest);
rest--;
if (rest === 0) {
clearInterval(id);
console.log("発射!");
}
}, 200);
}
startCountdown(3);
3
2
1
発射!
setInterval は、指定したミリ秒ごとにコールバックを呼び続けます。clearInterval で止めます。rest と id は、コールバックが覚えている箱の中にあります。
注意: クロージャが持っている箱は、その関数がどこかに残っている限り消えません。大きなデータを抱えた関数を、使い終わった後もイベントなどに登録したままにすると、メモリが解放されません。使い終わったコールバックは、clearInterval などで外します。
この章のまとめ: 関数は、生まれた場所の変数(の箱)を覚えている。外側の関数を呼ぶたびに新しい箱ができる。箱には返した関数からしか触れないので、状態を隠せる。ループでは let を使う。