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 đã phát hành PROXX, một bản sao hiện đại của Minesweeper 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 có GPU
  • 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 hồi sinh ở các thị trường mới nổi. Mức giá của họ cho phép một nhóm đối tượng hoàn toàn mới (trước đây không đủ khả năng chi trả) có thể truy cập mạng và sử dụng web hiện đại. Theo dự kiến, riêng tại Ấn Độ trong năm 2019, sẽ có khoảng 400 triệu điện thoại phổ thông được bán ra. Vì vậy, người dùng điện thoại phổ thông có thể 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. Làm cách nào để chúng tôi có thể giúp 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 là yếu tố quan trọng, bao gồm cả hiệu suất tải và hiệu suất thời gian chạy. Nghiên cứu đã chỉ ra rằng hiệu suất tốt có liên quan đến việc tăng khả năng giữ chân người dùng, cải thiện lượt chuyển đổi và quan trọng nhất là tăng khả năng tiếp cận. Jeremy Wagner có nhiều dữ liệu và thông tin chi tiết hơn về lý do hiệu suất 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

Bạn cần kiểm thử hiệu suất tải trên một thiết bị thực. Nếu không có thiết bị thực tế, bạn nên sử dụng WebPageTest, cụ thể là chế độ thiết lập "đơn giản". 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 độ phù hợp để đo lường. 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ị, buổi hòa nhạc hoặc trên chuyến bay. Tốc độ bạn sẽ trải nghiệm ở đó có thể gần giống với 3G và đôi khi còn tệ hơn.

Tuy nhiên, chúng ta sẽ tập trung vào 2G trong bài viết này vì PROXX nhắm đến đối tượng mục tiêu là điện thoại phổ thông và các thị trường mới nổi. Sau khi WebPageTest chạy thử nghiệm, bạn sẽ thấy một thác nước (tương tự như những gì bạn thấy trong DevTools) cũng như một dải 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 mạng 2G, trải nghiệm tải của phiên bản PROXX chưa được tối ưu hoá 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 3G, người dùng sẽ thấy 4 giây màu trắng trống trơn. Ở tốc độ 2G, người dùng không thấy gì trong hơn 8 giây. Nếu đọc bài viết lý do hiệu suất quan trọng, bạn sẽ biết rằng chúng tôi đã mất một lượng lớn người dùng tiềm năng do sự 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ểm sáng trong trường hợp này là bất cứ thứ gì xuất hiện trên màn hình đều có thể tương tác được. Mà có khó lắm không nhỉ?

[Lần sơn có ý nghĩa đầu tiên][FMP] trong phiên bản chưa được tối ưu hoá của PROXX về _kỹ thuật_ là [tương tác][TTI] nhưng không hữu ích cho người dùng.

Sau khi tải khoảng 62 KB JS đã nén gzip xuống và tạo DOM, người dùng sẽ thấy ứng dụng của chúng ta. Ứng dụng này về mặt kỹ thuật có tính tương tác. Tuy nhiên, khi xem hình ảnh, bạn sẽ thấy thực tế lại khác. Phông chữ web vẫn đang tải ở chế độ nền và người dùng sẽ không 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. Ứng dụng sẽ mất thêm một giây trên 3G và 3 giây trên 2G cho đến khi sẵn sàng hoạt động. Tổng cộng, ứng dụng sẽ mất 6 giây trên 3G và 11 giây trên 2G để có thể tương tác.

Phân tích dạng thác nước

Bây giờ, chúng ta đã biết nội dung mà người dùng nhìn thấy, chúng ta cần tìm hiểu lý do. Để làm việc này, chúng ta có thể xem thác nước và phân tích lý do tài nguyên tải quá muộn. Trong dấu vết 2G của PROXX, chúng ta có thể thấy hai dấu hiệu 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 hoàn tất và tài nguyên thứ ba chỉ bắt đầu khi tài nguyên thứ hai hoàn tất.
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 đường kẻ mảnh (dns, connect, ssl) biểu thị việc tạo một kết nối HTTP mới. Việc thiết lập một kết nối mới sẽ 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 cho:

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

Không thể tránh khỏi việc kết nối mới cho index.html. 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. Bạn có thể tránh kết nối mới cho Google Analytics bằng cách nội tuyến một số nội dung như Minimal Analytics, nhưng Google Analytics không chặn ứng dụng của chúng ta hiển thị hoặc tương tác, vì vậy, chúng ta không thực sự quan tâm đến tốc độ tải của ứng dụng. Lý tưởng nhất là Google Analytics nên được tải trong thời gian rảnh, khi mọi thứ khác đã tải xong. Nhờ đó, ứng dụng sẽ không chiếm băng thông hoặc sức mạnh xử lý trong lần tải đầu tiên. 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 kết nối không có thông tin 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, vì vậy, chúng ta không cần quan tâm nhiều đến việc này.

Tuy nhiên, 2 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 chúng ta xem CSS do fonts.googleapis.com phân phối, thì đó chỉ là hai quy tắc @font-face, mỗi 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 các tệp đó vào máy chủ của riêng mình.

Tải song song

Khi xem 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à trường hợp thông thường đối với các phần phụ thuộc của mô-đun. Mô-đun chính của chúng ta có thể có các lệnh nhập tĩnh, vì vậy, JavaScript không thể chạy cho đến khi các lệnh nhập đó được tải. Điều quan trọng cần nhận ra ở đây là những loại phần phụ thuộc này được biết tại thời điểm tạo bản 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 xem những thay đổi này đã đạt được kết quả gì. Điều quan trọng là không thay đổi bất kỳ biến nào khác trong chế độ thiết lập kiểm thử có thể làm sai lệch kết quả. Vì vậy, chúng ta sẽ sử dụng chế độ 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 phim:

