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

การค้นหาโมดูล (Module Resolution)

เมื่อคุณเขียน import 'express' หรือ require('express') Node จะทำตามอัลกอริทึมการค้นหาที่กำหนดตายตัวเพื่อหาไฟล์จริงบนดิสก์ การเข้าใจอัลกอริทึมนี้ช่วยให้คุณ debug ข้อผิดพลาด missing-module เข้าใจว่าทำไม subpath imports ถึงใช้งานได้ และให้เหตุผลเกี่ยวกับวิธีที่ ESM กับ CJS package ทำงานร่วมกัน

bare specifier คือชื่อโมดูลที่ไม่ได้ขึ้นต้นด้วย ./, ../, หรือ / — เช่น 'express' หรือ 'lodash/fp' Node ค้นหา bare specifiers โดยการเดินขึ้นไปตามโครงสร้าง directory จากไฟล์ที่ import โดยตรวจสอบแต่ละโฟลเดอร์ node_modules/ ไปเรื่อยๆ จนกว่าจะพบหรือถึง root ของ filesystem

Terminal window
# Given a file at /project/src/app.js importing 'express',
# Node checks these locations in order:
/project/src/node_modules/express
/project/node_modules/express ← found here
/node_modules/express

การค้นหาแบบนี้ทำให้ package ที่ซ้อนกันสามารถพกพาเวอร์ชัน dependency ของตัวเองได้โดยไม่ขัดแย้งกับ sibling

flowchart TD
  A["Specifier string"] --> B{"Starts with './' , '../' , or '/' ?"}
  B -->|"yes (relative/absolute)"| C{"File extension?"}
  C -->|".mjs"| D["Always ESM"]
  C -->|".cjs"| E["Always CJS"]
  C -->|".js or none"| F["Check nearest package.json 'type'"]
  B -->|"no (bare specifier)"| G["Walk up node_modules/ from the importing file"]
  G --> H{"Package found with 'exports' field?"}
  H -->|"yes"| I["Match subpath against exports map, first matching condition wins"]
  H -->|"no"| J["Fall back to 'main', then index.js"]
  I --> K["Resolved file"]
  J --> K
  F --> K
  D --> K
  E --> K
อัลกอริทึม resolution specifier ของ Node

Node ค้นหา specifier ไปยังไฟล์จริงโดยใช้กฎเหล่านี้:

  • .js — ถือเป็น CJS โดยค่าเริ่มต้น หรือ ESM เมื่อ package.json ที่ใกล้ที่สุดมี "type": "module"
  • .mjs — เป็น ESM เสมอ โดยไม่คำนึงถึง package.json
  • .cjs — เป็น CJS เสมอ โดยไม่คำนึงถึง package.json

ESM บังคับให้ใช้ นามสกุลที่ชัดเจน ใน specifier ต่างจาก CJS คุณไม่สามารถละเว้นนามสกุลและให้ Node เดาได้:

// @skip-verify multi-file: illustrative
// CJS — extension optional (Node tries .js, .json, index.js …)
const utils = require('./utils');
// ESM — extension required
import utils from './utils.js';

ระบบโมดูลทั้งสองสามารถอยู่ร่วมกันได้ แต่มีข้อจำกัด:

// @skip-verify multi-file: illustrative
// ── CJS file (utils.cjs) ─────────────────────────────────────
// CJS can require other CJS modules normally.
const fs = require('fs');
module.exports = { readConfig: () => fs.readFileSync('.env', 'utf8') };
// Since Node 22.12 (and 20.19 LTS), CJS CAN require() an ESM module:
// const esm = require('./modern.mjs'); // ✅ (throws ERR_REQUIRE_ASYNC_MODULE only if it uses top-level await)
// ── ESM file (app.mjs) ──────────────────────────────────────
// ESM CAN import a CJS module; it receives module.exports as the default.
import cjsUtils from './utils.cjs'; // ✅ default = module.exports object
// ESM can also import another ESM module normally.
import { helper } from './helpers.mjs'; // ✅

เดิมที CJS ไม่สามารถ require() โมดูล ESM ได้ — จะ throw ERR_REQUIRE_ESM — เพราะ ESM loading เป็นแบบ static และ asynchronous ส่วน CJS loading เป็นแบบ synchronous แต่ตั้งแต่ Node 22.12 (และ 20.19 LTS) เป็นต้นมา require() โมดูล ESM ทำงานได้เป็นค่าเริ่มต้น ตราบใดที่โมดูลนั้นไม่มี top-level await ส่วนโมดูลที่ใช้ top-level await ยังคง throw ERR_REQUIRE_ASYNC_MODULE เพราะการเรียก require() แบบ synchronous ไม่สามารถรอการประมวลผลแบบ async ได้

