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

Chiến lược phân đoạn webpack mới hơn trong Next.js và Gatsby giúp giảm thiểu mã trùng lặp để cải thiện hiệu suất tải trang.

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

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

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

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

Mặc dù thiết thực, nhưng khái niệm gói tất cả mã mô-đun dùng chung vào một phần duy nhất cũng có những hạn chế. Bạn có thể tải các mô-đun không được chia sẻ ở mọi điểm truy cập xuống cho các tuyến không sử dụng mô-đun đó, dẫn đến việc tải nhiều mã hơn mức cần thiết. Ví dụ: khi page1 tải đoạn common, nó sẽ tải mã cho moduleC mặc dù page1 không sử dụng moduleC. Vì lý do này, cùng với một số lý do khác, webpack v4 đã 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 hoạt động hiệu quả với hầu hết người dùng. Nhiều phần phân tách được tạo dựa trên một số điều kiện để ngăn việc tìm nạp mã trùng lặp trên nhiều tuyến.

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" (một phần chung) để phân tách khối. Ví dụ: Next.js sẽ tạo một gói commons chứa bất kỳ mô-đun nào được sử dụng trên 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 dùng chung có nghĩa là bạn có thể tải mã đó xuống và lưu vào bộ nhớ đệm cho mọi điểm truy cập, nhưng việc suy đoán dựa trên mức sử dụng trong việc thêm các mô-đun phổ biến được sử dụng trong hơn nửa trang sẽ 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 hơn.
  • Nếu bạn tăng tỷ lệ này, sẽ có nhiều mã bị trùng lặp trên nhiều tuyến.

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

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

Chiến lược phân đoạn chi tiết này mang lại các lợi ích sau:

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

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

Yêu cầu HTTP khác

SplitChunksPlugin đã xác định cơ sở để 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 gói "commons" và tìm kiếm quy tắc đơn lẻ vì một số lý do. Trong đó có 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 có hạn đến một nguồn gốc (6 đối với Chrome), vì vậy, việc giảm thiểu số lượng đoạn do trình kết hợp tạo ra có thể đảm bảo 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 kênh trong HTTP/2 cho phép truyền đồng thời nhiều yêu cầu bằng một kết nối qua một nguồn gốc. 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 mảnh 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. Nhóm Chrome và Next.js muốn biết 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 phần dùng chung có ảnh hưởng đến hiệu suất tải theo bất kỳ 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, đồng thời sửa đổi số lượng tối đa các yêu cầu song song bằng cách sử dụng thuộc tính maxInitialRequests.

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

Trong trung bình ba lần chạy thử nhiều lần trên một trang web, thời gian load, start-renderHiể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 thành 15). Điều thú vị là chúng tôi chỉ nhận thấy một mức hao tổn hiệu suất nhỏ sau khi phân tách mạnh mẽ 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 ngưỡng đáng tin cậy (20 đến 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ơ sở, 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 sẽ dẫn đến nhiều hơn một gói dùng chung, đồng thời việc phân tách các yêu cầu đó phù hợp cho từng điểm truy cập đã giúp giảm đáng kể số 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 mức phân đoạn

Thử nghiệm này chỉ nhằm sửa đổi số lượng yêu cầu để xem liệu có bất kỳ tác độ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ì làm 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 để làm mới trang vẫn không thay đổi nhiều. Đ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 giảm lượng mã.

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

Giảm kích thước bằng các phần nhỏ

Nhiều khung, 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 lượt chuyển đổi tuyến. Nhưng làm cách nào để họ xác định trước các phần động này tại thời điểm 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 các đoạn đã xuất được các điểm truy cập khác nhau sử dụng. Để cung cấp thông tin này cho ứ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}`)) || []
  )
}
Kết quả của nhiều phần được chia sẻ trong ứng dụng Next.js.

Chiến lược phân đoạn chi tiết mới hơn này được triển khai lần đầu trong Next.js sau một cờ, trong đó 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 nhận thấy tổng số JavaScript được dùng cho toàn bộ trang web của họ đã giảm đáng kể:

Trang web Tổng thay đổi JS Mức chênh lệch (%)
https://www.barnebys.com/ -238 KB Giảm 23%
https://sumup.com/ -220 KB -30%
https://www.hashicorp.com/ 11 MB -71%
Giảm kích thước JavaScript – trên tất cả các tuyến (đã 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 chiến lược phân đoạn chi tiết tương tự, họ cũng nhận thấy mức giảm đáng kể về JavaScript trên nhiều trang web lớn:

Trang web Tổng thay đổi 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 (đã nén)

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

Kết luận

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

  • Nếu bạn muốn xem cùng một tính năng tối ưu hoá phân đoạn được áp dụng cho ứng dụng React cơ bản, 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 một loại logic cho trang web của mình.
  • Đối với tính năng Tổng hợp, các đoạn được tạo theo từng phần 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.