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à hiển thị phía máy chủ trong PROXX.

Tại Google I/O 2019, Mariko, Jake và tôi đã ra mắt 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ể phát bằng trình đọc màn hình!) và khả năng hoạt động tốt trên cả điện thoại phổ thông cũng như 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ó tính năng nhập bằng cách chạm
  • Lượng bộ nhớ rất hạn chế

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

Lối 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. Kết quả đã chỉ ra là hiệu suất tốt tương quan với việc tăng tỷ lệ giữ chân người dùng, số lượt chuyển đổi được cải thiện và quan trọng nhất là tăng 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 của loạt nội dung 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 trong thời gian chạy.

Nắm bắt tình 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ó thiết bị thực, bạn nên sử dụng WebPageTest, cụ thể là quy trình thiết lập "đơn giản". WPT chạy một quá trình 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à một tốc độ đo lường tốt. Mặc dù bạn có thể đã quen với 4G, LTE hoặc thậm chí ngay cả 5G, nhưng thực tế của internet di động trông khá khác. Có thể bạn đang ở trên tàu, ở hội nghị, tại một buổi hoà nhạc hoặc trên một chuyến bay. Những gì bạn sẽ gặp phải ở đó rất có thể gần với 3G và đôi khi thậm chí còn tệ hơn.

