Cải thiện hiệu suất tải trang Next.js và Gatsby với tính năng phân đoạn chi tiết

Một chiến lược phân chia webpack mới hơn trong Next.js và Gatsby sẽ giảm thiểu mã trùng lặp để cải thiện hiệu suất tải trang.

Chrome đang hợp tác với các công cụ và khung trong hệ sinh thái nguồn mở JavaScript. Gần đây, một số điểm tối ưu hoá mới đã được thêm vào để cải thiện hiệu suất tải của Next.jsGatsby. Bài viết này đề cập đến một chiến lược phân đoạn chi tiết được cải thiện, hiện được vận chuyển theo mặc định trong cả hai khung.

Giới thiệu

Giống như nhiều khung web, Next.js và Gatsby sử dụng webpack làm trình kết hợp cốt lõi. webpack phiên bản 3 đã giới thiệu CommonsChunkPlugin để có thể xuất các mô-đun được chia sẻ giữa nhiều điểm truy cập trong một (hoặc một vài) đoạn "chung" (hoặc các đoạn). Bạn có thể tải riêng mã dùng chung xuống và lưu trữ trong bộ nhớ đệm của trình duyệt từ sớm, điều này có thể giúp cải thiện hiệu suất tải.

Mẫu này trở nên phổ biến với nhiều khung ứng dụng một trang áp dụng một điểm truy cập và cấu hình gói có dạng như sau:

Cấu hình điểm truy cập và gói phổ biến

Mặc dù có tính thực tế, nhưng khái niệm kết hợp tất cả mã mô-đun dùng chung vào một khối duy nhất có những hạn chế riêng. Các mô-đun không được chia sẻ ở mọi điểm truy cập có thể được tải xuống cho những tuyến đường không sử dụng mô-đun đó, dẫn đến việc tải xuống nhiều mã hơn mức cần thiết. Ví dụ: khi page1 tải đoạn common, đoạn này sẽ tải mã cho moduleC ngay cả khi page1 không dùng moduleC. Vì lý do này, cùng với một số lý do khác, webpack phiên bản 4 đã xoá trình bổ trợ này để thay thế bằng một trình bổ trợ mới: SplitChunksPlugin.

Cải thiện tính năng phân đoạn

Chế độ cài đặt mặc định cho SplitChunksPlugin phù hợp với hầu hết người dùng. Nhiều khối phân tách được tạo tuỳ thuộc vào một số điều kiện để ngăn chặn việc tìm nạp mã trùng lặp trên nhiều tuyến đường.

Tuy nhiên, nhiều khung web sử dụng trình bổ trợ này vẫn tuân theo phương pháp "single-commons" để phân chia khối. Ví dụ: Next.js sẽ tạo một gói commons chứa mọi mô-đun được dùng trong hơn 50% số trang và tất cả các phần phụ thuộc của khung (react, react-dom, v.v.).

