Idealnya, ketergantungan antar module mengalir satu arah: utama.mjs mengimpor pajak.mjs, dan pajak.mjs tidak mengimpor balik. Kenyataannya, pemecahan program tidak selalu secermat itu. Saat fitur berkembang, dua module bisa saling membutuhkan — pajak.mjs membutuhkan aturan diskon, sementara diskon.mjs membutuhkan perhitungan pajak — dan JavaScript justru mengizinkan situasi ini dengan aturan tertentu.

Chapter ini menunjukkan circular dependency yang benar-benar berjalan, membedah mengapa ia tidak melempar galat, lalu membahas cara memecahnya ketika siklus itu tidak diinginkan. Seluruh keluaran diuji nyata dengan node. Contohnya sengaja dibuat kecil — hanya dua module — supaya siklusnya mudah dilacak mata dan urutan evaluasinya bisa disusuri baris demi baris.

Dua Module yang Saling Membutuhkan #

Kasir milik Budi punya dua aturan yang saling terkait. Pajak dihitung dari nilai setelah diskon, sehingga pajak.mjs membutuhkan potongan dari diskon.mjs. Sebaliknya, laporan diskon.mjs perlu tahu pajak atas sebuah transaksi, sehingga ia mengimpor balik dari pajak.mjs. Berkas pertama:

js pajak.mjs
// pajak.mjs — perhitungan pajak; membutuhkan potongan dari diskon.mjs
import { potongan } from './diskon.mjs';

export const TARIF = 0.11;

export function dasarKenaPajak(total) {
  return total - potongan(total);
}

export function totalBayar(total) {
  return Math.round(dasarKenaPajak(total) * (1 + TARIF));
}

Berkas kedua mengimpor balik dari pajak.mjs — di sinilah siklusnya terbentuk:

js diskon.mjs
// diskon.mjs — aturan potongan; membutuhkan dasar kena pajak dari pajak.mjs
import { dasarKenaPajak, TARIF } from './pajak.mjs';

export let jumlahPotongan = 0;

export function potongan(total) {
  if (total >= 100000) {
    jumlahPotongan += 1;
    return 10000;
  }
  return 0;
}

export function pajakTransaksi(total) {
  return Math.round(dasarKenaPajak(total) * TARIF);
}

Berkas pengimpor tidak perlu tahu adanya siklus; ia cukup memakai kedua module:

js utama.mjs
// utama.mjs — memakai dua module yang saling membutuhkan
import { totalBayar } from './pajak.mjs';
import { pajakTransaksi, jumlahPotongan } from './diskon.mjs';

const total = 150000;
console.log(totalBayar(total));
console.log(pajakTransaksi(total));
console.log(jumlahPotongan);

Output yang Dihasilkan:

text
155400
15400
2

Perhitungannya lurus: dari total 150000, potongan 10000 menghasilkan dasar kena pajak 140000, sehingga totalBayar menghasilkan 155400 dan pajakTransaksi mencatat pajak 15400. Angka terakhir 2 adalah hitungan berapa kali potongan benar-benar dipanggil — sekali lewat totalBayar, sekali lagi lewat pajakTransaksi. Kedua module yang saling mengimpor bekerja tanpa satu pun galat.

Urutan Evaluasi di Balik Siklus #

Mengapa program ini tidak berbenturan dengan dirinya sendiri? Jawabannya ada pada dua fase yang berbeda:

  1. Fase penguraian (linking). Sebelum eksekusi, Node memetakan seluruh graf ketergantungan: utama.mjs → pajak.mjs → diskon.mjs → kembali ke pajak.mjs. Saat menemukan pajak.mjs untuk kedua kalinya, Node tidak mengevaluasinya ulang — ia mencatat siklusnya dan melanjutkan. Pada fase ini semua nama export sudah terdaftar, walaupun nilainya belum tentu terisi.
  2. Fase evaluasi. Node mengevaluasi module secara depth-first: karena pajak.mjs mengimpor diskon.mjs, urutan eksekusi badan module menjadi diskon.mjs lebih dulu, lalu pajak.mjs, baru utama.mjs. Kedua berkas hanya berisi deklarasi const dan deklarasi fungsi di tingkat atas — tidak ada pemanggilan fungsi yang berjalan saat evaluasi — sehingga tidak ada yang perlu membaca nilai yang belum terisi.

Deklarasi fungsi adalah kunci amannya: fungsi di-hoisting penuh sejak awal evaluasi, sehingga potongan di diskon.mjs sudah siap dipanggil meskipun badan diskon.mjs sendiri dievaluasi lebih dulu. Seluruh pemanggilan lintas module di contoh ini baru terjadi lewat utama.mjs — jauh setelah kedua module selesai dievaluasi.