Dù vậy, trong bài viết này, chúng ta sẽ tập trung vào 2G vì PROXX rõ ràng nhắm đến đối tượng mục tiêu của họ là điện thoại phổ thông và thị trường mới nổi. Sau khi WebPageTest chạy kiểm thử, bạn sẽ nhận được một thác nước (tương tự như nội dung 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 trong 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 không được tối ưu hóa khá kém:

Đoạn video cuộn cho biết những gì người dùng 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 thấy 4 giây là khoảng trống trắng. Qua 2G, người dùng hoàn toàn không thấy gì trong hơn 8 giây. Nếu đọc bài viết Lý do hiệu suất lại quan trọng, thì bạn sẽ biết rằng hiện chúng ta đã mất một lượng đáng kể người dùng tiềm năng do họ thiếu kiên nhẫn. Người dùng cần tải xuống toàn bộ 62 KB JavaScript để mọi nội dung xuất hiện trên màn hình. Điều đáng mong chờ trong tình huống này là nội dung thứ hai xuất hiện trên màn hình cũng 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ó _Technically_ [ tương tác][TTI] nhưng không hữu ích với người dùng.

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

Phân tích kiểu thác nước

Bây giờ, khi đã biết nội dung người dùng nhìn thấy, chúng ta cần tìm ra 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 nguyên tải quá muộn. Trong dấu vết 2G cho 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 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ề tài nguyên nào đang tải vào thời điểm nào và mất bao lâu.

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

Mỗi đường mỏng (dns, connect, ssl) đại diện cho việc tạo một kết nối HTTP mới. Thiết lập một 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 cho:

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

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 để nhận nội dung. Có thể tránh kết nối mới với Google Analytics bằng cách nội dòng một thứ gì đó như Minimum Analytics (Phân tích 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 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 đó, thiết bị sẽ không chiếm băng thông hoặc công suất 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ố 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 đăng nhập. Xin nhắc lại rằng tệp kê khai ứng dụng web không chặn ứng dụng kết xuất hoặc tương tác, vì vậy chúng ta không cần quan tâm nhiều.

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ị cũng như 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ỗi quy tắc cho một phông chữ. Kiểu phông chữ trên thực tế rất nhỏ nên chúng tôi đã quyết định đưa nó vào 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 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

Nhìn vào 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 bình 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ó dữ liệu nhập tĩnh, vì vậy JavaScript không thể chạy cho đến khi các nhập dữ liệu đó được tải. Điều quan trọng cần lưu ý ở đây là các loại phần phụ thuộc này đã được xác định trong thời gian 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 sau khi nhận được HTML.

Kết quả

Hãy xem những kết quả mà các thay đổi của chúng ta đã đạt được. Quan trọng là bạn không được 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 quy trình 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 cuộn phim:

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

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

Kết xuất trước

Mặc dù chúng tôi chỉ giảm TTI, nhưng chúng tôi chưa thực sự ảnh hưởng đến màn hình trắng vĩnh viễn mà người dùng phải chịu trong 8,5 giây. Có thể cho rằng những điểm cải tiến lớn nhất cho FMP là bằng cách gửi mã đánh dấu đã tạo 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à hiển thị 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 Hiển thị 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. Quá trình kết xuất phía máy chủ thực hiện việc này theo yêu cầu từ 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 quá trình 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 giao diện người dùng và cho phép bạn điều khiển từ xa thực thể đó bằng Node API. 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ì chúng tôi đang sử dụng các Mô-đun CSS, nên chúng tôi nhận CSS nội tuyến theo 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);

Với thay đổi này, chúng tôi có thể kỳ vọng cải thiện FMP của mình. Chúng ta vẫn cần phải tải và thực thi cùng một số lượng JavaScript như trước đây, vì vậy, chúng ta sẽ không nên kỳ vọng rằng TTI sẽ thay đổi nhiều. Nếu có, 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 duy nhất để tìm hiểu, đó là chạy WebPageTest.

Cuộn 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 lượng Nội dung đầu tiên của chúng tôi đã tăng từ 8,5 giây lên 4,9 giây, một sự cải tiến đáng kể. Chỉ số 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à thay đổi về nhận thức. Một số người thậm chí có thể gọi đó là công việc phức tạp. Bằng cách hiển thị hình ảnh trung gian của trò chơi, chúng ta đang cải thiện hiệu suất tải cảm nhận được.

Cùng dòng

Một chỉ số khác mà cả Công cụ cho nhà phát triển và WebPageTest đều cung cấp cho chúng tôi là Thời gian đến 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ột 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.

Khi xem xét thác nước, chúng ta có thể thấy rằng tất cả các yêu cầu đều dành phần lớn thời gian để chờ nhận byte đầu tiên của phản hồi.

Vấn đề này là mục đích của tính năng Gửi dữ liệu HTTP/2 ban đầu. Nhà phát triển ứng dụng biết rằng một số tài nguyên nhất định là cần thiết và có thể đẩy chúng xuống. Vào thời điểm ứng dụng nhận ra cần tìm nạp thêm tài nguyên, thì các tài nguyên đó đã có trong bộ nhớ đệm của trình duyệt. Thông báo đẩy HTTP/2 được coi là quá khó để đạt được mục tiêu và chúng tôi không khuyến khích việc này. Không gian gặp vấn đề này sẽ được xem xét 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à nội tuyến tất cả tài nguyên quan trọng mà không làm giảm hiệu quả lưu vào bộ nhớ đệm.

CSS quan trọng của chúng tôi đã được đưa vào nội dung 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 đặt các mô-đun quan trọng cùng dòng và các phần phụ thuộc của các mô-đun đó. Tác vụ này có độ khó khác nhau, tuỳ theo trình đóng gói mà bạn đang sử dụng.

Nhờ nội dung JavaScript, chúng tôi đã giảm chỉ số TTI từ 8,5 giây xuống còn 7,2 giây.

Việc này giúp chỉ số TTI của chúng tôi giảm đi 1 giây. Hiện tại, chúng ta đã đạt đến điểm mà index.html chứa mọi thứ cần thiết để kết xuất ban đầu và trở nên tương tác. HTML có thể hiển thị trong khi vẫn đang tải xuống, tạo FMP của chúng tôi. Thời điểm HTML hoàn tất phân tích cú pháp và thực thi thì ứng dụng 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ông tin cần thiết để có thể tương tác. Nhưng khi kiểm tra kỹ hơn, hoá ra nó còn chứa mọi dữ liệu khác. index.html của chúng tôi có kích thước khoảng 43 KB. Hãy đặt điều đó liên quan đến những gì người dùng có thể tương tác lúc đầu: Chúng ta có một biểu mẫu để định cấu hình trò chơi chứa một vài thành phần, một nút bắt đầu và có thể là một số mã để duy trì và tải các chế độ cài đặt của người dùng. Cũng được thôi. 43 KB có vẻ là quá nhiều.

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 công cụ tương tự để phân tích nội dung của gói. Theo 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 chiến thắng, màn hình thua và một loạt tiện ích. Chỉ một số mô-đun nhỏ trong số các mô-đun này cần cho trang đích. Việc chuyển mọi thứ không hoàn toàn cần thiết cho tính tương tác vào mô-đun tải từng phần sẽ làm giảm đáng kể TTI.

Việc phân tích nội dung "index.html" của PROXX cho thấy rất nhiều tài nguyên không cần thiết. Các tài nguyên quan trọng được đánh dấu.

Việc chúng ta 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ể tải từng phần theo yêu cầu. Các gói phổ biến như Webpack, RollupParcel hỗ trợ phân tách mã bằng cách sử dụng import() động. Trình đóng gói này sẽ phân tích mã của bạn và đưa vào nội tuyến tất cả các mô-đun được nhập tĩnh. Mọi dữ liệu mà 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 cũng sẽ tốn chi phí và chỉ nên thực hiện nếu bạn có thời gian rảnh. Giải pháp ở đây là nhập tĩnh những mô-đun rất cần thiết tại thời điểm tải và tải động mọi mô-đun khác. Nhưng bạn không nên đợi đến khoảnh khắc cuối cùng để tải từng phần các mô-đun mà chắc chắn sẽ được sử dụng. Bài hát Nhàn rỗi cho đến khẩn cấp của Phil Walton là một hình mẫu hiệu quả giúp xây dựng nền tảng vững chắc giữa tải từng phần và tải chậm.

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 tôi đã kết thúc trong lazy.js, hoá ra đây 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 hiển thị một phần giữ chỗ cho đến khi thành phần thực tế được tải.

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 phương thức 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 này đ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 />}
  />
);

