Skip to content

บทที่ 1 — Modules + npm: ระบบเลือดของโปรเจกต์ Node

← บทที่ 0: Node คืออะไร | สารบัญ | บทที่ 2: Core Modules + process →

ทุกโปรเจกต์ Node เริ่มต้นด้วย 2 เรื่องนี้เสมอ และเป็น 2 เรื่องที่มือใหม่งงมากที่สุด:

  1. Module — แตกโค้ดเป็นหลายไฟล์แล้วเอามาต่อกันยังไง (require vs import — ทำไมมี 2 แบบ?)
  2. npmnpm install ทำอะไรกันแน่, package.json คืออะไร, ตัวเลขเวอร์ชัน ^1.2.3 แปลว่าอะไร (จะอธิบายเต็มในข้อ 6)

เข้าใจ 2 เรื่องนี้ = เลิกกลัว error สีแดงตอนตั้งโปรเจกต์


1. ทำไมต้องมี module

โปรแกรมจริงมีโค้ดเป็นพัน ๆ บรรทัด — ยัดไว้ไฟล์เดียวคือหายนะ module คือการแบ่งโค้ดเป็นไฟล์ ๆ แล้วให้แต่ละไฟล์ "ส่งออก" (export) สิ่งที่อยากให้ไฟล์อื่นใช้ และ "นำเข้า" (import) สิ่งที่ต้องการจากไฟล์อื่น

ปัญหาคือ Node มี 2 ระบบ module ที่ต้องรู้จักทั้งคู่ เพราะคุณจะเจอทั้งสองในโลกจริง:

text
CommonJS (CJS)         ← ของเดิมของ Node ตั้งแต่ 2009  →  require() / module.exports
ES Modules (ESM)       ← มาตรฐานทางการของภาษา (ES2015) →  import / export

2. CommonJS (CJS) — ของดั้งเดิมของ Node

ตอน Node เกิด (2009) ภาษา JavaScript ยังไม่มี ระบบ module ทางการ Node เลยสร้างของตัวเองชื่อ CommonJS ขึ้นมาใช้ก่อน — ใช้ 2 คำ: require() (นำเข้า) และ module.exports (ส่งออก)

javascript
// math.cjs — ไฟล์ที่ "ส่งออก" ฟังก์ชัน
function add(a, b) {
  return a + b;
}
function sub(a, b) {
  return a - b;
}

// module.exports = object ที่บอกว่า "ไฟล์นี้ส่งออกอะไรบ้าง"
module.exports = { add, sub };
javascript
// app.cjs — ไฟล์ที่ "นำเข้า" มาใช้
// require() = อ่านไฟล์นั้น, รันมัน, แล้วคืน module.exports ของมันกลับมา
const math = require("./math.cjs");

console.log(math.add(2, 3));   // 5
// หรือ destructure เอาเฉพาะที่ใช้ (destructure = แกะเอาเฉพาะตัวที่ต้องการออกมาจาก object/array)
const { sub } = require("./math.cjs");
console.log(sub(5, 2));        // 3

จุดเด่นของ CJS: require() ทำงาน แบบ synchronous (synchronous = ทำเสร็จทีละบรรทัดตามลำดับ อ่านไฟล์เสร็จแล้วค่อยทำบรรทัดถัดไป) และเรียกที่ไหนก็ได้กลางโค้ด — ยืดหยุ่นแต่ไม่ค่อยเหมาะกับการ optimize (optimize = ทำให้โค้ดรันเร็วขึ้น/เล็กลง เช่นตัดของที่ import แล้วไม่ได้ใช้ทิ้ง — เครื่องมือทำแบบนี้ได้ง่ายกว่าถ้ารู้ล่วงหน้าว่าไฟล์ import อะไรบ้างตั้งแต่ต้น ซึ่ง require() กลางโค้ดทำให้รู้ล่วงหน้าไม่ได้)

