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

เจาะลึก package.json

package.json คือ manifest ของทุกโปรเจกต์ Node.js มันบอก Node และ npm ว่าใครเป็นเจ้าของ package นี้ เวอร์ชันอะไร รันอย่างไร ไฟล์ไหนเป็น public API และขึ้นกับ package อะไรบ้าง ทุกคำสั่ง npm install, npm run, และ npm publish อ่านข้อมูลจากไฟล์นี้

ฟิลด์หน้าที่
nameตัวระบุ package บน npm registry (ตัวพิมพ์เล็ก, URL-safe)
versionเวอร์ชันปัจจุบัน ตาม Semantic Versioning (MAJOR.MINOR.PATCH)
typeระบบ module เริ่มต้น: "module" สำหรับ ESM, "commonjs" (หรือไม่ระบุ) สำหรับ CJS
mainentry point แบบเก่าสำหรับผู้ใช้ CJS (require('your-pkg'))
exportsentry point map สมัยใหม่ — ควบคุมว่าผู้ใช้ import อะไรได้บ้างและจาก subpath ไหน
scriptsคำสั่ง shell ที่ตั้งชื่อไว้ รันด้วย npm run <name> (รวมถึง lifecycle hook เช่น start, test)
{
"name": "my-library",
"version": "1.2.0",
"description": "A small utility library",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.js"
},
"scripts": {
"build": "tsc",
"test": "node --test",
"start": "node dist/index.js",
"lint": "eslint src"
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"typescript": "~5.4.0"
}
}

ฟิลด์ exports มีความสำคัญกว่า main ใน Node.js สมัยใหม่ มันให้คุณเปิด subpath ได้หลายเส้นทาง และส่งไฟล์ต่างกันให้ผู้ใช้ ESM กับ CJS จาก package เดียวกัน

Demo ด้านล่างสร้าง fake package จริงใน node_modules/ ให้ main ชี้ไปไฟล์หนึ่งและ exports ชี้ไปอีกไฟล์หนึ่ง แล้วให้ Node resolve มัน:

Node.js
Output (verified on Node 24)
require.resolve picked: node_modules/demo-pkg/modern.cjs
via: exports

ทั้งสองฟิลด์ valid ทางเทคนิค — ไม่มี warning เรื่อง mismatch เลย — แต่ exports ชนะทุกครั้งที่มันมีอยู่ นี่คือ failure mode เดียวกับบั๊ก “main ยังชี้ไปที่ src/index.ts”: การอัปเดต package.json ที่ AI สร้างเพิ่ม exports map สมัยใหม่แต่ลืมอัปเดต main ให้ชี้ไปที่อะไรที่ ship ได้ด้วย และในทางกลับกัน

npm ใช้ Semantic Versioning สำหรับ dependency range ไวยากรณ์ range ใน package.json ควบคุมว่าเวอร์ชันไหนที่ npm install ถือว่า compatible

Rangeความหมายตัวอย่าง
^1.2.3Compatible — อนุญาต MINOR และ PATCH bump, lock MAJOR>=1.2.3 <2.0.0
~1.2.3Patch-only — อนุญาตเฉพาะ PATCH bump>=1.2.3 <1.3.0
1.2.3Exact — เวอร์ชันนี้เท่านั้น1.2.3
*เวอร์ชันใดก็ได้ — ไม่มีข้อจำกัด>=0.0.0
{
"dependencies": {
"express": "^4.18.2",
"dotenv": "~16.3.1",
"uuid": "9.0.0",
"some-experimental-pkg": "*"
}
}

ในทางปฏิบัติ ^ คือตัวเลือกที่ใช้บ่อยที่สุดสำหรับ dependencies: มันทำให้คุณได้ release ล่าสุดที่ compatible อยู่เสมอ พร้อมป้องกัน breaking change ที่มาจาก major version ใหม่

