Hồi tách app ra thành nhiều package dùng chung, mình chọn cách "chuẩn chỉ" mà ai cũng chỉ: npm link. Chạy ngon đúng ba ngày. Tới ngày thứ tư, sửa interface trong @app/core mà app bên kia vẫn thấy bản cũ, vì symlink trỏ sai thư mục mà không ai phát hiện. Từ đó mình chuyển sang pnpm workspace và tới giờ chưa có ý định quay lại.

Vấn đề: một package, ba nơi dùng

Repo nhỏ thì không sao, tới lúc có web, mobile API và một worker dùng chung logic tiền tệ, mình bắt đầu copy file qua lại. Copy thì nhanh, nhưng sửa một chỗ phải nhớ sửa ba chỗ. Chưa kể type bắt đầu lệch nhau mà TypeScript không hề hay biết.

Cái mình cần là: ba app dùng chung một package, sửa ở một chỗ, cả ba thấy ngay, và mọi thứ vẫn nằm trong một repo để commit cùng nhau.

  • npm link tạo symlink thủ công ở phạm vi toàn cục. Quên unlink là rác máy, còn sai đường dẫn là debug mù.
  • npm workspaces có sẵn, nhưng nó flatten node_modules lên gốc, nên package nào cũng thấy mọi thứ cài trên đời.
  • pnpm dùng store chung trên máy rồi symlink cứng vào từng package. Mỗi package chỉ "thấy" đúng những gì nó khai báo trong package.json.

Điểm cuối là điểm mình thích nhất, dù lúc đầu hơi ghét nó. Nói ở phần bẫy bên dưới.

Khai báo workspace

Cả thiết lập gói trong một file:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Xong. Không cần lerna.json, không cần script cài đặt gì thêm. pnpm install ở gốc là nó quét và nối hết các package lại.

Trỏ package nội bộ bằng workspace protocol

Đây là chỗ nhiều người bỏ qua:

{
  "name": "@app/web",
  "dependencies": {
    "@app/core": "workspace:*",
    "@app/ui": "workspace:^"
  }
}

workspace:* nghĩa là "lấy đúng bản trong repo, đừng kéo từ registry". Nếu sau này bạn publish package lên npm, pnpm tự thay bằng version thật lúc pack. Không đụng tới npm link lần nào nữa.

TypeScript: exports quan trọng hơn bạn nghĩ

// packages/core/src/money.ts
export type Currency = "VND" | "USD";

export interface Money {
  amount: number;
  currency: Currency;
}

export const format = (m: Money): string =>
  new Intl.NumberFormat("vi-VN", {
    style: "currency",
    currency: m.currency,
    maximumFractionDigits: 0,
  }).format(m.amount);
// packages/core/package.json
{
  "name": "@app/core",
  "version": "0.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

Thiếu exports là kiểu lỗi khó chịu nhất: editor import ngon lành, mà vite build đỏ lòm. Lý do là editor đọc types, còn bundler đọc exports. Hai thằng đọc hai nguồn khác nhau, và chỉ một trong hai cấu hình đúng thì bạn tưởng mọi thứ ổn.

Chạy lệnh theo từng package

pnpm -F @app/web dev
pnpm -F "./packages/*" build
pnpm -r --filter "...[origin/main]" test

-r chạy đệ quy và pnpm tự sắp đúng thứ tự phụ thuộc, nên core build xong mới tới web. Dòng cuối chỉ chạy test cho package bị ảnh hưởng bởi thay đổi so với main, tiết kiệm kha khá thời gian CI.

Bẫy số 1: phantom dependency

Cho @app/web import lodash mà không khai trong package.json. Với npm workspaces thì vẫn chạy, vì node_modules bị flatten. Sang pnpm là đỏ ngay: Cannot find module 'lodash'.

Đau lúc đầu thật. Nhưng nó lộ ra mấy món mình xài "chùa" bao lâu nay, và nhờ vậy bản deploy không còn phụ thuộc vào thứ tình cờ có mặt trong node_modules.

Bẫy số 2: phụ thuộc vòng

@app/ui import @app/core, rồi @app/core lại import @app/ui. pnpm báo lỗi vòng lặp ngay lúc cài. Bản thân lỗi đó là tín hiệu kiến trúc: hai package này chắc nên tách phần dùng chung ra package thứ ba.

Bẫy số 3: CI cài sai

Trên CI nhớ dùng pnpm install --frozen-lockfile để lockfile lệch là fail ngay chứ không âm thầm cập nhật. Và cache pnpm store, đừng cache node_modules — nó dễ hỏng giữa các lần chạy.

Khi nào đừng dùng monorepo

Nếu bạn chỉ có một app và một package, thêm workspace là thêm một tầng cấu hình để bảo trì mà chẳng đổi lấy được gì. Còn khi số package dùng chung vượt qua hai bàn tay, công cụ này trả lại thời gian gấp nhiều lần công sức bỏ ra.

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích
Workspace Nhóm package trong cùng một repo, pnpm quản lý chung một lockfile
workspace protocol Cú pháp workspace:* trỏ tới package nội bộ thay vì registry
Phantom dependency Package import thứ chưa khai báo trong package.json
exports map Trường exports định nghĩa entry point package cho bundler/Node
--frozen-lockfile Cài đúng theo lockfile, lệch là báo lỗi
-r / --filter Chạy lệnh đệ quy hoặc lọc theo package chỉ định

Xem các bài viết khác