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

Module โหลดจริง ๆ ยังไง

require, module, exports, __dirname, และ __filename ไม่ใช่ global — มันดูเหมือนเป็น global เพราะทุกไฟล์ CJS ถูก compile ข้างใน wrapper function ที่รับสิ่งเหล่านี้เป็น parameter Module.wrap() คือ function ตัวจริงที่ Node ใช้สร้าง wrapper นั้น:

Node.js
Output (verified on Node 24)
require: function | module: object | exports: object | __dirname: string | __filename: string
(function (exports, require, module, __filename, __dirname) { console.log(1)
}

นี่คือเหตุผลที่ไฟล์ CJS ใช้ require กับ __dirname ได้โดยไม่ต้อง import จากที่ไหนเลย และก็เป็นเหตุผลที่ทั้งห้าชื่อนี้ไม่มีอยู่ใน ESM เลย — ไฟล์ ESM ไม่ได้ถูกรันข้างใน wrapper นี้เลย

การ require() ครั้งที่สองของ path ที่ resolve แล้วเหมือนกัน คืน exports object ตัวเดิมเป๊ะ — Node cache ตาม resolved file path ไม่ใช่ตาม content การลบ cache entry บังคับให้ require() ครั้งถัดไปรันไฟล์ใหม่ตั้งแต่ต้น สร้าง exports object ใหม่เอี่ยม:

Node.js
Output (verified on Node 24)
same object on a normal second require (cached): true
same object after deleting the cache entry: false

นี่คือกลไกเบื้องหลังการ hot-reload ไฟล์ config หรือ plugin ตอน runtime: resolve path ครั้งเดียว, delete require.cache[resolvedPath], แล้ว require() มันใหม่ มันทำให้ cache entry ของไฟล์นั้นไฟล์เดียว invalid — อะไรก็ตามที่ ไฟล์นั้น require ยังถูก cache แยกต่างหาก ดังนั้นการ reload ไม่ cascade เว้นแต่คุณจะไล่ require.cache แล้วลบทุก module ใน dependency subtree เอง

require.resolve(specifier) คือ resolution algorithm อย่างเดียว ไม่มีการรัน — มีประโยชน์สำหรับจุดประสงค์ cache-keying แบบนี้เป๊ะ ๆ และสำหรับเช็กว่า module resolve ได้หรือเปล่าโดยไม่มี side effect ของการโหลดจริง

ESM loader ก็มี phase เหมือนกัน — และมันไม่ใช่ขั้นตอนที่ข้ามการคิดได้

หัวข้อที่มีชื่อว่า “ESM loader ก็มี phase เหมือนกัน — และมันไม่ใช่ขั้นตอนที่ข้ามการคิดได้”
flowchart LR
Resolve["Resolve
specifier -> a URL"] --> Load["Load
URL -> source text"]
Load --> Link["Link
bind imported/exported names (live bindings)"]
Link --> Evaluate["Evaluate
run the module body top to bottom"]
สิ่งที่ import ทำก่อนโค้ดของคุณจะรัน

Resolve แปลง specifier อย่าง './a.mjs' หรือ 'lodash' ให้เป็น URL แบบเต็ม Load ดึง source text ของ URL นั้นมา Link เชื่อมทุกชื่อ import/export เข้ากับ live binding ที่มันอ้างถึง — ก่อนที่ body ของ module ใดจะรันเลย — นี่คือสิ่งที่ทำให้ ESM import เป็น live reference ไม่ใช่ค่าที่ copy มา Evaluate สุดท้ายรัน top-level code ของแต่ละ module ตามลำดับ dependency ครั้งเดียวเป๊ะ ๆ hook --import/register() (ด้านล่าง) ดักจับสอง phase แรก ไม่มีอะไรให้คุณดักจับ Link หรือ Evaluate จากนอก loader เองได้

Cycle คือ module สองตัวที่ require/import กันและกัน (ตรงหรือทางอ้อม) เพราะ CJS resolve ค่า ตอน require (snapshot ของ module.exports ณ ตอนนั้น) ในขณะที่ ESM resolve live binding (เช็กใหม่ทุกครั้งที่เข้าถึง พร้อม temporal-dead-zone check) รูปร่าง cycle เดียวกันจึงพังต่างกัน — หรือไม่พังเลย — ขึ้นอยู่กับว่าเขียนด้วยระบบ module ไหน:

Node.js
Output (verified on Node 24)
CJS cycle -- no error, just a partial object at the point of the cycle:
  b saw a.fromA already set: true
  b saw a.fromBSeenFromA not set yet (a was still mid-execution): true
ESM cycle -- threw ReferenceError (temporal dead zone), not a partial object: true

CJS ไม่เคยพังตอน cycle เลย — คุณได้ property ที่บังเอิญถูก assign ไว้ก่อน cycle เกิดขึ้นแบบเงียบ ๆ และการคิดว่าคืออะไรบ้างต้อง trace ลำดับการรันเอง live binding ของ ESM ทำให้รูปร่างเดียวกันนี้เป็น error ที่ชัดเจนและ catch ได้ทันทีที่ binding ที่ยังไม่ initialize ถูกอ่านจริง ๆ — ประสบการณ์แย่กว่าในตอนนั้น แต่มันเปลี่ยนบั๊กเงียบ ๆ ที่ขึ้นกับลำดับให้กลายเป็นบั๊กที่ดังชัดเจน

Node ให้คุณดักจับ resolve/load ด้วย module แบบ customization hook ที่ต่อเข้าด้วย register() และโหลดล่วงหน้าด้วย --import:

hooks.mjs — registers the hook module
// @skip-verify multi-file: illustrative, run together via the CLI transcript below
import { register } from 'node:module';
register('./resolve-hook.mjs', import.meta.url);
resolve-hook.mjs — the actual hook
// @skip-verify multi-file: illustrative, run together via the CLI transcript below
export async function resolve(specifier, context, nextResolve) {
if (specifier === 'virtual-greeting') {
const source = encodeURIComponent('export default "hello from a virtual module";');
return { url: `data:text/javascript,${source}`, shortCircuit: true };
}
return nextResolve(specifier, context); // not ours -- defer to the default resolver
}
main.mjs
// @skip-verify multi-file: illustrative, run together via the CLI transcript below
const greeting = await import('virtual-greeting');
console.log(greeting.default);
Terminal window
node --import ./hooks.mjs main.mjs
hello from a virtual module

นี่คือกลไกที่รองรับอย่างเป็นทางการเบื้องหลังเครื่องมือที่ transpile แบบ on-the-fly, resolve URL scheme กำหนดเอง, หรือ redirect specifier — มันคือสิ่งที่ --experimental-strip-types และ TypeScript loader อย่าง ESM mode ของ ts-node สร้างต่อยอดด้วย การส่ง --import module ธรรมดา (โดยไม่เรียก register() ข้างในมัน) แค่ preload module นั้นเท่านั้น — มันไม่ได้ต่อ resolve/load interception ให้อัตโนมัติ

Type stripping เปิดโดย default ใน Node 24.13 — การรันไฟล์ .ts ไม่ต้องใช้ flag เลย และ flag ที่มีอยู่ (--no-strip-types) คือตัวปิดมัน:

Terminal window
node sample.ts # no flag needed
port is 3000
Terminal window
node --no-strip-types sample.ts
SyntaxError: Missing initializer in const declaration

Stripping แบบ default เป็น erase-only: มันลบ type annotation ทิ้ง แต่ปฏิเสธ TypeScript syntax ใด ๆ ที่ไม่มีความหมายใน JavaScript ที่ถูกต้องเมื่อ type หายไป — ที่เด่นชัดที่สุดคือ enum ซึ่งไม่ใช่แค่ annotation แต่เป็น construct ระดับ runtime ที่ TypeScript compile เป็น object:

Terminal window
node sample-with-enum.ts # default strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

--experimental-transform-types (ยัง experimental บน 24.13 และมันพิมพ์ ExperimentalWarning) ไปไกลกว่าแค่ strip — มันจริง ๆ แล้ว transform construct อย่าง enum ให้เป็นสิ่งที่เทียบเท่าใน JavaScript แทนที่จะปฏิเสธมัน:

Terminal window
node --experimental-transform-types sample-with-enum.ts
(node:12345) ExperimentalWarning: Transform Types is an experimental feature and might change at any time
0

สำหรับ single-file executable, --experimental-sea-config (Single Executable Applications) รับ JSON config แล้วรวมกับ postject inject script ที่ bundle ไว้เข้าไปใน copy ของ node binary เอง — ควรรู้ว่ามันมีอยู่ แต่มันเป็นขั้นตอน packaging สำหรับการแจกจ่าย ไม่ใช่อะไรที่คุณหยิบมาใช้ระหว่างพัฒนา

import.meta.resolve(specifier) รัน resolution algorithm เดียวกับ import แบบ synchronous โดยไม่โหลดอะไรเลย — มีประโยชน์สำหรับเช็ก “specifier นี้ resolve ได้ไหม” และสำหรับสร้างเครื่องมือที่ต้องการ file URL จริง:

Node.js
Output (verified on Node 24)
function
node:fs
string string

import.meta.dirname/import.meta.filename คือ equivalent ตรง ๆ ของ __dirname/__filename แบบ CJS ใน ESM — ไม่ต้องมี boilerplate fileURLToPath(dirname(import.meta.url)) อีกต่อไปสำหรับกรณีทั่วไป แยกกันไป node --print "expr" (-p) evaluate expression แล้วพิมพ์ผลลัพธ์ — สะดวกสำหรับเช็กเร็ว ๆ (node -p "process.version", node -p "require('node:os').cpus().length") โดยไม่ต้องเปิด REPL หรือเขียนไฟล์

ไฟล์ CJS คือการเรียก function: require() เรียก wrapper function แบบ synchronous ตรงนั้นเลย และ module.exports มีหน้าตายังไง ณ ตอนนั้นเป๊ะ ๆ คือสิ่งที่คุณได้กลับมา — snapshot หนึ่งอัน Module แบบ ESM เหมือนสัญญาที่ถูกเจรจากันก่อนที่ใครจะรันอะไรเลยมากกว่า: exports ของทุกไฟล์ถูกต่อเป็น reference ที่ live และถูกเช็กก่อน (Link) และมีแค่ตอนที่ทุกไฟล์ใน graph ตกลงกันแล้วว่าอะไรต่อกับอะไร โค้ดของใครก็ตามถึงจะรันจริง (Evaluate) — นี่คือเหตุผลที่การอ่านผิดจังหวะระหว่างการเจรจานั้นเป็น error ที่ชัดเจน แทนที่จะเป็น snapshot ที่ผิดแบบเงียบ ๆ

โมเดลแบบ synchronous snapshot ของ CJS คิดตามได้ง่ายกว่าทีละบรรทัดและไม่ต้องมีขั้นตอน loader แบบ async เลย แลกกับ cycle ที่ resolve ไปเป็น partial state อะไรก็ตามที่บังเอิญมีอยู่ — กับดักด้าน correctness ที่พลาดง่ายตอน review โมเดลแบบ phased live-binding ของ ESM จับกับดักตัวนั้นเป๊ะ ๆ ให้กลายเป็น error ที่ชัดเจน แลกกับ mental model ที่ซับซ้อนกว่า (สี่ phase ไม่ใช่ function call เดียว) และ loader ที่เป็น asynchronous โดยธรรมชาติ ซึ่งเป็นส่วนหนึ่งของเหตุผลที่ interop ระหว่างสองระบบ (require(esm), dynamic import() จาก CJS) มีข้อจำกัดจริงที่บันทึกไว้ ไม่ใช่สะพานที่ไร้รอยต่อ

  • “require() อ่านและรันไฟล์ใหม่ทุกครั้ง” มีแค่ require() ครั้งแรกของ resolved path หนึ่งเท่านั้นที่รันไฟล์ require() ครั้งถัดไปของ path เดียวกันคืน exports object ที่ cache ไว้จนกว่าคุณจะ evict มันออกจาก require.cache เอง
  • “Cycle ของ ESM เป็นไปไม่ได้ หรือพังเสมอ” มันเป็นไปได้และพบบ่อย (module สองตัวที่อ้างอิงกันก็พอแล้ว) — มันจะพังก็ต่อเมื่อโค้ดอ่านlive binding จริง ๆ ก่อนที่ declaration ของมันจะ initialize เสร็จ cycle ที่แต่ละฝั่งแค่ define function (ไม่ได้อ่านตอน cycle) ทำงานได้ปกติ
  • “Type stripping แปลว่า Node รัน TypeScript” Type stripping แบบ default ลบ annotation และ syntax ที่ไม่มีความหมายตอน runtime ทิ้ง — มันไม่เช็ก type และมันปฏิเสธ TypeScript syntax ที่ต้องการการ transform จริง (อย่าง enum) แทนที่จะ compile มันแบบเงียบ ๆ

โค้ดที่ AI สร้างซึ่งสมมติว่า require() กับ import() ใช้แทนกันได้ หรือ cyclic import จะ “ทำงานได้เอง” แบบที่ดูเหมือนมันทำได้ใน CJS เป็นแหล่งบั๊ก ESM ที่ subtle และพบบ่อย

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

config.mjs
// AI-written pair of ESM modules meant to share state
export let ready = false;
import './init.mjs';
// init.mjs
import { ready } from './config.mjs';
console.log('config ready at init time:', ready);
// ... later, some other module sets ready = true after real init work
เฉลย

นี่สะท้อน ESM cycle ที่พิสูจน์ไว้ข้างบนพอดี: init.mjs import ready จาก config.mjs ในขณะที่ config.mjs ยัง evaluate ไม่เสร็จ (มันอยู่บรรทัดที่ import init.mjs พอดี) เพราะ ready ถูกประกาศด้วย let (live binding ที่มี temporal dead zone) การอ่านมันจาก init.mjs ณ จุดนี้ใน cycle จะ throw ReferenceError แทนที่จะคืน false แบบเงียบ ๆ อย่างที่ setup แบบ CJS เทียบเท่าจะทำ โค้ดที่ AI สร้างสมมติว่าลำดับ import “ทำงานได้เอง” แบบที่ลำดับ require() ทำได้อย่างเป็นธรรมชาติ — วิธีแก้คือทำลาย cycle (มี module ที่สามที่ทั้งคู่พึ่งพา โดยไม่ import กลับไปหาฝั่งไหนเลย) หรืออ่าน ready จากข้างใน function ที่ถูกเรียกหลังจากทั้งสอง module evaluate เสร็จแล้วเท่านั้น ไม่ใช่ที่ top level ของ module เองระหว่าง cycle

Module.wrap() เปิดเผยอะไรเกี่ยวกับ require, module, และ __dirname ในไฟล์ CJS?
บรรทัดนี้พิมพ์อะไร?
const first = require(resolvedPath);
delete require.cache[resolvedPath];
const second = require(resolvedPath);
console.log(first === second);
ทำไม cyclic ESM import ถึง throw ReferenceError ได้ ในขณะที่ CJS cycle เทียบเท่าไม่ throw?
บน Node 24.13 การรัน node script.ts โดยไม่มี flag เลย จะเกิดอะไรขึ้น ถ้าไฟล์มีเนื้อหานี้?
// script.ts
const port: number = 3000;
console.log(port);
ผลจริงของการส่ง hook module ให้ --import โดยไม่ได้เรียก register() ข้างในคืออะไร?