Hầu hết mọi người bắt đầu học JavaScript bằng cách xây dựng các thứ gì đó. Bạn kết nối một nút bấm, lấy một ít dữ liệu, và quan sát DOM thay đổi. Sau đó, các lớp trừu tượng bắt đầu rò rỉ. Những lỗi phát sinh mà không có lý do rõ ràng: các biến tồn tại trước khi được khai báo, các hàm ghi nhớ những biến mà chúng không nên có quyền truy cập, và từ khóa this trỏ vào window, một nút bấm, hoặc chẳng trỏ vào đâu cả. Đó thường là khoảnh khắc bạn nhận ra mình cần phải tìm hiểu sâu hơn bên dưới lớp vỏ để hiểu xem engine thực sự đang làm gì.
Execution Context: Quy trình thiết lập hai giai đoạn
Khi engine JavaScript trong trình duyệt hoặc Node.js gặp một đoạn script, nó không chỉ đơn thuần đọc tệp từ trên xuống dưới như một người đang quét trang giấy. Thay vào đó, nó xây dựng một execution context (ngữ cảnh thực thi), một vùng chứa lưu giữ mọi thứ cần thiết để chạy một đoạn mã cụ thể. Mỗi execution context đều trải qua hai giai đoạn riêng biệt.
Giai đoạn khởi tạo bộ nhớ (Memory Creation Phase). Trong lượt quét đầu tiên này, engine sẽ quét toàn bộ scope và cấp phát bộ nhớ cho mọi khai báo biến và hàm mà nó tìm thấy. Nếu thấy một var, nó sẽ dành sẵn một khoảng trống và lưu trữ undefined như một giá trị giữ chỗ. Nếu thấy một khai báo hàm, nó sẽ lưu trữ toàn bộ thân hàm. Đây là lý do tại sao một hàm được khai báo bằng từ khóa function truyền thống có thể được gọi từ những dòng code xuất hiện sớm hơn trong cùng một scope. Engine đã biết về nó trước khi bắt đầu thực thi.
Giai đoạn thực thi mã (Code Execution Phase). Bây giờ, engine sẽ chạy mã của bạn theo từng dòng. Các phép gán diễn ra tại đây. Các biểu thức được tính toán. Các hàm được gọi. Nếu bạn viết var name = "Alice";, giá trị giữ chỗ từ giai đoạn một cuối cùng sẽ được thay thế bằng chuỗi ký tự. Việc hiểu rõ hành vi hai lượt quét này sẽ giải tỏa được rất nhiều sự bối rối. Engine không hề cẩu thả; nó đang tuân theo một quy trình thiết lập nghiêm ngặt.
Biến và Temporal Dead Zone
Việc lựa chọn giữa var, let, và const không chỉ là sở thích về phong cách viết code. var có phạm vi hàm (function-scoped), nghĩa là nó hoàn toàn phớt lờ các dấu ngoặc nhọn. Nếu bạn khai báo nó bên trong một khối if, nó sẽ bị rò rỉ ra bên ngoài. Hành vi đó có thể hợp lý trong thời kỳ đầu của JavaScript, nhưng trong các ứng dụng hiện đại, nó gây ra những vấn đề đau đầu về bảo trì. let và const có phạm vi khối (block-scoped). Chúng tôn trọng các dấu ngoặc nhọn và biến mất khi khối lệnh kết thúc.
Cũng có một sự khác biệt tinh tế nhưng quan trọng trong cách hoisting xử lý chúng. Các khai báo var được hoisting và được khởi tạo ngay lập tức với undefined. let và const về mặt kỹ thuật cũng được hoisting; engine biết chúng tồn tại trước khi nó chạm tới dòng khai báo. Nhưng chúng không được khởi tạo. Chúng nằm trong một trạng thái lấp lửng gọi là temporal dead zone (vùng chết tạm thời). Nếu bạn cố gắng đọc chúng trước khi dòng khai báo được thực thi, bạn sẽ nhận được lỗi ReferenceError thay vì một giá trị undefined âm thầm. Lỗi crash này thực tế lại rất hữu ích. Nó ngăn chặn logic tiếp tục chạy dựa trên những dữ liệu chưa được khởi tạo.
Lexical Scope và Closures
Scope trả lời một câu hỏi đơn giản: tôi có thể truy cập biến này ở đâu? JavaScript sử dụng lexical scope, nghĩa là quyền truy cập của một hàm được quyết định bởi vị trí vật lý mà nó được viết trong mã nguồn, chứ không phải bởi nơi nó được gọi. Nếu bạn định nghĩa một hàm bên trong một hàm khác, hàm bên trong có thể hướng ra ngoài để đọc các biến từ hàm cha. Hàm bên ngoài thì không thể hướng vào trong. Mối quan hệ này là tĩnh. Bạn có thể truyền hàm bên trong đó qua các module, lưu trữ nó trong một biến toàn cục, và gọi nó từ một tệp hoàn toàn khác. Nó vẫn sẽ ghi nhớ các biến từ scope nơi nó được sinh ra.
Hành vi đó tự nhiên tạo ra closures (bao đóng). Một closure hình thành khi một hàm bên trong giữ một tham chiếu đến một biến từ scope bên ngoài của nó. Ngay cả sau khi hàm bên ngoài thực thi xong và các biến cục bộ của nó đáng lẽ đã bị thu gom rác (garbage collected), JavaScript vẫn bảo tồn chúng trong bộ nhớ vì hàm bên trong vẫn cần đến chúng. Hàm bên trong mang theo môi trường xung quanh nó đi cùng.
Đây không chỉ là một chi tiết mang tính học thuật. Closures cung cấp cho bạn một cách thực tế để tạo ra trạng thái riêng tư (private state) trong một ngôn ngữ thiếu các từ khóa sửa đổi quyền truy cập (access modifiers) rõ ràng.
function makeCounter() {
let count = 0;
return function() {
count = count + 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
Ở đây, count được ẩn đi. Không có gì bên ngoài hàm được trả về có thể đặt lại (reset) hoặc đọc trực tiếp nó. Đó là một biến riêng tư được xây dựng chỉ từ cơ chế của scope.
Hoisting trong thực tế
It is common to hear that JavaScript "moves declarations to the top." That is a useful mental model, but the code does not actually get rewritten. During the memory creation phase, the engine simply registers declarations before execution begins. A statement like var x = 5; behaves as if the declaration and initialization are decoupled. The declaration var x; is processed early and initialized to undefined. The assignment x = 5; stays exactly where you wrote it and runs during the execution phase.
Because of this, var can lead to surprising results. A variable used near the top of a function might hold undefined even though a giant assignment sits at the bottom. Using let and const eliminates that particular foot-gun because the temporal dead zone forces you to keep your declarations above your usage.
How this Gets Its Value
If execution context and scope determine where variables live, this determines which object is currently in charge. Unlike lexical variables, this is not decided by where a function is written. It is decided entirely by how the function is called.
Default binding occurs when you invoke a plain, standalone function. In non-strict mode, this falls back to the global object. In a browser, that is window. Call a function without any context, and you might accidentally be touching global state without realizing it.
Implicit binding happens when you call a function as a method on an object. If you write user.sayName(), the dot quietly tells the engine to set this to user for the duration of that call. The site of the call matters more than the site of the definition.
Explicit binding lets you override everything manually. call() and apply() invoke a function immediately while forcing this to be a specific object you provide. The only difference between them is how arguments are passed: call takes a comma-separated list, while apply takes an array. bind() works differently. It does not invoke the function right away. Instead, it returns a new function with this permanently locked to the value you supplied. This is invaluable for callbacks that might otherwise lose their context when passed around.
New binding comes into play when you use the new keyword in front of a function call. The engine constructs a brand-new empty object, sets its prototype linkage, and points this inside the constructor to that fresh instance.
Writing Code That Lasts
Understanding mechanics is only half the craft. The other half is writing code that humans can read six months from now.
DRY (Don't Repeat Yourself) sounds obvious, but it is violated constantly. If you find yourself writing the same validation logic or API call pattern in three different files, extract it. Write one function. One source of truth means one place to update when requirements change, and that saves time in ways that are difficult to overstate.
KISS (Keep It Simple, Stupid) is a defense against ego. Nested ternaries and one-liner closures feel clever, but they cost hours during debugging. The closure pattern I showed earlier is powerful, yet nesting five levels deep just because you can is a mistake. Simple code survives team turnover, production incidents, and those middle-of-the-night alerts when something breaks and nobody remembers why.
The Real Payoff
Studying execution
