Turborepo Monorepos: Cấu Hình Thực Sự Mở Rộng Được
Cách tôi cấu trúc Turborepo monorepo cho nhiều ứng dụng chia sẻ package. Cấu hình workspace, pipeline caching, shared TypeScript config, và những sai lầm tôi mắc phải trong tháng đầu tiên.
Tôi dành ba năm đầu sự nghiệp để copy hàm tiện ích giữa các repository. Không phóng đại. Tôi có file formatDate.ts sống trong bảy dự án khác nhau, mỗi cái có bug hơi khác nhau. Khi tôi sửa lỗi múi giờ trong một dự án, tôi quên mất sáu dự án còn lại. Cuối cùng một người dùng ở Úc sẽ gửi ticket và tôi phát hiện bản sửa chưa bao giờ được đưa lên production ở ứng dụng cụ thể đó.
Monorepo giải quyết điều này. Không phải vì nó theo xu hướng, không phải vì Google làm vậy, mà vì tôi chán ngấy làm bộ đăng ký package bằng tay. Một repository, code chia sẻ, thay đổi nguyên tử trên mọi ứng dụng sử dụng nó.
Nhưng monorepo cũng có những kiểu lỗi riêng. Tôi đã thử ba công cụ khác nhau, lãng phí hàng tuần cho caching bị hỏng, đấu với lỗi phụ thuộc vòng lúc nửa đêm, và học được rằng "cứ bỏ hết vào một repo" cũng chẳng có ích gì nếu thiếu chi tiết.
Đây là cấu hình Turborepo mà tôi thực sự sử dụng. Nó chạy bốn ứng dụng production với mười hai shared package. Build mất dưới 90 giây nhờ remote caching. Lập trình viên mới có thể clone và chạy pnpm dev và mọi thứ hoạt động trong dưới hai phút. Tôi mất khoảng một tháng sai lầm để đến được đây.
Tại Sao Phải Monorepo Ngay Từ Đầu#
Hãy thành thật về các đánh đổi. Monorepo không miễn phí. Bạn đang đổi một bộ vấn đề lấy bộ khác, và bạn cần biết mình đang mua gì.
Bạn Được Gì#
Chia sẻ code mà không cần publish. Đây là điểm quan trọng nhất. Bạn viết thư viện React component trong packages/ui. Web app, admin dashboard, và docs site của bạn đều import từ đó. Khi bạn thay đổi một button component, mọi ứng dụng nhận được ngay lập tức. Không cần tăng version, không cần npm publish, không cần "production đang dùng version nào nhỉ?"
// Trong apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";Các import này trông giống package bên ngoài. Chúng resolve thông qua workspace dependency. Nhưng chúng trỏ đến source code trong cùng repository.
Thay đổi nguyên tử xuyên package. Hãy tưởng tượng bạn cần thêm prop isLoading cho shared Button component. Trong thế giới polyrepo, bạn sẽ thay đổi thư viện component, publish version mới, cập nhật version trong mọi ứng dụng sử dụng, rồi mở PR trong mỗi repo. Đó là ba đến năm PR cho một prop.
Trong monorepo, đó là một commit:
feat: add isLoading prop to Button component
- packages/ui/src/Button.tsx (thêm prop, render spinner)
- apps/web/src/pages/checkout.tsx (sử dụng isLoading)
- apps/admin/src/pages/users.tsx (sử dụng isLoading)
Một PR. Một review. Một merge. Mọi thứ luôn đồng bộ vì về mặt vật lý nó không thể lệch.
Tooling thống nhất. Một cấu hình ESLint. Một cấu hình Prettier. Một base TypeScript config. Một CI pipeline. Khi bạn nâng cấp ESLint, bạn nâng cấp nó ở mọi nơi trong một buổi chiều, không phải qua ba sprint trên tám repo.
Bạn Phải Trả Gì#
Phức tạp ở gốc. package.json gốc của bạn trở thành hạ tầng. CI pipeline cần hiểu package nào thay đổi. IDE của bạn có thể gặp khó với số lượng file quá lớn. Thao tác Git chậm đi khi repo phát triển (dù điều này mất vài năm với hầu hết đội ngũ).
Thời gian CI có thể phình lên. Nếu bạn build mọi thứ trên mỗi commit, bạn sẽ lãng phí lượng compute khổng lồ. Bạn cần một công cụ hiểu dependency graph và chỉ build những gì thay đổi. Đó là toàn bộ lý do Turborepo tồn tại.
Ma sát khi onboarding. Lập trình viên mới cần hiểu workspace, hoisting, internal package, build pipeline. Không chỉ là "clone rồi chạy." Đúng, cuối cùng nó nên như vậy, nhưng để đạt được điều đó cần nỗ lực có chủ đích.
Đánh giá thành thật: nếu bạn có một ứng dụng không có shared code, monorepo là overhead không có lợi ích. Nếu bạn có hai hoặc nhiều ứng dụng chia sẻ bất cứ thứ gì — component, tiện ích, type, config — monorepo tự hoàn vốn trong tháng đầu tiên.
Turborepo vs Nx vs Lerna#
Tôi đã dùng cả ba. Đây là phiên bản ngắn.
Lerna là công cụ monorepo gốc cho JavaScript. Nó quản lý versioning và publishing. Nhưng nó không hiểu build pipeline hay caching. Nó bị bỏ rơi, được Nx hồi sinh, và giờ cảm giác giống lớp tương thích hơn là công cụ độc lập. Nếu bạn cần publish package lên npm, Lerna + Nx có thể xử lý. Nhưng cho monorepo nội bộ nơi bạn chỉ chia sẻ code giữa các ứng dụng của mình, nó quá rườm rà.
Nx mạnh mẽ. Thực sự mạnh mẽ. Nó có generator, plugin cho mọi framework, dependency graph trực quan, distributed task execution. Nó cũng có đường cong học tập trông như vách đá. Tôi đã thấy các đội dành hai tuần chỉ để cấu hình Nx trước khi viết bất kỳ code sản phẩm nào. Nếu bạn ở công ty có 50+ lập trình viên và hàng trăm package, Nx có lẽ là lựa chọn đúng. Với trường hợp sử dụng của tôi, nó như máy ủi khi tôi cần cái xẻng.
Turborepo làm tốt ba thứ: nó hiểu dependency graph của bạn, nó cache output build, và nó chạy task song song. Thế thôi. Toàn bộ cấu hình là một file turbo.json. Bạn có thể đọc xong trong hai phút. Nó không sinh code, không có plugin, không cố thay thế build tool của bạn. Nó là task runner rất, rất giỏi trong việc biết gì cần chạy và gì có thể bỏ qua.
Tôi chọn Turborepo vì tôi có thể giải thích toàn bộ cấu hình cho thành viên mới trong 15 phút. Với Nx, cùng cuộc trò chuyện đó mất một giờ và họ vẫn còn câu hỏi.
Feature | Turborepo | Nx | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file) | High (multiple) | Medium
Learning curve | ~1 day | ~1 week | ~2 days
Build caching | Yes (remote) | Yes (remote) | No (native)
Task orchestration| Yes | Yes | Basic
Code generation | No | Yes (extensive) | No
Framework plugins | No | Yes | No
Best for | Small-medium | Large orgs | Publishing
Cấu Trúc Repository#
Đây là cây thư mục thực tế. Không phải ví dụ "bắt đầu" — đây là monorepo production trông như thế nào sau sáu tháng.
acme-monorepo/
├── apps/
│ ├── web/ # Ứng dụng khách hàng chính
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Dashboard quản trị nội bộ
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Trang tài liệu
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # API server Express/Fastify
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Thư viện React component dùng chung
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Hàm tiện ích dùng chung
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # TypeScript type dùng chung
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Preset tsconfig dùng chung
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Cấu hình ESLint dùng chung
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Preset Tailwind dùng chung
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
Phân Tách apps/ vs packages/#
Đây là nguyên tắc tổ chức cơ bản. apps/ chứa những thứ có thể deploy — web app, API, bất cứ thứ gì có lệnh dev hoặc start. packages/ chứa thư viện — code tồn tại để được sử dụng bởi app hoặc package khác.
Quy tắc đơn giản: app sử dụng package. Package không bao giờ import từ app. Package có thể import từ package khác.
Nếu một package bắt đầu import từ app, bạn đang đảo ngược dependency graph và Turborepo sẽ phát hiện nó là vòng lặp.
Cấu Hình Workspace#
File pnpm-workspace.yaml gốc cho pnpm biết tìm package ở đâu:
packages:
- "apps/*"
- "packages/*"Đó là toàn bộ file. pnpm quét các thư mục đó, đọc mỗi package.json, và tạo workspace map. Khi apps/web khai báo "@acme/ui": "workspace:*" là dependency, pnpm liên kết nó với packages/ui cục bộ thay vì tìm trong npm registry.
package.json Gốc#
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
}
}Lưu ý: package.json gốc không có runtime dependency. Nó thuần túy là điều phối. turbo là task runner, prettier xử lý format (vì nó là công cụ duy nhất không cần cấu hình riêng cho mỗi package), và typescript được hoist để mọi package dùng cùng version.
Quy Ước Đặt Tên#
Mọi package được đặt tên có scope: @acme/ui, @acme/utils, @acme/config-typescript. Scope ngăn xung đột với npm package và khiến nó rõ ràng ngay lập tức trong bất kỳ import nào bạn đang dùng code nội bộ hay bên ngoài.
// Package bên ngoài - từ npm
import { clsx } from "clsx";
// Package nội bộ - từ monorepo của chúng ta
import { Button } from "@acme/ui";Tôi thêm tiền tố config- cho config package để nhóm chúng trực quan: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Một số đội dùng @acme/tsconfig, @acme/eslint-config. Cách nào cũng được. Chỉ cần nhất quán.
Cấu Hình Pipeline turbo.json#
Đây là nơi Turborepo chứng minh giá trị. File turbo.json định nghĩa task pipeline — task nào tồn tại, chúng phụ thuộc gì, và chúng tạo ra gì.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true,
"inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
}
}
}Hãy để tôi phân tích từng trường vì đây là nơi hầu hết mọi người bị nhầm lẫn.
dependsOn và Ký Hiệu ^#
"dependsOn": ["^build"] nghĩa là: "Trước khi chạy task này trong package X, trước tiên hãy chạy build trong mọi package mà X phụ thuộc vào."
Ký hiệu ^ nghĩa là "dependency ngược dòng." Không có nó, "dependsOn": ["build"] sẽ nghĩa là "chạy build trong cùng package trước" — hữu ích nếu task test cần build xảy ra trước trong cùng package.
Đây là ví dụ cụ thể. Dependency graph của bạn trông như thế này:
apps/web → @acme/ui → @acme/utils
→ @acme/types
Khi bạn chạy turbo build, Turborepo giải quyết graph:
- Build
@acme/types(không có dependency) - Build
@acme/utils(không có dependency) - Build
@acme/ui(phụ thuộc vào types và utils — đợi chúng) - Build
apps/web(phụ thuộc vào ui — đợi nó)
Bước 1 và 2 chạy song song. Bước 3 đợi cả hai. Bước 4 đợi bước 3. Turborepo tìm ra điều này từ khai báo dependency trong package.json. Bạn không phải chỉ định thứ tự thủ công.
outputs#
Điều này rất quan trọng cho caching. Khi Turborepo cache một build, nó lưu các file được liệt kê trong outputs. Khi nó có cache hit, nó khôi phục các file đó. Nếu bạn quên liệt kê thư mục output, cache sẽ "thành công" nhưng file của bạn không xuất hiện. Đây là sai lầm lớn nhất của tôi trong tuần đầu — tôi bỏ sót .next/** và không hiểu tại sao ứng dụng Next.js luôn rebuild.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]Loại trừ !.next/cache/** rất quan trọng. Next.js có cache riêng bên trong .next/cache/. Bạn không muốn lưu cache của cache — nó làm phình remote cache storage và không giúp ích gì.
inputs#
Mặc định, Turborepo hash mọi file trong package để xác định nó có thay đổi không. Trường inputs thu hẹp phạm vi đó. Nếu chỉ file trong src/ ảnh hưởng output build, thì thay đổi README.md không nên vô hiệu hóa cache.
"inputs": ["src/**", "package.json", "tsconfig.json"]Cẩn thận với điều này. Nếu bạn thêm file config ảnh hưởng build (ví dụ postcss.config.js) và không đưa vào inputs, bạn sẽ nhận output cache lỗi thời. Khi nghi ngờ, để inputs không đặt và để Turborepo hash mọi thứ.
globalDependencies#
File được liệt kê ở đây vô hiệu hóa cache cho mọi package khi chúng thay đổi. File môi trường là ví dụ kinh điển — nếu .env.local thay đổi, mọi thứ có thể đọc từ process.env cần rebuild.
"globalDependencies": ["**/.env.*local"]Tôi cũng thêm tsconfig.json gốc ở đây vì base TypeScript config ảnh hưởng tất cả package:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent và dev#
Task dev có "cache": false và "persistent": true. Cache dev server vô nghĩa — đó là process chạy dài. Flag persistent cho Turborepo biết task này không kết thúc, nên nó không nên đợi trước khi chạy task khác.
Khi bạn chạy turbo dev, Turborepo khởi động tất cả dev server song song — ứng dụng Next.js, API server, docs site — tất cả trong một terminal với output xen kẽ. Mỗi dòng có tiền tố tên package để bạn phân biệt.
Package Dùng Chung Chi Tiết#
packages/ui — Thư Viện Component#
Đây là package mọi đội xây dựng đầu tiên. Shared React component được sử dụng trên tất cả frontend app.
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
"clean": "rm -rf dist",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"tsup": "^8.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}Một vài điểm cần chú ý:
"version": "0.0.0" — Internal package không cần version thật. Giao thức workspace:* bảo pnpm luôn dùng bản cục bộ. Số version không liên quan.
"private": true — Package này sẽ không bao giờ được publish lên npm. Nếu bạn muốn publish nó, xóa flag này và thiết lập chiến lược versioning phù hợp.
Trường exports — Đây là cách hiện đại để định nghĩa entry point của package. Nó thay thế các trường main, module, và types. Export "." là đường dẫn import mặc định. Bạn có thể thêm sub-path export cho import chi tiết:
{
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./Button": {
"types": "./src/components/Button.tsx",
"default": "./src/components/Button.tsx"
},
"./styles.css": "./src/styles.css"
}
}Quyết định quan trọng: build hay không? Một số đội bỏ qua bước build hoàn toàn cho internal package. Thay vì compile với tsup, họ trỏ exports trực tiếp vào TypeScript source và để bundler của ứng dụng sử dụng (Next.js, Vite) xử lý compilation. Đây gọi là "internal packages" trong thuật ngữ Turborepo và nó đơn giản hơn:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}Không có bước build. Không có thư mục dist. next.config.ts của ứng dụng sử dụng cần transpilePackages:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;Tôi dùng cách tiếp cận không build cho hầu hết internal package. Nó nhanh hơn khi phát triển và ít thứ có thể hỏng hơn. Ngoại lệ là package cần hỗ trợ consumer không dùng TypeScript hoặc có yêu cầu build phức tạp.
packages/utils — Tiện Ích Dùng Chung#
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./validation": "./src/validation.ts"
},
"dependencies": {
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}Sub-path export cho phép ứng dụng sử dụng chỉ import những gì họ cần:
// Chỉ import tiện ích ngày tháng, không phải toàn bộ package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
// Import mọi thứ (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";Barrel export trong src/index.ts re-export mọi thứ:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";Một lưu ý về barrel export: chúng tiện lợi nhưng có thể giết tree-shaking trong một số bundler. Nếu apps/web import một hàm từ @acme/utils, bundler đơn giản có thể bao gồm mọi thứ. Next.js xử lý tốt điều này với tree-shaking tích hợp, nhưng nếu bạn nhận thấy vấn đề kích thước bundle, pattern sub-path export an toàn hơn.
packages/types — TypeScript Type Dùng Chung#
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./api": "./src/api.ts",
"./user": "./src/user.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}Package này có zero runtime dependency. Nó chỉ chứa TypeScript type và interface. Nó không bao giờ cần build vì type bị xóa khi compile.
// packages/types/src/user.ts
export interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "viewer";
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserInput {
email: string;
name: string;
role?: User["role"];
}
export interface UserFilters {
role?: User["role"];
search?: string;
page?: number;
limit?: number;
}// packages/types/src/api.ts
export interface ApiResponse<T> {
data: T;
meta?: {
page: number;
limit: number;
total: number;
};
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };packages/config-typescript — TSConfig Dùng Chung#
Package này quan trọng hơn bạn nghĩ. Cấu hình TypeScript trong monorepo là nơi mọi thứ rối nhanh chóng.
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}Cấu hình base mà tất cả package kế thừa:
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true
},
"exclude": ["node_modules"]
}Cấu hình dành riêng cho Next.js:
// packages/config-typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [
{ "name": "next" }
]
}
}Cấu hình Node.js cho backend package:
// packages/config-typescript/node.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src"
}
}Bây giờ mỗi app hoặc package kế thừa preset phù hợp:
// apps/web/tsconfig.json
{
"extends": "@acme/config-typescript/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}// apps/api/tsconfig.json
{
"extends": "@acme/config-typescript/node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}// packages/ui/tsconfig.json
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}Path Alias @/*#
Mỗi app có cùng path alias @/* trỏ đến thư mục src/ riêng. Nghĩa là @/components/Header trong apps/web resolve thành apps/web/src/components/Header, và @/components/Header trong apps/admin resolve thành apps/admin/src/components/Header.
Đây là đường dẫn cục bộ trong app. Chúng không vượt qua ranh giới package. Với import xuyên package, bạn luôn dùng tên package: @acme/ui, không phải @/../../packages/ui. Nếu bạn thấy import tương đối đi lên quá root package (../../packages/something), đó là dấu hiệu code xấu. Dùng workspace dependency thay thế.
composite và Project Reference#
TypeScript project reference (composite: true) cho phép tsc build package tăng dần và hiểu dependency xuyên dự án. Điều này tùy chọn với Turborepo — Turborepo tự xử lý điều phối build — nhưng nó có thể tăng tốc kiểm tra type trong IDE.
// packages/ui/tsconfig.json (với composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}Tôi thành thật: project reference thêm phức tạp và tôi đã bỏ chúng khỏi hầu hết cấu hình. --filter và dependsOn của Turborepo đã xử lý thứ tự build. Tôi chỉ thêm composite: true khi hiệu năng IDE trở thành vấn đề (thường ở 15+ package).
packages/config-eslint — Linting Dùng Chung#
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" }
],
},
}
);// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
export default [
...baseConfig,
{
plugins: {
"@next/next": nextPlugin,
"react": reactPlugin,
"react-hooks": hooksPlugin,
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react-hooks/exhaustive-deps": "warn",
},
},
];Mỗi app tham chiếu config của mình:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];Remote Caching#
Local caching tốt — nó ngăn bạn rebuild package không thay đổi khi phát triển. Nhưng remote caching mới thực sự mang tính chuyển đổi. Nó nghĩa là CI server hưởng lợi từ build cục bộ của bạn và ngược lại.
Cách Hoạt Động#
Khi Turborepo chạy task, nó tính hash dựa trên:
- File source (content hash)
- Cấu hình
inputscủa task - Biến môi trường
- Hash của dependency ngược dòng
Nếu hash khớp kết quả đã cache trước đó, Turborepo khôi phục outputs từ cache thay vì chạy task. Với remote caching, các output đã cache được lưu tại vị trí chia sẻ mà bất kỳ máy nào — laptop của bạn, laptop đồng nghiệp, CI — đều truy cập được.
Vercel Remote Cache#
Cấu hình đơn giản nhất. Vercel duy trì hạ tầng cache miễn phí (có giới hạn):
# Đăng nhập Vercel (thiết lập một lần)
npx turbo login
# Liên kết repo với dự án Vercel
npx turbo linkThế thôi. Turborepo sẽ đẩy và kéo cache artifact từ server Vercel. Trên CI, bạn đặt biến môi trường:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Tạo token từ Vercel dashboard trong phần cài đặt team. TURBO_TEAM là slug team của bạn.
Self-Hosted Remote Cache#
Nếu bạn không thể dùng Vercel (môi trường air-gapped, yêu cầu data residency), bạn có thể self-host. API remote cache Turborepo được tài liệu hóa tốt và có nhiều triển khai mã nguồn mở:
- ducktors/turborepo-remote-cache — Server Node.js lưu artifact trong S3, GCS, hoặc file system cục bộ
- fox1t/turborepo-remote-cache — Tương tự, hỗ trợ Azure Blob
# docker-compose.yml cho self-hosted cache
services:
turbo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
TURBO_TOKEN: "your-secret-token"
STORAGE_PROVIDER: "s3"
S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
S3_REGION: "eu-central-1"
S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
S3_BUCKET: "turbo-cache"Sau đó cấu hình monorepo để sử dụng:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}Tỷ Lệ Cache Hit#
Theo kinh nghiệm của tôi, Turborepo được cấu hình tốt đạt 80-95% tỷ lệ cache hit trong CI. Điều đó nghĩa là chỉ 5-20% task thực sự chạy trên bất kỳ CI pipeline nào. Phần còn lại được khôi phục từ cache trong vài giây.
Chìa khóa cho tỷ lệ cache hit cao:
- Thu hẹp
inputs. Nếu chỉsrc/ảnh hưởng build, đừng để thay đổi README vô hiệu hóa cache. - Đừng đặt dữ liệu thay đổi trong
globalDependencies. File.envthay đổi mỗi lần deploy giết cache của bạn. - Pin dependency. Thay đổi lockfile vô hiệu hóa mọi thứ downstream.
- Giữ package nhỏ và tập trung.
packages/utilskhổng lồ với 200 file sẽ bị vô hiệu hóa cache thường xuyên vì bất kỳ thay đổi nào với bất kỳ tiện ích nào đều phá cache toàn bộ package.
CI/CD Với Turborepo#
Đây là workflow GitHub Actions tôi sử dụng. Nó đã được tinh chỉnh qua hàng chục lần lặp.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
ci:
name: Build, Lint, Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Cần commit cha cho phát hiện thay đổi
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build
- name: Lint
run: pnpm turbo lint
- name: Type Check
run: pnpm turbo type-check
- name: Test
run: pnpm turbo testCờ --filter#
Cờ --filter là cách bạn chạy task cho package cụ thể hoặc dựa trên những gì thay đổi. Điều này thiết yếu cho CI hiệu quả:
# Build chỉ web app và dependency của nó
pnpm turbo build --filter=apps/web...
# Build chỉ package thay đổi kể từ main
pnpm turbo build --filter=...[main]
# Build chỉ @acme/ui và mọi thứ phụ thuộc vào nó
pnpm turbo build --filter=...@acme/ui
# Build chỉ package trong thư mục packages/
pnpm turbo build --filter="./packages/*"Cú pháp ba chấm rất quan trọng:
--filter=@acme/ui...— Package và tất cả dependency của nó (thứ nó cần)--filter=...@acme/ui— Package và tất cả dependent của nó (thứ cần nó)--filter=@acme/ui— Chỉ bản thân package
Phát Hiện Package Bị Ảnh Hưởng#
Với pull request, bạn có lẽ không muốn build mọi thứ. Bạn muốn build chỉ những gì bị ảnh hưởng bởi thay đổi:
# Chỉ build/test những gì thay đổi so với main
- name: Build affected
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected
run: pnpm turbo test --filter="...[origin/main]"Cú pháp [origin/main] bảo Turborepo so sánh commit hiện tại với origin/main và chỉ chạy task cho package có thay đổi. Kết hợp với remote caching, hầu hết PR pipeline hoàn thành trong dưới 2 phút ngay cả cho monorepo lớn.
Chiến Lược Matrix Cho Deployment#
Nếu mỗi app deploy độc lập, dùng chiến lược matrix:
deploy:
needs: ci
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
strategy:
matrix:
app: [web, admin, docs, api]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build --filter=apps/${{ matrix.app }}...
- name: Deploy ${{ matrix.app }}
run: ./scripts/deploy.sh ${{ matrix.app }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Mỗi matrix job build một app và dependency của nó. Nhờ remote caching, shared package được build một lần và cache — mỗi matrix job tiếp theo khôi phục chúng từ cache.
Những Cạm Bẫy Phổ Biến#
Tôi dành cả phần này vì tôi đã gặp phải từng cái một trong tháng đầu. Học từ tài liệu hiệu quả. Học từ sai lầm của tôi miễn phí.
1. Phụ Thuộc Vòng#
Dependency graph phải là DAG (đồ thị có hướng không chu trình). Nếu @acme/ui import từ @acme/utils và @acme/utils import từ @acme/ui, bạn có chu trình. Turborepo sẽ phàn nàn và từ chối build.
Cách sửa hầu như luôn là tách code dùng chung vào package thứ ba:
TRƯỚC (hỏng):
@acme/ui → @acme/utils → @acme/ui ← chu trình!
SAU (sửa):
@acme/ui → @acme/types
@acme/utils → @acme/types
Di chuyển type hoặc hằng số dùng chung vào @acme/types và để cả hai package phụ thuộc vào nó.
Nguyên nhân phổ biến khác: bạn đặt React hook trong @acme/utils mà import component từ @acme/ui. Hook phụ thuộc vào UI component thuộc về @acme/ui, không phải @acme/utils. Tiện ích nên độc lập framework bất cứ khi nào có thể.
2. Thiếu outputs Vô Hiệu Hóa Cache#
Đây là bug khó chịu nhất. Mọi thứ dường như hoạt động cục bộ. CI build pass. Nhưng mỗi build mất đủ thời gian vì không có gì được cache.
Tình huống: package của bạn build ra build/ thay vì dist/, nhưng turbo.json chỉ liệt kê dist/** trong output:
// turbo.json
"build": {
"outputs": ["dist/**"] // nhưng package build ra build/
}Turborepo cache task (vì nó chạy thành công) nhưng không lưu file output nào. Lần sau, nó nhận cache "hit" và không khôi phục gì. Ứng dụng sử dụng không tìm thấy output build và thất bại, hoặc — tệ hơn — âm thầm dùng file lỗi thời.
Sửa: kiểm tra thư mục output build của mỗi package và đảm bảo turbo.json bao phủ tất cả:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}Hoặc dùng override theo package trong turbo.json:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Override theo package
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. Phối Hợp Dev Server#
Chạy turbo dev khởi động tất cả dev server cùng lúc. Điều này ổn cho đến khi các app cố dùng cùng port. Gán port rõ ràng là bắt buộc:
// apps/web/package.json
"scripts": {
"dev": "next dev --port 3000"
}
// apps/admin/package.json
"scripts": {
"dev": "next dev --port 3001"
}
// apps/docs/package.json
"scripts": {
"dev": "next dev --port 3002"
}
// apps/api/package.json
"scripts": {
"dev": "tsx watch src/index.ts" // Dùng port 4000 từ config
}Vấn đề khác: hot module replacement cho shared package. Khi bạn chỉnh sửa file trong packages/ui, thay đổi cần lan truyền đến dev server Next.js đang chạy. Nếu bạn dùng cách tiếp cận "internal packages" (không có bước build, import TypeScript trực tiếp), điều này hoạt động tự động — Next.js theo dõi file source thông qua node_modules được symlink.
Nếu package có bước build, bạn cần script dev của package chạy ở chế độ watch:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}Turborepo chạy tất cả task dev song song, nên package rebuild khi thay đổi và ứng dụng sử dụng nhận output mới.
4. Version Drift Giữa Các Package#
Điều này tinh vi và nguy hiểm. Nếu apps/web dùng React 19 và apps/admin vô tình dùng React 18, bạn có thể không nhận ra cho đến khi component từ @acme/ui hoạt động khác nhau trong mỗi app.
Giải pháp: quản lý shared dependency ở cấp gốc. Trường overrides của pnpm trong package.json gốc buộc một version duy nhất ở mọi nơi:
// Root package.json
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}Chạy pnpm ls react --recursive thường xuyên để xác minh mọi thứ dùng cùng version.
5. Biến Môi Trường Trong Shared Package#
Shared package không nên đọc process.env trực tiếp. Nếu @acme/utils đọc process.env.API_URL, nó tạo liên kết ngầm với môi trường của ứng dụng sử dụng. Thay vào đó, truyền cấu hình tường minh:
// XẤU: packages/utils/src/api.ts
const API_URL = process.env.API_URL; // Env nào? App nào?
export function fetchData(path: string) {
return fetch(`${API_URL}${path}`);
}
// TỐT: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
return {
fetch: (path: string) => fetch(`${baseUrl}${path}`),
};
}Ứng dụng sử dụng cung cấp cấu hình:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);Điều này giữ package thuần túy và dễ test.
6. Ghost Dependency#
pnpm mặc định nghiêm ngặt về dependency — nó không hoist package như npm. Đây là tính năng, không phải bug. Nghĩa là nếu @acme/ui import clsx nhưng không liệt kê trong package.json, pnpm sẽ báo lỗi. npm sẽ âm thầm resolve từ node_modules cha.
Luôn khai báo mọi import trong package.json của package sử dụng. Không còn dựa vào hoisting.
7. Suy Giảm Hiệu Năng IDE#
Với 15+ package, TypeScript language server có thể bắt đầu gặp khó. Một số mẹo:
- Thêm
"exclude": ["node_modules", "**/dist/**"]vàotsconfig.jsongốc - Dùng "Files: Exclude" của VS Code để ẩn thư mục
dist/,.next/, và.turbo/ - Cân nhắc
"disableSourceOfProjectReferenceRedirect": truetrong tsconfig nếu Go to Definition đưa bạn đến file.d.tssâu trongnode_modules
Cấu Trúc Starter: Tất Cả Gộp Lại#
Hãy để tôi gộp tất cả lại. Đây là mọi file bạn cần để khởi tạo Turborepo monorepo với hai ứng dụng Next.js và ba shared package.
Gốc#
// package.json
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
},
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true,
"inputs": [
"src/**",
"test/**",
"**/*.test.ts",
"**/*.test.tsx",
"vitest.config.ts"
]
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}// tsconfig.json (gốc — chỉ cho IDE, không dùng cho build)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}apps/web#
// apps/web/package.json
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
reactStrictMode: true,
};
export default nextConfig;// apps/web/tsconfig.json
{
"extends": "@acme/config-typescript/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}apps/admin#
// apps/admin/package.json
{
"name": "admin",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3"
}
}packages/ui#
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}// packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Input } from "./components/Input";
export { Modal } from "./components/Modal";
export { Card } from "./components/Card";
export { Badge } from "./components/Badge";
// Re-export type
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";// packages/ui/src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@acme/utils";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
{
"bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
"bg-gray-100 text-gray-900 hover:bg-gray-200": variant === "secondary",
"border border-gray-300 bg-transparent hover:bg-gray-100": variant === "outline",
"bg-transparent hover:bg-gray-100": variant === "ghost",
"bg-red-600 text-white hover:bg-red-700": variant === "destructive",
},
{
"h-8 px-3 text-sm": size === "sm",
"h-10 px-4 text-sm": size === "md",
"h-12 px-6 text-base": size === "lg",
},
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{children}
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = "Button";packages/utils#
// packages/utils/package.json
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./cn": "./src/cn.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.1"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// packages/utils/src/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// packages/utils/src/date.ts
export function formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) return "just now";
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function formatDate(date: Date, locale = "en-US"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}// packages/utils/src/string.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trimEnd() + "...";
}
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";packages/types#
// packages/types/package.json
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./user": "./src/user.ts",
"./api": "./src/api.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}.gitignore#
# Dependency
node_modules/
# Build output
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Môi trường
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
Bắt Đầu Từ Số Không#
Nếu bạn bắt đầu từ đầu, đây là chuỗi lệnh chính xác:
# Tạo repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Khởi tạo root package
pnpm init
# Tạo file workspace
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Cài Turborepo
pnpm add -D turbo -w
# Tạo thư mục
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
# Khởi tạo app (từ thư mục của chúng)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Thêm workspace dependency
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
# Cài đặt mọi thứ
pnpm install
# Xác minh hoạt động
pnpm turbo build
pnpm turbo devLần pnpm turbo build đầu tiên sẽ chậm — mọi thứ build từ đầu. Lần thứ hai gần như tức thì nếu không có gì thay đổi. Đó là cache đang hoạt động.
Mở Rộng Vượt Xa Cơ Bản#
Khi bạn vượt qua cấu hình ban đầu, một vài pattern xuất hiện khi monorepo phát triển.
Bộ Sinh Package#
Sau package thứ mười, tạo cái mới bằng cách copy và chỉnh sửa trở nên nhàm chán. Tạo script:
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const name = process.argv[2];
if (!name) {
console.error("Usage: tsx scripts/create-package.ts <package-name>");
process.exit(1);
}
const dir = join(process.cwd(), "packages", name);
mkdirSync(join(dir, "src"), { recursive: true });
writeFileSync(
join(dir, "package.json"),
JSON.stringify(
{
name: `@acme/${name}`,
version: "0.0.0",
private: true,
exports: { ".": "./src/index.ts" },
scripts: {
lint: "eslint src/",
"type-check": "tsc --noEmit",
},
devDependencies: {
"@acme/config-typescript": "workspace:*",
typescript: "^5.7.3",
},
},
null,
2
)
);
writeFileSync(
join(dir, "tsconfig.json"),
JSON.stringify(
{
extends: "@acme/config-typescript/base",
compilerOptions: {
outDir: "./dist",
rootDir: "./src",
},
include: ["src"],
exclude: ["node_modules", "dist"],
},
null,
2
)
);
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");npx tsx scripts/create-package.ts email
# Tạo packages/email với tất cả boilerplateTrực Quan Hóa Dependency Graph Workspace#
Turborepo có lệnh graph tích hợp:
pnpm turbo build --graph=graph.svgLệnh này tạo SVG của dependency graph. Tôi chạy lệnh này trước các refactor lớn để hiểu phạm vi ảnh hưởng của thay đổi. Nếu chạm vào @acme/types rebuild 12 package, có lẽ đã đến lúc tách nó thành @acme/types-user và @acme/types-billing.
Pruning Cho Docker#
Khi deploy một app từ monorepo, bạn không muốn copy toàn bộ repo vào Docker image. Turborepo có lệnh prune:
pnpm turbo prune --scope=apps/web --dockerLệnh này tạo thư mục out/ chỉ chứa file cần thiết cho apps/web và dependency của nó. Dockerfile dùng output đã prune:
FROM node:20-alpine AS base
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY out/full/ .
RUN pnpm turbo build --filter=web...
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]Docker image chỉ chứa web app và dependency của nó. Không có admin app, không có API server, không có package không dùng. Kích thước image vẫn hợp lý ngay cả khi monorepo phát triển.
Một Tháng Sau: Tôi Sẽ Làm Gì Khác Đi#
Nếu tôi bắt đầu lại, biết những gì tôi biết bây giờ:
-
Bắt đầu với internal package (không có bước build). Tôi lãng phí hai tuần cấu hình tsup build cho package mà chỉ ứng dụng Next.js sử dụng.
transpilePackagestrong Next.js config đơn giản và nhanh hơn. Chỉ thêm bước build khi bạn cần. -
Giữ số lượng
packages/thấp ban đầu. Tôi tách quá sớm.packages/utils,packages/types, vàpackages/uiđủ để bắt đầu. Bạn luôn có thể tách sau khi package trở nên cồng kềnh. Tách sớm nghĩa là nhiều filepackage.jsonphải bảo trì và nhiều cạnh trong dependency graph. -
Thiết lập remote caching từ ngày đầu.
npx turbo login && npx turbo linkmất 30 giây. Thời gian tiết kiệm trên build chỉ trong tuần đầu tiên đã biện minh cho nó. -
Tài liệu hóa lệnh workspace. Lập trình viên mới không biết rằng
pnpm --filter @acme/ui add lodashcài vào package cụ thể, hoặcpnpm turbo build --filter=apps/web...chỉ build những gì cần thiết. Phần "Monorepo Cheatsheet" đơn giản trong contributing guide giúp mọi người tiết kiệm thời gian. -
Thực thi hướng dependency từ ngày đầu. Nếu bạn cho phép dù chỉ một import từ app vào package, ranh giới sẽ xói mòn nhanh chóng. Thêm lint rule hoặc CI check. Hướng là
apps -> packages -> packages. Không bao giờ ngược lại.
Monorepo không phải mục tiêu. Mục tiêu là ship tính năng mà không phải đấu với codebase của chính mình. Turborepo là công cụ nhẹ nhất tôi tìm được để thực hiện điều đó. Nó xử lý task graph, nó xử lý caching, và nó không can thiệp vào mọi thứ khác.
Bắt đầu đơn giản. Thêm phức tạp khi repo đòi hỏi. Và pin port của bạn.