ข้ามไปยังเนื้อหา

Modules: ESM และ CommonJS

Node.js รองรับสองระบบ module ที่ไม่สามารถผสมกันได้อย่างอิสระ:

ฟีเจอร์ESMCommonJS
Syntaximport / exportrequire() / module.exports
นามสกุลไฟล์.mjs หรือ .js พร้อม "type":"module".cjs หรือ .js ไม่มี "type":"module"
การโหลดStatic วิเคราะห์เมื่อ parseDynamic รันเมื่อ runtime
Top-level awaitรองรับไม่รองรับ
Default ใน Node 24+ใช่ เมื่อมี "type": "module" — และ CJS ก็ require() ได้Legacy default

CommonJS คือระบบ module ดั้งเดิมของ Node.js ยังคงใช้กันอย่างแพร่หลายใน packages และ tooling เก่า

// @skip-verify multi-file: illustrative
// math.js (CommonJS)
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply }; // named exports via object
// OR: module.exports = add; // single default export
// main.js (CommonJS)
const { add, multiply } = require('./math');
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20

require() เป็น synchronous และโหลดไฟล์ทันทีเมื่อบรรทัดนั้นถูก execute ซึ่งหมายความว่าสามารถ require() ภายใน if block หรือฟังก์ชันได้

ESM คือมาตรฐาน JavaScript module อย่างเป็นทางการ ตอนนี้เป็นระบบที่แนะนำสำหรับ Node.js projects ใหม่

// @skip-verify multi-file: illustrative
// math.mjs (ESM — named exports)
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// utils.mjs (ESM — default export)
export default function formatNumber(n) {
return n.toFixed(2);
}
// main.mjs (ESM — named import)
import { add, multiply } from './math.mjs';
import formatNumber from './utils.mjs';
console.log(add(2, 3)); // 5
console.log(formatNumber(3.14159)); // '3.14'

import statements เป็น static — ต้องปรากฏที่ด้านบนของไฟล์และถูก resolve ก่อนโค้ดใดๆ รัน ทำให้ bundlers สามารถทำ tree-shaking (การกำจัด dead-code) ได้

สองวิธีในการบอก Node.js ว่าไฟล์ใช้ ESM:

  1. "type": "module" ใน package.json — ไฟล์ .js ทั้งหมดใน package นั้นถือเป็น ESM ใช้ .cjs สำหรับไฟล์ใดๆ ที่ต้องคงเป็น CommonJS
  2. นามสกุล .mjs — ถือเป็น ESM เสมอไม่ว่า package.json จะเป็นอะไร
{
"name": "my-app",
"type": "module"
}
// @skip-verify multi-file: illustrative
// Named: export multiple bindings, import with exact names (or rename)
export const PI = 3.14159;
export function area(r) { return PI * r * r; }
import { PI, area } from './circle.mjs';
import { area as circleArea } from './circle.mjs'; // rename
// Default: one main export per file, imported with any name
export default class Logger { /* ... */ }
import Logger from './logger.mjs';
import MyLogger from './logger.mjs'; // same thing, different local name

snippet นี้รันบน Node.js runtime (module resolution ต้องการ Node) แสดงการทำงานของ ES module ที่มี named exports end-to-end โดยใช้ inline dynamic import()

// ESM inline via data: URL — requires Node runtime
const src = `
export function add(a, b) { return a + b; }
export const VERSION = '1.0.0';
`;
const mod = await import('data:text/javascript,' + encodeURIComponent(src));
console.log('add(3, 4):', mod.add(3, 4));
console.log('VERSION:', mod.VERSION);
Node.js

Needs the Node.js runtime — open in StackBlitz to run.

codebase Node.js จริงผสม package CommonJS เก่ากับโค้ด ESM ใหม่อยู่ตลอด ดังนั้นกฎ interop มีผลแม้ในโปรเจกต์ “ESM ล้วน” ทันทีที่มัน depend package เก่าตัวหนึ่ง รายละเอียดเต็ม — รวมถึง module resolution algorithm และ exports map — อยู่ที่ Module Resolution นี่คือ cheat sheet สำหรับสิ่งที่คุณจะเจอจริงๆ ตอนเขียนโค้ด application