ผลในทางปฏิบัติสำหรับคนเขียน library: ไม่ต้อง ship ทั้ง CJS และ ESM อีกต่อไป การเผยแพร่แบบ ESM อย่างเดียวใช้ได้กับทุกสาย Node ที่ยังรองรับ (22, 24, 26) เพราะฝั่ง CJS require() มันได้ export map แบบ dual ในหัวข้อถัดไปยังช่วย tooling รุ่นเก่า แต่ไม่ใช่สิ่งจำเป็นอีกแล้ว

ฟิลด์ "exports" ใน package.json เป็นวิธีที่ authoritative สำหรับ package ในการประกาศ public API surface path ใดก็ตามที่ไม่ได้ระบุใน "exports" เป็น private — ผู้ใช้ไม่สามารถ import โดยตรงได้

{
"name": "my-lib",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}

ด้วย map นี้:

  • import 'my-lib' จะค้นหาไปที่ dist/index.mjs
  • require('my-lib') จะค้นหาไปที่ dist/index.cjs
  • import 'my-lib/utils' จะค้นหาไปที่ dist/utils.mjs
  • import 'my-lib/internal/secret' จะ throw — "./internal/secret" ไม่ได้ถูก export

ภายใน conditions object Node จะตรวจ key ตามลำดับที่เขียนไว้ และใช้ key แรกที่มันรู้จัก — ไม่ได้เลือก condition ที่ “ตรงที่สุด” หรือ “ดีที่สุด” ถ้า condition สองตัวใช้ได้ทั้งคู่ ตัวที่เขียนไว้ก่อนจะชนะ ดังนั้นการสลับลำดับโดยไม่ระวังจะเปลี่ยนผลลัพธ์การ resolve แบบเงียบๆ เมื่อมี condition "types" อยู่ (สำหรับผู้ใช้ TypeScript) ต้องวางไว้เป็นตัวแรกในทุก conditions object เสมอ — ตัว resolver ของ Node เองจะข้าม "types" ไป แต่ถ้าวางไว้หลัง "import"/"require" อาจเสี่ยงที่ resolver ตัวอื่นที่ไม่ได้ special-case "types" จะจับ key ผิดตัวเป็นตัวแรกแทน

Terminal window
# scratch package: exports = { ".": { "import": "./esm.mjs", "default": "./cjs.cjs" } }
$ node -e "import('order-demo').then(m => console.log(m.via))"
import-wins
# Reorder to { "default": "./cjs.cjs", "import": "./esm.mjs" } — same import() call:
$ node -e "import('order-demo').then(m => console.log(m.via))"
undefined # "default" matched first, so import() got the CJS build instead

"exports" ควบคุมสิ่งที่ผู้ใช้ภายนอกเข้าถึงได้; "imports" เป็น map เทียบเท่าสำหรับ alias ภายในของ package เอง เขียนด้วย # นำหน้าเสมอ เพื่อไม่ให้ชนกับ bare specifier จาก node_modules ได้เลย มันเป็นตัวแทนที่สะอาดกว่าสำหรับ relative path เปราะบางอย่าง ../../../shared/utils.js ที่กระจายอยู่ทั่ว codebase

Package ยังสามารถimport ตัวเองด้วยชื่อของตัวเองได้ด้วย สิ่งนี้ทำงานได้เพราะมี "exports" เท่านั้น — self-reference ใช้ไม่ได้กับ package ที่ไม่มี "exports" — และมันหมายความว่าโค้ดของ library สามารถ import { helper } from 'my-lib' ภายในตัวเอง โดยใช้ entry point สาธารณะตัวเดียวกันเป๊ะกับที่ผู้ใช้เข้าถึง แทนที่จะเข้าไปยุ่งกับ relative path ภายใน

Node.js
Output (verified on Node 24)
HELLO DANA!
HELLO SAM! / HELLO SAM!

#utils ภายใน index.js ไม่แตะ node_modules lookup เลย — มัน resolve ตรงผ่าน "imports" map ของ package เอง self-reference import (from 'demo-imports') ผ่าน "exports" map ตัวเดียวกันเป๊ะกับที่ผู้ใช้ภายนอกจะใช้ นั่นคือเหตุผลที่ greet กับ selfGreet ให้ผลลัพธ์เหมือนกัน: มันคือ function ที่ export ออกมาตัวเดียวกันจริงๆ

"exports" map ที่ชี้ "require" กับ "import" ไปที่ไฟล์แยกกันสองไฟล์เป็นรูปแบบทั่วไปสำหรับ ship ทั้ง CJS และ ESM build อันตราย: ถ้าสองไฟล์นั้นแต่ละไฟล์นิยาม class (หรือ stateful singleton ใดๆ) ของตัวเองแยกกัน แทนที่จะแชร์ source of truth เดียวกัน ผู้ใช้ที่เข้าถึง package ผ่านทั้งสอง condition จะได้ implementation ที่ไม่เกี่ยวข้องกันสองตัวที่แค่ดูเหมือนกัน

