Các kỹ thuật giúp ứng dụng web tải nhanh, ngay cả trên điện thoại phổ thông

Cách chúng tôi sử dụng tính năng phân tách mã, cùng dòng mã và kết xuất phía máy chủ trong PROXX.

Tại Google I/O 2019, Mariko, Jake và tôi đã giới thiệu PROXX, một bản sao của Minesweeper hiện đại dành cho web. Điểm khác biệt của PROXX là tập trung vào khả năng hỗ trợ tiếp cận (bạn có thể chơi trò chơi này bằng trình đọc màn hình!) và khả năng chạy trên điện thoại phổ thông cũng như trên máy tính để bàn cao cấp. Điện thoại phổ thông bị hạn chế theo nhiều cách:

  • CPU yếu
  • GPU yếu hoặc không tồn tại
  • Màn hình nhỏ không có phương thức nhập bằng cách chạm
  • Dung lượng bộ nhớ rất hạn chế

Tuy nhiên, chúng chạy một trình duyệt hiện đại và có giá cả rất phải chăng. Vì lý do này, điện thoại phổ thông đang trỗi dậy tại các thị trường mới nổi. Mức giá của họ cho phép đối tượng hoàn toàn mới, những người trước đây không thể đủ khả năng chi trả, lên mạng và sử dụng web hiện đại. Năm 2019, dự kiến sẽ có khoảng 400 triệu điện thoại phổ thông sẽ được bán riêng ở Ấn Độ. Vì vậy, người dùng điện thoại phổ thông có thể sẽ trở thành một phần đáng kể trong đối tượng của bạn. Ngoài ra, tốc độ kết nối giống như 2G là tiêu chuẩn tại các thị trường mới nổi. Chúng ta đã làm gì để PROXX hoạt động tốt trong điều kiện điện thoại phổ thông?

Trò chơi PROXX.

Hiệu suất rất quan trọng, bao gồm cả hiệu suất tải và hiệu suất trong thời gian chạy. Thực tế cho thấy hiệu suất tốt tương quan với việc tăng tỷ lệ giữ chân người dùng, cải thiện số lượt chuyển đổi và quan trọng nhất là mức độ hoà nhập. Jeremy Wagner có nhiều dữ liệu và thông tin chi tiết hơn về lý do hiệu suất lại quan trọng.

Đây là phần 1 trong loạt video gồm 2 phần. Phần 1 tập trung vào hiệu suất tải và phần 2 sẽ tập trung vào hiệu suất thời gian chạy.

Ghi lại hiện trạng

Việc kiểm thử hiệu suất tải trên thiết bị thực là rất quan trọng. Nếu không có sẵn một thiết bị thực, tôi khuyên bạn nên sử dụng WebPageTest, cụ thể là thao tác "đơn giản" thiết lập. WPT chạy pin kiểm thử tải trên một thiết bị thực có kết nối 3G được mô phỏng.

3G là tốc độ tốt để đo. Mặc dù bạn có thể đã quen với 4G, LTE hoặc thậm chí là 5G, nhưng thực tế Internet di động có vẻ khá khác. Có thể bạn đang ở trên tàu, tại một hội nghị, tại một buổi hoà nhạc hay trên chuyến bay. Những gì bạn sẽ gặp phải ở đó rất có thể là mạng 3G và đôi khi thậm chí còn tệ hơn.

Mặc dù vậy, trong bài viết này, chúng ta sẽ tập trung vào 2G vì PROXX đang nhắm mục tiêu rõ ràng đến điện thoại phổ thông và thị trường mới nổi trong đối tượng mục tiêu của mình. Khi WebPageTest chạy kiểm thử, bạn sẽ nhận được một thác nước (tương tự như những gì bạn thấy trong Công cụ cho nhà phát triển) cũng như một cuộn phim ở trên cùng. Cuộn phim hiển thị những gì người dùng nhìn thấy khi ứng dụng của bạn đang tải. Trên 2G, trải nghiệm tải của phiên bản PROXX chưa được tối ưu hóa là khá tệ:

