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

การจัดการ Dependencies

package.json แยก dependency ออกเป็นหลายส่วนตามจุดประสงค์การใช้งาน การเข้าใจความแตกต่างช่วยให้ production bundle มีขนาดเล็กลง ป้องกัน peer conflict และทำให้ CI builds มีความน่าเชื่อถือ

dependencies คือ package ที่โค้ดของคุณต้องการเมื่อ ทำงานจริง เมื่อผู้ใช้ติดตั้ง package ของคุณ npm จะ install dependencies เหล่านี้ไปด้วย

Terminal window
npm install express

คำสั่งนี้เพิ่ม express เข้าไปในส่วน "dependencies" ของ package.json:

{
"dependencies": {
"express": "^4.18.2"
}
}

devDependencies คือ package ที่ต้องใช้เฉพาะระหว่าง การพัฒนา เช่น test runner, linter, bundler หรือ type definitions ไม่ถูก install เมื่อผู้ใช้ติดตั้ง package ของคุณ และไม่ถูก install ใน environment production เมื่อรัน npm ci --omit=dev

Terminal window
npm install --save-dev eslint vitest typescript
{
"devDependencies": {
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}

peerDependencies ระบุ package ที่ ผู้ใช้ต้องจัดหาเอง นี่คือรูปแบบที่ถูกต้องสำหรับ plugin, framework integration และ shared library ที่ไม่ควรถูก bundle แยกไว้ในทุก package ที่ใช้มัน

{
"name": "eslint-plugin-my-rules",
"version": "1.0.0",
"peerDependencies": {
"eslint": ">=8.0.0"
}
}

ตั้งแต่ npm 7 เป็นต้นมา npm install จะ ติดตั้งให้อัตโนมัติ เมื่อ peer dependency ที่ขาดไปสามารถ resolve version ที่ตรงกับ range ได้ — ไม่ต้องมีขั้นตอนหรือ flag เพิ่มเติม npm จะแจ้งเตือน peer dependency ก็ต่อเมื่อไม่สามารถ resolve ได้อัตโนมัติเท่านั้น (เช่น version ที่ติดตั้งอยู่แล้วขัดกับ range ที่ต้องการ) ตัว plugin เองไม่เคยรวมสำเนา eslint ของตัวเองมาด้วย — มันใช้ตัวที่อยู่ในโปรเจกต์ของผู้ใช้

npm สร้าง package-lock.json เมื่อรัน npm install ครั้งแรก lockfile นี้ระบุ version ที่แน่นอนของทุก dependency (รวมถึง dependency ลำดับที่สอง) เพื่อให้ทุกคนในทีมและ CI ได้รับ dependency tree ที่เหมือนกันทุกประการ

{
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTg=="
}
}

Lockfile entry แต่ละอันมีฟิลด์ integrity ด้วย — hash แบบ base64 ของ byte ของ tarball จริงที่ npm resolve มา นี่คือสิ่งที่ทำให้ npm ci เป็น security boundary ไม่ใช่แค่ version pin: การ re-download ที่ได้ byte ต่างออกไป (registry ที่ถูก compromise, การส่งที่เสียหาย, mirror ที่ถูกแก้ไข) จะ fail การตรวจ hash แทนที่จะติดตั้งไปอย่างเงียบๆ

Node.js
Output (verified on Node 24)
matches lockfile: true
tampered matches lockfile: false
Terminal window
# npm install — resolves and potentially updates the lockfile
npm install
# npm ci — installs exactly what is in the lockfile; fails if package.json
# and package-lock.json are out of sync; always starts with a clean slate
npm ci

ใช้ npm install ระหว่างพัฒนาเมื่อต้องการอัปเดต dependency ใช้ npm ci ใน CI/CD และ Docker build เพื่อให้ได้ reproducible install ที่เร็วและปลอดภัย

