Xây dựng Monorepo TypeScript hiện đại

Table of Contents
Sự Phát Triển Của Monorepo
Monorepo từ trước đến nay vẫn luôn nhận được những phản ứng trái chiều trong cộng đồng JavaScript và TypeScript. Trong một thời gian dài, các công cụ đơn giản là không đủ để xử lý quy mô, độ phức tạp và yêu cầu về hiệu suất của các ứng dụng cấp doanh nghiệp. Các công cụ như Lerna đã mở đường, nhưng thường khiến các nhà phát triển phải vật lộn với các bản dựng chậm chạp, các vấn đề khó chịu về nâng cấp gói (package hoisting) và một đường cong học tập dốc đã cản trở việc áp dụng rộng rãi hơn.
Nhanh chóng đến ngày nay, bối cảnh đã thay đổi hoàn toàn. Sự ra đời của pnpm workspaces kết hợp với Turborepo đã cách mạng hóa cách chúng ta cấu trúc và duy trì các codebase TypeScript hiện đại. Việc chứa hàng chục ứng dụng và gói chia sẻ trong một kho lưu trữ Git duy nhất không còn chỉ là khả thi; nó được cho là cách hiệu quả nhất để mở rộng nỗ lực kỹ thuật trên nhiều nhóm.
Trong hướng dẫn này, chúng ta sẽ đi sâu vào việc xây dựng một monorepo TypeScript hiện đại. Chúng ta sẽ bỏ qua các ví dụ "Hello World" cơ bản và khám phá các kỹ thuật nâng cao cần thiết cho một kiến trúc mạnh mẽ, sẵn sàng cho sản xuất. Hướng dẫn này vượt ra ngoài những điều cơ bản để giải quyết các thách thức trong thế giới thực gặp phải khi triển khai các kho lưu trữ nguyên khối ở quy mô lớn.
Tại sao lại là pnpm Workspaces?
Trước khi đi sâu vào Turborepo, chúng ta cần một trình quản lý gói vững chắc. Mặc dù npm và Yarn đều hỗ trợ workspaces, pnpm đã nổi lên như người chiến thắng rõ ràng cho monorepos nhờ cách tiếp cận độc đáo của nó đối với việc giải quyết dependency và quản lý không gian đĩa.
Không giống như các trình quản lý gói truyền thống nâng tất cả các dependency lên một thư mục node_modules gốc (làm phẳng cây dependency), pnpm sử dụng một kho lưu trữ có địa chỉ nội dung và symlink. Điều này dẫn đến một cấu trúc node_modules nghiêm ngặt, trong đó một gói chỉ có quyền truy cập vào các dependency mà nó liệt kê rõ ràng trong package.json của nó.
Sự nghiêm ngặt này rất quan trọng trong một monorepo. Nó ngăn chặn "phantom dependencies"—một vấn đề tai tiếng khi gói A vô tình dựa vào một dependency bắc cầu được cài đặt bởi gói B. Khi gói B bị xóa hoặc cập nhật, gói A bị hỏng bất ngờ, thường chỉ được phát hiện trong quá trình tích hợp liên tục hoặc triển khai. Với pnpm, phantom dependencies thực tế bị loại bỏ, đảm bảo các bản dựng xác định và đáng tin cậy trên tất cả các workspace.
Để bật pnpm workspaces, chỉ cần tạo một tệp pnpm-workspace.yaml ở thư mục gốc của kho lưu trữ của bạn:
packages:
- 'apps/*'
- 'packages/*'
Turborepo: Hệ thống Build Hiệu suất Cao
Trong khi pnpm quản lý các dependency, chúng ta cần một hệ thống build để điều phối các tác vụ trên monorepo. Đây là nơi Turborepo tỏa sáng. Turborepo là một hệ thống build hiệu suất cao được viết bằng Go (và Rust, trong các phiên bản mới hơn) sử dụng bộ nhớ đệm thông minh và thực thi song song để giảm đáng kể thời gian build.
Turborepo hiểu biểu đồ dependency của workspace của bạn. Nếu app-web phụ thuộc vào packages/ui và packages/utils, Turborepo biết rằng nó phải build ui và utils trước khi build app-web.
Quan trọng hơn, nó lưu trữ đầu ra của các tác vụ này. Nếu bạn chưa thay đổi packages/utils, Turborepo sẽ không lãng phí chu kỳ CPU để build lại nó; nó sẽ ngay lập tức phát lại đầu ra đã lưu trong bộ nhớ đệm từ một lần chạy trước đó, bỏ qua hoàn toàn tác vụ.
Cấu hình turbo.json
Trái tim của Turborepo là tệp cấu hình turbo.json. Đây là một ví dụ về thiết lập nâng cao, sẵn sàng cho sản xuất:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}
Lưu ý cú pháp ^build. Điều này cho Turborepo biết rằng tác vụ build của một gói cụ thể phụ thuộc vào tác vụ build của các dependency của nó. Cú pháp đơn giản này mở khóa việc sắp xếp tô pô, đảm bảo mọi thứ được build theo đúng thứ tự toán học dựa trên biểu đồ dependency.
Cấu hình TypeScript Nâng cao
Một trong những phần khó nhất của một monorepo TypeScript là cấu hình các tệp tsconfig.json một cách chính xác. Chúng ta muốn chia sẻ các cấu hình chung để tránh sự sai lệch, đồng thời cho phép các gói cụ thể ghi đè các cài đặt (ví dụ: một ứng dụng React cần các tùy chọn trình biên dịch khác với một công cụ CLI Node.js).
Chiến lược Cấu hình Cơ sở
Tạo một gói tsconfig trung tâm (ví dụ: packages/tsconfig) xuất các cấu hình nền tảng. Điều này ngăn chặn sự trùng lặp và đảm bảo tính nhất quán.
// packages/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true
},
"exclude": ["node_modules"]
}
Sau đó, bạn có thể cung cấp các biến thể như react-library.json hoặc nextjs.json. Trong các ứng dụng và gói thực tế của bạn, bạn mở rộng các cấu hình cơ sở này, giữ cho tệp cục bộ sạch sẽ và tập trung vào các ghi đè:
// apps/web/tsconfig.json
{
"extends": "@my-org/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
Chiến lược Linting và Định dạng Thống nhất
Duy trì phong cách mã nhất quán trên nhiều dự án là một đặc điểm nổi bật của một monorepo được kiến trúc tốt. Thay vì duy trì các tệp .eslintrc.js và .prettierrc khác nhau trong mỗi ứng dụng và gói, bạn có thể tập trung các cấu hình này giống như cài đặt TypeScript.
Tạo một thư mục packages/eslint-config. Bên trong, định nghĩa các quy tắc tiêu chuẩn của bạn, mở rộng các cấu hình phổ biến như eslint-config-turbo hoặc eslint-config-prettier. Bằng cách xuất các quy tắc này dưới dạng một gói, các workspace khác có thể đơn giản mở rộng chúng trong các cấu hình cục bộ của họ. Mô hình nguồn chân lý duy nhất này ngăn chặn sự sai lệch cấu hình và đảm bảo rằng một bản sửa lỗi linting được áp dụng trong một gói sẽ được thực thi trên toàn bộ kho lưu trữ. Hơn nữa, bằng cách định nghĩa một script lint trong turbo.json gốc, bạn có thể thực thi các tác vụ linting đồng thời trên tất cả các workspace, giảm đáng kể thời gian phản hồi trong quá trình tích hợp liên tục.
Các Gói Nội bộ và Chia sẻ Mã
Một monorepo hiện đại phụ thuộc rất nhiều vào các gói nội bộ. Thay vì sao chép các hàm tiện ích hoặc thành phần UI trên nhiều ứng dụng, bạn trích xuất chúng vào các workspace chuyên dụng như packages/utils hoặc packages/ui.
Với pnpm workspaces, bạn liên kết các gói này nội bộ bằng cách sử dụng giao thức workspace:*. Trong apps/web/package.json của bạn:
{
"dependencies": {
"@my-org/ui": "workspace:*",
"@my-org/utils": "workspace:*"
}
}
Giao thức workspace:* đảm bảo bạn luôn sử dụng phiên bản cục bộ của gói. Khi kết hợp với Turborepo, các thay đổi trong @my-org/ui sẽ tự động kích hoạt việc build lại apps/web nếu cần, tất cả trong khi vẫn tôn trọng bộ nhớ đệm. Điều này tạo ra trải nghiệm phát triển liền mạch, nơi các thay đổi giữa các gói được phản ánh ngay lập tức.
Build hay Không Build?
Một quyết định kiến trúc phổ biến khi thiết lập các gói nội bộ này là liệu chúng có nên được biên dịch trước (build thành một thư mục dist/) hay được nhập trực tiếp dưới dạng mã nguồn TypeScript vào các ứng dụng tiêu thụ.
Cách tiếp cận 1: Các gói được biên dịch trước
Đây là cách an toàn nhất và truyền thống nhất. Mỗi gói chịu trách nhiệm build chính nó bằng cách sử dụng một bundler hoặc compiler như tsc, tsup, hoặc vite. Sau đó, các ứng dụng tiêu thụ nhập các tệp .js và .d.ts đã được chuyển đổi. Điều này đảm bảo các ranh giới nghiêm ngặt và đảm bảo khả năng tương thích, nhưng yêu cầu một bước build chuyên dụng cho mỗi gói nội bộ.
Cách tiếp cận 2: Nhập mã nguồn (Biên dịch Just-in-Time)
Trong cách tiếp cận hiện đại này, các framework ứng dụng như Next.js hoặc Vite (trong thư mục apps/ của bạn) được cấu hình để chuyển đổi mã nguồn TypeScript của các gói nội bộ của bạn trực tiếp. Điều này loại bỏ hoàn toàn bước build cho các gói nội bộ, tăng tốc đáng kể quá trình phát triển cục bộ vì không có quá trình build trung gian nào phải chờ đợi. Các công cụ như next-transpile-modules (hiện đã được tích hợp trực tiếp vào Next.js phiên bản 13 trở lên) giúp quy trình làm việc này trở nên liền mạch.
Mặc dù Nhập mã nguồn cực kỳ nhanh cho việc phát triển cục bộ, các gói được biên dịch trước cung cấp khả năng đóng gói mạnh mẽ hơn và hoàn toàn cần thiết nếu bạn có kế hoạch xuất bản các gói ra bên ngoài một registry như npm. Một cách tiếp cận kết hợp thường được coi là thực hành tốt nhất: sử dụng nhập mã nguồn cho mã chia sẻ chỉ nội bộ và biên dịch trước bất kỳ thư viện nào dành cho người dùng công cộng.
Thực thi Ranh giới và Công cụ
Khi monorepo phát triển từ hàng chục lên hàng trăm gói, việc thực thi các ranh giới kiến trúc trở nên cực kỳ quan trọng. Bạn không muốn ứng dụng web frontend trực tiếp nhập các chuỗi kết nối cơ sở dữ liệu nhạy cảm từ gói tiện ích backend.
Các công cụ như eslint-plugin-workspaces hoặc các quy tắc thực thi ranh giới chuyên biệt có thể đảm bảo rằng các dependency chỉ chảy theo một hướng duy nhất, được mong đợi. Kết hợp việc thực thi nghiêm ngặt này với một quy trình CI/CD mạnh mẽ tận dụng bộ nhớ đệm từ xa của Turborepo, và bạn có một kiến trúc mạnh mẽ.
Bộ nhớ đệm từ xa có lẽ là tính năng biến đổi nhất của Turborepo. Bằng cách chia sẻ bộ nhớ đệm của Turborepo trên toàn bộ nhóm kỹ thuật của bạn và môi trường Tích hợp Liên tục của bạn, bạn đảm bảo rằng nếu một người (hoặc máy chủ CI) đã build một gói, không ai khác phải build lại nó nữa. Việc tiết kiệm thời gian ở quy mô lớn là rất lớn, biến các bản build kéo dài nhiều phút thành các lần truy cập bộ nhớ đệm dưới một giây.
Kết luận
Xây dựng một monorepo TypeScript hiện đại không còn là một nhiệm vụ khó khăn chỉ dành cho các công ty công nghệ khổng lồ với các nhóm trải nghiệm nhà phát triển chuyên trách. Bằng cách tận dụng sự nghiêm ngặt của pnpm workspaces và tốc độ cực nhanh của Turborepo, các nhóm ở mọi quy mô có thể kiến trúc các codebase có tính gắn kết cao, khả năng mở rộng vô hạn và là một niềm vui tuyệt đối khi phát triển. Vượt ra ngoài các hướng dẫn đơn giản, áp dụng các mẫu kiến trúc nâng cao này và xem tốc độ kỹ thuật của bạn tăng vọt lên những tầm cao mới.
Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Các Mẫu TypeScript Nâng Cao cho Ứng Dụng Doanh Nghiệp
Nắm vững các mẫu TypeScript doanh nghiệp nâng cao: branded types, conditional response types, template literal routing và toán tử satisfies.
Read more
GraphQL Federation Nâng Cao: Xây Dựng Supergraph Phân Tán
Xây dựng các API GraphQL phân tán cấp doanh nghiệp bằng Apollo Federation v2: giải quyết thực thể subgraph, tổng hợp schema, chỉ thị @key, chiến lược caching và định tuyến gateway với tối ưu hóa hiệu suất.
Read more
Di chuyển từ Jest sang Vitest trong kiến trúc Turborepo Monorepo
Hướng dẫn di chuyển từng bước hoàn chỉnh để chuyển đổi các bộ kiểm thử Jest sang Vitest trong Turborepo monorepo TypeScript với thời gian thực thi nhanh hơn 10 lần.
Read more