← Lập trình JavaScript nâng cao

Bài 4 · Vận dụng · 22 phút

npm nâng cao & package managers

Biên soạn bởi Nguyễn Anh Tuấn

Quản lý gói nâng cao trong Node: workspaces/monorepo, publish gói lên npm registry, đọc sâu lockfile, và so sánh npm/yarn/pnpm/bun để chọn đúng công cụ.

Ở khoá Cơ bản (bài Module (ESM) & npm) bạn đã có nền: package.json, npm install, semver ^/~. Bài này không dạy lại - nó trả lời các câu hỏi của dự án THẬT, bắt đầu từ chỗ hay bị khai bừa: một gói thuộc loại phụ thuộc nào?

package.json (trích) - ba loại phụ thuộc, ba lời khai khác nhau

{
  "dependencies": { "ms": "^2.1.3" },
  "devDependencies": { "vitest": "^3.2.4" },
  "peerDependencies": { "eslint": "^9.0.0" }
}

dependencies: cần lúc CHẠY - ai cài gói của bạn cũng kéo theo. devDependencies (cài bằng npm install -D, viết đầy đủ là --save-dev): chỉ phục vụ lúc phát triển - test, build, lint. peerDependencies là lời khai của plugin: “tôi cần dự án host ĐÃ CÓ eslint 9” - plugin không ôm bản eslint riêng, để cả dự án dùng chung MỘT bản. Gói ngoài đời khai đúng như vậy: chạy npm view react-dom peerDependencies sẽ thấy nó đòi host có react: '^19.2.7' (kết quả lúc viết bài).

Mảnh thứ hai là scripts, kèm một quy ước ít người biết: script tên x được npm tự kẹp giữa prexpostx nếu chúng tồn tại:

package.json (trích) - rồi chạy: npm run build

{
  "scripts": {
    "prebuild": "echo don dep dist/ cu",
    "build": "echo dong goi ung dung vao dist/"
  }
}

Kết quả khi chạy

> @meo/demo-scripts@1.0.0 prebuild
> echo don dep dist/ cu

don dep dist/ cu

> @meo/demo-scripts@1.0.0 build
> echo dong goi ung dung vao dist/

dong goi ung dung vao dist/
  • dependencies: cần lúc chạy · devDependencies (-D): chỉ lúc dev/build/test · peerDependencies: “host phải có sẵn X”.
  • Từ npm 7, peer dependency còn thiếu sẽ được npm tự cài (kiểm thật: npm install react-dom kéo luôn react@19.2.7 vào).
  • npm run x tự chạy prex → x → postx - móc tiện để dọn dẹp trước build, kiểm tra sau build.

Bạn cài MỘT gói debug, nhưng debug lại cần ms - một transitive dependency (phụ thuộc của phụ thuộc). Dự án thật có hàng trăm gói kiểu này mà bạn chưa từng gõ tên:

cài 1 gói mà “added 2 packages” - ms theo debug vào (lược dòng audit); npm ls --all in cả cây

npm install debug
npm ls --all

Kết quả khi chạy

added 2 packages, and audited 3 packages in 1s
du-an-meo@1.0.0 /tmp/npmlab/du-an-meo
`-- debug@4.4.3
  `-- ms@2.1.3

Mở package-lock.json sẽ hiểu vì sao nó đáng tin: TỪNG gói trong cây - kể cả ms - bị ghim version chính xác, resolved (tải từ đâu) và integrity (hash sha512: nội dung tải về phải khớp từng bit, chống gói bị tráo trên đường đi):

package-lock.json (trích - hash rút gọn bằng …, lược vài trường phụ)

"node_modules/debug": {
  "version": "4.4.3",
  "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+…AjNbcqA==",
  "dependencies": { "ms": "^2.1.3" }
},
"node_modules/ms": {
  "version": "2.1.3",
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5o…3WRTlA=="
}

Trên máy CI, thứ bạn cần là “cài đúng y bản đã chạy ở máy dev”. npm install GIẢI khoảng phiên bản và sẵn sàng GHI LẠI lockfile; npm ci (clean install) ngược hẳn: xoá node_modules cũ, cài đúng 100% theo lockfile, không sửa lockfile - và fail ngay khi package.json lệch lockfile:

thêm dayjs vào package.json mà không install - npm ci từ chối (lược phần usage dài phía sau)

npm pkg set dependencies.dayjs='^1.11.0'   # ghi thêm 1 dòng vào package.json, KHÔNG cài gì
npm ci

Kết quả khi chạy

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: dayjs@1.11.21 from lock file
...

Chuyện thật ở Mèo Ham Học

Chính trang bạn đang đọc từng vỡ build trên Cloudflare vì máy dev dùng pnpm ĐỜI MỚI HƠN máy build: bản mới tự sinh một file cấu hình mà pnpm bản cũ không hiểu - build fail tức thì. Thuốc chữa nằm ngay trong package.json: trường "packageManager" (ở đây là "pnpm@10.11.1") cùng corepack (đi kèm Node, bật bằng corepack enable) bảo đảm mọi máy chạy ĐÚNG một bản công cụ. Lockfile ghim cây phụ thuộc; packageManager ghim chính cái máy cài.
  • Lockfile ghim cả cây (kể cả transitive dependency) + integrity hash → cài lặp lại y hệt; commit nó, đừng sửa tay.
  • CI dùng npm ci: tôn trọng tuyệt đối lockfile, fail sớm khi lệch - còn npm install có thể ghi lại lockfile.
  • Trường packageManager + corepack ghim luôn phiên bản công cụ - hết cảnh “máy em chạy được mà”.

Khi thư viện @meo/core và công cụ dòng lệnh @meo/cli phải tiến hoá cùng nhau, tách hai repo là tự chuốc khổ (sửa core → publish → sang cli cài lại… chỉ để thử MỘT dòng). Gom về một repo - monorepo - thì npm hỗ trợ sẵn qua workspaces: package.json GỐC chỉ cần "private": true (gốc không bao giờ publish) và "workspaces": ["packages/*"] - nơi ở của các gói con. Trong packages/cli/package.json, khai "@meo/core": "^1.0.0" vào dependencies như mọi gói khác, kèm script "start": "node index.js". Code hai gói:

gói này import gói kia y như một gói tải từ registry

// packages/core/index.js - gói thư viện
export function chao(ten) {
  return `Meo ${ten} xin chao tu @meo/core!`;
}

// packages/cli/index.js - gói ứng dụng
import { chao } from "@meo/core";
console.log(chao("Miu"));

một phiên terminal ở thư mục gốc - output thật (lược dòng audit và “total 0” của ls)

npm install                  # MỘT lần ở gốc cho cả repo
ls -l node_modules/@meo      # gói con được LINK vào, không copy
npm run start -w @meo/cli    # -w: chạy script của một workspace từ gốc

Kết quả khi chạy

added 2 packages, and audited 5 packages in 391ms
lrwxrwxrwx 1 root root 18 Jun 11 02:49 cli -> ../../packages/cli
lrwxrwxrwx 1 root root 19 Jun 11 02:49 core -> ../../packages/core

> @meo/cli@1.0.0 start
> node index.js

Meo Miu xin chao tu @meo/core!

Phụ thuộc của mọi workspace được hoisting - kéo lên node_modules GỐC dùng chung, cả repo cài một lần; còn gói anh em chỉ là symlink, nên sửa code core là cli thấy ngay - không có bước “cài lại bản mới”.

  • workspaces: nhiều gói một repo, npm install MỘT lần ở gốc; phụ thuộc chung được hoisting lên node_modules gốc.
  • Gói con link nhau bằng symlink - sửa @meo/core, @meo/cli thấy tức thì.
  • Chạy script từng gói ngay từ gốc: npm run start -w @meo/cli.

Muốn chia sẻ gói cho mèo con khác qua registry (kho npmjs.com), package.json cần thêm ba trường kiểm soát: "exports" - entry point hiện đại, ai import gói thì nhận đúng file bạn chỉ định; "files" - CHỈ thứ được liệt kê mới vào gói; "bin" - cài xong là có lệnh chạy từ terminal. Tên dạng @meo/chao gọi là scoped package: phần @meo là “sân riêng” theo tài khoản/tổ chức, hết lo trùng tên.

