Closure giữ trạng thái của một lần gọi cụ thể
Theo dõi hai bộ đếm, listener cũ khi tạo lại, binding trong vòng lặp và tham chiếu đối tượng; phân biệt trạng thái hiện tại với chuỗi đã tính trước.

Closure giữ quyền truy cập các binding trong môi trường từ vựng nơi hàm được tạo. Có thể hiểu binding là mối liên kết giữa một tên biến và giá trị trong môi trường ấy, không phải bản sao đóng băng của mọi giá trị nhìn thấy được. Phân biệt này giải thích vì sao event handler thấy bộ đếm đã đổi, trong khi hàm khác vẫn trả thông báo được dựng trước lúc bộ đếm thay đổi.
Nếu bạn mới học JavaScript và đã đọc được hàm cùng click listener, ví dụ sau sẽ giúp bạn theo dõi trạng thái qua các lần gọi. Trang tạo hai bộ đếm độc lập rồi cho phép tạo lại bộ đếm thứ nhất. Hãy theo dõi ba thứ riêng: binding tên count, số đang được lưu trong nó, và chuỗi đã tính từ số ấy trước đó. Ví dụ dùng các số nhỏ viết trực tiếp trong mã; đây không phải thư viện bộ đếm tổng quát có kiểm tra đầu vào hay miền số nguyên vô hạn.
Một lần gọi chia sẻ trạng thái; lần gọi khác có trạng thái riêng
Hàm tạo trả về ba hàm. increment đổi count, current đọc nó, còn initial đọc binding khác chứa chuỗi đã được dựng.
function makeCounter(start, prefix) {
let count = start;
const initialMessage = `${prefix}: ${count}`;
return {
increment() { count += 1; return count; },
current() { return count; },
initial() { return initialMessage; },
};
}
Trong cùng một lần gọi, increment và current cùng tham chiếu binding count. Nếu bộ đếm thứ nhất bắt đầu từ 0, tăng hai lần sẽ khiến current() trả 2. Lần gọi thứ hai bắt đầu từ 10 có binding riêng; lần tăng đầu của nó trả 11. Mã nguồn các hàm giống nhau không khiến hai lần gọi ấy dùng chung một binding.
Thông báo ban đầu đi theo đường khác. Khi lần gọi đầu thực thi, template literal tạo Số đếm: 0. Phép tính sau đó đổi count nhưng không chạy lại biểu thức trước ấy. initialMessage vẫn chứa chuỗi gốc. Khai báo const ngăn gán giá trị khác vào binding đó; chuỗi cũng là giá trị bất biến, nên bản thân chuỗi đang có không thể bị sửa thành Số đếm: 2.
Đây là sơ đồ quan hệ có thể quan sát, không phải cách bố trí stack và heap bắt buộc. Hướng dẫn closure của MDN mô tả hàm đi cùng môi trường từ vựng và đưa ví dụ cả trạng thái riêng dùng chung lẫn các lần gọi hàm tạo độc lập. Engine có thể thực hiện hành vi đó mà không giữ mọi biến cục bộ đúng như hình vẽ.
Giao mỗi bộ đếm cho một chủ sở hữu rõ ràng
Tạo trang UTF-8 thông thường với khai báo viewport đáp ứng. Đặt HTML trong body, CSS trong phần tử style và hai khối JavaScript theo thứ tự bên dưới HTML. Trạng thái ban đầu vẫn đọc được khi tắt script; các nút giữ trạng thái vô hiệu hóa cho đến khi có hành vi.
<main class="counter-demo">
<h1>Hai lần gọi, hai môi trường bộ đếm</h1>
<div class="counter-grid">
<section id="counter-left" class="counter-panel"
aria-labelledby="title-left" data-prefix="Số đếm">
<h2 id="title-left">Bộ đếm thứ nhất</h2>
<p><span id="label-left">Giá trị hiện tại</span>:
<output data-current aria-labelledby="label-left">0</output>
</p>
<p>Thông báo ban đầu:
<span data-initial>Số đếm: 0</span>
</p>
<button type="button" disabled>Tăng bộ đếm thứ nhất</button>
</section>
<section id="counter-right" class="counter-panel"
aria-labelledby="title-right" data-prefix="Số đếm">
<h2 id="title-right">Bộ đếm thứ hai</h2>
<p><span id="label-right">Giá trị hiện tại</span>:
<output data-current aria-labelledby="label-right">10</output>
</p>
<p>Thông báo ban đầu:
<span data-initial>Số đếm: 10</span>
</p>
<button type="button" disabled>Tăng bộ đếm thứ hai</button>
</section>
</div>
<p><button id="rebuild-left" type="button" disabled>Tạo lại bộ đếm thứ nhất từ 100</button></p>
<a href="#counter-notes">Đọc ghi chú về bộ đếm</a>
<h2 id="counter-notes" tabindex="-1">Trạng thái hiện tại và thông báo ban đầu</h2>
<p>Tăng bộ đếm làm đổi giá trị hiện tại. Thông báo ban đầu giữ nguyên như lúc bộ đếm ấy được tạo. Tạo lại sẽ sinh bộ đếm thứ nhất mới.</p>
<noscript><p>Cần bật JavaScript để dùng ví dụ; các trạng thái ban đầu vẫn đọc được.</p></noscript>
</main>
Mỗi output có tên riêng, tách khỏi con số đang đổi. Nhãn nút cho biết nó thay bộ đếm nào. Tạo lại là hành động khác với nhãn riêng; đọc một bộ đếm không nên âm thầm gây tác dụng phụ thay thế nó.
.counter-demo, .counter-demo * { box-sizing: border-box; }
.counter-demo {
max-inline-size: 48rem;
margin-inline: auto;
padding: 1.5rem;
color: #303326;
background: #f4f0e6;
font: 1rem/1.6 system-ui, sans-serif;
overflow-wrap: anywhere;
}
.counter-grid { display: grid; gap: 1rem; }
.counter-panel { padding: 1rem; border: 1px solid #77824d; }
.counter-panel h2 { margin-block-start: 0; }
.counter-demo button {
max-inline-size: 100%;
padding: .5rem .75rem;
border: 2px solid currentColor;
color: inherit;
background: transparent;
font: inherit;
overflow-wrap: anywhere;
}
.counter-demo a { color: inherit; text-underline-offset: .2em; }
.counter-demo :where(button, a, [tabindex]) { scroll-margin-block: 1rem; }
.counter-demo :where(button, a, [tabindex]):focus {
outline: 3px solid #1c51a0;
outline-offset: 2px;
}
@media (min-width: 42rem) {
.counter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
Bố cục xếp dọc khi trang hẹp và cho phép nhãn dịch dài xuống dòng. Kiểu focus cùng scroll margin giữ các điều khiển dễ nhận biết khi di chuyển bằng bàn phím. Những lựa chọn trình bày này không đổi quan hệ giữa các binding.
Hàm thiết lập dưới đây sở hữu một listener. Nó còn trả hàm đọc và phương thức dọn dẹp để bên gọi kết thúc đúng lượt thiết lập ấy trước khi tạo lượt khác trên cùng các nút DOM.
function mountCounter(root, start) {
const counter = makeCounter(start, root.dataset.prefix);
const button = root.querySelector('button');
const output = root.querySelector('[data-current]');
const initial = root.querySelector('[data-initial]');
let disposed = false;
output.textContent = String(counter.current());
initial.textContent = counter.initial();
const increment = () => {
output.textContent = String(counter.increment());
};
button.addEventListener('click', increment);
button.disabled = false;
return {
current: counter.current,
initial: counter.initial,
dispose() {
if (disposed) return;
disposed = true;
button.removeEventListener('click', increment);
button.disabled = true;
},
};
}
const leftRoot = document.querySelector('#counter-left');
let left = mountCounter(leftRoot, 0);
const right = mountCounter(document.querySelector('#counter-right'), 10);
const rebuild = document.querySelector('#rebuild-left');
rebuild.addEventListener('click', () => {
left.dispose();
left = mountCounter(leftRoot, 100);
});
rebuild.disabled = false;
Kích hoạt nút tăng thứ nhất hai lần và nút thứ hai một lần. Giá trị hiện tại thành 2 và 11; thông báo ban đầu vẫn là Số đếm: 0 và Số đếm: 10. Nhấn nút tạo lại, cặp giá trị thứ nhất thành 100 và Số đếm: 100, trong khi bộ đếm thứ hai vẫn là 11. Tăng bộ đếm thứ nhất thêm lần nữa sẽ được 101.
Hàm current trả ra đọc trực tiếp binding của hàm tạo. Nó không dùng this, nên truyền hàm ấy sang chỗ khác không đòi hỏi gắn một đối tượng nhận lời gọi. Đó là đặc điểm của cài đặt này, không phải quy tắc rằng mọi phương thức đối tượng đều có thể tách ra gọi an toàn.
Tạo lại không viết lại closure cũ
Một lần gọi mountCounter tạo counter, listener increment và trạng thái dọn dẹp mới. Gán kết quả vào biến left bên ngoài làm đổi handle mà mã tạo lại sẽ dùng ở lượt sau. Nó không thay các binding mà hàm từ lần gọi trước đang đọc.
Bạn có thể quan sát điều đó bằng cách giữ handle cũ trước khi tạo lại. Nếu bộ đếm cũ đã lên 2, current() trên handle giữ lại vẫn trả 2 về sau. Handle mới trả 100. Dọn dẹp kết thúc việc tham gia của listener; nó không xóa số cũ và không khiến getter được giữ riêng trở nên không gọi được.
Ví dụ gọi dispose trước khi gắn listener thay thế. Nếu cố ý gắn bộ đếm bắt đầu từ 0 và 100 lên cùng một nút mà không dọn dẹp, một lần click sẽ tăng cả hai lên 1 và 101. Cả hai callback đều là closure hợp lệ với trạng thái khác nhau. Chúng còn ghi vào cùng output, nên callback được đăng ký sau để lại số 101 trên giao diện. Chỉ nhìn dòng cuối có thể không phát hiện rằng hai chủ sở hữu cùng phản hồi.
Tài liệu addEventListener giải thích vì sao các đối tượng hàm riêng vẫn có thể cùng được đăng ký dù mã nguồn giống hệt nhau. Gọi thiết lập lần nữa không phải chỉ thị thay listener trước.
removeEventListener cần đúng đối tượng callback ban đầu, loại sự kiện và thiết lập capture khớp nhau, như tài liệu gỡ listener mô tả. Viết arrow function mới có cùng thân hàm không xác định được arrow đã đăng ký. Hàm dọn dẹp của ta giữ chính hàm increment; cả lúc đăng ký lẫn lúc gỡ đều dùng chế độ mặc định không capture.
Cờ disposed khiến việc dọn dẹp lặp lại không gây tác động thêm đối với chủ sở hữu này. Cụ thể, handle cũ đã được dọn dẹp không thể vô hiệu hóa nút của lượt thay thế nếu bị gọi dispose lần nữa. Điều đó không tạo ra bộ quản lý tài nguyên tổng quát: bên gọi vẫn phải tuân theo hợp đồng chỉ có một chủ sở hữu trên mỗi vùng tại một thời điểm, và dọn toàn bộ trang còn cần gỡ listener của nút tạo lại.
Sửa thuộc tính và gán lại đi qua các binding khác nhau
Closure có thể giữ tham chiếu đối tượng mà không nhận một bản sao sâu của đối tượng ấy. Đoạn độc lập này tách sửa thuộc tính khỏi thay biến bên ngoài:
(() => {
function makeReader(source) {
return () => source.title;
}
let model = { title: 'First' };
const read = makeReader(model);
model.title = 'Changed';
model = { title: 'Second' };
console.log(read()); // Changed
console.log(model.title); // Second
})();
Tham số hàm source nhận tham chiếu của đối tượng thứ nhất. Đổi model.title sửa thuộc tính của chính đối tượng đó, nên hàm đọc trả ra thấy Changed. Gán đối tượng mới vào binding model bên ngoài sau đó không gán vào binding tham số source. Vì thế hàm đọc tiếp tục dùng đối tượng thứ nhất, còn model.title cho kết quả Second.
Ban đầu cả hai mũi tên đi tới cùng đối tượng, nhưng chúng không phải một binding. Nếu hàm được viết trực tiếp thành () => model.title ở phạm vi bên ngoài, nó sẽ đọc binding bên ngoài và thấy Second. Chọn giữa hai thiết kế là chọn quyền sở hữu: giữ đối tượng đã truyền vào một lần gọi cụ thể, hay đọc đối tượng mà binding dùng chung đang trỏ tới.
Tương tự, const model = {...} ngăn gán lại binding ấy nhưng không đóng băng thuộc tính của đối tượng. Hiển thị cũ có thể đến từ chuỗi tính trước, đối tượng cũ được giữ lại, hoặc listener của lượt thiết lập trước. Gọi tên chính xác binding và thao tác giúp chẩn đoán rõ hơn là coi mọi trường hợp đều là một vấn đề “stale closure”.
Callback trong vòng lặp cho thấy cùng quy tắc
Chạy riêng ví dụ console nhỏ này:
(() => {
const shared = [];
for (var i = 0; i < 3; i += 1) shared.push(() => i);
const separate = [];
for (let j = 0; j < 3; j += 1) separate.push(() => j);
console.log(shared.map(read => read())); // [3, 3, 3]
console.log(separate.map(read => read())); // [0, 1, 2]
})();
Vòng dùng var có một i thuộc phạm vi hàm. Cả ba hàm đọc cùng tham chiếu nó, và vòng lặp đã để nó ở giá trị 3 trước khi các hàm đọc chạy. Khai báo let trong phần đầu vòng thứ hai tạo binding theo từng lượt lặp thích hợp, nên các hàm ấy lần lượt trả 0, 1 và 2.
Chỉ đổi một khai báo ở đâu đó thành let chưa phải lời giải thích đầy đủ. Nếu khai báo let i trước vòng lặp rồi cập nhật chính binding đó trong vòng, các hàm đọc về sau vẫn cùng thấy 3. Vị trí khai báo và quy tắc binding của vòng lặp mới là điều quyết định. Chia sẻ trạng thái đôi khi là chủ đích; hãy dùng binding riêng theo lần gọi hoặc lượt lặp khi độc lập mới là yêu cầu thật.
Dọn dẹp và thu gom rác trả lời hai câu hỏi khác nhau
Listener gắn lên đích tồn tại lâu có thể giữ closure và dữ liệu được tham chiếu ở trạng thái còn truy cập được. Bỏ một panel khỏi giao diện không gỡ listener đã gắn ở nơi khác, chẳng hạn document. Ngược lại, chu trình tham chiếu giữa một nút đã tách khỏi tài liệu và listener của nó không tự động là rò rỉ nếu không còn đường truy cập nào tới chúng.
Hướng dẫn quản lý bộ nhớ của MDN giải thích khả năng truy cập và thu gom rác. Trong ví dụ này, giữ getter cũ là cố ý giữ trạng thái bộ đếm có thể quan sát được. Gỡ listener cũ làm đổi đồ thị sự kiện; nó không chứng minh lúc nào engine thu hồi bộ nhớ, giữ bao nhiêu byte hay còn tham chiếu khác không. Hãy khảo sát heap khi nhận định bộ nhớ tăng thay vì đếm closure trong mã nguồn.
Kiểm tra của tác giả chạy trang hoàn chỉnh trên Chrome 153 bằng thao tác bàn phím và con trỏ thực, ở cả hai ngôn ngữ với cỡ chữ mặc định thực sự là 16 hoặc 32 pixel. Tạo lại nhiều lần vẫn giữ một chủ sở hữu hoạt động; phép thử cố ý thiết lập chồng lấn tái hiện hai cập nhật độc lập. Các đối chứng vòng lặp, tham chiếu đối tượng và định danh listener cũng cho kết quả đã nêu. Chúng xác lập hành vi hữu hạn, không phải kết luận khả năng tiếp cận trên nhiều trình duyệt hay phép đo thu gom rác.
Bài tập cuối: giữ const readBefore = left.current, tạo lại bộ đếm thứ nhất, rồi so readBefore() với left.current(). Tiếp theo, thay hàm đọc qua tham số bằng hàm đọc binding bên ngoài trong phần đối tượng. Trước khi chạy mỗi thay đổi, hãy vẽ binding mà từng hàm đọc. Kết quả nên suy ra từ các mũi tên ấy thay vì từ nhãn “closure”.