Kembali ke tabel perbandingan di chapter pertama: scope teratas script klasik menempel pada objek global, sedangkan module memiliki scope sendiri. Klaim semacam ini mudah dibaca, tetapi baru meyakinkan setelah dibuktikan. Chapter ini menguji batas scope module dengan program yang berjalan nyata: satu module keranjang yang menyimpan state internal, satu berkas pengimpor yang berusaha membaca apa yang tidak ditawarkan, dan satu percobaan menjalankan module langsung tanpa impor. Percobaan akses yang gagal sengaja disertakan — bukan untuk ditiru, tetapi supaya batasnya terlihat nyata pada keluaran program.

Setiap Module Punya Scope Sendiri #

Berkas keranjang.mjs menyimpan dua variabel di tingkat atas — total dan riwayat — yang keduanya sengaja tidak diexport. Yang ditawarkan ke luar hanyalah dua fungsi sebagai antarmuka:

js keranjang.mjs
// keranjang.mjs — state keranjang dijaga di dalam module
let total = 0;
const riwayat = [];

export function tambah(harga) {
  total += harga;
  riwayat.push(harga);
  return total;
}

export function jumlahItem() {
  return riwayat.length;
}

Variabel total dan riwayat hidup di scope module keranjang.mjs. Fungsi tambah dan jumlahItem lahir di scope yang sama, sehingga keduanya dapat membaca dan mengubah kedua variabel itu — mekanisme closure yang sudah kita pelajari di section Functions, kini bekerja pada skala satu berkas.

Membuktikan Tidak Ada Kebocoran #

Berkas utama.mjs memakai antarmuka yang ditawarkan, lalu sengaja mencoba membaca total — variabel internal yang tidak diexport. Percobaan itu dibungkus try–catch agar program tetap berjalan sampai akhir:

js utama.mjs
// utama.mjs — hanya melihat API yang diexport keranjang.mjs
import { tambah, jumlahItem } from './keranjang.mjs';

console.log(tambah(18000));
console.log(tambah(8000));
console.log(jumlahItem());

try {
  console.log(total);
} catch (error) {
  console.log(error.name + ": " + error.message);
}

console.log(globalThis.total);

Output yang Dihasilkan:

text
18000
26000
2
ReferenceError: total is not defined
undefined

Mari kita telaah baris-baris pentingnya:

  1. Baris 4–6 (console.log(tambah(...)); ...): dua pemanggilan tambah membuktikan state internal hidup dan akurat — total naik dari 18000 ke 26000, dan riwayat berisi dua entri sehingga jumlahItem() mengembalikan 2.
  2. Baris 8–12 (try { console.log(total); } ...): nama total tidak dikenal di scope utama.mjs. Kegagalan pencarian nama memunculkan ReferenceError: total is not defined, yang tertangkap oleh catch — inilah bukti bahwa variabel tanpa export tidak menembus batas module.
  3. Baris 14 (console.log(globalThis.total);): pemeriksaan tambahan pada objek global menjawab undefined. Berbeda dari var pada script klasik yang menempel ke objek global, deklarasi tingkat atas module tidak meninggalkan jejak apa pun di sana. Dengan demikian tidak ada satu pun jalur yang membuat total terbaca dari luar: bukan lewat nama langsung, bukan pula lewat objek global.

Pengujian pada baris 8–12 sengaja memakai try–catch supaya keluaran lengkap bisa ditampilkan dalam satu kali jalankan. Tanpa pembungkus itu, program berhenti tepat di baris console.log(total); — ReferenceError yang tidak tertangkap menghentikan eksekusi, dan dua keluaran terakhir tidak pernah muncul.

Batas scope ini bekerja pada tahap pencarian nama. Ketika baris console.log(total); berjalan, JavaScript mencari nama total mulai dari scope lokal, naik ke scope module utama.mjs, dan berhenti di sana — scope module lain tidak pernah menjadi bagian rantai pencarian. Nama total yang hidup di keranjang.mjs adalah milik module itu; satu-satunya jalan sah menuju nilainya adalah lewat fungsi yang diexport, sebagaimana tambah dan jumlahItem melakukannya.

Module Berjalan Langsung Tanpa Impor #

Aturan sebaliknya juga layak dibuktikan: sebuah module tidak wajib mengimpor atau mengexport apa pun. Berkas .mjs tetap diperlakukan sebagai module dan dapat langsung dijalankan. keranjang.mjs tidak memiliki satu pun pernyataan yang mencetak, sehingga menjalankannya langsung menghasilkan keluaran kosong tanpa galat:

bash
node keranjang.mjs
echo "kode keluar: $?"

Output yang Dihasilkan:

text
kode keluar: 0

Kode keluar 0 adalah tanda Node mengeksekusi berkas tersebut sebagai module tanpa masalah: import dan export hanyalah fasilitas, bukan syarat, bagi sebuah berkas untuk menjadi module.

Satu State untuk Semua Pengimpor #

Konsekuensi lanjutan dari scope module adalah sifat satu state untuk semua: karena satu module hanya dievaluasi sekali — bukti urutannya sudah kita lihat di chapter Imports, ketika toko.mjs diimpor dua kali dengan dua bentuk berbeda — state internalnya juga hanya ada satu. Untuk keranjang.mjs, artinya hanya ada satu keranjang: bila berkas ketiga mengimpor tambah dan memanggilnya, hitungan total melanjutkan angka yang sudah ada, bukan mulai dari nol.

Sifat ini menjadikan module state sering dipakai sebagai titik temu konfigurasi — semua berkas membaca dan mengubah satu objek yang sama, tanpa perlu melempar-lempar referensi. Pola yang menyertainya: bila isi internal perlu dibuka untuk dibaca, sediakan fungsi yang mengembalikan salinannya, bukan referensi langsungnya:

js Mengembalikan salinan, bukan referensi
function ambilRiwayat() {
  return [...riwayat];
}

Dengan pola itu, pengimpor tidak dapat mengubah riwayat lewat referensi yang dipegangnya; setiap perubahan state tetap harus melalui fungsi module. Spread [...riwayat] menyalin elemen array ke array baru — perilaku salinan dangkal (shallow copy) yang sudah kita bahas di section Objects.

Di Balik Layar: Strict Mode, this, dan import.meta #

Dua perilaku tingkat atas module yang menyertai aturan scope:

  • Mode strict aktif otomatis. Seluruh badan module berjalan dalam strict mode tanpa perlu menulis "use strict". Konsekuensinya terasa pada penugasan yang tidak sah: pada script klasik tanpa strict mode, penugasan ke properti read-only gagal senyap; di module, percobaan yang sama melempar TypeError — sebagaimana dibuktikan dengan Object.freeze di section Objects.
  • this di tingkat atas bernilai undefined. Pada script klasik, this tingkat atas menunjuk objek global; pada module, ikatan itu tidak tersedia:
js
// di dalam berkas module (.mjs)
console.log(this === undefined);

Output yang Dihasilkan:

text
true

Selain keduanya, module menyediakan objek import.meta yang berisi informasi tentang module itu sendiri — di Node, import.meta.url memuat alamat berkas lengkap. Detailnya berguna saat berurusan dengan jalur berkas dan akan muncul kembali di section yang menyentuh ekosistem Node.

Scope module menutup pertanyaan “di mana variabel hidup”. Pertanyaan berikutnya lebih licin: apa yang terjadi ketika dua module justru saling membutuhkan — situasi yang dikenal sebagai circular dependency.