Chúng tôi đã giảm được index.html xuống chỉ còn 20 KB, bằng một nửa so với 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!

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

FMP và TTI của chúng tôi chỉ cách nhau 100 mili giây, vì chỉ có 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 là hoàn toàn tương tác. Tất cả các mô-đun khác ít thiết yếu hơn đều được tải trong nền.

Thao tác bằng tay nhiều hơn

Nếu bạn nhìn vào danh sách các mô-đun quan trọng của chúng tôi ở trên, bạn sẽ thấy rằng công cụ kết xuất không phải là một phần của 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ể vô hiệu hoá nút "Bắt đầu" cho đến khi công cụ kết xuất hình ảnh sẵn sàng bắt đầu trò chơi, nhưng theo trải 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, dù việc này là không cần thiết. Trong hầu hết các 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 trước khi người dùng nhấn "Bắt đầu". Trong một số ít trường hợp, khi người dùng kết nối mạng nhanh hơn, chúng ta sẽ hiển thị một màn hình tải đơn giản đợi các mô-đun còn lại hoàn tất.

Kết luận

Việc đo lường rất quan trọng. Để tránh dành thời gian cho các 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, các phép đo phải được thực hiện trên thiết bị thực qua kết nối 3G hoặc trên 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ề 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 có thể gây ra thời gian tải lâu. Dưới đây là danh sách kiểm tra những điều 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à tài nguyên cùng dòng cần thiết cho lượt 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 cảm nhận được.
  • Sử dụng tính năng phân tách mã linh hoạt để giảm số lượng mã cần thiết cho hoạt động tương tác.

Hãy chú ý 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 trong thời gian chạy trên các thiết bị bị hạn chế cao.