Video trên cuộn phim cho thấy những gì người dùng nhìn thấy khi PROXX đang tải trên một thiết bị thực, cấp thấp qua kết nối 2G được mô phỏng.

Khi tải qua mạng 3G, người dùng thấy một màu trắng không có 4 giây. Trên 2G, người dùng hoàn toàn không thấy gì trong hơn 8 giây. Nếu đọc tại sao hiệu suất lại quan trọng, bạn sẽ biết rằng hiện chúng tôi đã mất một phần đáng kể người dùng tiềm năng do thiếu kiên nhẫn. Người dùng cần tải tất cả 62 KB JavaScript xuống để nội dung xuất hiện trên màn hình. Điều đáng kinh ngạc trong trường hợp này là bất kỳ nội dung thứ hai nào xuất hiện trên màn hình cũng đều có tính tương tác. Mà có khó lắm không nhỉ?

[Nội dung có ý nghĩa đầu tiên][FMP] trong phiên bản chưa được tối ưu hoá của PROXX có tính _Technically_ [tương tác][TTI] nhưng vô ích đối với người dùng.

Sau khi khoảng 62 KB JS được tải xuống và DOM đã được tạo, người dùng sẽ thấy được ứng dụng của chúng tôi. Ứng dụng có tính tương tác về mặt kỹ thuật. Tuy nhiên, khi nhìn vào hình ảnh, chúng ta thấy một thực tế khác. Phông chữ trên web vẫn đang tải trong nền và người dùng không thể thấy văn bản cho đến khi phông chữ sẵn sàng. Mặc dù trạng thái này đủ điều kiện là Nội dung hiển thị có ý nghĩa đầu tiên (FMP), nhưng chắc chắn trạng thái này không đủ điều kiện là tương tác đúng cách, vì người dùng không thể phân biệt dữ liệu đầu vào. Phải mất thêm 3 giây trên 3G và 3 giây trên 2G cho đến khi ứng dụng sẵn sàng hoạt động. Nhìn chung, ứng dụng mất 6 giây trên 3G và 11 giây trên 2G để tương tác.

Phân tích thác nước

Giờ đây, khi đã biết nội dung người dùng nhìn thấy, chúng ta cần tìm hiểu lý do. Để làm được điều này, chúng ta có thể xem xét thác nước và phân tích lý do tại sao tài nguyên tải quá muộn. Trong dấu vết 2G cho PROXX, chúng ta có thể thấy hai cờ đỏ chính:

  1. Có nhiều đường kẻ mỏng nhiều màu.
  2. Các tệp JavaScript tạo thành một chuỗi. Ví dụ: tài nguyên thứ hai chỉ bắt đầu tải sau khi tài nguyên đầu tiên kết thúc và tài nguyên thứ ba chỉ bắt đầu khi tài nguyên thứ hai kết thúc.
Thác nước cung cấp thông tin chi tiết về những tài nguyên nào đang tải khi nào và trong bao lâu.

Giảm số lượng kết nối

Mỗi dòng mỏng (dns, connect, ssl) tượng trưng cho việc tạo một kết nối HTTP mới. Việc thiết lập kết nối mới rất tốn kém vì mất khoảng 1 giây trên 3G và khoảng 2,5 giây trên 2G. Trong thác nước, chúng ta thấy một kết nối mới dành cho:

  • Yêu cầu số 1: index.html của chúng tôi
  • Yêu cầu số 5: Kiểu phông chữ của fonts.googleapis.com
  • Yêu cầu số 8: Google Analytics
  • Yêu cầu số 9: Tệp phông chữ từ fonts.gstatic.com
  • Yêu cầu số 14: Tệp kê khai ứng dụng web