คำถามคำตอบบน Node 24
CJS require() ไฟล์ ESM ได้ไหม?ได้ โดย default — ยกเว้น ไฟล์ ESM นั้นใช้ top-level await ซึ่งจะ throw ERR_REQUIRE_ASYNC_MODULE
__dirname มีอยู่ในไฟล์ ESM ไหม?ไม่มี — การอ้างถึงมันจะ throw ReferenceError ใช้ import.meta.dirname แทน
import binding อัปเดตตามเมื่อ module ต้นทาง reassign ใหม่ไหม?ใช่ — ESM import เป็น live binding ไม่ใช่ copy
ค่าที่ destructure จาก require() อัปเดตแบบเดียวกันไหม?ไม่ — CJS ให้ snapshot ของค่า ณ ตอนที่ require() รัน
import x from './cjs-file.cjs' เห็นอะไรเมื่อ module.exports = someValue?someValue ในฐานะ default export
ไฟล์ .js ธรรมดาต้องมี "type": "module" เพื่อใช้ import/export ไหม?บน Node 24, Node สามารถ auto-detect syntax ของ ESM และรันไฟล์เป็น module ได้แม้ไม่มี field นี้ (detect-module) — แต่ "type" ที่ระบุชัดเจนยังเป็นตัวเลือกที่ชัดเจนและ resolve เร็วกว่า
flowchart TD
  A["require('./file.mjs')"] --> B{"Does file.mjs use top-level await?"}
  B -->|"no"| C["Works — module.exports-style access to the ESM's exports"]
  B -->|"yes"| D["Throws ERR_REQUIRE_ASYNC_MODULE"]
  D --> E["Fix: use dynamic import('./file.mjs') instead, awaited inside an async function"]
require() ไฟล์ ESM ทำงานได้เว้นแต่ไฟล์นั้นมี top-level await
Node.js
Output (verified on Node 24)
require(sync esm).add(2,3): 5
require(async esm) threw: ERR_REQUIRE_ASYNC_MODULE

Demo ถัดไปเขียน sibling .mjs หนึ่งไฟล์และ .cjs สองไฟล์ เพื่อโชว์ live binding และ interop ของ default-export เคียงข้างกัน แล้วลบทั้งสามไฟล์ที่มันสร้างทิ้ง

Node.js
Output (verified on Node 24)
esm import counter before inc(): 0
esm import counter after inc(): 1
cjs require counter before inc(): 0
cjs require counter after inc() (still the old copy): 0
cjs module.exports as import default: Hi, Dana

counter ใน ESM sibling ถูก export เป็น live binding: ฝั่งที่ import อ่านตัวแปรจริงของ module นั้น จึงเห็น 1 หลัง inc() รัน ส่วน object { counter, inc } ของ CJS sibling ถูก destructure — counter ณ จุดนั้นถูก copy ออกมาเป็นตัวเลขธรรมดา ดังนั้นไม่ว่า sibling จะทำอะไรทีหลัง ก็ไม่มีอะไรเปลี่ยนตัวแปร counter ในเครื่อง import ได้

ต่อไป: อ่านต่อ cheat sheet นี้ที่ Module Resolution ซึ่งครอบคลุมว่า Node หา specifier บนดิสก์ตั้งแต่แรกได้อย่างไร — การค้นหา node_modules, exports map และลำดับ condition

ให้นึกภาพ CJS และ ESM เป็นระบบไปรษณีย์สองระบบที่ส่งไปที่ mailbox เดียวกัน (ไฟล์บนดิสก์) CJS ส่ง snapshot ที่ปิดผนึกแล้ว — ไม่ว่า module.exports จะหน้าตาอย่างไรตอนที่ require() รัน มันถูก copy ออกมา; ไม่มีอะไรที่มาทีหลังอัปเดตมันได้ ESM ส่ง สายไฟที่มีชีวิต ไปยังตัวแปรจริงของ module ต้นทาง — import { x } from './m.js' binding ไม่ใช่ copy ของ x มันคือ view แบบ read-only ไปยัง x จริงของฝั่ง export ซึ่งเป็นเหตุผลที่มันอัปเดตตามฝั่ง export __dirname และ import.meta.dirname เป็นสองระบบที่ต่างกันสำหรับคำถามพื้นฐานเดียวกัน (“ไฟล์นี้อยู่ folder ไหน”) และมีแค่ระบบของ ESM เท่านั้นที่สะกดว่า import.meta.dirname

