SemVer: ý nghĩa của ^ và ~, và vì sao một "minor update" vẫn làm vỡ build
Semantic Versioning đặt một hợp đồng lên số phiên bản MAJOR.MINOR.PATCH: tăng MAJOR = có breaking change, MINOR = thêm tính năng tương thích ngược, PATCH = vá lỗi tương thích ngược. Range specifier ^ và ~ trong package.json quyết định npm tự nâng tới đâu trong hợp đồng đó. Hiểu chúng là điều kiện để kiểm soát chuyện "tự dưng build vỡ sau khi cài lại" — vì range cho phép nâng tự động, và SemVer là lời hứa của maintainer, không phải đảm bảo: một bản "minor" hay "patch" vẫn có thể vô tình chứa breaking change.
Cơ chế hoạt động
^ cho phép nâng minor và patch (giữ nguyên major); ~ chỉ cho phép nâng patch:
{
"express": "^4.18.0", // chấp nhận >=4.18.0 và <5.0.0 (minor + patch)
"lodash": "~4.17.21", // chấp nhận >=4.17.21 và <4.18.0 (chỉ patch)
"react": "18.2.0" // ghim chính xác — không tự nâng gì
}
Khi cài lại không có lockfile, npm lấy phiên bản cao nhất khớp range. Nên ^4.18.0 có thể resolve thành 4.19.5 ở lần cài sau nếu maintainer phát hành thêm — đây là chỗ "tự nâng" xảy ra. Lockfile cố định phiên bản thực tế bất kể range, nên hai cùng tồn tại: range nói "được nâng tới đâu", lockfile nói "đang dùng đúng cái nào".
Vấn đề gặp trong production
Failure mode: breaking change lọt qua minor/patch. SemVer là quy ước con người tuân thủ, không phải máy ép. Một maintainer có thể vô tình (hoặc do hiểu khác về "breaking") đưa một thay đổi phá tương thích vào bản minor hoặc patch. Vì range ^/~ tự nhận các bản đó, build có thể vỡ sau khi cài lại dù bạn "không đổi gì". Đây là lý do lockfile + npm ci quan trọng: chúng chặn việc tự nâng ngoài ý muốn, để nâng cấp luôn là một hành động có chủ đích, được test.
Failure mode: nâng major mà không đọc changelog. Tăng major nghĩa là có breaking change — nâng express@4 lên express@5 mà không đọc migration guide gần như chắc chắn vỡ. Major update phải được xử lý như một task riêng: đọc changelog, sửa code theo, test kỹ, không gộp chung với thay đổi khác.
Failure mode: ghim quá chặt làm bỏ lỡ bản vá bảo mật. Ngược lại với nâng mù: ghim cứng mọi dependency và không bao giờ cập nhật khiến bỏ lỡ các patch vá lỗ hổng. Cân bằng: cho phép patch tự động (an toàn nhất theo SemVer) cho phần lớn, ghim chặt phần cực nhạy cảm, và có quy trình cập nhật minor/major định kỳ có kiểm soát.
Failure mode: pre-release và 0.x hành xử khác. Phiên bản 0.x.y không theo hợp đồng major thông thường — trong 0.x, một bản minor có thể chứa breaking change (vì API chưa ổn định). ^0.2.3 chỉ cho phép tới <0.3.0, không phải <1.0.0. Dependency 0.x cần thận trọng hơn vì chưa cam kết ổn định.
Cách debug và monitor
Khi "build vỡ mà không đổi code", kiểm tra lockfile có được commit và CI có dùng npm ci không — nếu CI chạy npm install không lockfile, nó có thể đã tự nâng qua range. So sánh lockfile giữa lần chạy được và lần vỡ (git diff trên lockfile) để xác định package nào đã đổi phiên bản. Khi cố tình nâng, đọc changelog cho khoảng phiên bản đó và chạy full test suite. Dùng Dependabot/Renovate để nhận PR nâng cấp riêng lẻ kèm changelog — mỗi nâng cấp là một PR test được độc lập, thay vì nâng hàng loạt khó truy lỗi. Theo dõi npm outdated để thấy khoảng cách giữa phiên bản đang dùng và mới nhất.
Tradeoff
Range rộng (^) nhận tính năng và vá nhanh, ít phải sửa package.json, nhưng rủi ro tự nâng vào một bản vô tình breaking. Ghim chặt ổn định và tái lập tuyệt đối nhưng chậm nhận vá (gồm vá bảo mật) và tốn công cập nhật thủ công. Quy tắc thực tế: dùng ^/~ trong package.json để khai ý định, nhưng dựa vào lockfile + npm ci để kiểm soát phiên bản thực tế ở mọi môi trường; nâng cấp là hành động có chủ đích (đọc changelog, test, một PR mỗi nâng cấp), không phải hệ quả ngẫu nhiên của việc cài lại; cho phép patch tự động, xử lý major như task riêng, và thận trọng đặc biệt với dependency 0.x. Cân giữa "cập nhật" và "ổn định" theo mức quan trọng.
Câu hỏi phỏng vấn
^và~khác gì nhau, và vì sao một bản minor/patch vẫn có thể làm vỡ build?
^ cho phép npm nâng tự động trong cùng major — cả minor và patch (^4.18.0 = >=4.18.0 <5.0.0); ~ chỉ cho phép nâng patch (~4.17.21 = >=4.17.21 <4.18.0); ghim chính xác (18.2.0) không tự nâng gì. Khi cài lại không có lockfile, npm lấy phiên bản cao nhất khớp range, nên ^ có thể tự nhảy lên một minor mới. Một bản minor/patch vẫn có thể làm vỡ build vì SemVer là quy ước con người tuân thủ, không phải đảm bảo do máy ép: maintainer có thể vô tình đưa breaking change vào bản đáng lẽ tương thích ngược, và range ^/~ sẽ tự nhận nó — build vỡ dù "không đổi gì". Điểm ăn điểm: vì vậy phải commit lockfile và dùng npm ci để chặn tự nâng ngoài ý muốn (nâng cấp luôn là hành động có chủ đích, được test, mỗi PR một nâng cấp), xử lý major như task riêng có đọc changelog, cho phép patch tự động để không bỏ lỡ vá bảo mật, và lưu ý 0.x không theo hợp đồng major thông thường nên minor trong 0.x có thể breaking.
Hands-on
Tạo một package.json với một dependency khai ^, một khai ~, và một ghim chính xác; chạy npm outdated để thấy khoảng cách tới bản mới nhất và lý giải bản nào sẽ tự nâng tới đâu. Xóa lockfile, cài lại, và so sánh phiên bản resolve để thấy ^ có thể nhảy minor; rồi khôi phục lockfile và dùng npm ci để xác nhận nó cố định. Nâng một package qua một bản major thật (ví dụ một thư viện có breaking change), đọc changelog, sửa code theo migration guide, và chạy test để cảm nhận vì sao major là task riêng. Cuối cùng cấu hình Renovate/Dependabot trên một repo và quan sát mỗi nâng cấp đến dưới dạng một PR riêng kèm changelog.
Top comments (0)