CommonJS vs modul ES

require vs import, berkas .mjs, dan transisi ekosistem Node ke ESM.

Daftar isi

Node.js mendukung dua sistem modul bawaan, yaitu CommonJS yang memakai require dan module.exports, serta modul ES (ESM) yang memakai import dan export. Topik ini membahas mekanisme keduanya, perbedaan perilaku yang menentukan pilihan saat menulis kode, dan cara Node menentukan sistem mana yang berlaku pada sebuah berkas. Setelah membaca topik ini, perbedaan antara berkas .cjs, .mjs, dan .js beserta implikasi pemakaiannya dapat dipahami dengan jelas.

Cara kerja CommonJS#

CommonJS adalah sistem modul asli Node.js. Setiap berkas .cjs (atau .js tanpa type: "module" pada package.json terdekat) diperlakukan sebagai modul CommonJS. Pemuatan modul bersifat sinkron dan dilakukan saat require dipanggil, sehingga kode di dalam modul dieksekusi tepat ketika require pertama kali memintanya. Hasil eksekusi modul disimpan dalam cache, dan pemanggilan require berikutnya pada jalur yang sama mengembalikan objek yang identik.

Ekspor dilakukan melalui module.exports, yaitu properti pada objek module yang disuntikkan Node ke setiap berkas CommonJS. Impor dilakukan dengan require, yang menerima jalur relatif berawalan ./ atau ../, nama modul inti seperti node:os, atau nama paket dari node_modules. Nilai yang dikembalikan oleh require adalah objek yang sama dengan module.exports pada berkas target. Variabel __dirname dan __filename juga disuntikkan otomatis dan berisi jalur direktori serta jalur lengkap berkas yang sedang berjalan.

js demo-cjs.cjs
// CommonJS: memakai require dan module.exports
const os = require('node:os');

function infoPlatform() {
  return {
    platform: os.platform(),
    arch: os.arch(),
    node: process.version,
  };
}

module.exports = { infoPlatform };

console.log(infoPlatform());
text Keluaran
{ platform: 'linux', arch: 'x64', node: 'v24.22.2' }

Modul ES (ESM)#

Modul ES distandarisasi oleh ECMAScript dan diadopsi Node sebagai opsi stabil sejak versi 12, lalu menjadi pilihan yang dianjurkan untuk kode baru. Pemuatan ESM bersifat asinkron dan statis, yaitu deklarasi import diurai saat modul dimuat tanpa mengeksekusi kode. Konsekuensinya, semua impor harus berada di tingkat atas dan tidak boleh berada di dalam blok kondisional. Sifat statis ini memungkinkan analisis dependensi yang dimanfaatkan oleh tree shaking, yaitu eliminasi kode yang tidak terpakai pada proses build.

Ekspor dilakukan dengan kata kunci export, baik secara bernama (export function) maupun default (export default). Impor dilakukan dengan import, yang dapat mengambil ekspor bernama memakai kurung kurawal atau ekspor default tanpa kurung kurawal. Modul ESM juga menyimpan cache, tetapi cache dikelola oleh module loader Node, bukan oleh objek require. Pada ESM, __dirname dan __filename tidak tersedia, dan padanannya diperoleh dari import.meta.url yang dikonversi memakai fileURLToPath dari node:url.

mjs demo-esm.mjs
// Modul ES: memakai import dan export
import os from 'node:os';

export function infoPlatform() {
  return {
    platform: os.platform(),
    arch: os.arch(),
    node: process.version,
  };
}

console.log(infoPlatform());
text Keluaran
{ platform: 'linux', arch: 'x64', node: 'v24.22.2' }

Fitur yang hanya ada di ESM#

ESM mendukung beberapa fitur yang tidak sah pada CommonJS. Fitur-fitur ini menjadi alasan utama kode baru beralih ke ESM.

Top-level await memungkinkan pemakaian await di tingkat atas modul tanpa dibungkus fungsi async. Pada CommonJS, pemakaian await di tingkat atas memicu error sintaks.

mjs tla.mjs
// Top-level await hanya sah pada modul ES
const hasil = await Promise.resolve(42);
console.log('hasil await:', hasil);
text Keluaran
hasil await: 42

Impor berkas JSON pada ESM memakai atribut with { type: 'json' } yang memberi tahu Node bahwa berkas target adalah JSON. Pada CommonJS, require('./data.json') langsung mengembalikan objek hasil parsing JSON tanpa atribut tambahan. Berkas JSON yang diimpor harus berada di samping berkas yang mengimpornya, sehingga contoh berikut dijalankan dari direktori topik agar data.json dapat ditemukan.