💡 อัปเดต 2026: เดิมที CJS กับ ESM แยกขาดกันชัดเจน (CJS โหลด ESM ไม่ได้ ต้อง dynamic import) — แต่ Node เวอร์ชันใหม่ ๆ เพิ่มความสามารถ require(esm) ให้ CJS โหลดไฟล์ ESM แบบ synchronous ได้แล้ว (ยังมีข้อจำกัด เช่น ไฟล์ ESM นั้นต้องไม่มี top-level await) — ตรวจสอบเวอร์ชัน Node ขั้นต่ำที่แน่ชัดในเอกสารทางการก่อนใช้ เพราะฟีเจอร์นี้ยังค่อนข้างใหม่ เส้นแบ่งระหว่าง 2 ระบบเริ่มเบลอลง แต่ของใหม่ก็ยัง เลือก ESM ไว้ก่อน


3. ES Modules (ESM) — มาตรฐานทางการของภาษา (ใช้ตัวนี้ในของใหม่)

ปี 2015 ภาษา JavaScript มีระบบ module ทางการแล้ว: import / export — ตัวเดียวกับที่ใช้ใน React/frontend และเป็น มาตรฐานที่ควรใช้กับโปรเจกต์ใหม่

javascript
// math.mjs — ส่งออกด้วย export
export function add(a, b) {
  return a + b;
}
export function sub(a, b) {
  return a - b;
}
// จะมี "default export" 1 ตัวต่อไฟล์ก็ได้
export default function multiply(a, b) {
  return a * b;
}
javascript
// app.mjs — นำเข้าด้วย import
// ⚠️ ESM ต้องใส่นามสกุลไฟล์เต็ม ๆ ใน relative import เสมอ ("./math.mjs" ไม่ใช่ "./math")
import multiply, { add, sub } from "./math.mjs";
//     ↑ default    ↑ named exports (ต้องชื่อตรงกับที่ export)

console.log(add(2, 3));        // 5
console.log(multiply(2, 4));   // 8

// import ทั้งหมดเป็น namespace ก็ได้
import * as math from "./math.mjs";
console.log(math.sub(5, 2));   // 3

ความต่างที่สำคัญ: import ต้องอยู่ บนสุดของไฟล์ (static = ตายตัว เขียนได้ที่เดียวบนสุด ย้ายไปกลางโค้ดไม่ได้) — Node อ่าน import ทั้งหมดก่อนเริ่มรัน ทำให้ optimize/วิเคราะห์ได้ดีกว่า ถ้าจะ import แบบมีเงื่อนไขกลางโค้ดต้องใช้ dynamic import (dynamic = ยืดหยุ่น เรียกตอนไหนก็ได้) ซึ่งคืน Promise (Promise = วัตถุที่แทนงานที่จะเสร็จในอนาคต — บทที่ 3 อธิบายเต็ม):

javascript
// dynamic import — ใช้ตอนต้องการโหลด module แบบมีเงื่อนไข / lazy
// process.env.DEBUG = ตัวแปรสภาพแวดล้อมชื่อ DEBUG (ตั้งค่าจากนอกโปรแกรม เช่น `DEBUG=1 node app.js`) — อธิบายเต็มบทที่ 2
if (process.env.DEBUG) {
  const { inspect } = await import("node:util");   // คืน Promise → ต้อง await
  console.log(inspect(someObject));
}

💡 await กลางโค้ดได้ทำไม? ปกติ await (= "รอผลของ Promise") ต้องอยู่ใน async function (ฟังก์ชันที่รองรับงาน async — บทที่ 3 อธิบายเต็ม) — แต่ Node 14.8+ รองรับ top-level await ในไฟล์ ESM (มี "type": "module" หรือไฟล์ .mjs) เลยใช้ await import(...) ได้ตรง ๆ ที่ระดับบนสุดของไฟล์ไม่ต้องห่อฟังก์ชัน · ใน CommonJS ยังต้องห่อด้วย async function แล้วเรียกเสมอ


4. Node รู้ได้ยังไงว่าไฟล์นี้เป็น CJS หรือ ESM

นี่คือจุดที่มือใหม่พังบ่อยที่สุด Node ตัดสินจาก 2 อย่าง:

เงื่อนไขNode มองเป็น
นามสกุล .mjsESM เสมอ
นามสกุล .cjsCommonJS เสมอ
นามสกุล .js และ package.json มี "type": "module"ESM
นามสกุล .js และ ไม่มี "type" (หรือ "type": "commonjs")CommonJS (ค่า default เก่า)

แปลว่า: ในโปรเจกต์ใหม่ ใส่ "type": "module" ใน package.json แล้วไฟล์ .js ทุกไฟล์จะเป็น ESM อัตโนมัติ — เป็นวิธีที่แนะนำในปี 2026

⚠️ Pitfall ที่เจอบ่อยสุด: เขียน import ในไฟล์ .js แต่ลืมใส่ "type": "module" → Node เด้ง error SyntaxError: Cannot use import statement outside a module (แปล: "ใช้คำสั่ง import นอกไฟล์ที่เป็น module ไม่ได้") — แก้ด้วยการเพิ่ม "type": "module" หรือเปลี่ยนนามสกุลเป็น .mjs (สำหรับคน TypeScript ใช้ .mts เป็นคู่)

ปัญหา ESM ที่ต้องรู้: ไม่มี __dirname / require

ใน CJS มีตัวแปรวิเศษ __dirname (path โฟลเดอร์ปัจจุบัน) และ require ให้ใช้ฟรี — ใน ESM ไม่มี ต้องสร้างเอง:

javascript
// ESM: หา path ของไฟล์/โฟลเดอร์ปัจจุบัน
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

// import.meta.url = URL ของไฟล์นี้ เช่น 'file:///home/app/index.js'
const __filename = fileURLToPath(import.meta.url);   // → /home/app/index.js
const __dirname = dirname(__filename);               // → /home/app

// Node 20.11+ มีทางลัด:
console.log(import.meta.dirname);   // → /home/app (ไม่ต้องแปลงเอง)

💡 จำง่าย ๆ: เห็น require/module.exports/__dirname = CJS · เห็น import/export/import.meta = ESM · โปรเจกต์ใหม่เลือก ESM


5. package.json — บัตรประชาชนของโปรเจกต์

ทุกโปรเจกต์ Node มีไฟล์ package.json ที่ราก — เป็น "บัตรประชาชน + รายการของที่ต้องใช้" สร้างด้วย:

bash
npm init -y     # -y = ตอบ yes ทุกข้อ สร้างไฟล์ default ให้เลย

