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

Modules & Resolution

JavaScript มี module format สองแบบ และ TypeScript ต้องรองรับทั้งคู่:

  • CommonJS (CJS) — format เก่าของ Node: require() และ module.exports โหลดแบบ synchronous ด้วยการ resolve ไฟล์บน disk
  • ES Modules (ESM) — มาตรฐาน: import และ export ใช้ native ใน browser และ Node สมัยใหม่ วิเคราะห์แบบ static ได้

ความสับสนส่วนใหญ่ในโปรเจกต์ TypeScript ย้อนกลับไปที่ความไม่ตรงกันระหว่าง module format ที่คุณ เขียน, format ที่ tsc emit และ format ที่ runtime คาดหวัง option module และ moduleResolution คือวิธีที่คุณทำให้ทั้งสามอย่างนี้ตรงกัน

เมื่อคุณเขียน import { x } from "./util" tsc ต้องแปลง "./util" ให้เป็นไฟล์จริง กลยุทธ์ที่ใช้ขึ้นกับ moduleResolution:

flowchart TB
  q["How is your code loaded?"] --> node["Runs in Node directly"]
  q --> bundled["Goes through a bundler"]
  node --> nodenext["moduleResolution: NodeNext
respects package.json type + exports"]
  bundled --> bundler["moduleResolution: Bundler
lets the bundler resolve"]
การเลือก moduleResolution mode
  • NodeNext / Node16 — จำลอง Node สมัยใหม่แบบเป๊ะ: อ่าน "type": "module" ใน package.json, เคารพ field "exports" และบังคับกฎ ESM (รวมถึงต้องเขียน file extension ใน relative import: import "./util.js")
  • Bundler — สำหรับ code ที่ผ่าน Vite, esbuild หรือ webpack โหมดนี้ผ่อนเรื่อง extension และปล่อยให้ bundler จัดการการหาไฟล์จริง ให้ตรงกับพฤติกรรมของเครื่องมือเหล่านั้น
  • Node10 (เดิมชื่อ Node) — algorithm CommonJS แบบเก่า หลีกเลี่ยงในโปรเจกต์ใหม่ ไม่เข้าใจ field "exports"

mental model สำคัญ: moduleResolution ควรอธิบายว่า code ของคุณถูก โหลดจริง อย่างไร Node รันโดยตรงใช่ไหม? ใช้ NodeNext ผ่าน bundler ใช่ไหม? ใช้ Bundler

ภายใต้ NodeNext native ESM ต้องมี extension ใน import และ — น่าสับสน — คุณเขียน extension .js แม้ไฟล์บน disk จะเป็น .ts:

// util.ts exists on disk, but the emitted import must point at the emitted .js
import { helper } from "./util.js"; // ✅ correct under NodeNext ESM
import { helper } from "./util"; // ❌ ESM needs the extension

เรื่องนี้ทำทุกคนสะดุดครั้งหนึ่ง เหตุผลคือ tsc ไม่ rewrite import path ของคุณ และตอน runtime ไฟล์จะเป็น util.js ดังนั้น source ต้องเรียกชื่อไฟล์ตอน runtime ไว้ตั้งแต่แรก

CJS กับ ESM มีความเห็นไม่ตรงกันเรื่อง default export ซึ่งในอดีตทำให้ import express from "express" fail กับ module ที่เป็น CommonJS esModuleInterop: true แทรก interop shim เล็ก ๆ ที่ทำให้ default และ namespace import ของ CommonJS module ทำงานตามที่คุณคาดหวัง flag นี้เปิดเป็น default ใน config ที่แนะนำ และคุณเกือบจะอยากได้เสมอ — การปิดคือแหล่งของ import error ที่เลี่ยงได้

`moduleResolution` ควรถูกตั้งให้อธิบายอะไร?
ภายใต้ `NodeNext` ESM คุณเขียน extension อะไรเมื่อ import `./util.ts`?
`esModuleInterop` ทำอะไร?
`moduleResolution` mode ไหนที่เข้าใจ field `exports` ใน `package.json`?