package.json của gói sắp publish

{
  "name": "@meo/chao",
  "version": "1.0.0",
  "type": "module",
  "exports": { ".": "./dist/index.js" },
  "files": ["dist"],
  "bin": { "meo-chao": "./dist/cli.js" }
}

npm pack đóng thử tarball Y NHƯ publish sẽ làm nhưng để lại máy cho bạn soi. Thư mục này có cả ghi-chu.txttest/ - xem chúng có lọt vào không:

npm pack + soi ruột tarball (phần kích thước từng file và Tarball Details lược bằng …)

npm pack
tar -tzf meo-chao-1.0.0.tgz

Kết quả khi chạy

npm notice package: @meo/chao@1.0.0
npm notice Tarball Contents
...
npm notice total files: 4
meo-chao-1.0.0.tgz
package/dist/cli.js
package/dist/index.js
package/package.json
package/README.md

ghi-chu.txt và test/ không lọt: ngoài "files", chỉ bộ luôn-kèm (package.json, README, LICENSE) được vào tarball. Còn bước đẩy lên registry thì thế này - bài học chỉ MÔ TẢ, không chạy thật, để khỏi xả gói rác lên kho chung:

quy trình publish (mô tả - KHÔNG chạy trong bài này)

npm login                      # đăng nhập tài khoản npm (mở trình duyệt xác nhận)
npm publish --access public    # gói scoped mặc định bị coi là private - phải nói rõ public
# tài khoản bật 2FA sẽ được hỏi mã OTP ngay lúc publish - RẤT nên bật
# script "prepublishOnly" (nếu có) tự chạy TRƯỚC publish: đặt test/build vào đó làm chốt chặn cuối

Bốn công cụ giải cùng một bài toán, lệnh na ná nhau - khác biệt thật nằm ở cơ chế đặt gói lên đĩa:

Công cụCơ chế lưu góiĐáng nhớ
npmnode_modules phẳng (hoisting), mỗi dự án một bản đầy đủđi kèm Node - mặc định, tương thích rộng nhất
yarnnhư npm, hoặc Plug'n'Play: bỏ node_modules, tra bảng .pnp.cjsPnP chặt chẽ nhưng kén công cụ đi kèm
pnpmstore chung cả máy (content-addressable), dự án chỉ link vào (hard link + symlink)tiết kiệm đĩa, nhanh; node_modules nghiêm - chặn phantom dependency
buncài phẳng như npm, từ cache chung của máytốc độ là ưu tiên số một; kiêm luôn RUNTIME thay Node

Hai dòng đáng dừng lại. pnpm: mười dự án cùng cần lodash thì đĩa chỉ chứa MỘT bản trong store; và vì node_modules của nó không phẳng, code import một gói bạn KHÔNG khai báo (nó chỉ tình cờ nằm đó do gói khác kéo về - một phantom dependency) sẽ vỡ ngay thay vì âm thầm chạy. bun: không chỉ cài gói, nó còn là một runtime chạy JS thay Node - còn bộ máy mà nó muốn thay thì bạn đã mổ xẻ ở bài Bản chất: event loop & libuv.

  • Lệnh gần giống nhau; khác ở cơ chế: npm phẳng · yarn có PnP tuỳ chọn · pnpm store + link · bun = package manager kiêm runtime.
  • Dự án mới: chọn MỘT công cụ, khoá bằng trường packageManager, đừng trộn lockfile - trang Mèo Ham Học chạy pnpm ghim đúng kiểu đó (Bước 2).
  • Hiểu cơ chế rồi thì đổi công cụ không đáng sợ: package.json và registry vẫn là một.

Tiếp theo

Đồ nghề quản lý gói đã đủ cho cả khoá. Bài kế quay lại viết code thật: Làm việc với tệp & thư mục - fs/promises, path, duyệt cây thư mục, theo dõi thay đổi. npm xong phần đồ nghề; giờ tới lúc đụng vào đĩa thật.