ได้ไฟล์ประมาณนี้ (ตัวอย่างนี้เพิ่ม // comment เพื่ออธิบายแต่ละ field):

⚠️ อย่าก๊อปตัวอย่างนี้ไปวางใน package.json ตรง ๆJSON มาตรฐาน ไม่รองรับ comment จะ parse ไม่ผ่าน · ตัวอย่างนี้ใช้ fence jsonc (JSON with Comments) เพื่ออ่านง่ายเท่านั้น เวลาใช้จริงให้ลบ // ทั้งหมดออก

jsonc
{
  "name": "my-app",              // ชื่อ package (ตัวเล็ก ไม่มีเว้นวรรค)
  "version": "1.0.0",            // เวอร์ชันของเรา (รูปแบบ semver)
  "type": "module",              // ⭐ บอกว่าไฟล์ .js เป็น ESM
  "main": "index.js",            // ไฟล์เริ่มต้นเมื่อคนอื่น require/import package นี้
  "scripts": {                   // คำสั่งลัด (ดูข้อ 8)
    "start": "node index.js",
    "dev": "node --watch index.js",
    "test": "node --test"
  },
  "dependencies": {              // library ที่ต้องใช้ตอนรันจริง (production = ของจริงที่ผู้ใช้ใช้งาน)
    "express": "^5.0.0"
  },
  "devDependencies": {           // library ที่ใช้แค่ตอนพัฒนา/เทส (ไม่ขึ้น production)
    "vitest": "^3.0.0"
  }
}

dependencies vs devDependencies — ต่างกันตรงไหนและทำไมต้องแยก:

  • dependencies = ของที่โค้ด production ต้องใช้ (เช่น express — ถ้าไม่มี server รันไม่ได้)
  • devDependencies = ของที่ใช้แค่ตอนพัฒนา (เช่น vitest ทดสอบ, eslint ตรวจโค้ด, typescript แปลงโค้ด) — ตอน build (= แปลงโค้ดให้พร้อมรันจริง) ขึ้น production (= เซิร์ฟเวอร์จริงที่ผู้ใช้เข้าใช้งาน) จะ npm ci --omit=dev เพื่อไม่ติดตั้งของกลุ่มนี้ → image (= แพ็คเกจของ Docker ที่บรรจุโปรแกรม + ไฟล์ที่ต้องใช้รวมกัน — Docker คือเครื่องมือห่อโปรแกรมให้รันเหมือนกันทุกเครื่อง ยังไม่ต้องรู้รายละเอียดตอนนี้) เล็กลง ปลอดภัยขึ้น

6. semver — ตัวเลขเวอร์ชัน ^1.2.3 แปลว่าอะไร

npm ใช้ Semantic Versioning (semver) — เวอร์ชันมี 3 ตัวเลข MAJOR.MINOR.PATCH:

text
        1   .   2   .   3
        │       │       │
     MAJOR    MINOR    PATCH
   (พังของเก่า) (เพิ่มของ  (แก้บั๊ก
    breaking)   ใหม่ ไม่พัง)  ไม่เพิ่มของ)
  • PATCH (1.2.3 → 1.2.4) แก้บั๊ก ไม่เปลี่ยนพฤติกรรม → อัปได้สบาย
  • MINOR (1.2.3 → 1.3.0) เพิ่มฟีเจอร์ใหม่ แต่ของเก่ายังใช้ได้ → อัปได้
  • MAJOR (1.2.3 → 2.0.0) มี breaking change → อัปแล้วโค้ดอาจพัง ต้องอ่าน changelog

เครื่องหมายหน้าเวอร์ชันใน package.json บอกว่า "ยอมให้อัปขึ้นได้แค่ไหน":

สัญลักษณ์ตัวอย่างความหมาย
^ (caret)^1.2.3อัปได้ถึงก่อน MAJOR ถัดไป → 1.x.x ใด ๆ ที่ ≥ 1.2.3 (default ของ npm)
~ (tilde)~1.2.3อัปได้แค่ PATCH → 1.2.x ที่ ≥ 1.2.3
ไม่มี1.2.3เป๊ะ ตัวนี้เท่านั้น
* / latest*อะไรก็ได้ (อันตราย อย่าใช้)

⚠️ เคสพิเศษของ ^ กับเวอร์ชัน 0.x: ^0.2.3 อัปได้แค่ 0.2.x (≥ 0.2.3) ไม่ขึ้น 0.3.0 เพราะ semver ถือว่าเวอร์ชัน 0.x ยังเป็นช่วงทดลอง แม้แต่การอัป minor (0.2 → 0.3) ก็อาจ break ได้

^ ปลอดภัยตราบใดที่ library ทำตาม semver จริง — แต่บาง library ก็ปล่อย breaking change ใน minor (มนุษย์พลาดได้) นี่คือเหตุผลที่ต้องมี lockfile (ไฟล์ตรึงเวอร์ชัน — อธิบายเต็มในข้อ 7)


7. npm install ทำอะไร + ทำไม lockfile สำคัญที่สุด

bash
npm install express        # ติดตั้ง express, เพิ่มลง dependencies
npm install -D vitest      # -D = devDependencies
npm install -g @nestjs/cli # -g = global (ติดตั้งทั้งเครื่อง ใช้เป็น CLI — ตัวอย่างนี้แสดง syntax เฉย ๆ ในทางปฏิบัติปี 2026 แนะนำ npx แทน ดูย่อหน้าถัดไป)
npm install                # ไม่ระบุชื่อ = ติดตั้งทุกอย่างใน package.json

