Tăng tốc TypeScript Monorepo: Hướng dẫn thực tế để tối ưu hóa Build Pipeline

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Cái giá của việc mở rộng: Tại sao Monorepo trở nên chậm chạp

Monorepo hứa hẹn việc chia sẻ mã nguồn liền mạch, nhưng chúng thường biến CI/CD pipeline thành một “nút thắt cổ chai” gây khó chịu. Tôi đã từng tham gia một nhóm mà một thay đổi nhỏ trong tiện ích logging dùng chung đã kích hoạt việc build lại 15 phút cho mọi ứng dụng trong repository. Chúng tôi không chỉ mất năng suất; chúng tôi còn đang đốt ngân sách CI để chờ trình biên dịch xử lý lại mã nguồn vốn dĩ không hề thay đổi.

Các công cụ tiêu chuẩn như Lerna hoặc npm workspaces cơ bản quản lý dependency khá tốt, nhưng chúng thiếu sự thông minh để tối ưu hóa việc thực thi. Chúng không nhận ra rằng App A vẫn hợp lệ ngay cả khi bạn sửa đổi App B. Turborepo lấp đầy khoảng trống này. Nó hoạt động như một lớp điều phối (orchestration layer) giúp lập bản đồ đồ thị phụ thuộc (dependency graph) và cache mọi tác vụ có thể.

Làm chủ các lớp điều phối này là một bước tiến quan trọng đối với các kỹ sư chuyển từ ứng dụng đơn lẻ sang hạ tầng doanh nghiệp. Nếu việc build của bạn mất hơn ba phút, bạn đang mất tập trung và tiền bạc. Trong các nhóm có tốc độ phát triển cao, việc giảm thời gian build từ 20 phút xuống còn 3 phút có thể tiết kiệm hàng chục giờ kỹ thuật mỗi tuần.

Bắt đầu: Khởi tạo Workspace

Cách dễ nhất để hiểu Turborepo là xem nó xử lý một dự án mới. Mặc dù bạn có thể tích hợp thủ công vào các repo hiện có, nhưng bộ khởi tạo chính thức cung cấp mô hình tư duy đáng tin cậy nhất về cấu trúc thư mục.

# Khởi tạo một Turborepo workspace mới
npx create-turbo@latest my-monorepo

Sau khi cài đặt xong, hãy kiểm tra cấu trúc cốt lõi:

  • apps/: Các dự án có thể triển khai (deployable), chẳng hạn như trang web Next.js hoặc dashboard Vite.
  • packages/: Các thư viện dùng chung cho UI components, cấu hình TypeScript và các hàm tiện ích.
  • turbo.json: Trung tâm điều khiển cho toàn bộ hệ thống build của bạn.

Cấu hình dùng chung là lợi ích lớn nhất ở đây. Thay vì duy trì các tệp tsconfig.json riêng biệt, bạn có thể export một cấu hình cơ sở từ packages/tsconfig và kế thừa nó. Điều này đảm bảo mọi nhà phát triển trong nhóm đều tuân theo các quy tắc nghiêm ngặt giống nhau mà không cần giám sát thủ công.

Cấu hình Build Pipeline trong turbo.json

Mọi thứ tập trung vào đối tượng pipeline trong turbo.json. Đây là nơi bạn lập bản đồ các phụ thuộc của tác vụ. Đối với các dự án TypeScript, thứ tự build là không thể thương lượng: nếu ứng dụng web của bạn import một package ui, package đó phải được chuẩn bị xong trước.

Hãy xem xét cấu hình tối ưu này cho một monorepo sẵn sàng cho production:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    }
  }
}

Giải thích các logic chính:

  • Ký tự mũ (^build): Điều này cho Turbo biết rằng việc build một package phụ thuộc vào việc các dependency của nó phải được build trước. Đây là bí mật để thực thi ổn định dựa trên đồ thị.
  • Định nghĩa outputs: Điều này cho Turbo biết chính xác thư mục nào cần lưu trữ. Nếu đầu vào không thay đổi kể từ lần chạy cuối cùng, Turbo sẽ chỉ cần khôi phục các thư mục này từ cache trong vài mili giây.
  • Lưu bộ nhớ đệm có chọn lọc (Selective Caching): Chúng ta đặt cache: false cho tác vụ dev. Các server phát triển là các tiến trình liên tục, vì vậy không có đầu ra tĩnh nào để lưu trữ.