Việc kết nối mới cho index.html là không thể tránh khỏi. Trình duyệt phải tạo kết nối với máy chủ của chúng tôi để tải nội dung. Có thể tránh kết nối mới cho Google Analytics bằng cách đặt cùng dòng một mục như Analytics tối thiểu, nhưng Google Analytics không chặn ứng dụng của chúng tôi hiển thị hoặc trở nên tương tác, vì vậy, chúng tôi thực sự không quan tâm đến tốc độ tải của ứng dụng. Tốt nhất là bạn nên tải Google Analytics trong thời gian không hoạt động, khi mọi thứ khác đã được tải. Bằng cách đó, nền tảng này sẽ không chiếm băng thông hoặc công suất xử lý trong quá trình tải ban đầu. Kết nối mới cho tệp kê khai ứng dụng web được quy định theo thông số kỹ thuật tìm nạp, vì tệp kê khai phải được tải qua một kết nối không được xác thực. Xin nhắc lại, tệp kê khai ứng dụng web không chặn ứng dụng hiển thị hoặc tương tác, nên chúng ta không cần quan tâm lắm.

Tuy nhiên, hai phông chữ và kiểu của chúng là một vấn đề vì chúng chặn hiển thị và cả tính tương tác. Nếu xem CSS do fonts.googleapis.com phân phối, thì đó chỉ là hai quy tắc @font-face, một quy tắc cho một phông chữ. Trên thực tế, kiểu phông chữ quá nhỏ đến mức chúng tôi quyết định chèn phông chữ vào trong HTML, loại bỏ một kết nối không cần thiết. Để tránh chi phí thiết lập kết nối cho các tệp phông chữ, chúng ta có thể sao chép chúng vào máy chủ của riêng mình.

Đang tải song song

Khi xem xét thác nước, chúng ta có thể thấy rằng sau khi tệp JavaScript đầu tiên tải xong, các tệp mới sẽ bắt đầu tải ngay lập tức. Đây là điều điển hình cho các phần phụ thuộc của mô-đun. Mô-đun chính của chúng ta có thể có tính năng nhập tĩnh, vì vậy, JavaScript không thể chạy cho đến khi các dữ liệu nhập đó được tải. Điều quan trọng cần nhận biết ở đây là xác định các loại phần phụ thuộc này tại thời điểm xây dựng. Chúng ta có thể sử dụng thẻ <link rel="preload"> để đảm bảo tất cả các phần phụ thuộc bắt đầu tải ngay khi chúng tôi nhận được HTML.

Kết quả

Hãy cùng xem những thay đổi của chúng tôi đã mang lại những gì. Điều quan trọng là không được thay đổi bất kỳ biến nào khác trong cách thiết lập thử nghiệm có thể làm sai lệch kết quả, vì vậy chúng ta sẽ sử dụng thiết lập đơn giản của WebPageTest cho phần còn lại của bài viết này và xem xét cuộn phim:

Chúng tôi sử dụng cuộn phim của WebPageTest để xem những thay đổi của chúng tôi đã đạt được những gì.

Những thay đổi này đã làm giảm TTI từ 11 xuống còn 8,5, tức là khoảng 2,5 giây thời gian thiết lập kết nối mà chúng tôi muốn loại bỏ. Bạn làm tốt lắm.

Kết xuất trước

Mặc dù vừa giảm TTI, nhưng chúng ta chưa thực sự ảnh hưởng đến màn hình trắng dài vĩnh viễn mà người dùng phải chịu trong 8,5 giây. Có thể xem điểm cải tiến lớn nhất cho FMP có thể đạt được bằng cách gửi mã đánh dấu có kiểu trong index.html. Các kỹ thuật phổ biến để đạt được điều này là kết xuất trước và kết xuất phía máy chủ. Hai kỹ thuật này có liên quan chặt chẽ và được giải thích trong phần Kết xuất trên web. Cả hai kỹ thuật đều chạy ứng dụng web trong Nút và chuyển đổi tuần tự DOM kết quả thành HTML. Tính năng kết xuất phía máy chủ thực hiện việc này theo yêu cầu ở phía máy chủ, trong khi quá trình kết xuất trước thực hiện việc này tại thời điểm xây dựng và lưu trữ kết quả dưới dạng index.html mới. Vì PROXX là một ứng dụng JAMStack và không có phía máy chủ, nên chúng tôi đã quyết định triển khai tính năng kết xuất trước.