คำกล่าวอ้าง “fail อย่างชัดเจน” ของ npm ci ไม่ใช่ทฤษฎี — นี่คือ transcript จริง โปรเจกต์หนึ่งติดตั้ง lockfile แล้ว package.json drift ออกไป (เพิ่ม dependency ด้วยมือโดยไม่ได้รัน npm install ใหม่):

Terminal window
npm ci
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: [email protected] from lock file
npm error Missing: [email protected] from lock file

มันไม่พยายาม reconcile หรือติดตั้งบางส่วนเลย — มัน refuse ทั้งหมด นี่คือ failure mode ที่ควรจับ lockfile ที่ล้าสมัยได้ใน CI ก่อนที่ dependency tree ที่แย่จะไปถึง production

จุดที่ความแตกต่างนี้ถูกทำผิดบ่อยที่สุดคือ Dockerfile การใช้ npm install ใน image build ทำลายจุดประสงค์ทั้งหมดของ lockfile — image build แต่ละครั้งสามารถ resolve transitive version ต่างกันเล็กน้อย แม้จาก package.json เดียวกันเป๊ะ:

# Anti-pattern: npm install in a Docker image
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY . .
CMD ["node", "server.js"]
# Correct: npm ci pins the exact tree the lockfile recorded
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

npm ci --omit=dev ยังลบ node_modules ก่อนติดตั้งด้วย ดังนั้น layer เก่าจาก build ก่อนหน้าจึงไม่มีทาง leak package dev-only (หรือแย่กว่านั้นคือ version อื่นของ dependency จริง) เข้าไปใน production image ได้ npm install ใน Dockerfile คือบั๊กจริง ไม่ใช่แค่เรื่อง style: build สองครั้งจาก source tree เดียวกันเป๊ะ ห่างกันหลายวัน สามารถสร้าง image ที่ต่างกัน ไม่ reproducible ได้

overrides บังคับ version ที่กำหนดสำหรับ dependency ที่ไหนก็ตามที่มันปรากฏใน tree รวมถึงที่ซ้อนอยู่ภายใน dependency ที่คุณไม่ได้ควบคุม — เครื่องมือสำหรับ transitive package ที่ถูก pin ไว้ที่ version เก่าที่มีช่องโหว่ ที่ maintainer ของมันเองยังไม่ bump

{
"overrides": {
"minimatch": "^3.1.2"
}
}

Before/after จริง capture มาจากโปรเจกต์ที่มี direct dependency ตัวเดียวที่ตัวมันเอง pin minimatch เก่าที่มีช่องโหว่แบบตรงเป๊ะ:

Terminal window
npm ls minimatch
[email protected] /path/to/overrides-demo
└─┬ [email protected] -> ./outer
{
"overrides": { "minimatch": "^3.1.2" }
}
Terminal window
npm install && npm ls minimatch
[email protected] /path/to/overrides-demo
└─┬ [email protected] -> ./outer

minimatch transitive ย้ายจาก 3.0.4 ไปเป็น 3.1.5 โดยไม่ต้องแตะ outer-lib เลย overrides บังคับ version ที่ขัดกับ peer requirement ของ dependency เองไม่ได้ — npm ยังตรวจ compatibility อยู่ — แต่มันคือเครื่องมือที่ถูกต้องสำหรับสถานการณ์แบบนี้เป๊ะ ที่การรอให้ maintainer ทุกคนตลอดสาย publish bump ไม่ใช่ตัวเลือก

npm audit ตรวจ dependency ที่ติดตั้งกับ npm security advisory database และรายงานช่องโหว่ที่รู้จัก พร้อม severity และวิธีแก้ที่แนะนำ ผลลัพธ์จริงบนโปรเจกต์ที่ pin minimatch เก่าไว้โดยตั้งใจ:

Terminal window
npm audit
# npm audit report
minimatch <=3.1.3
Severity: high
minimatch ReDoS vulnerability - https://github.com/advisories/GHSA-f8q6-p94x-37v3
minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern - https://github.com/advisories/GHSA-3ppc-4f35-3m26
minimatch has ReDoS: matchOne() combinatorial backtracking via multiple non-adjacent GLOBSTAR segments - https://github.com/advisories/GHSA-7r86-cg39-jmmj
minimatch ReDoS: nested *() extglobs generate catastrophically backtracking regular expressions - https://github.com/advisories/GHSA-23c5-xmqv-rm74
fix available via `npm audit fix --force`
Will install [email protected], which is outside the stated dependency range
node_modules/minimatch
1 high severity vulnerability

npm audit fix apply fix ที่ยังอยู่ใน semver range เดิม; npm audit fix --force จะติดตั้ง version ที่นอกช่วงที่ประกาศไว้ (ตามที่ flag ไว้ข้างบน) เพื่อปิดช่องโหว่ ซึ่งอาจเป็น breaking change — ควร review เสมอก่อนรันมันแบบไม่มีคนดูใน CI

Workspaces ให้ root package.json ตัวเดียวจัดการหลาย package ใน repository เดียว (monorepo) โดย symlink cross-package dependency เข้าไปใน node_modules ของแต่ละ package แทนที่จะ publish แล้วติดตั้งใหม่ทุกครั้งที่มีการเปลี่ยนแปลง

{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*"]
}
flowchart TD
  Root["root package.json (workspaces: packages/*)"] --> Core["packages/core (@ws-demo/core)"]
  Root --> Cli["packages/cli (@ws-demo/cli)"]
  Cli -->|"depends on ws-demo/core (workspace range: *)"| CoreLink["node_modules/@ws-demo/core -> symlink to packages/core"]
  CoreLink -.->|"resolves to"| Core
Workspace สองแพ็คเกจ ที่ symlink แทนการ publish

ผลลัพธ์จริงจาก workspace สอง package (packages/core, packages/cli ที่ cli depend อยู่บน core):

Terminal window
npm ls --all
ws-demo@ /path/to/ws-demo
├─┬ @ws-demo/[email protected] -> ./packages/cli
│ └── @ws-demo/[email protected] deduped -> ./packages/core
└── @ws-demo/[email protected] -> ./packages/core

-w/--workspace scope คำสั่งไปยัง package เดียวโดยไม่ต้อง cd เข้าไป:

Terminal window
npm -w @ws-demo/core exec -- node -e "console.log(require('./index.js').add(2,3))"
5

ไม่มี tarball ไม่มีขั้นตอน publish ไม่มี version bump — @ws-demo/cli เห็นสิ่งที่อยู่บน disk ใน packages/core ตอนนี้เลยผ่าน symlink ซึ่งเป็นสิ่งที่ทำให้ workspaces เร็วสำหรับการ iterate หลาย package ไปพร้อมกัน

ทุกคำสั่งจัดการ dependency ในบทนี้ตอบคำถามเดียวกัน: “byte ที่แน่ชัดที่ฉันกำลังจะรันคืออะไร และฉันเชื่อที่มาของมันได้ไหม” Lockfile ตอบคำถามนี้สำหรับการติดตั้งครั้งเดียว (integrity hash) npm ci ตอบสำหรับทั้ง environment (fail ถ้า lockfile กับ manifest ไม่ตรงกัน ล้าง node_modules ก่อนเสมอ) overrides ตอบเมื่อ transitive dependency ที่คุณไม่ได้ควบคุม ship สิ่งที่คุณต้องบังคับเปลี่ยนอยู่ดี npm audit ตอบเทียบกับ public vulnerability database ไม่มีอันไหนเกี่ยวกับ “ทำให้ dependency ทำงาน” — ทั้งหมดเกี่ยวกับการทำให้แน่ใจว่าสิ่งที่ติดตั้งจริงตรงกับสิ่งที่คุณเชื่อว่าติดตั้งไป