json data.json
{
  "nama": "contoh-paket",
  "versi": "1.2.0",
  "tag": ["esm", "json"]
}
mjs json-import.mjs
// tidak-dijalankan secara mandiri: butuh sibling data.json
import data from './data.json' with { type: 'json' };

console.log('nama:', data.nama);
console.log('versi:', data.versi);
console.log('tag:', data.tag);
bash
# jalankan dari direktori topik agar data.json dapat ditemukan
node json-import.mjs
text Keluaran
nama: contoh-paket
versi: 1.2.0
tag: [ 'esm', 'json' ]

Cara Node memilih sistem modul#

Node menentukan sistem modul sebuah berkas berdasarkan tiga sinyal, yaitu ekstensi, properti type pada package.json, dan flag --input-type untuk kode yang dibaca dari stdin. Aturan ekstensi bersifat mutlak: .mjs selalu ESM, .cjs selalu CommonJS, dan .js mengikuti properti type pada package.json terdekat di pohon direktori.

json Contoh: package.json yang menetapkan .js sebagai ESM
{
  "name": "contoh-esm",
  "version": "1.0.0",
  "type": "module"
}

Jika package.json terdekat memiliki "type": "module", semua berkas .js di direktori itu dan subdirektorinya diperlakukan sebagai ESM. Sebaliknya, "type": "commonjs" atau ketiadaan properti type membuat .js diperlakukan sebagai CommonJS. Berkas .mjs dan .cjs mengabaikan properti type, sehingga ekstensi menjadi penanda eksplisit yang berguna saat sebuah paket memuat kedua sistem sekaligus. Pemakaian ekstensi .mjs dan .cjs juga menjadi praktik aman ketika berkas dipindahkan antar direktori atau dipublikasikan sebagai paket, karena sistem modulnya tidak bergantung pada package.json yang mungkin tidak ikut serta.

Interop antar sistem#

Interoperabilitas antara ESM dan CommonJS bersifat asimetris. ESM dapat mengimpor modul CommonJS melalui import default, karena module.exports pada CommonJS dipetakan menjadi ekspor default pada ESM. Impor bernama dari modul CommonJS tidak dijamin berhasil karena analisis statis tidak dapat menelusuri properti pada module.exports yang ditetapkan saat runtime.

Sebaliknya, modul CommonJS tidak dapat memakai import dari ESM secara langsung dan harus memakai await import() yang bersifat asinkron.

Untuk kasus yang membutuhkan require dari dalam ESM, Node menyediakan createRequire dari node:module. Fungsi ini membuat fungsi require yang beroperasi pada URL berkas ESM, sehingga modul CommonJS dapat dimuat secara sinkron dari dalam ESM. Pola ini berguna saat sebuah basis kode ESM perlu memuat paket lama yang masih CommonJS.

js Contoh: createRequire dari dalam ESM
// tidak-dijalankan: fragmen konseptual, butuh berkas paket-lama.cjs yang tidak ada
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const lama = require('./paket-lama.cjs');

console.log(lama.fungsi());

Kesalahan umum#

Pemakaian __dirname pada berkas .mjs memicu ReferenceError karena variabel tersebut tidak disuntikkan pada ESM. Ganti dengan kombinasi fileURLToPath(import.meta.url) dan dirname() dari node:path untuk mendapatkan nilai yang setara.

Penyebutan require pada berkas .mjs juga memicu ReferenceError. Jika modul CommonJS memang harus dimuat dari ESM, gunakan createRequire(import.meta.url) untuk membuat fungsi require yang sah di konteks ESM.

Pemakaian import di dalam blok kondisional pada ESM ditolak saat penguraian, karena import harus berada di tingkat atas. Jika impor perlu bersifat dinamis, gunakan await import() yang mengembalikan promise modul.

Ringkasan#

  • CommonJS memakai require dan module.exports dengan pemuatan sinkron dan cache objek, sedangkan ESM memakai import dan export dengan pemuatan asinkron dan deklarasi statis.
  • Ekstensi .mjs selalu ESM, .cjs selalu CommonJS, dan .js mengikuti properti type pada package.json terdekat.
  • ESM mendukung top-level await dan impor JSON dengan atribut with { type: 'json' }, yang tidak tersedia pada CommonJS.
  • Pada ESM, padanan __dirname adalah kombinasi import.meta.url dan fileURLToPath, sedangkan require dibuat melalui createRequire dari node:module.

Referensi#