const splitChunksConfigs = {
  
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

Mặc dù việc đưa mã phụ thuộc vào khung vào một đoạn mã dùng chung có nghĩa là mã đó có thể được tải xuống và lưu vào bộ nhớ đệm cho mọi điểm truy cập, nhưng phương pháp phỏng đoán dựa trên mức sử dụng để đưa các mô-đun phổ biến được dùng trong hơn một nửa số trang không hiệu quả lắm. Việc sửa đổi tỷ lệ này sẽ chỉ dẫn đến một trong hai kết quả sau:

  • Nếu bạn giảm tỷ lệ này, nhiều mã không cần thiết sẽ được tải xuống.
  • Nếu bạn tăng tỷ lệ này, nhiều mã sẽ được sao chép trên nhiều tuyến đường.

Để giải quyết vấn đề này, Next.js đã áp dụng một cấu hình khác cho SplitChunksPlugin giúp giảm mã không cần thiết cho mọi tuyến đường.

  • Mọi mô-đun đủ lớn của bên thứ ba (lớn hơn 160 KB) đều được chia thành từng đoạn riêng
  • Một đoạn frameworks riêng biệt được tạo cho các phần phụ thuộc của khung (react, react-dom, v.v.)
  • Tạo bao nhiêu đoạn dùng chung tuỳ ý (tối đa 25 đoạn)
  • Kích thước tối thiểu để tạo một đoạn đã được thay đổi thành 20 KB

Chiến lược chia nhỏ chi tiết này mang lại những lợi ích sau:

  • Thời gian tải trang được cải thiện. Việc phát ra nhiều đoạn mã dùng chung thay vì một đoạn mã duy nhất sẽ giảm thiểu lượng mã không cần thiết (hoặc trùng lặp) cho mọi điểm truy cập.
  • Cải thiện tính năng lưu vào bộ nhớ đệm trong quá trình chỉ đường. Việc chia các thư viện lớn và các phần phụ thuộc của khung thành các khối riêng biệt sẽ giảm khả năng làm mất hiệu lực bộ nhớ đệm vì cả hai đều khó có thể thay đổi cho đến khi bạn nâng cấp.

Bạn có thể xem toàn bộ cấu hình mà Next.js đã áp dụng trong webpack-config.ts.

Nhiều yêu cầu HTTP hơn

SplitChunksPlugin xác định cơ sở cho việc phân đoạn chi tiết và việc áp dụng phương pháp này cho một khung như Next.js không phải là một khái niệm hoàn toàn mới. Tuy nhiên, nhiều khung vẫn tiếp tục sử dụng một chiến lược duy nhất về gói "commons" và chiến lược suy nghiệm vì một số lý do. Điều này bao gồm cả mối lo ngại rằng nhiều yêu cầu HTTP hơn có thể ảnh hưởng tiêu cực đến hiệu suất của trang web.

Trình duyệt chỉ có thể mở một số lượng kết nối TCP giới hạn đến một nguồn duy nhất (6 cho Chrome), vì vậy, việc giảm thiểu số lượng đoạn mà trình kết hợp xuất ra có thể đảm bảo rằng tổng số yêu cầu nằm dưới ngưỡng này. Tuy nhiên, điều này chỉ đúng với HTTP/1.1. Tính năng đa hợp trong HTTP/2 cho phép truyền trực tuyến nhiều yêu cầu song song bằng một kết nối duy nhất qua một nguồn gốc duy nhất. Nói cách khác, chúng ta thường không cần lo lắng về việc giới hạn số lượng đoạn mã do trình kết hợp phát ra.

Tất cả các trình duyệt chính đều hỗ trợ HTTP/2. Các nhóm Chrome và Next.js muốn xem liệu việc tăng số lượng yêu cầu bằng cách chia gói "commons" duy nhất của Next.js thành nhiều đoạn mã dùng chung có ảnh hưởng đến hiệu suất tải theo cách nào hay không. Họ bắt đầu bằng cách đo lường hiệu suất của một trang web duy nhất trong khi sửa đổi số lượng yêu cầu song song tối đa bằng cách sử dụng thuộc tính maxInitialRequests.

Hiệu suất tải trang khi số lượng yêu cầu tăng lên

Trong trung bình 3 lần chạy nhiều thử nghiệm trên một trang web duy nhất, load, start-renderThời gian hiển thị nội dung đầu tiên đều giữ nguyên khi thay đổi số lượng yêu cầu ban đầu tối đa (từ 5 đến 15). Điều thú vị là chúng tôi chỉ nhận thấy hiệu suất giảm nhẹ sau khi chia thành hàng trăm yêu cầu.

Hiệu suất tải trang với hàng trăm yêu cầu

Điều này cho thấy việc duy trì dưới một ngưỡng đáng tin cậy (20~25 yêu cầu) đã tạo ra sự cân bằng phù hợp giữa hiệu suất tải và hiệu quả lưu vào bộ nhớ đệm. Sau một số thử nghiệm cơ bản, 25 được chọn làm số lượng maxInitialRequest.

Việc sửa đổi số lượng yêu cầu tối đa xảy ra song song dẫn đến nhiều hơn một gói dùng chung và việc tách riêng các yêu cầu này một cách thích hợp cho từng điểm truy cập đã giảm đáng kể lượng mã không cần thiết cho cùng một trang.

Giảm tải trọng JavaScript bằng cách tăng số lượng đoạn mã

Thử nghiệm này chỉ nhằm mục đích sửa đổi số lượng yêu cầu để xem có ảnh hưởng tiêu cực nào đến hiệu suất tải trang hay không. Kết quả cho thấy việc đặt maxInitialRequests thành 25 trên trang thử nghiệm là tối ưu vì việc này giúp giảm kích thước tải trọng JavaScript mà không làm chậm trang. Tổng lượng JavaScript cần thiết để truyền dữ liệu cho trang vẫn gần như không đổi. Điều này giải thích lý do hiệu suất tải trang không nhất thiết phải cải thiện khi lượng mã giảm xuống.

webpack sử dụng 30 KB làm kích thước tối thiểu mặc định cho một đoạn mã cần được tạo. Tuy nhiên, việc kết hợp giá trị maxInitialRequests là 25 với kích thước tối thiểu là 20 KB lại mang đến kết quả lưu vào bộ nhớ đệm tốt hơn.

Giảm kích thước bằng các khối chi tiết

Nhiều khung hình, bao gồm cả Next.js, dựa vào tính năng định tuyến phía máy khách (do JavaScript xử lý) để chèn các thẻ tập lệnh mới hơn cho mọi quá trình chuyển đổi tuyến đường. Nhưng làm cách nào để chúng xác định trước các khối động này tại thời gian xây dựng?

Next.js sử dụng tệp kê khai bản dựng phía máy chủ để xác định những đoạn đầu ra nào được dùng bởi các điểm truy cập khác nhau. Để cung cấp thông tin này cho cả ứng dụng, một tệp kê khai bản dựng rút gọn phía máy khách đã được tạo để liên kết tất cả các phần phụ thuộc cho mọi điểm truy cập.

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
Đầu ra của nhiều đoạn mã được chia sẻ trong một ứng dụng Next.js.

Chiến lược phân chia chi tiết mới hơn này được triển khai lần đầu trong Next.js đằng sau một cờ, nơi chiến lược này được thử nghiệm trên một số người dùng sớm. Nhiều người đã thấy tổng lượng JavaScript được dùng cho toàn bộ trang web của họ giảm đáng kể:

Trang web Tổng thay đổi về JS Mức chênh lệch (%)
https://www.barnebys.com/ -238 KB -23%
https://sumup.com/ -220 KB Giảm 30%
https://www.hashicorp.com/ -11 MB -71%
Giảm kích thước JavaScript – trên tất cả các tuyến đường (được nén)

Phiên bản cuối cùng được vận chuyển theo mặc định trong phiên bản 9.2.

Gatsby

Gatsby từng tuân theo cùng một phương pháp sử dụng phương pháp phỏng đoán dựa trên mức sử dụng để xác định các mô-đun phổ biến:

config.optimization = {
  
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

Bằng cách tối ưu hoá cấu hình webpack để áp dụng một chiến lược phân chia thành các phần nhỏ tương tự, họ cũng nhận thấy mức giảm đáng kể về JavaScript ở nhiều trang web lớn:

Trang web Tổng thay đổi về JS Mức chênh lệch (%)
https://www.gatsbyjs.org/ -680 KB -22%
https://www.thirdandgrove.com/ -390 KB Dưới 25%
https://ghost.org/ -1,1 MB -35%
https://reactjs.org/ -80 Kb -8%
Giảm kích thước JavaScript – trên tất cả các tuyến đường (được nén)

Hãy xem PR để hiểu cách họ triển khai logic này vào cấu hình webpack, được phân phối theo mặc định trong phiên bản 2.20.7.

Kết luận

Khái niệm về việc gửi các khối chi tiết không dành riêng cho Next.js, Gatsby hoặc thậm chí là webpack. Mọi người nên cân nhắc cải thiện chiến lược phân đoạn của ứng dụng nếu ứng dụng đó tuân theo phương pháp gói "chung" lớn, bất kể khung hoặc trình đóng gói mô-đun được sử dụng.

  • Nếu bạn muốn xem các hoạt động tối ưu hoá phân đoạn tương tự được áp dụng cho một ứng dụng React thuần tuý, hãy xem ứng dụng React mẫu này. Ứng dụng này sử dụng phiên bản đơn giản của chiến lược phân đoạn chi tiết và có thể giúp bạn bắt đầu áp dụng cùng loại logic cho trang web của mình.
  • Đối với Rollup, các đoạn mã được tạo chi tiết theo mặc định. Hãy xem manualChunks nếu bạn muốn định cấu hình hành vi theo cách thủ công.