💡 2026 แนวคิด: การติดตั้งแบบ global (-g) เป็นของยุคเก่า — ปัจจุบันแนะนำใช้ npx <tool> (รันแบบไม่ติดตั้งถาวร — ดูข้อ 9) สำหรับงานครั้งคราว หรือใส่ใน devDependencies ของโปรเจกต์เพื่อตรึงเวอร์ชันให้ทุกคนในทีมตรงกัน · global install ทำให้เพื่อนร่วมทีมได้เวอร์ชันไม่ตรงกัน + ไม่ได้ถูกตรึงโดย lockfile

npm install ทำ 3 อย่าง:

  1. อ่าน package.json ดูว่าต้องการอะไรบ้าง
  2. คำนวณว่าจะดึงเวอร์ชันไหนมา (ตาม ^/~) แล้วโหลดเก็บใน node_modules/ (โฟลเดอร์ที่เก็บโค้ด library ทั้งหมด — ใหญ่มาก ห้าม commit ขึ้น git, ใส่ .gitignore)
  3. เขียน package-lock.json — บันทึก "เวอร์ชันเป๊ะ ๆ ที่ติดตั้งจริง" ของทุก package รวมทั้ง dependency ของ dependency

ทำไม lockfile คือพระเอกตัวจริง: package.json บอกว่า "เอา express ^4.19.0" ซึ่งกว้าง — วันนี้ได้ 4.19.2 พรุ่งนี้อาจได้ 4.21.0 ทำให้เครื่องคุณกับเครื่องเพื่อน (และ server) ได้เวอร์ชันไม่ตรงกัน ผลคือเจอปัญหาแบบ "ในเครื่องผมรันได้นะ!" 🐛 (มุกยอดฮิตในวงการ dev ที่มักพูดตอนโค้ดรันได้ในเครื่องตัวเองแต่รันไม่ได้ที่อื่น เพราะเวอร์ชัน library ไม่ตรงกัน) package-lock.json ตรึงเวอร์ชันเป๊ะ ทุกคนได้เหมือนกันหมด — ต้อง commit ขึ้น git เสมอ

bash
# ใน CI / production ใช้คำสั่งนี้ ไม่ใช่ npm install:
# CI = Continuous Integration (ระบบ server ที่รัน test/build ของโปรเจกต์อัตโนมัติทุกครั้งที่มีคน push code)
npm ci      # ติดตั้งจาก lockfile เป๊ะ ๆ (เร็วกว่า + ไม่แก้ lockfile) — ถ้า lockfile กับ package.json ไม่ตรงจะ error ทันที

💡 กฎทอง: node_modules/ อยู่ใน .gitignore (สร้างใหม่ได้จาก lockfile) · package-lock.json commit เสมอ · บนเครื่องนักพัฒนาใช้ npm install · ใน CI/production ใช้ npm ci


8. npm scripts — คำสั่งลัดของโปรเจกต์

แทนที่จะจำคำสั่งยาว ๆ ตั้งชื่อสั้น ๆ ใน "scripts" แล้วเรียกด้วย npm run:

jsonc
"scripts": {
  "start": "node index.js",                    // npm start (start/test เรียกได้โดยไม่ต้อง run)
  "dev": "node --watch index.js",              // npm run dev — --watch = restart อัตโนมัติเมื่อไฟล์เปลี่ยน
  "test": "node --test",                       // npm test
  "lint": "eslint .",                          // npm run lint
  "build": "tsc"                               // npm run build
}
bash
npm run dev      # รัน "node --watch index.js"
npm start        # ลัด: start กับ test ไม่ต้องพิมพ์ run
npm test

ข้อดี: คนเข้าทีมใหม่ไม่ต้องเดาว่ารันโปรเจกต์ยังไง — เปิด package.json ดู scripts ก็รู้