Node.js
Output (verified on Node 24)
cjsInstance instanceof CjsMoney: true
cjsInstance instanceof esmMod.Money: false
esmInstance instanceof CjsMoney: false
esmInstance instanceof esmMod.Money: true

require() resolve ผ่าน "require" condition เสมอ และ import() resolve ผ่าน "import" condition เสมอ ไม่ว่าตัว caller เองจะใช้ระบบโมดูลแบบไหน — ส่วนนั้นไม่ใช่อันตราย อันตรายคือ index.cjs กับ index.mjs ต่างประกาศ class Money ของตัวเอง ส่วนหนึ่งของแอปที่ใช้ require('demo-dual') กับอีกส่วนที่ใช้ import('demo-dual') จะได้ instance ที่ fail ทุก instanceof check เมื่อเทียบกัน แม้ทั้งคู่จะอ้างว่าเป็น “Money ของ package เดียวกัน” นี่คือ failure mode เดียวกันเป๊ะกับที่บทเรียน build and emit ของคอร์ส TypeScript ครอบคลุมจากฝั่ง compiler — การ emit output CJS กับ ESM ที่แยกกันจริงๆ จาก source file เดียว แทนที่จะให้ build เดียวที่ทั้งสอง condition ชี้ไปที่มัน คือสิ่งที่ป้องกันปัญหานี้

NODE_PATH เป็น environment variable ที่เพิ่ม directory พิเศษเข้าไปใน module search path ของ Node ทำให้ bare specifier resolve ออกไปนอก node_modules walk ปกติได้ มันยังใช้งานได้อยู่วันนี้ แต่มันมาก่อนอัลกอริทึม resolution ที่หน้านี้อธิบาย และสร้างปัญหาที่ exports/imports map ถูกออกแบบมาเพื่อหลีกเลี่ยง:

  • มันไม่ใช่ส่วนหนึ่งของ identity ของไฟล์ที่ resolve ได้ codebase เดียวกันเป๊ะ resolve ต่างกันขึ้นอยู่กับ environment variable ที่ตั้งไว้นอกโปรเจกต์ — git clone และ npm install ไม่พอที่จะ reproduce วิธี import resolve อีกต่อไป
  • มัน shadow การ resolve dependency จริงอย่างเงียบๆ โมดูลที่พบผ่าน NODE_PATH ไม่ถูกบันทึกไว้ที่ไหนใน package.json หรือ lockfile เลย ไม่มีอะไรบันทึกว่า dependency นี้มีอยู่
  • มันไม่ compose กับ "exports" conditional exports, subpath restriction, และ self-reference ทั้งหมดสมมติว่าใช้ node_modules walk มาตรฐาน NODE_PATH ข้าม walk นั้นไปเลย

ทางเลือกที่ portable กว่าสำหรับ “ฉันอยากได้ shortcut path ไปยัง internal module บางตัว” คือ "imports" map ที่กล่าวไปข้างต้น — มัน commit เข้า package.json, versioned ไปกับโค้ด และทำงานเหมือนกันทุก developer และทุก CI run โดยไม่ต้องตั้งค่า environment ใดๆ

คุณสามารถ pipe ESM source โดยตรงไปยัง Node โดยไม่ต้องมีไฟล์บนดิสก์:

Terminal window
echo "import os from 'node:os'; console.log(os.platform());" | node --input-type=module

วิธีนี้มีประโยชน์สำหรับการทดลองอย่างรวดเร็วหรือ CI one-liner ที่ต้องการ ESM semantics โดยไม่ต้องแตะต้อง package.json ของโปรเจกต์

Node.js

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

Resolution คืออัลกอริทึม lookup ที่รันใหม่ทุกครั้งสำหรับทุก specifier ไม่ใช่ cache ของ “ของอยู่ตรงไหน” Node ไม่เคยถาม “ครั้งล่าสุดเจอ express ที่ไหน” — สำหรับ bare specifier มันเดินขึ้นจากตำแหน่งของไฟล์ที่ import อยู่ผ่านทุก node_modules ระหว่างทางไปจนถึง root และสำหรับ package ที่มี "exports" มันเดิน conditions object จากบนลงล่างแล้วเอาตัวแรกที่ match ไฟล์สองไฟล์ที่ห่างกัน 10 directory สามารถ resolve bare specifier เดียวกันไปที่เวอร์ชันที่ติดตั้งต่างกันโดยสิ้นเชิง และนั่นไม่ใช่บั๊ก — มันคือกลไกที่ทำให้ nested dependency หลีกเลี่ยง version conflict ได้