Có nhiều cách để triển khai trình kết xuất trước. Trong PROXX, chúng tôi đã chọn sử dụng Puppeteer để khởi động Chrome mà không cần bất kỳ giao diện người dùng nào và cho phép bạn điều khiển từ xa phiên bản đó bằng API Nút. Chúng tôi sử dụng mã này để chèn mã đánh dấu và JavaScript, sau đó đọc lại DOM dưới dạng một chuỗi HTML. Vì đang sử dụng Mô-đun CSS, nên chúng tôi cung cấp CSS cùng lúc với các kiểu mà chúng tôi cần miễn phí.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Qua đó, chúng tôi có thể kỳ vọng rằng FMP sẽ cải thiện. Chúng ta vẫn cần tải và thực thi cùng một lượng JavaScript như trước đây, do đó TTI sẽ không thay đổi nhiều. Nếu có bất cứ điều gì, index.html của chúng ta đã trở nên lớn hơn và có thể đẩy TTI trở lại một chút. Chỉ có một cách để tìm hiểu, đó là chạy WebPageTest.

Loạt phim cho thấy sự cải thiện rõ ràng đối với chỉ số FMP của chúng tôi. TTI hầu như không bị ảnh hưởng.

Thời gian hiển thị có ý nghĩa đầu tiên đã chuyển từ 8,5 giây sang 4,9 giây, một bước cải tiến đáng kể. TTI của chúng tôi vẫn diễn ra vào khoảng 8,5 giây nên phần lớn không bị ảnh hưởng bởi thay đổi này. Những gì chúng tôi làm ở đây là một thay đổi nhận thức. Một số người thậm chí có thể gọi đây là hành động đơn giản. Bằng cách hiển thị hình ảnh trung gian của trò chơi, chúng ta đang thay đổi hiệu suất tải theo cảm nhận theo hướng tốt hơn.

Cùng dòng

Một chỉ số khác mà cả Công cụ cho nhà phát triển và WebPageTest cung cấp cho chúng ta là Thời gian cho byte đầu tiên (TTFB). Đây là thời gian tính từ byte đầu tiên của yêu cầu được gửi đến byte đầu tiên của phản hồi đang được nhận. Thời gian này cũng thường được gọi là Thời gian trọn vòng (RTT), mặc dù về mặt kỹ thuật có sự khác biệt giữa hai số này: RTT không bao gồm thời gian xử lý yêu cầu ở phía máy chủ. DevTools và WebPageTest trực quan hóa TTFB bằng một màu sáng trong khối yêu cầu/phản hồi.

Phần ánh sáng của yêu cầu biểu thị yêu cầu đang chờ nhận byte đầu tiên của phản hồi.

Nhìn vào thác nước, chúng ta có thể thấy rằng tất cả yêu cầu đều dành phần lớn thời gian để chờ byte đầu tiên của phản hồi đến.

Đây là vấn đề mà ban đầu chúng tôi dự tính dành cho tính năng Đẩy HTTP/2. Nhà phát triển ứng dụng biết rằng cần có một số tài nguyên nhất định và có thể đẩy nhu cầu đó xuống. Vào thời điểm máy khách nhận thấy cần tìm nạp các tài nguyên bổ sung thì các tài nguyên đó đã có trong bộ nhớ đệm của trình duyệt. Việc đẩy HTTP/2 hoá ra là quá khó để làm đúng và được coi là không khuyến khích. Không gian xảy ra sự cố này sẽ được truy cập lại trong quá trình tiêu chuẩn hoá HTTP/3. Hiện tại, giải pháp dễ nhất là điều chỉnh nội tuyến tất cả các tài nguyên quan trọng nhưng vẫn cần tiết kiệm hiệu quả khi lưu vào bộ nhớ đệm.