Kita susuri urutannya berdasarkan berkas yang sudah ditulis. Node memulai dari utama.mjs, menemukan impor ./pajak.mjs, dan langsung beralih mengevaluasinya. pajak.mjs menemukan impor ./diskon.mjs, sehingga badan diskon.mjs dievaluasi lebih dulu — meskipun utama.mjs menulis impor pajak.mjs di baris lebih awal. diskon.mjs mengimpor pajak.mjs yang tengah dievaluasi; Node tidak mengulanginya, cukup mencatat siklusnya. Badan diskon.mjs selesai, badan pajak.mjs menyusul, dan terakhir badan utama.mjs berjalan hingga tiga keluaran tercetak.

Batasnya: Partial Bindings #

Situasi di atas aman karena urutan aksesnya benar. Tapi fase linking memang membuat semua nama terdaftar lebih dulu, dan di situlah istilah partial binding berlaku: ketika sebuah module tengah dievaluasi di tengah siklus, module lain dapat melihat nama-namanya yang belum terisi. Bila contoh di atas diubah — misalnya diskon.mjs membaca TARIF langsung di tingkat atas dengan const salin = TARIF; — eksekusinya berhenti karena saat itu pajak.mjs belum sempat mengisi TARIF:

text
ReferenceError: Cannot access 'TARIF' before initialization

Pola aman yang bisa kita pegang: di dalam siklus, pastikan nilai tingkat atas dari module lain hanya diakses di dalam badan fungsi — bukan saat evaluasi module berjalan. Contoh utama chapter ini mengikuti pola itu sepenuhnya, sehingga tidak ada satu pun ReferenceError pada jalannya program. Bila nilai memang harus tersedia saat evaluasi, beri akses lewat fungsi pengakses: pengimpor memanggil fungsi tersebut nanti, ketika nilai sudah pasti terisi.

Live Binding sebagai Bukti #

Keluaran 2 di atas juga membuktikan sifat live binding yang disinggung di chapter Named Exports. Variabel jumlahPotongan diexport sebagai let dan diubah dari dalam diskon.mjs setiap kali potongan diberikan. utama.mjs tidak pernah mengubahnya, tetapi tetap melihat nilai terbaru 2 — impor bernama adalah tampilan langsung ke module asalnya, bukan salinan yang dibekukan saat impor. Jika utama.mjs membaca jumlahPotongan lagi setelah transaksi berikutnya, nilainya terus bertambah tanpa impor ulang apa pun.

Cara Memecah Ketergantungan Melingkar #

Siklus yang bekerja bukan berarti layak dipertahankan. Ketergantungan melingkar membuat dua module tidak bisa dipahami sendiri-sendiri, dan pengujian satu module mesti menyeret module pasangannya. Untuk mengenali siklus di proyek yang lebih besar, gambarkan graf ketergantungannya: satu kotak per berkas, satu panah per pernyataan impor — siklus adalah rangkaian panah yang kembali ke kotak asalnya. Alat linter umumnya menyediakan aturan pendeteksi siklus otomatis, topik yang tersambung di section Tooling dan Code Quality. Tiga jalan keluar yang lazim:

  • Pindahkan aturan bersama ke module ketiga. Bagian yang dibutuhkan keduanya — di sini potongan — diangkat ke module baru, misalnya aturan-dasar.mjs, lalu kedua module mengimpornya.
  • Kirim nilai lewat parameter. Bila satu arah hanya membutuhkan satu angka, kirimkan angka itu sebagai argumen alih-alih mengimpor module pasangannya. Pada contoh kita, pajakTransaksi sebenarnya hanya membutuhkan angka dasar kena pajak — angka itu dapat dikirim pemanggil, dan siklusnya hilang tanpa perlu berkas baru.
  • Gabungkan keduanya. Bila kedua module memang satu kesatuan aturan harga, menyatukannya jujur daripada memaksakan batas berkas yang bocor.
text
Sebelum (siklus):                    Sesudah (satu arah):

pajak.mjs ⇄ diskon.mjs               pajak.mjs → aturan-dasar.mjs ← diskon.mjs

Dengan siklus yang terkendali — atau yang sudah dipecah — fondasi module praktis lengkap. Yang tersisa adalah pertanyaan skala: ketika jumlah module bertambah banyak, bagaimana menyusunnya agar mudah ditemukan dan diimpor? Itu topik chapter penutup section ini.