Chiến lược hiệu suất TypeScript

Nhiều nhà phát triển sai lầm khi buộc mọi package phải chạy tsc riêng trong quá trình phát triển cục bộ. Điều này tạo ra chi phí dư thừa không cần thiết. Thay vào đó, hãy thử hai điều chỉnh sau:

  1. Sử dụng Just-in-Time Transpilation: Đối với các package dùng chung nội bộ, đừng biên dịch trước. Các công cụ như Next.js (thông qua transpilePackages) hoặc Vite có thể sử dụng trực tiếp các tệp .ts gốc. Điều này giúp tăng tốc đáng kể vòng lặp hot-reload.
  2. Tách biệt Type Checking: Coi tsc --noEmit như một tác vụ pipeline riêng biệt. Bằng cách thêm tác vụ type-check chạy song song với linting, bạn đảm bảo rằng việc kiểm tra kiểu (type validation) không chặn các artifact build thực tế.
"type-check": {
  "dependsOn": ["^build"],
  "outputs": []
}

Giám sát và Remote Caching

Để xác minh thiết lập của bạn, hãy chạy lệnh build hai lần. Lần chạy đầu tiên thực thi mọi thứ bình thường. Lần chạy thứ hai sẽ kết thúc gần như ngay lập tức, hiển thị trạng thái >>> FULL TURBO trong terminal của bạn.

# Lần chạy đầu tiên (Cache Miss)
npx turbo build

# Chạy lại ngay lập tức (Cache Hit)
npx turbo build

Caching tiêu chuẩn chỉ hoạt động trên máy cục bộ của bạn. Trong môi trường CI như GitHub Actions, cache sẽ bị mất giữa các lần chạy trừ khi bạn sử dụng Remote Caching. Bằng cách kết nối với một nhà cung cấp như Vercel hoặc một S3 bucket tự lưu trữ, CI server của bạn có thể lấy các artifact được tạo bởi các nhà phát triển tại địa phương. Tôi đã từng thấy một nhóm giảm hóa đơn tính toán hàng tháng của họ xuống 400 đô la chỉ bằng cách bật tính năng này.

Nếu bạn gặp các tác vụ chậm, hãy sử dụng cờ --summarize. Nó tạo ra một báo cáo JSON chi tiết trong thư mục .turbo giải thích chính xác lý do tại sao xảy ra cache miss. Đó là công cụ đầu tiên tôi sử dụng khi việc build có cảm giác chậm chạp.

npx turbo build --summarize

Việc trực quan hóa graph của dự án cũng hữu ích tương tự khi repo lớn dần. Chạy npx turbo build --graph để tạo tệp DOT. Dán tệp này vào một trình trực quan hóa giúp bạn phát hiện các phụ thuộc vòng (circular dependencies) hoặc sự kết hợp không cần thiết có thể làm chậm pipeline của bạn.

Bảo trì dài hạn

Turborepo không phải là giải pháp “thiết lập rồi quên luôn”. Khi bạn mở rộng quy mô, hãy giữ cho các package dùng chung của bạn tinh gọn. Package càng nhỏ, bạn càng có nhiều khả năng nhận được cache hit khi sửa đổi các phần khác của hệ thống. Luôn đảm bảo các export trong package.json của bạn được định nghĩa rõ ràng để Turbo có thể theo dõi các thay đổi một cách chính xác.

Áp dụng một hệ thống dựa trên đồ thị có nghĩa là bạn ngừng chiến đấu với các công cụ của mình. Bạn sẽ có một chu kỳ phát triển nhanh hơn và hóa đơn CI thấp hơn đáng kể. Hãy tập trung vào mã nguồn, và để lớp điều phối xử lý những phần việc nặng nhọc.

Share: