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

การค้นหาโมดูล (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

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

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

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

// CJS — extension optional (Node tries .js, .json, index.js …)
const utils = require('./utils');
// ESM — extension required
import utils from './utils.js';

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

// ── 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 ได้

ฟิลด์ "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

คุณสามารถ 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.

เมื่อ Node ค้นหา bare specifier เช่น `"express"` จะมองที่ไหนก่อน?
ทำไม ESM specifiers ต้องระบุนามสกุลไฟล์อย่างชัดเจน?
ข้อใดถูกต้องเกี่ยวกับการทำงานร่วมกันของ ESM/CJS?