9. npx, pnpm, yarn — ญาติ ๆ ของ npm

  • npx — รัน CLI tool โดยไม่ต้องติดตั้งถาวร เหมาะกับ tool ที่ใช้ครั้งเดียว เช่นสร้างโปรเจกต์:
    bash
    # vite = เครื่องมือสร้าง/รันโปรเจกต์ frontend สมัยใหม่ (React/Vue/Svelte) ใช้บ่อยเป็นจุดเริ่ม
    npx create-vite@latest my-app    # ดาวน์โหลด create-vite ชั่วคราว รันแล้วทิ้ง
    npx prettier --write .           # รัน prettier ที่ติดตั้งในโปรเจกต์
  • pnpm — package manager ทางเลือก เร็วกว่า npm มาก + ประหยัด disk (เก็บ package ไว้ที่เดียวแล้วใช้ลิงก์แทนการ copy ซ้ำทุกโปรเจกต์) — นิยมขึ้นเรื่อย ๆ คำสั่งคล้าย npm: pnpm install, pnpm add express
  • yarn — package manager รุ่นเก่ากว่า ยังเจอในโปรเจกต์เดิม ๆ
npmpnpmyarn
มากับ Node❌ (ติดตั้งเอง)
ความเร็วกลางเร็วสุด ⭐เร็ว
ประหยัด disk✅ ⭐กลาง
lockfilepackage-lock.jsonpnpm-lock.yamlyarn.lock

💡 มือใหม่ใช้ npm ไปก่อน (มากับ Node ไม่ต้องลงอะไร) — เมื่อโปรเจกต์ใหญ่ขึ้นหรือทำ monorepo (= หลายโปรเจกต์/แพ็คเกจที่เก็บรวมในไฟล์ repo เดียวกัน) ค่อยขยับไป pnpm · อย่าใช้หลายตัวปนกันในโปรเจกต์เดียว (lockfile จะตีกัน)


🛠️ Checkpoint 1 — ลงมือก่อนไปบทถัดไป

  1. สร้างโฟลเดอร์ใหม่ → npm init -y → เพิ่ม "type": "module" ใน package.json
  2. สร้าง math.js (export add, sub) และ app.js (import มาใช้) แล้วรัน node app.js ให้สำเร็จ
  3. ลองลบ "type": "module" ออก แล้วรันอีกครั้ง — อ่าน error ที่ได้ แล้วแก้กลับ (เข้าใจว่าทำไมพัง)
  4. npm install dayjs แล้ว import มา format วันที่ — เปิดดู package.json ว่า dependencies เปลี่ยนไหม, มี package-lock.json กับ node_modules/ โผล่มาไหม
  5. เพิ่ม script "dev": "node --watch app.js" แล้วรัน npm run dev — แก้ไฟล์แล้วบันทึก ดูว่ามัน restart เองไหม
  6. ใน ESM ลองพิมพ์ console.log(import.meta.dirname) เทียบกับการพยายามใช้ __dirname ตรง ๆ (จะ error)

เฉลยข้อ 3: ได้ SyntaxError: Cannot use import statement outside a module — เพราะไม่มี "type": "module" Node เลยมองไฟล์ .js เป็น CommonJS ซึ่งไม่รู้จัก import


สรุปบทที่ 1

  • Node มี 2 ระบบ module: CJS (require/module.exports — ของเดิม) และ ESM (import/export — มาตรฐานทางการ ใช้กับของใหม่)
  • Node แยก CJS/ESM จาก นามสกุลไฟล์ (.mjs/.cjs) หรือ "type" ใน package.json
  • ESM ไม่มี __dirname/require ต้องใช้ import.meta.dirname / import.meta.url
  • package.json = บัตรประชาชน + รายการ dependency · แยก dependencies (รันจริง) กับ devDependencies (พัฒนา)
  • semver ^ = อัปได้ถึงก่อน major ถัดไป, ~ = อัปแค่ patch
  • package-lock.json ตรึงเวอร์ชันเป๊ะ → commit เสมอ · CI/production ใช้ npm ci
  • npx รัน tool ชั่วคราว · pnpm เร็ว+ประหยัด disk

บทต่อไปลงลึก "ของแถม" ที่ Node ให้มาฟรี — process, path, os และ global objects ที่เบราว์เซอร์ไม่มี

→ บทที่ 2: Core Modules + process