CSS quan trọng của chúng tôi đã có sẵn nhờ các Mô-đun CSS và trình kết xuất trước dựa trên Puppeteer của chúng tôi. Đối với JavaScript, chúng ta cần nội tuyến các mô-đun quan trọng và các phần phụ thuộc của các mô-đun đó. Nhiệm vụ này có độ khó khác nhau, dựa trên trình gói mà bạn đang sử dụng.

Với nội tuyến của JavaScript, chúng tôi đã giảm TTI của mình từ 8,5 giây xuống còn 7,2 giây.

Việc này đã làm mất 1 giây của TTI. Hiện chúng ta đã đạt đến điểm khi index.html chứa mọi thứ cần thiết cho việc kết xuất ban đầu và trở thành khả năng tương tác. HTML có thể hiển thị trong khi vẫn đang tải xuống, tạo ra FMP của chúng tôi. Ngay khi HTML hoàn tất phân tích cú pháp và thực thi, ứng dụng sẽ có tính tương tác.

Phân tách mã linh hoạt

Có, index.html của chúng tôi chứa mọi thứ cần thiết để trở nên tương tác. Nhưng khi kiểm tra kỹ hơn, hoá ra thư viện này cũng chứa mọi thông tin khác. index.html của chúng tôi có kích thước khoảng 43 KB. Hãy đặt vấn đề đó liên quan đến những gì người dùng có thể tương tác ngay từ đầu: Chúng ta có một biểu mẫu để định cấu hình trò chơi, trong đó có một số thành phần, một nút bắt đầu và có thể là một vài đoạn mã để duy trì và tải chế độ cài đặt của người dùng. Vậy là đủ rồi. 43 KB có vẻ cao hơn.

Trang đích của PROXX. Chỉ các thành phần quan trọng được sử dụng ở đây.

Để biết kích thước gói của chúng tôi đến từ đâu, chúng tôi có thể sử dụng trình khám phá bản đồ nguồn hoặc một công cụ tương tự để phân tích các thành phần của gói. Theo dự đoán, gói của chúng ta sẽ chứa logic trò chơi, công cụ kết xuất, màn hình chiến thắng, màn hình thua cuộc và một loạt tiện ích. Chỉ cần một số ít mô-đun trong số này cho trang đích. Việc chuyển những thứ không cần thiết cho tính tương tác vào một mô-đun tải từng phần sẽ làm giảm đáng kểTTI.

Phân tích nội dung trong "index.html" của PROXX cho thấy có nhiều tài nguyên không cần thiết. Các tài nguyên thiết yếu được làm nổi bật.

Việc cần làm là phân tách mã. Quá trình phân tách mã sẽ chia gói nguyên khối thành các phần nhỏ hơn có thể được tải từng phần theo yêu cầu. Các trình gói phổ biến như Webpack, RollupParcel hỗ trợ việc phân tách mã bằng cách sử dụng import() động. Trình gói này sẽ phân tích mã của bạn và nội tuyến tất cả các mô-đun được nhập tĩnh. Mọi thứ bạn nhập động sẽ được đưa vào tệp riêng và chỉ được tìm nạp từ mạng sau khi lệnh gọi import() được thực thi. Tất nhiên việc nhấn vào mạng là có phí và chỉ nên thực hiện nếu bạn có thời gian rảnh. Câu đố ở đây là nhập tĩnh các mô-đun rất cần thiết vào thời điểm tải và tải động mọi thứ khác. Tuy nhiên, bạn không nên chờ đến giây phút cuối cùng để tải từng phần các mô-đun mà chắc chắn sẽ được sử dụng. Nhàn rỗi cho đến khẩn cấp của Phil Walton là một mẫu tuyệt vời để tạo nền tảng lành mạnh giữa tải từng phần và tải nhanh.

