Module โหลดจริง ๆ ยังไง
CommonJS: ทุกไฟล์ถูกห่อด้วย function
หัวข้อที่มีชื่อว่า “CommonJS: ทุกไฟล์ถูกห่อด้วย function”require, module, exports, __dirname, และ __filename ไม่ใช่ global — มันดูเหมือนเป็น global เพราะทุกไฟล์ CJS ถูก compile ข้างใน wrapper function ที่รับสิ่งเหล่านี้เป็น parameter Module.wrap() คือ function ตัวจริงที่ Node ใช้สร้าง wrapper นั้น:
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.cache และเทคนิค reload
หัวข้อที่มีชื่อว่า “require.cache และเทคนิค reload”การ require() ครั้งที่สองของ path ที่ resolve แล้วเหมือนกัน คืน exports object ตัวเดิมเป๊ะ — Node cache ตาม resolved file path ไม่ใช่ตาม content การลบ cache entry บังคับให้ require() ครั้งถัดไปรันไฟล์ใหม่ตั้งแต่ต้น สร้าง exports object ใหม่เอี่ยม:
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"]
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 ทำงานต่างกันใน CJS กับ ESM
หัวข้อที่มีชื่อว่า “Cycle ทำงานต่างกันใน CJS กับ ESM”Cycle คือ module สองตัวที่ require/import กันและกัน (ตรงหรือทางอ้อม) เพราะ CJS resolve ค่า ตอน require (snapshot ของ module.exports ณ ตอนนั้น) ในขณะที่ ESM resolve live binding (เช็กใหม่ทุกครั้งที่เข้าถึง พร้อม temporal-dead-zone check) รูปร่าง cycle เดียวกันจึงพังต่างกัน — หรือไม่พังเลย — ขึ้นอยู่กับว่าเขียนด้วยระบบ module ไหน:
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: trueCJS ไม่เคยพังตอน cycle เลย — คุณได้ property ที่บังเอิญถูก assign ไว้ก่อน cycle เกิดขึ้นแบบเงียบ ๆ และการคิดว่าคืออะไรบ้างต้อง trace ลำดับการรันเอง live binding ของ ESM ทำให้รูปร่างเดียวกันนี้เป็น error ที่ชัดเจนและ catch ได้ทันทีที่ binding ที่ยังไม่ initialize ถูกอ่านจริง ๆ — ประสบการณ์แย่กว่าในตอนนั้น แต่มันเปลี่ยนบั๊กเงียบ ๆ ที่ขึ้นกับลำดับให้กลายเป็นบั๊กที่ดังชัดเจน
--import และ register(): ดักจับ loader จากภายนอก
หัวข้อที่มีชื่อว่า “--import และ register(): ดักจับ loader จากภายนอก”Node ให้คุณดักจับ resolve/load ด้วย module แบบ customization hook ที่ต่อเข้าด้วย register() และโหลดล่วงหน้าด้วย --import:
// @skip-verify multi-file: illustrative, run together via the CLI transcript belowimport { register } from 'node:module';register('./resolve-hook.mjs', import.meta.url);// @skip-verify multi-file: illustrative, run together via the CLI transcript belowexport 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}// @skip-verify multi-file: illustrative, run together via the CLI transcript belowconst greeting = await import('virtual-greeting');console.log(greeting.default);node --import ./hooks.mjs main.mjshello 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 ให้อัตโนมัติ
ความจริงเรื่อง strip-types บน Node 24.13
หัวข้อที่มีชื่อว่า “ความจริงเรื่อง strip-types บน Node 24.13”Type stripping เปิดโดย default ใน Node 24.13 — การรันไฟล์ .ts ไม่ต้องใช้ flag เลย และ flag ที่มีอยู่ (--no-strip-types) คือตัวปิดมัน:
node sample.ts # no flag neededport is 3000node --no-strip-types sample.tsSyntaxError: Missing initializer in const declarationStripping แบบ default เป็น erase-only: มันลบ type annotation ทิ้ง แต่ปฏิเสธ TypeScript syntax ใด ๆ ที่ไม่มีความหมายใน JavaScript ที่ถูกต้องเมื่อ type หายไป — ที่เด่นชัดที่สุดคือ enum ซึ่งไม่ใช่แค่ annotation แต่เป็น construct ระดับ runtime ที่ TypeScript compile เป็น object:
node sample-with-enum.ts # default strip-only modeSyntaxError [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 แทนที่จะปฏิเสธมัน:
node --experimental-transform-types sample-with-enum.ts(node:12345) ExperimentalWarning: Transform Types is an experimental feature and might change at any time0สำหรับ single-file executable, --experimental-sea-config (Single Executable Applications) รับ JSON config แล้วรวมกับ postject inject script ที่ bundle ไว้เข้าไปใน copy ของ node binary เอง — ควรรู้ว่ามันมีอยู่ แต่มันเป็นขั้นตอน packaging สำหรับการแจกจ่าย ไม่ใช่อะไรที่คุณหยิบมาใช้ระหว่างพัฒนา
import.meta.resolve และ node --print
หัวข้อที่มีชื่อว่า “import.meta.resolve และ node --print”import.meta.resolve(specifier) รัน resolution algorithm เดียวกับ import แบบ synchronous โดยไม่โหลดอะไรเลย — มีประโยชน์สำหรับเช็ก “specifier นี้ resolve ได้ไหม” และสำหรับสร้างเครื่องมือที่ต้องการ file URL จริง:
Output (verified on Node 24)
function
node:fs
string stringimport.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 หรือเขียนไฟล์
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ไฟล์ CJS คือการเรียก function: require() เรียก wrapper function แบบ synchronous ตรงนั้นเลย และ module.exports มีหน้าตายังไง ณ ตอนนั้นเป๊ะ ๆ คือสิ่งที่คุณได้กลับมา — snapshot หนึ่งอัน Module แบบ ESM เหมือนสัญญาที่ถูกเจรจากันก่อนที่ใครจะรันอะไรเลยมากกว่า: exports ของทุกไฟล์ถูกต่อเป็น reference ที่ live และถูกเช็กก่อน (Link) และมีแค่ตอนที่ทุกไฟล์ใน graph ตกลงกันแล้วว่าอะไรต่อกับอะไร โค้ดของใครก็ตามถึงจะรันจริง (Evaluate) — นี่คือเหตุผลที่การอ่านผิดจังหวะระหว่างการเจรจานั้นเป็น error ที่ชัดเจน แทนที่จะเป็น snapshot ที่ผิดแบบเงียบ ๆ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”โมเดลแบบ 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 เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ AI สร้างซึ่งสมมติว่า require() กับ import() ใช้แทนกันได้ หรือ cyclic import จะ “ทำงานได้เอง” แบบที่ดูเหมือนมันทำได้ใน CJS เป็นแหล่งบั๊ก ESM ที่ subtle และพบบ่อย
🐛 หา bug ในโค้ดนี้
// AI-written pair of ESM modules meant to share stateexport let ready = false;import './init.mjs';
// init.mjsimport { 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