This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.

Để viết mã nguồn JavaScript hiệu quả và chẩn đoán được các lỗi logic hay rò rỉ bộ nhớ phức tạp, chúng ta phải hiểu rõ cơ chế vận hành bên dưới trình duyệt. JavaScript không chỉ đơn thuần chạy từ trên xuống dưới; nó được biên dịch động Just-In-Time (JIT) và quản lý bộ nhớ cực kỳ chặt chẽ bởi các công cụ như Google V8 Engine.

1. Cấu Trúc Bên Trong Của JavaScript Engine (V8) & Cơ Chế Tối Ưu Hóa

Google V8 Engine (sử dụng trong Chrome, Edge, Node.js) không chạy mã nguồn JS bằng cách thông dịch (interpreting) từng dòng từ đầu đến cuối như các ngôn ngữ scripting cổ điển. Thay vào đó, nó sử dụng trình biên dịch động JIT (Just-In-Time Compiler) để biên dịch mã nguồn trực tiếp thành mã máy trước khi thực thi với hiệu suất rất cao.

Quy trình biên dịch và thực thi trong V8:

  1. Parser & AST: Trình phân tích cú pháp (Parser) quét mã nguồn JS thô và chuyển đổi nó thành một AST (Abstract Syntax Tree - Cây cú pháp trừu tượng) để cấu trúc hóa ngữ pháp.
  2. Ignition Interpreter: Trình diễn dịch Ignition nhận AST và chuyển đổi nó thành mã Bytecode trung gian. Bytecode này giúp tiết kiệm bộ nhớ RAM đáng kể so với việc tạo mã máy trực tiếp.
  3. TurboFan Compiler (JIT): Trong quá trình chạy, V8 giám sát các đoạn code được gọi nhiều lần (gọi là hot code). TurboFan Compiler sẽ lấy các đoạn bytecode đó biên dịch thẳng thành mã máy tối ưu hóa cao (Optimized Machine Code). Nếu giả định kiểu dữ liệu của mã tối ưu bị sai lệch ở runtime (ví dụ: một hàm cộng luôn nhận `Number` đột ngột nhận vào một `String`), V8 sẽ thực hiện quá trình Deoptimization để trả ngược về bytecode cho Ignition xử lý.

Cơ chế tối ưu hóa tài nguyên: Hidden Classes (Shapes) & Inline Cache (IC)

JavaScript là ngôn ngữ kiểu động (dynamically-typed) và cho phép thêm/bớt thuộc tính của đối tượng sau khi khởi tạo. Điều này khiến việc truy cập thuộc tính chậm hơn nhiều so với C++ (nơi vị trí bộ nhớ của các thành viên lớp được cố định lúc compile).

Để khắc phục, V8 tự động sinh ra các Hidden Classes (hoặc Shapes) ngầm tại runtime:

  • Khi hai đối tượng được tạo ra với cùng các thuộc tính và theo cùng một thứ tự, chúng sẽ chia sẻ chung một Hidden Class.
  • Nếu bạn khai báo thuộc tính lệch thứ tự (ví dụ: const obj1 = {a: 1, b: 2}; const obj2 = {b: 2, a: 1};), V8 sẽ buộc phải tạo ra 2 Hidden Classes riêng biệt. Điều này làm mất đi khả năng tối ưu hóa của Inline Cache (IC) - cơ chế ghi nhớ offset của thuộc tính trong bộ nhớ để bỏ qua bước tra cứu đắt đỏ ở các lần gọi tiếp theo.

2. Vòng Đời Execution Context & Bản Chất Hoisting / TDZ

Mỗi khi JavaScript chạy, nó luôn được đặt trong một Execution Context (Ngữ cảnh thực thi). Có hai loại context chính: Global Execution Context (tạo ra khi bắt đầu chạy file) và Function Execution Context (tạo ra mỗi khi một hàm được gọi).

Một Execution Context trải qua hai giai đoạn hoạt động:

Giai đoạn 1: Khởi Tạo (Creation Phase)

Đây là giai đoạn chuẩn bị trước khi bất kỳ dòng code nào chạy. JS Engine quét qua hàm để:

  1. Tạo ra Environment Record (Bảng ghi môi trường) để đăng ký biến và hàm.
  2. Thiết lập Scope Chain (Chuỗi phạm vi).
  3. Xác định ngữ cảnh hoạt động của từ khóa this.

Đây chính là nguồn gốc của Hoisting (Cơ chế kéo khai báo):

  • Biến khai báo bằng var sẽ được cấp phát bộ nhớ và gán giá trị mặc định là undefined.
  • Khai báo hàm trực tiếp (Function Declarations) được nạp nguyên vẹn định nghĩa hàm vào bộ nhớ. Do đó, bạn có thể gọi hàm trước khi khai báo nó.
  • Biến khai báo bằng letconst cũng được hoisted, nhưng trình duyệt giữ chúng trong Temporal Dead Zone (TDZ - Vùng chết tạm thời). Nếu bạn truy cập các biến này trước dòng khai báo thực tế, JS Engine lập tức ném ra lỗi ReferenceError thay vì trả về undefined như var.

Giai đoạn 2: Thực Thi (Execution Phase)

