เจาะลึก package.json
Manifest ของโปรเจกต์
หัวข้อที่มีชื่อว่า “Manifest ของโปรเจกต์”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 |
main | entry point แบบเก่าสำหรับผู้ใช้ CJS (require('your-pkg')) |
exports | entry 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 เดียวกัน
exports ชนะ main — พิสูจน์ ไม่ใช่แค่กล่าวอ้าง
หัวข้อที่มีชื่อว่า “exports ชนะ main — พิสูจน์ ไม่ใช่แค่กล่าวอ้าง”Demo ด้านล่างสร้าง fake package จริงใน node_modules/ ให้ main ชี้ไปไฟล์หนึ่งและ exports ชี้ไปอีกไฟล์หนึ่ง แล้วให้ Node resolve มัน:
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 ได้ด้วย และในทางกลับกัน
Semver ranges
หัวข้อที่มีชื่อว่า “Semver ranges”npm ใช้ Semantic Versioning สำหรับ dependency range ไวยากรณ์ range ใน package.json ควบคุมว่าเวอร์ชันไหนที่ npm install ถือว่า compatible
| Range | ความหมาย | ตัวอย่าง |
|---|---|---|
^1.2.3 | Compatible — อนุญาต MINOR และ PATCH bump, lock MAJOR | >=1.2.3 <2.0.0 |
~1.2.3 | Patch-only — อนุญาตเฉพาะ PATCH bump | >=1.2.3 <1.3.0 |
1.2.3 | Exact — เวอร์ชันนี้เท่านั้น | 1.2.3 |
* | เวอร์ชันใดก็ได้ — ไม่มีข้อจำกัด | >=0.0.0 |
ตัวอย่าง Dependencies block
หัวข้อที่มีชื่อว่า “ตัวอย่าง Dependencies block”{ "dependencies": { "express": "^4.18.2", "dotenv": "~16.3.1", "uuid": "9.0.0", "some-experimental-pkg": "*" }}ในทางปฏิบัติ ^ คือตัวเลือกที่ใช้บ่อยที่สุดสำหรับ dependencies: มันทำให้คุณได้ release ล่าสุดที่ compatible อยู่เสมอ พร้อมป้องกัน breaking change ที่มาจาก major version ใหม่
^0.x คือกับดัก: caret หมายถึง patch-only เมื่อต่ำกว่า 1.0.0
หัวข้อที่มีชื่อว่า “^0.x คือกับดัก: caret หมายถึง patch-only เมื่อต่ำกว่า 1.0.0”npm semver spec ถือว่า major version 0.x เป็น unstable โดยตัวมันเอง ดังนั้น ^ บนเวอร์ชัน 0.x จะอนุญาตเฉพาะ patch bump เท่านั้น — ไม่ใช่ minor นี่คือสิ่งตรงข้ามกับที่คนส่วนใหญ่คิดว่า ^ หมายถึง และพลาดดูง่ายเมื่อกวาดตาดู package.json ที่ผสม ^1.x กับ ^0.x
npx semver พิสูจน์ได้ — ป้อน range กับรายการเวอร์ชัน มันจะพิมพ์เฉพาะตัวที่ range อนุญาต:
# ^0.3.1 on a *pre-1.0* package: only 0.3.x patches pass, 0.4.0 does NOTnpx semver -r "^0.3.1" 0.3.1 0.3.9 0.4.00.3.10.3.9เทียบกับ ^1.2.3 ที่ caret ตัวเดียวกันอนุญาต minor ถัดไป:
npx semver -r "^1.2.3" 1.2.3 1.9.9 2.0.01.2.31.9.90.4.0 ถูกกรองออกจากรายการแรกอย่างเงียบๆ — npm install บน "^0.3.1" จะไม่มีวันขยับไปที่ 0.4.0 แม้ว่ามันจะดูเหมือน minor bump ปกติ ถ้า package ที่คุณ depend อยู่ยังอยู่ที่ 0.x ให้ถือว่าทุก release เป็น breaking change ที่เป็นไปได้ โดยไม่สนใจว่า caret ใน package.json ของคุณสื่ออะไร
การสร้าง package.json ด้วย npm init
หัวข้อที่มีชื่อว่า “การสร้าง package.json ด้วย npm init”npm init พาคุณผ่าน interactive prompt เพื่อสร้าง package.json npm init -y ข้ามทุก prompt และรับค่า default ทันที — มีประโยชน์เมื่อต้องการไฟล์ minimal อย่างรวดเร็ว
# Interactive wizardnpm init
# Accept all defaults immediatelynpm 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
sideEffects และ tree-shaking
หัวข้อที่มีชื่อว่า “sideEffects และ tree-shaking”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, .nvmrc, และ engine-strict
หัวข้อที่มีชื่อว่า “engines, .nvmrc, และ engine-strict”"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 จริง
engine-strict=trueเมื่อมีทั้งสองอย่าง การติดตั้งบน Node เวอร์ชันที่ไม่ compatible จะ fail อย่างชัดเจนแทนที่จะติดตั้งต่อแล้วพังตอน runtime:
# package.json: "engines": { "node": ">=99.0.0" } (deliberately impossible)# .npmrc: engine-strict=truenpm installnpm error code EBADENGINEnpm error engine Unsupported enginenpm 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 ใหม่ต้องการ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ฟิลด์ใน 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 หรือไม่พังเลยจนกว่าคนจะอ่านผิด
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
ใช้ ^ range ทุกที่ | ได้ bug-fix และ feature update อัตโนมัติโดยไม่ต้องแตะ package.json | บน dependency ที่เป็น 0.x, ^ แคบลงเหลือ patch-only อย่างเงียบๆ — อ่านผิดง่ายว่า “เหมือน ^ ทั่วไป” |
ใช้ ~ range | ควบคุมแน่นกว่า, การเปลี่ยนแปลง behavior ที่ไม่คาดคิดน้อยกว่า | พลาด bug fix ที่มาใน minor release; ต้อง npm update มือมากกว่า |
"sideEffects": false | Bundle เล็กลงจาก tree-shaking ที่ก้าวร้าวขึ้น | ผิดถ้า package มีไฟล์ side-effecting แม้แค่ไฟล์เดียว — บั๊กโผล่เฉพาะใน bundled build |
engine-strict=true | Install fail ชัดเจนบน Node เวอร์ชันที่ไม่ compatible แทนที่จะพังตอน runtime | เพิ่ม friction ให้คนที่ใช้ Node เวอร์ชัน “ผิด” รวมถึง CI image ที่ต้อง bump ตามกัน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
^หมายถึง minor และ patch bump ปลอดภัยเสมอ” ปลอดภัยแค่เหนือ1.0.0เท่านั้น บนเวอร์ชัน0.x,^อนุญาตแค่ patch bump — npm ถือว่าทั้งสาย0.xเป็น unstable โดยนิยาม - “
sideEffects: falseเป็นแค่ hint ที่ bundler อาจใช้” มันเป็นคำสัญญาที่หนักแน่น bundler ที่เชื่อมันจะลบไฟล์ที่มันคิดว่าไม่ได้ใช้ แม้ไฟล์นั้นจะรัน side effect ที่จำเป็นอยู่ตอน import จริงๆ - “
enginesblock การติดตั้งบน Node เวอร์ชันที่ไม่ compatible โดยค่าเริ่มต้น” โดยค่าเริ่มต้นมันเป็นแค่ advisory — warning ไม่ใช่ failure มันจะกลายเป็น hard failure ก็ต่อเมื่อมีengine-strict=trueใน.npmrc
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”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 เลย