ตัวเลือกประโยชน์ต้นทุน
npm ci ใน CI/DockerReproducible, เร็ว (ไม่มีขั้นตอน resolve), fail ชัดเจนเมื่อ driftใช้เพิ่มหรืออัปเดต dependency ไม่ได้ — ยังต้อง npm install ในเครื่อง local ก่อน
overrides สำหรับ transitive pinแก้ dependency ที่มีช่องโหว่/พังที่ซ้อนอยู่โดยไม่ต้อง fork หรือรอ maintainerข้าม version ที่ maintainer เทสต์ไว้แล้ว — อาจสร้าง incompatibility ที่ override ไม่รู้
npm audit fix --forceปิดช่องโหว่ที่ fix ปกติของ npm เข้าไม่ถึงติดตั้ง version นอกช่วงอัตโนมัติ — เป็นการเปลี่ยน behavior จริง ไม่ใช่แค่ patch
WorkspacesIterate ในเครื่องได้เร็วข้าม package, ไม่ต้อง publish-แล้วติดตั้งใหม่วนไปเพิ่มความซับซ้อนของ tooling monorepo; บั๊กที่เกิดเฉพาะใน workspace อาจมองไม่เห็นจนกว่า package จะถูก publish แยกกันจริง
  • “npm ci แค่เป็น npm install ที่เร็วกว่า” มันเป็น contract ที่ต่างกัน: มัน refuse รันทั้งหมดถ้า package.json กับ lockfile ไม่ตรงกัน และมันลบ node_modules ก่อนเสมอ ความเร็วเป็นแค่ผลข้างเคียง ไม่ใช่จุดประสงค์
  • “overrides มีผลแค่กับ direct dependency ของคุณเองเท่านั้น” มันเข้าถึงทั้ง tree รวมถึง package ที่ซ้อนอยู่หลายชั้นที่คุณไม่เคยระบุใน package.json ของตัวเองเลย
  • “npm audit fix กับ npm audit fix --force ทำสิ่งเดียวกัน แค่แรงกว่า” เวอร์ชันธรรมดา apply fix เฉพาะที่อยู่ใน semver range เดิมของคุณ --force จะกระโดดไป version นอกช่วงที่ประกาศไว้ — เป็น upgrade จริง ที่มีโอกาส breaking change จริง

Dockerfile ที่ AI generate เอื้อมไปหา npm install บ่อยกว่า npm ci มาก เพราะ npm install คือคำสั่งที่ปรากฏในเกือบทุก tutorial และ README — npm ci เป็นความรู้เฉพาะ CI ที่โผล่ใน training data ทั่วไปน้อยกว่ามาก

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

FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]
เฉลย

npm install ภายใน Docker image build สามารถ resolve dependency tree ต่างจากที่บันทึกไว้ใน package-lock.json ได้ แม้จะมาจาก package.json เดียวกันเป๊ะ — lockfile ถูก copy เข้ามาแต่ไม่เคยถูก enforce build สองครั้งของ commit เดียวกัน ห่างกันหลายวัน สามารถ ship transitive version ต่างกันอย่างเงียบๆ วิธีแก้คือ RUN npm ci --omit=dev: มันติดตั้งตรงตามที่ lockfile ระบุ, fail การ build ถ้า lockfile ไม่ sync, และข้าม devDependencies ที่ไม่มีที่ทางใน production image

ควรระบุ test framework อย่าง Vitest ไว้ในส่วนใดของ package.json?
จุดประสงค์หลักของ peerDependencies คืออะไร?
จะเกิดอะไรขึ้นเมื่อรัน npm ci แล้ว package.json ไม่ sync กับ package-lock.json?
Dockerfile นี้ผิดตรงไหน ในเมื่อ package-lock.json ถูก copy เข้ามาแล้ว?
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
โปรเจกต์หนึ่ง depend บน outer-lib ซึ่ง pin [email protected] เก่าที่มีช่องโหว่ไว้ตรงเป๊ะ (ไม่มี range) ฟิลด์ใดใน package.json ที่ให้คุณบังคับ minimatch ไปยัง version ที่ patch แล้วทุกที่ใน tree โดยไม่ต้อง fork หรือรอ maintainer ของ outer-lib?