Chúng ta sử dụng băng hình của WebPageTest để xem những thay đổi đã đạt được.

Những thay đổi này đã giảm TTI từ 11 xuống 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ỏ. Chúng ta đã làm rất tốt.

Kết xuất trước

Mặc dù chúng ta chỉ 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ô tận mà người dùng phải chịu đựng trong 8,5 giây. Có thể nói, những đ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 định 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ủ. Đây là hai kỹ thuật có liên quan chặt chẽ với nhau 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 Node và chuyển đổi tuần tự DOM thu được 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);

Khi áp dụng điều này, chúng ta có thể mong đợi 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ó gì thì index.html của chúng ta đã lớn hơn và có thể đẩy lùi TTI một chút. Chỉ có một cách để tìm hiểu, đó là chạy WebPageTest.

Băng hình cho thấy sự cải thiện rõ ràng đối với chỉ số FMP. TTI hầu như không bị ảnh hưởng.

Hiển thị nội dung trên màn hình đầu tiên đã chuyển từ 8,5 giây xuống còn 4,9 giây, một sự cải thiện đáng kể. TTI của chúng tôi vẫn diễn ra trong khoảng 8,5 giây nên thay đổi này hầu như không ảnh hưởng đến TTI. Việc chúng ta làm ở đây là một thay đổi trực quan. Một số người thậm chí có thể gọi đây là hành động đơn giản. Bằng cách kết xuất hình ảnh trung gian của trò chơi, chúng ta đang thay đổi hiệu suất tải được cảm nhận để tốt hơn.

Nội tuyến

Một chỉ số khác mà cả DevTools 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ừ 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 hoá TTFB bằng màu sáng trong khối yêu cầu/phản hồi.

Phần sáng của yêu cầu cho biết 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.

Vấn đề này là lý do ban đầu của HTTP/2 Push. 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 các tài nguyên đó xuống. Khi ứng dụng nhận ra rằng cần tìm nạp thêm tài nguyên, 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 vấn đề này sẽ được xem xét lại trong quá trình chuẩn hoá HTTP/3. Hiện tại, giải pháp dễ dàng nhất là nội tuyến tất cả tài nguyên quan trọng, nhưng sẽ làm giảm hiệu quả lưu vào bộ nhớ đệm.

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

Bằng cách nội tuyến JavaScript, chúng tôi đã giảm TTI từ 8,5 giây xuống còn 7,2 giây.

Điều này giúp giảm 1 giây 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 FMP. Ngay khi HTML được phân tích cú pháp và thực thi xong, ứ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 ta 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, tôi nhận thấy tệp này cũng chứa mọi thứ 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 nội dung mà 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. Đó là tất cả. 43 KB có vẻ như là quá nhiều.

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

Để hiểu kích thước gói của mình đến từ đâu, chúng ta 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 thành phần của gói. Như dự đoán, gói của chúng ta chứa logic trò chơi, công cụ kết xuất, màn hình thắng, màn hình thua 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 hoạt động 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 "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 quan trọng được làm nổi bật.

Việc chúng ta cần làm là phân tách mã. Tính năng phân tách mã sẽ chia gói nguyên khối của bạn 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 một cách linh độ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 thần chú ở đây là nhập tĩnh các mô-đun cần thiết một cách quan trọng tại thời điểm tải và tải linh động mọi thứ khác. Tuy nhiên, bạn không nên đợi đến phút cuối cùng để tải lười các mô-đun 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 tôi đã tạo một tệp lazy.js nhập mọi thứ một cách tĩnh mà chúng ta không cần. Trong tệp chính, chúng ta có thể động nhập 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 có vẻ hơi phức tạp vì Preact không thể xử lý các thành phần tải lười 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 hiển thị 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();
    }
  };
}

Khi đã có điều này, chúng ta có thể sử dụng Lời hứa của một thành phần trong các hàm render(). Ví dụ: thành phần <Nebula> hiển thị hình nền động sẽ được thay thế bằng <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 ảnh hưởng như thế nào đến FMP và TTI? WebPageTest sẽ cho bạn biết!

Băng phim xác nhận: TTI hiện tại là 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 tôi chỉ cách nhau 100 mili giây, vì đây chỉ là vấn đề về việc phân tích cú pháp và thực thi JavaScript nội tuyến. Chỉ sau 5,4 giây trên mạng 2G, ứng dụng đã có thể tương tác hoàn toàn. Tất cả các mô-đun khác, ít thiết yếu hơn đều được tải ở chế độ nền.

More Sleight of Hand

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 ta có thể tắt nút "Start" (Bắt đầu) cho đến khi công cụ kết xuất 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 chế độ cài đặt trò chơi nên việc này là không cần thiết. Hầu hết thời gian, công cụ kết xuất và các mô-đun còn lại sẽ tải xong khi người dùng nhấn vào "Bắt đầu". Trong trường hợp hiếm hoi 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 mất thời gian cho những vấn đề không thực tế, 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 các thiết bị thực có kết nối 3G hoặc trên WebPageTest nếu không có thiết bị thực.

Băng hình có thể cung cấp thông tin chi tiết về cảm nhận của người dùng khi tải ứng dụng. 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 kết nối.
  • Tải trước hoặc thậm chí là các tài nguyên nội tuyến cần thiết cho lần kết xuất và tương tác đầu tiên.
  • Tạo trước ứng dụng để cải thiện hiệu suất tải.
  • Sử dụng tính năng phân tách mã mạnh mẽ để giảm lượng mã cần thiết cho tính tương tác.

Hãy chú ý theo dõi phần 2 để 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ị bị hạn chế nghiêm ngặt.