Trong PROXX, chúng ta đã tạo một tệp lazy.js để nhập tĩnh mọi thứ mà chúng ta không cần. Trong tệp chính, chúng ta có thể nhập động lazy.js. Tuy nhiên, một số thành phần Preact của chúng ta đã kết thúc trong lazy.js, điều này hoá ra chỉ là một chức năng vì Preact không thể xử lý các thành phần tải từng phần ngay từ đầu. Vì lý do này, chúng ta đã viết một trình bao bọc thành phần deferred nhỏ cho phép kết xuất phần giữ chỗ cho đến khi thành phần thực tế tải xong.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Với vị trí này, chúng ta có thể sử dụng Promise của một thành phần trong các hàm render(). Ví dụ: thành phần <Nebula> kết xuất hình nền động sẽ được thay thế bằng một <div> trống trong khi thành phần đang tải. Sau khi thành phần này được tải và sẵn sàng sử dụng, <div> sẽ được thay thế bằng thành phần thực tế.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Sau khi có tất cả những điều này, chúng tôi đã giảm index.html xuống chỉ còn 20 KB, chưa bằng một nửa kích thước ban đầu. Điều này có ảnh hưởng gì đến FMP và TTI? WebPageTest sẽ cho bạn biết!

Cuộn phim xác nhận: TTI của chúng tôi hiện ở mức 5,4 giây. Một cải tiến đáng kể so với phiên bản 11 giây ban đầu của chúng tôi.

FMP và TTI của chúng ta chỉ cách nhau 100 mili giây vì vấn đề phân tích cú pháp và thực thi JavaScript cùng dòng. Chỉ sau 5,4 giây trên 2G, ứng dụng đã hoàn toàn tương tác được. Tất cả các mô-đun khác, ít thiết yếu hơn đều được tải ở chế độ nền.

Nhiều tay hơn

Nếu xem danh sách các mô-đun quan trọng ở trên, bạn sẽ thấy công cụ kết xuất không nằm trong các mô-đun quan trọng. Tất nhiên, trò chơi không thể bắt đầu cho đến khi chúng ta có công cụ kết xuất để kết xuất trò chơi. Chúng tôi có thể vô hiệu hoá nút "Bắt đầu" cho đến khi công cụ kết xuất của chúng ta sẵn sàng bắt đầu trò chơi, nhưng theo kinh nghiệm của chúng tôi, người dùng thường mất đủ thời gian để định cấu hình cài đặt trò chơi rằng điều này là không cần thiết. Trong hầu hết trường hợp, công cụ kết xuất và các mô-đun còn lại khác đều tải xong vào thời điểm người dùng nhấn "Start". Trong một số ít trường hợp, khi người dùng nhanh hơn kết nối mạng, chúng tôi sẽ hiển thị một màn hình tải đơn giản để chờ các mô-đun còn lại hoàn tất.

Kết luận

Việc đo lường là rất quan trọng. Để tránh tốn thời gian cho những vấn đề không có thực, bạn nên luôn đo lường trước khi triển khai các biện pháp tối ưu hoá. Ngoài ra, bạn nên đo lường trên thiết bị thực khi có kết nối 3G hoặc trên công cụ WebPageTest nếu không có thiết bị thực nào trong tay.

Cuộn phim có thể cung cấp thông tin chi tiết về việc tải ứng dụng cảm thấy người dùng như thế nào. Thác nước có thể cho bạn biết những tài nguyên nào chịu trách nhiệm về thời gian tải có thể lâu. Dưới đây là danh sách kiểm tra những việc bạn có thể làm để cải thiện hiệu suất tải:

  • Phân phối nhiều thành phần nhất có thể qua một mối kết nối.
  • Tải trước hoặc thậm chí là các tài nguyên cùng dòng là bắt buộc cho lần hiển thị và hoạt động tương tác đầu tiên.
  • Kết xuất trước ứng dụng của bạn để cải thiện hiệu suất tải mà người dùng nhận thấy.
  • Tận dụng tính năng chia tách mã linh hoạt để giảm lượng mã cần thiết cho tính tương tác.

Hãy theo dõi phần 2, trong đó chúng ta thảo luận về cách tối ưu hoá hiệu suất thời gian chạy trên các thiết bị được ràng buộc siêu hạn chế.