ChoiceBenefitCost
CommonJS (require)เรียบง่าย, synchronous, dynamic (require() ใน if ได้); package Node ที่มีอยู่เดิมทุกตัวยังใช้ได้ไม่มี top-level await; export แบบ snapshot ไม่ live; ต้องใช้เทคนิค createRequire สำหรับฟีเจอร์ที่มีแค่ใน import.meta
ESM (import/export)ระบบ module มาตรฐานของ JS; static analysis และ tree-shaking; live binding; top-level awaitrequire() ไฟล์ ESM ที่มี top-level await ไม่ได้; __dirname/__filename ไม่มีอยู่
"type": "module" ใน package.jsonชัดเจน เร็ว — Node ไม่ต้อง sniff เนื้อหาไฟล์ไฟล์ .js ทุกไฟล์ใน package นั้นต้องเป็น ESM ที่ถูกต้อง (ใช้ .cjs สำหรับข้อยกเว้น)
พึ่งพา detect-module (ไม่มี field "type")ใช้ได้กับไฟล์ .js ที่กำกวมในวันนี้resolve ช้ากว่า (Node ต้องตรวจ syntax) และไม่ชัดเจนกับผู้อ่านว่าไฟล์นี้ใช้ระบบ module ไหน
  • “require() ไฟล์ ESM ไม่ได้เลย” โดยส่วนใหญ่ทำได้ — ข้อยกเว้นคือไฟล์ ESM ที่ใช้ top-level await โดยเฉพาะ ซึ่ง throw ERR_REQUIRE_ASYNC_MODULE เพราะ require() ต้อง return แบบ synchronous
  • “__dirname เป็น undefined ใน ES module” มันไม่มีอยู่เลย — การอ้างถึงมัน throw ReferenceError ไม่ใช่ “ให้ undefined” ตัวแทนโดยตรงคือ import.meta.dirname
  • “การ import CommonJS module จาก ESM ก็ได้ live binding เหมือนกัน” ไม่ — CJS module ไม่มี live binding เลยไม่ว่าฝั่งไหน Live binding เป็นฟีเจอร์เฉพาะของ ESM-to-ESM เท่านั้น การ import CJS จาก ESM ยังได้ snapshot ครั้งเดียว (ห่อให้ module.exports ปรากฏเป็น default)

Snippet Node.js ที่ AI สร้างมักถูกเขียนจาก training data เก่าที่สมมติโลกแบบ CommonJS ล้วน ดังนั้นมันผสม require() กับ import/export ในไฟล์เดียวกัน หรือใช้ __dirname ในไฟล์ที่ package.json บอกว่า "type": "module" ทั้งสองแบบล้มเหลวทันทีและดัง — แต่ก็ต่อเมื่อไฟล์นั้นรันจริง ไม่ใช่ตอนอ่านผ่านๆ

🐛 หา bug ในโค้ดนี้

import fs from 'node:fs';
const configPath = __dirname + '/config.json';
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
console.log(config);
เฉลย

ไฟล์นี้ใช้ import จึงเป็น ES module — และ __dirname ไม่มีอยู่ใน ES module เลย การอ้างถึงมันจะ throw ReferenceError: __dirname is not defined ไม่ใช่ undefined แบบเงียบๆ วิธีแก้คือ import.meta.dirname (Node 20.11+ ไม่ต้องใช้ flag): const configPath = import.meta.dirname + '/config.json'; ถ้าโค้ดต้องรองรับ Node เวอร์ชันเก่าด้วย รูปแบบที่ portable คือ path.dirname(fileURLToPath(import.meta.url))

การเพิ่ม "type": "module" ใน package.json ทำอะไร?
ข้อใดเกี่ยวกับ import เป็นความจริงเมื่อเทียบกับ require()?
จะ export ค่าหลักหนึ่งตัวจากไฟล์ ESM ได้อย่างไร?
โค้ดนี้พิมพ์อะไร?
// counter.mjs
export let counter = 0;
export function inc() { counter++; }

// main.mjs
import { counter, inc } from './counter.mjs';
console.log(counter);
inc();
console.log(counter);
เกิดอะไรขึ้นเมื่อรันโค้ดนี้?
// utils.js — package.json has "type": "module"
import path from 'node:path';
console.log(path.join(__dirname, 'data.json'));