ตัวเลือกประโยชน์ต้นทุน
นิยาม "exports" ด้วย subpath ที่เข้มงวดล็อค public API; refactor internal ได้อิสระโดยไม่พัง consumerอะไรที่ไม่อยู่ในรายการจะเข้าถึงไม่ได้ทันที — breaking change จริงสำหรับใครก็ตามที่พึ่ง internal path ที่ไม่มีเอกสาร
Ship ทั้ง "require" และ "import" condition จากไฟล์แยกกันรองรับผู้ใช้ CJS และ ESM จาก package เดียวDual-package hazard: state หรือ class ที่แชร์กันแต่นิยามแยกในแต่ละไฟล์จะแยกออกจากกันข้าม module graph ทั้งสอง
ใช้ "imports" / #alias สำหรับ internal pathVersioned, refactor-safe, ไม่มี relative-path ที่ยุ่งเหยิงต้องคอย sync map เพิ่มอีกหนึ่งตัวเมื่อไฟล์ย้ายที่
ใช้ NODE_PATHไม่ต้องแก้ package.json เลยไม่ reproducible ข้ามเครื่อง/CI; มองไม่เห็นสำหรับคนที่อ่าน dependency tree
  • “Node เลือก condition ที่ ‘ดีที่สุด’ หรือ ‘ตรงที่สุด’ ใน exports map” ไม่จริง มันไม่ได้จัดอันดับ condition เลย — มันเอา key แรก ที่มันรู้จัก ตามลำดับที่ object เขียนไว้ condition ที่ “ดูถูกกว่า” ที่อยู่ลำดับสองไม่มีโอกาสเลย
  • “require และ import package เดียวกันให้ object เดียวกันเสมอ” จริงก็ต่อเมื่อ "exports" map ชี้ทั้งสอง condition ไปที่ไฟล์เดียวกัน (หรือไฟล์เดียวกันที่ re-export) ถ้าชี้ไปที่ไฟล์แยกกันสองไฟล์ คุณจะได้ module instance ที่เป็นอิสระจากกันสองตัว — dual-package hazard
  • “NODE_PATH เป็นแค่วิธีเก่าที่เทียบเท่าสำหรับเพิ่ม search path” มันข้าม resolution model ของ "exports"/"imports" ทั้งหมด และมองไม่เห็นสำหรับอะไรก็ตามที่อ่าน package.json หรือ lockfile — มันไม่ใช่ตัวแทนที่เทียบเท่ากันได้ มันคือ escape hatch ที่มีต้นทุนด้าน reproducibility จริง

โค้ดที่ AI generate ที่ ship package พร้อมทั้ง CJS และ ESM build มักใช้ pattern class หรือ module-level state เดียวกันซ้ำในแต่ละ build โดยไม่สังเกตว่ามันคือไฟล์แยกกันโดยสิ้นเชิงในแง่ resolution บั๊กจะโผล่ก็ต่อเมื่อ dependency graph ของผู้ใช้โหลด package ผ่านทั้งสอง condition พร้อมกัน — มองไม่เห็นใน test ที่มี entry point เดียว

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

{
"name": "state-store",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
}
เฉลย

"types" ต้องอยู่แรกสุดใน conditions object ไม่ใช่ลำดับที่สาม resolver ของ Node เองข้าม "types" ไป (มันสำคัญเฉพาะกับ tooling ของ TypeScript) แต่ resolver ที่ไม่ special-case "types" — รวมถึง editor integration บางตัว — จะ match condition ไหนก็ตามที่มาก่อน ในที่นี้คือ "import" แล้วเสิร์ฟ declaration file ผิดหรือไม่มีเลยอย่างเงียบๆ "default" อยู่ท้ายสุดถูกต้องแล้ว (มันตั้งใจให้เป็น catch-all) แต่การแก้สำหรับ "types" คือย้ายมันไปเป็น key แรกสุด: { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.cjs" }

เมื่อ Node ค้นหา bare specifier เช่น "express" จะมองที่ไหนก่อน?
ทำไม ESM specifiers ต้องระบุนามสกุลไฟล์อย่างชัดเจน?
ข้อใดถูกต้องเกี่ยวกับการทำงานร่วมกันของ ESM/CJS?
ผู้ใช้รัน require('this-pkg') (ไม่มี condition "require" อยู่เลย) ไฟล์ไหนจะ resolve?
{
  "exports": {
    ".": { "import": "./esm.mjs", "default": "./cjs.cjs" }
  }
}
Exports map ของ package หนึ่งมี "require" ชี้ไปที่ index.cjs และ "import" ชี้ไปที่ index.mjs — ไฟล์แยกกันสองไฟล์ แต่ละไฟล์นิยาม class Widget ของตัวเอง จะเกิดอะไรขึ้นเมื่อส่วนหนึ่งของแอปใช้ require() และอีกส่วนใช้ import() กับ package เดียวกัน แล้วเทียบ Widget จากทั้งสองด้วย instanceof?