Câu hỏi thường gặp

Không - phần nền (package.json cơ bản, semver ^/~, node_modules, ESM vs CommonJS, bundler) nằm trọn trong bài “Module (ESM) & npm” của khoá Cơ bản; bài này xây tiếp lên đó. Mẹo nhớ nhanh: ^ giữ nguyên MAJOR, ~ giữ nguyên MINOR - còn phiên bản CHÍNH XÁC là việc của lockfile.

Ôn lại bài Module (ESM) & npm →

Vì nó là bản ghi duy nhất cho biết cả cây phụ thuộc đã được giải ra phiên bản nào. package.json chỉ ghi KHOẢNG (^4.4.0); không có lockfile, mỗi máy cài một thời điểm sẽ giải ra một cây khác nhau - bug kiểu “máy em chạy được mà” sinh ra từ đó. Commit lockfile; còn node_modules thì không bao giờ.

Đừng - file đó do npm sinh ra, các trường version/resolved/integrity ràng buộc lẫn nhau, sửa tay rất dễ tạo một cây tự mâu thuẫn. Cách đúng: giải quyết conflict trong package.json trước (nếu có), rồi chạy npm install để npm sinh lại lockfile nhất quán, xem diff và commit.

Rất hạn chế: npm chỉ cho unpublish thoải mái trong 72 giờ đầu; qua mốc đó phải thoả điều kiện ngặt hơn (không gói nào phụ thuộc, lượt tải thấp…). Lý do: gói đang được người khác dùng mà biến mất sẽ làm vỡ build dây chuyền - sự cố left-pad năm 2016 là ví dụ kinh điển. Cách thường làm: publish bản vá mới rồi npm deprecate bản lỗi.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Một plugin ESLint khai "eslint": "^9.0.0" trong peerDependencies. Điều này nghĩa là gì?

  1. 1

    Ba loại phụ thuộc

    Mèo con tạo dự án mới, npm install msnpm install -D vitest. Mở package.json xem hai gói nằm mục nào; xoá node_modules rồi cài lại bằng npm install --omit=dev.

    Hoàn thành khi: ms nằm ở dependencies, vitest ở devDependencies; bản cài --omit=dev không còn vitest trong node_modules.

  2. 2

    pre/post script

    Thêm ba script chao, prechao, postchao vào package.json (mỗi cái echo một câu khác nhau) rồi chạy npm run chao.

    Hoàn thành khi: Ba dòng in đúng thứ tự pre → chính → post dù mèo con chỉ gọi MỘT lệnh.

  3. 3

    Soi transitive dependency

    Cài debug, chạy npm ls --all, rồi mở package-lock.json tìm mục node_modules/ms.

    Hoàn thành khi: Chỉ ra được ms là phụ thuộc của debug (mèo con không hề gõ tên nó) nhưng vẫn bị ghim đúng version + integrity trong lockfile.

  4. 4

    Làm npm ci nổi giận

    Trong dự án trên, thêm tay một gói bất kỳ vào "dependencies" của package.json (KHÔNG chạy install), rồi chạy npm ci.

    Hoàn thành khi: npm ci từ chối với “Missing: … from lock file”; chạy npm install xong thì npm ci lại qua được.

  5. 5

    Monorepo của mèo

    Dựng lại monorepo ở Bước 3, thêm hàm tamBiet(ten) vào @meo/core rồi gọi nó từ @meo/cli - KHÔNG chạy lại npm install.

    Hoàn thành khi: npm run start -w @meo/cli thấy hàm mới ngay, vì node_modules/@meo/core chỉ là symlink (kiểm bằng ls -l node_modules/@meo).

  6. 6

    Tarball nói thật

    Trong gói @meo/chao ở Bước 4, tạo thêm bi-mat.txt, chạy npm pack + tar -tzf xem ruột; rồi thêm "bi-mat.txt" vào "files" và pack lại.

    Hoàn thành khi: Lần đầu tarball KHÔNG chứa bi-mat.txt, lần hai thì có - trường "files" quyết định thứ được publish.