Most people begin JavaScript by building things. You wire up a button, fetch some data, and watch the DOM change. Then the abstractions leak. Bugs surface that make no sense: variables exist before their declarations, functions remember variables they should not have access to, and the this keyword points at the window, a button, or nothing at all. That is usually the moment you realize you need to look under the hood and understand what the engine is actually doing.
Execution Context: The Two-Phase Setup
When the JavaScript engine in your browser or Node.js encounters a script, it does not simply read the file from top to bottom like a person scanning a page. Instead, it builds an execution context, a container that holds everything needed to run a particular chunk of code. Every execution context moves through two distinct phases.
Memory Creation Phase. During this initial pass, the engine scans the entire scope and allocates memory for every variable and function declaration it finds. If it sees a var, it reserves space and stores undefined as a placeholder. If it sees a function declaration, it stores the complete function body. This is why a function declared with the traditional function keyword can be called from lines that appear earlier in the same scope. The engine already knows about it before it starts executing.
Code Execution Phase. Now the engine runs your code line by line. Assignments happen here. Expressions are evaluated. Functions are invoked. If you wrote var name = "Alice";, the placeholder from phase one finally gets replaced with the string. Understanding this two-pass behavior clears up a surprising amount of confusion. The engine is not sloppy; it is following a strict setup routine.
Variables and the Temporal Dead Zone
Choosing between var, let, and const is not just a stylistic preference. var is function-scoped, which means it ignores braces entirely. Declare it inside an if block, and it leaks outward. That behavior might have made sense in early JavaScript, but in modern applications it causes real maintenance headaches. let and const are block-scoped. They respect curly braces and disappear when the block ends.
There is also a subtle but crucial difference in how hoisting treats them. var declarations are hoisted and immediately initialized with undefined. let and const are technically hoisted too; the engine knows they exist before it reaches the declaration line. But they are not initialized. They sit in a limbo called the temporal dead zone. If you try to read them before the declaration line executes, you get a hard ReferenceError rather than a sneaky undefined. That crash is actually helpful. It prevents logic from proceeding on top of uninitialized data.
Lexical Scope and Closures
Scope answers a simple question: where can I access this variable? JavaScript uses lexical scope, which means a function's access rights are decided by where it was physically written in the source code, not by where it ends up being called. If you define a function inside another function, the inner one can reach outward to read variables from its parent. The outer function cannot reach inward. This relationship is static. You could pass that inner function across modules, store it in a global variable, and call it from an entirely different file. It still remembers the variables from the scope where it was born.
That behavior naturally produces closures. A closure forms when an inner function keeps a reference to a variable from its outer scope. Even after the outer function finishes executing and its local variables should have been garbage collected, JavaScript preserves them in memory because the inner function still needs them. The inner function carries its surrounding environment with it.
This is not merely an academic detail. Closures give you a practical way to create private state in a language that lacks explicit access modifiers.
function makeCounter() {
let count = 0;
return function() {
count = count + 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
Here, count is hidden. Nothing outside the returned function can reset it or read it directly. That is a private variable built from scope mechanics alone.
Hoisting in Practice
Sering terdengar bahwa JavaScript "memindahkan deklarasi ke atas." Itu adalah model mental yang berguna, tetapi kode tersebut sebenarnya tidak ditulis ulang. Selama fase pembuatan memori, engine hanya mendaftarkan deklarasi sebelum eksekusi dimulai. Pernyataan seperti var x = 5; berperilaku seolah-olah deklarasi dan inisialisasi terpisah. Deklarasi var x; diproses lebih awal dan diinisialisasi menjadi undefined. Penugasan x = 5; tetap berada tepat di tempat Anda menulisnya dan dijalankan selama fase eksekusi.
Karena hal ini, var dapat menyebabkan hasil yang mengejutkan. Sebuah variabel yang digunakan di dekat bagian atas fungsi mungkin bernilai undefined meskipun ada penugasan besar di bagian bawah. Menggunakan let dan const menghilangkan masalah tersebut karena temporal dead zone memaksa Anda untuk menempatkan deklarasi di atas penggunaannya.
Bagaimana this Mendapatkan Nilainya
Jika konteks eksekusi dan cakupan (scope) menentukan di mana variabel berada, this menentukan objek mana yang sedang memegang kendali. Berbeda dengan variabel leksikal, this tidak ditentukan oleh di mana sebuah fungsi ditulis. Hal ini sepenuhnya ditentukan oleh bagaimana fungsi tersebut dipanggil.
Default binding terjadi saat Anda memanggil fungsi biasa yang berdiri sendiri. Dalam mode non-strict, this akan kembali ke objek global. Di browser, itu adalah window. Panggil sebuah fungsi tanpa konteks apa pun, dan Anda mungkin secara tidak sengaja menyentuh status global tanpa menyadarinya.
Implicit binding terjadi saat Anda memanggil fungsi sebagai metode pada sebuah objek. Jika Anda menulis user.sayName(), tanda titik tersebut secara diam-diam memberi tahu engine untuk mengatur this menjadi user selama pemanggilan tersebut. Lokasi pemanggilan lebih penting daripada lokasi pendefinisiannya.
Explicit binding memungkinkan Anda menimpa semuanya secara manual. call() dan apply() memanggil fungsi secara langsung sambil memaksa this menjadi objek spesifik yang Anda berikan. Satu-satunya perbedaan di antara keduanya adalah cara argumen diteruskan: call menerima daftar yang dipisahkan koma, sedangkan apply menerima array. bind() bekerja secara berbeda. Ia tidak langsung memanggil fungsi tersebut. Sebaliknya, ia mengembalikan fungsi baru dengan this yang terkunci secara permanen pada nilai yang Anda berikan. Ini sangat berharga untuk callback yang mungkin kehilangan konteksnya saat diteruskan ke tempat lain.
New binding berperan saat Anda menggunakan kata kunci new di depan pemanggilan fungsi. Engine membuat objek kosong yang benar-benar baru, mengatur tautan prototipenya, dan mengarahkan this di dalam konstruktor ke instansi baru tersebut.
Menulis Kode yang Tahan Lama
Memahami mekanika hanyalah setengah dari keahlian. Setengah lainnya adalah menulis kode yang dapat dibaca manusia enam bulan dari sekarang.
DRY (Don't Repeat Yourself) terdengar jelas, tetapi sering kali dilanggar. Jika Anda mendapati diri Anda menulis logika validasi atau pola pemanggilan API yang sama di tiga file berbeda, ekstraklah. Tulis satu fungsi. Satu sumber kebenaran (source of truth) berarti satu tempat untuk diperbarui saat persyaratan berubah, dan hal itu menghemat waktu dengan cara yang sangat signifikan.
KISS (Keep It Simple, Stupid) adalah pertahanan terhadap ego. Nested ternaries dan closure satu baris terasa cerdas, tetapi memakan waktu berjam-jam saat proses debugging. Pola closure yang saya tunjukkan sebelumnya memang kuat, namun membuat sarang hingga lima tingkat hanya karena Anda bisa adalah sebuah kesalahan. Kode yang sederhana dapat bertahan melewati pergantian tim, insiden produksi, dan peringatan tengah malam saat sesuatu rusak dan tidak ada yang ingat mengapa.
Hasil yang Sesungguhnya
Mempelajari eksekusi