npm semver spec ถือว่า major version 0.x เป็น unstable โดยตัวมันเอง ดังนั้น ^ บนเวอร์ชัน 0.x จะอนุญาตเฉพาะ patch bump เท่านั้น — ไม่ใช่ minor นี่คือสิ่งตรงข้ามกับที่คนส่วนใหญ่คิดว่า ^ หมายถึง และพลาดดูง่ายเมื่อกวาดตาดู package.json ที่ผสม ^1.x กับ ^0.x

npx semver พิสูจน์ได้ — ป้อน range กับรายการเวอร์ชัน มันจะพิมพ์เฉพาะตัวที่ range อนุญาต:

Terminal window
# ^0.3.1 on a *pre-1.0* package: only 0.3.x patches pass, 0.4.0 does NOT
npx semver -r "^0.3.1" 0.3.1 0.3.9 0.4.0
0.3.1
0.3.9

เทียบกับ ^1.2.3 ที่ caret ตัวเดียวกันอนุญาต minor ถัดไป:

Terminal window
npx semver -r "^1.2.3" 1.2.3 1.9.9 2.0.0
1.2.3
1.9.9

0.4.0 ถูกกรองออกจากรายการแรกอย่างเงียบๆ — npm install บน "^0.3.1" จะไม่มีวันขยับไปที่ 0.4.0 แม้ว่ามันจะดูเหมือน minor bump ปกติ ถ้า package ที่คุณ depend อยู่ยังอยู่ที่ 0.x ให้ถือว่าทุก release เป็น breaking change ที่เป็นไปได้ โดยไม่สนใจว่า caret ใน package.json ของคุณสื่ออะไร

npm init พาคุณผ่าน interactive prompt เพื่อสร้าง package.json npm init -y ข้ามทุก prompt และรับค่า default ทันที — มีประโยชน์เมื่อต้องการไฟล์ minimal อย่างรวดเร็ว

Terminal window
# Interactive wizard
npm init
# Accept all defaults immediately
npm init -y

หลังจากรัน npm init -y คุณจะได้ package.json แบบ minimal ใน directory ปัจจุบัน:

{
"name": "my-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

แก้ไขไฟล์โดยตรง หรือใช้ npm pkg set name="my-library" type="module" เพื่ออัปเดตฟิลด์ทีละตัวจาก terminal

Bundler (webpack, esbuild, Rollup, Vite) สามารถตัด export ที่ไม่ได้ใช้ออกจาก bundle สุดท้ายได้ — แต่ต้องพิสูจน์ได้ว่าการ import โมดูลหนึ่งแล้วไม่ใช้ export ของมันเลยไม่มีผลข้างเคียงที่สังเกตได้ โมดูลที่รันโค้ดตอน import เพื่อ side effect ล้วนๆ (register polyfill, patch global, inject CSS) จะทำลายการพิสูจน์นั้น เว้นแต่จะบอก bundler อย่างชัดเจน

{
"name": "my-library",
"sideEffects": false
}

"sideEffects": false คือคำสัญญาต่อทุก bundler ว่า “ทุกไฟล์ใน package นี้ตัดทิ้งได้อย่างปลอดภัยถ้าไม่มีอะไร import จากมัน” ถ้าคำสัญญานั้นเป็นเท็จ — แม้แค่ไฟล์เดียว — bundler สามารถลบโค้ดที่ผู้ใช้พึ่งพาอยู่จริงอย่างเงียบๆ (เช่น polyfill ที่รันตอน import) ทำให้เกิดบั๊กที่โผล่เฉพาะใน production build ที่ bundle แล้ว ไม่เคยเกิดตอน development หรือรันตรงๆ ด้วย Node รูปแบบที่ปลอดภัยและแม่นยำกว่าคือระบุไฟล์ที่ไม่ side-effect-free ตรงๆ:

{
"sideEffects": ["./src/polyfills.js", "*.css"]
}

Node เองไม่อ่าน "sideEffects" เลย — มันเป็น signal เฉพาะสำหรับ bundler เท่านั้น ตั้งค่าผิดไม่มีทางทำให้ node app.js พัง มันจะพังเฉพาะ bundled build เท่านั้น ซึ่งเป็นเหตุผลว่าทำไมบั๊กนี้ ship ออกไปโดยไม่มีใครสังเกตได้ง่าย

"engines" ประกาศว่า package รองรับ Node (และ npm) เวอร์ชันไหนบ้าง ด้วยตัวมันเองมันเป็นแค่เอกสาร — npm พิมพ์ warning เมื่อไม่ตรง แต่ยังติดตั้งต่อ:

{
"engines": {
"node": ">=20.0.0"
}
}

สองอย่างนี้ทำให้มัน enforced แทนที่จะเป็นแค่ advisory:

  • .nvmrc — ไฟล์ text ธรรมดา (มีแค่ version string เช่น 24.13.0) ที่ nvm use อ่านเพื่อสลับ Node เวอร์ชันที่ active มันมีผลแค่กับ shell ของ developer เท่านั้น ไม่มีผลต่อ npm install หรือ CI เว้นแต่ script จะรัน nvm use เอง
  • engine-strict=true ใน .npmrc — เปลี่ยน warning ของ "engines" ให้เป็น install failure จริง
.npmrc
engine-strict=true

เมื่อมีทั้งสองอย่าง การติดตั้งบน Node เวอร์ชันที่ไม่ compatible จะ fail อย่างชัดเจนแทนที่จะติดตั้งต่อแล้วพังตอน runtime:

Terminal window
# package.json: "engines": { "node": ">=99.0.0" } (deliberately impossible)
# .npmrc: engine-strict=true
npm install
npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm: [email protected]
npm error notsup Not compatible with your version of node/npm: [email protected]
npm error notsup Required: {"node":">=99.0.0"}
npm error notsup Actual: {"node":"v24.13.0","npm":"11.18.0"}

ถ้าไม่มี engine-strict mismatch เดียวกันเป๊ะจะพิมพ์แค่ warning แล้วติดตั้งต่อ — ความล้มเหลวจะโผล่มาทีหลัง เป็น runtime error ที่งงๆ บนอะไรก็ตามที่ syntax หรือ API ของ engine ใหม่ต้องการ

ฟิลด์ใน package.json แบ่งเป็นสองประเภทที่ต่างกันมาก: บางฟิลด์ถูกอ่านโดย Node หรือ npm ตอน install/run time (type, exports, main, engines เมื่อมี engine-strict, overrides) และบังคับผลลัพธ์บางอย่างจริง; บางฟิลด์ถูกอ่านโดย tooling ภายนอกเท่านั้น (sideEffects โดย bundler, browser โดย bundler, scripts ส่วนใหญ่โดยอะไรก็ตามที่คุณพิมพ์หลัง npm run) แค่อ่านชื่อฟิลด์ไม่พอที่จะรู้ว่าตั้งผิดจะทำให้ node app.js พัง จะพังแค่ bundled build หรือไม่พังเลยจนกว่าคนจะอ่านผิด

ตัวเลือกประโยชน์ต้นทุน
ใช้ ^ range ทุกที่ได้ bug-fix และ feature update อัตโนมัติโดยไม่ต้องแตะ package.jsonบน dependency ที่เป็น 0.x, ^ แคบลงเหลือ patch-only อย่างเงียบๆ — อ่านผิดง่ายว่า “เหมือน ^ ทั่วไป”
ใช้ ~ rangeควบคุมแน่นกว่า, การเปลี่ยนแปลง behavior ที่ไม่คาดคิดน้อยกว่าพลาด bug fix ที่มาใน minor release; ต้อง npm update มือมากกว่า
"sideEffects": falseBundle เล็กลงจาก tree-shaking ที่ก้าวร้าวขึ้นผิดถ้า package มีไฟล์ side-effecting แม้แค่ไฟล์เดียว — บั๊กโผล่เฉพาะใน bundled build
engine-strict=trueInstall fail ชัดเจนบน Node เวอร์ชันที่ไม่ compatible แทนที่จะพังตอน runtimeเพิ่ม friction ให้คนที่ใช้ Node เวอร์ชัน “ผิด” รวมถึง CI image ที่ต้อง bump ตามกัน
  • “^ หมายถึง minor และ patch bump ปลอดภัยเสมอ” ปลอดภัยแค่เหนือ 1.0.0 เท่านั้น บนเวอร์ชัน 0.x, ^ อนุญาตแค่ patch bump — npm ถือว่าทั้งสาย 0.x เป็น unstable โดยนิยาม
  • “sideEffects: false เป็นแค่ hint ที่ bundler อาจใช้” มันเป็นคำสัญญาที่หนักแน่น bundler ที่เชื่อมันจะลบไฟล์ที่มันคิดว่าไม่ได้ใช้ แม้ไฟล์นั้นจะรัน side effect ที่จำเป็นอยู่ตอน import จริงๆ
  • “engines block การติดตั้งบน Node เวอร์ชันที่ไม่ compatible โดยค่าเริ่มต้น” โดยค่าเริ่มต้นมันเป็นแค่ advisory — warning ไม่ใช่ failure มันจะกลายเป็น hard failure ก็ต่อเมื่อมี engine-strict=true ใน .npmrc

Training data ปฏิบัติกับ range ^0.x เหมือนกับ ^1.x ทุกประการ — special case สำหรับเวอร์ชัน pre-1.0 เป็นช่องว่างจริงในสัญชาตญาณ semver ของคนส่วนใหญ่ (และของโมเดลส่วนใหญ่) ไม่ใช่ typo มันโผล่ใน diff ของ package.json ที่ AI generate เป็น range ที่ดูปกติทุกประการแต่ผิดอย่างเงียบๆ

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

{
"name": "my-cli-tool",
"version": "1.0.0",
"dependencies": {
"fast-json-schema": "^0.3.1"
}
}
เฉลย

สิ่งนี้ดูเหมือน caret range ปกติ แต่ fast-json-schema ยังอยู่ที่ major 0.x สำหรับ dependency ที่เป็น 0.x, ^ อนุญาตแค่ patch bump (0.3.x) — มันจะไม่มีวันขยับไปที่ 0.4.0 แม้ว่ามันจะดูเหมือน minor update ปกติ ถ้าเจตนาคือ “0.3.x หรือ 0.4.x ก็ใช้ได้” range ที่ถูกต้องคือ ~0.3.1 || ~0.4.0 หรือ pin เวอร์ชันตรงๆ แล้ว bump เองอย่างตั้งใจ เพราะ package ที่ยังไม่ถึง 1.0 ไม่ได้สัญญาความ compatible ข้าม minor version เลย

ฟิลด์ใดใน package.json ที่แนะนำให้ใช้แทน "main" ใน Node.js เวอร์ชันใหม่ สำหรับการควบคุมสิ่งที่ package เปิดเผย?
คุณต้องการอนุญาตเฉพาะการอัปเดต patch-level ของ dependency โดยไม่ให้มี minor หรือ major bump ควรใช้ semver range prefix ใด?
"npm init -y" ทำอะไร?
ผู้ใช้รัน require('demo-pkg') การ import จะ resolve ไปที่ไฟล์ไหนจริงๆ?
{
  "name": "demo-pkg",
  "main": "./legacy.cjs",
  "exports": "./modern.cjs"
}
Dependency ตัวหนึ่งยังอยู่ที่เวอร์ชัน 0.x range ใน package.json คือ "^0.3.1" npm ถือว่าเวอร์ชันไหนต่อไปนี้ compatible สำหรับ npm install?