JS Engine chạy mã nguồn dòng theo dòng, thực hiện các phép gán giá trị thực tế cho các biến và thực thi các lời gọi hàm trên Call Stack.

execution_context.js
// Hoisting với var và function declaration
console.log(myVar); // undefined (không gây crash)
sayHello();        // "Xin chào từ V8!"

var myVar = "Dữ liệu";
function sayHello() {
  console.log("Xin chào từ V8!");
}

Hãy tự tay chỉnh sửa và chạy đoạn code dưới đây để cảm nhận sự khác biệt giữa var (hoisting → undefined) và let (TDZ → ReferenceError):

▶ Thử chạy: Hoisting & TDZ

3. Scope Chain, Lexical Environment & Bố Cục Bộ Nhớ Của Closures

Mỗi Execution Context sở hữu một Lexical Environment (Môi trường từ vựng). Nó bao gồm Environment Record (lưu trữ các biến cục bộ) và một Outer Reference (tham chiếu trỏ đến môi trường từ vựng của cha chứa nó). Chuỗi tham chiếu Outer này tạo nên Scope Chain.

Bản chất bộ nhớ của Closures: Heap allocation

Trong các ngôn ngữ lập trình truyền thống như C/C++, khi một hàm kết thúc, toàn bộ stack frame chứa các biến cục bộ của nó sẽ bị giải phóng hoàn toàn khỏi bộ nhớ Stack.

Tuy nhiên, JavaScript hỗ trợ Closures: Hàm con có thể ghi nhớ và truy cập vào các biến của hàm cha ngay cả khi hàm cha đã kết thúc và pop khỏi Call Stack. Để làm được điều này:

  1. V8 phân tích mã nguồn lúc compile. Nếu một biến cục bộ trong hàm cha bị đóng gói (captured) bởi một hàm con, biến đó sẽ không được phân bổ trên Stack.
  2. Thay vào đó, V8 sẽ cấp phát một đối tượng Context đặc biệt trên Heap để lưu trữ biến này.
  3. Hàm con sinh ra sẽ giữ một thuộc tính nội bộ ẩn là [[Scopes]] trỏ trực tiếp đến đối tượng Context này trên Heap. Do đó, chừng nào hàm con còn tồn tại trong bộ nhớ, vùng nhớ Heap của các biến cha vẫn được Garbage Collector giữ lại và bảo vệ an toàn.

Sơ đồ trực quan Call Stack & Heap khi thực thi Closure:

Call Stack (Ngăn xếp cuộc gọi):
┌───────────────────────────────┐
│ innerFunction() Context       │ ── Scope Chain ──┐
├───────────────────────────────┤                  │
│ (outerFunction đã bị pop!)    │                  │
├───────────────────────────────┤                  │
│ Global Execution Context      │ ─────────────────┼──┐
└───────────────────────────────┘                  │  │
                                                   ▼  ▼
Memory Heap (Bộ nhớ Heap động):                 ┌─────────────┐
┌────────────────────────────────────────┐      │ globalVar   │
│  Đối tượng Closure Context (Heap):     │      └─────────────┘
│  ┌──────────────────────────────────┐  │◄───── outerVar (Vẫn sống trên Heap)
│  │ count = 1                        │  │
│  └──────────────────────────────────┘  │
│  Đối tượng innerFunction (Functor):    │
│  ┌──────────────────────────────────┐  │
│  │ [[Scopes]] ── trỏ tới Context ───┼──┼──────► [Heap Context]
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘
              

Cạm bẫy rò rỉ bộ nhớ (Memory Leak) kinh điển từ Closures

Do closures giữ các tham chiếu Lexical Environment trên Heap, nếu không cẩn thận bạn sẽ tạo ra rò rỉ bộ nhớ nghiêm trọng. Lỗi phổ biến nhất xảy ra khi các closures khác nhau chia sẻ chung một Lexical Environment Context. Nếu một closure lớn giữ một tài nguyên khổng lồ nhưng không bao giờ chạy, tài nguyên đó vẫn không thể bị dọn rác vì closure thứ hai vẫn đang được gọi ở nơi khác.

4. Câu Hỏi Trắc Nghiệm Ôn Tập

Trắc nghiệm: Vùng chết tạm thời (TDZ) và let/const

Kết quả của đoạn mã nguồn sau khi thực thi là gì?
console.log(a); let a = 5;

A. Ghi ra màn hình: undefined
B. Lỗi: ReferenceError: Cannot access 'a' before initialization
C. Ghi ra màn hình: 5

Tải file code thực hành minh họa bài học

Chúng tôi cung cấp một file script hoàn chỉnh minh họa chi tiết về Hoisting TDZ, Scope Chain, các trường hợp dùng chung Hidden Class tối ưu hóa trong V8 và cơ chế rò rỉ RAM do Closures:

Tải về execution_context.js

Related Articles

Bài viết liên quan trong series

Lesson 4: OOP in JS: Prototype Chain & ES6 Classes Bài 4: Hướng đối tượng trong JS: Prototype & ES6 Class Lesson 2: Modern JavaScript: Destructuring, Spread & Optional Chaining Bài 2: JavaScript Hiện Đại: Destructuring, Spread & Optional Chaining Back to JavaScript Series Overview Quay lại Lộ trình JavaScript Series