Cách chúng tôi sử dụng tính năng phân tách mã, nội tuyến 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 bằng trình đọc màn hình!) và khả năng chạy tốt trên điện thoại phổ thông cũng như trên thiết bị máy tính 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 thao tác chạm
- Dung lượng bộ nhớ rất hạn chế
Tuy nhiên, các thiết bị này 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 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 năm 2019, chỉ riêng Ấn Độ đã bán được khoảng 400 triệu điện thoại phổ thông. 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 tương tự như 2G là tiêu chuẩn ở 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?
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 số 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 của loạt bài 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 trạng thái hiện tại
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 một loạt các bài 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 sắp ra mắt, nhưng thực tế về Internet di động lại hoàn toàn 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. Băng phim cho thấy nội dung mà người dùng nhìn thấy trong khi ứng dụng đ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ệ:
Khi tải qua 3G, người dùng sẽ thấy 4 giây màu trắng trống rỗng. Ở 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 xuống toàn bộ 62 KB JavaScript để mọi 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à mọi thứ 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ỉ?
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ế 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à Lần vẽ đầu tiên có ý nghĩa (FMP), nhưng chắc chắn không đủ điều kiện là tương tác đúng cách, vì người dùng không thể biết bất kỳ dữ liệu đầu vào nà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:
- Có nhiều đường kẻ mỏng nhiều màu.
- 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.
Giảm số lượng kết nối
Mỗi đường 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 kết nối mới sẽ tốn kém vì mất khoảng 1 giây trên mạng 3G và khoảng 2,5 giây trên mạng 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 #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 để nhận 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, hai phông chữ và kiểu của chúng là vấn đề vì chúng chặn quá trình kết xuất và cả khả năng 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ữ. Thực tế, kiểu phông chữ quá nhỏ nên chúng tôi quyết định đưa kiểu phông chữ vào HTML, xoá 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 ta 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:
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 tính năng kết xuất trước thực hiện việc này tại thời điểm tạo bản dựng và lưu trữ đầu ra 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. Thư viện này 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 API Node. Chúng ta sử dụng hà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 ta có thể chèn CSS vào cùng dòng của các kiểu mà chúng ta cần mà không mất 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, vì vậy, chúng ta không nên kỳ vọng TTI thay đổi nhiều. Nếu có gì, 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: chạy WebPageTest.
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 đó là ảo thuật. 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ừ khi byte đầu tiên của yêu cầu được gửi đến khi byte đầu tiên của phản hồi được nhận. Thời gian này cũng thường được gọi là Thời gian truyền dữ liệu qua lại (RTT), mặc dù về mặt kỹ thuật, có sự khác biệt giữa hai con 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.
Khi xem 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ờ 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 dây. 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. HTTP/2 Push quá khó để sử dụng đúng cách và không được 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.
Điều này giúp giảm 1 giây TTI. Bây giờ, chúng ta đã đạt đến điểm mà index.html
chứa mọi thứ cần thiết cho quá trình 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. 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ã mạnh
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, bạn sẽ thấy tệp này cũng chứa mọi thứ khác. index.html
của chúng ta có kích thước khoảng 43 KB. Hãy liên hệ điều đó với những gì người dùng có thể tương tác ở đầu trò chơi: 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ã để lưu 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.
Để 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. Bạn chỉ cần một số ít mô-đun trong số này cho trang đích. Việc di chuyển mọi thứ không bắt buộc đối với tính tương tác vào một mô-đun tải từng phần sẽ làm giảm TTI một cách đáng kể.
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 đóng gói phổ biến như Webpack, Rollup và Parcel hỗ trợ tính năng phân tách mã bằng cách sử dụng import()
động. Trình tạo gói 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 truy cập vào mạng sẽ tốn chi phí và bạn chỉ nên thực hiện nếu có thời gian rảnh. Câu thần chú ở đây là nhập tĩnh các mô-đun quan trọng cần thiết 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. Idle Until Urgent (Trạng thái rảnh cho đến khi khẩn cấp) của Phil Walton là một mẫu tuyệt vời để tìm ra điểm cân bằng giữa tính năng tải lười và tải háo hức.
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 đượ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 />}
/>
);
Với 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!
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 quan trọng hơn sẽ được tải ở chế độ nền.
Các thủ thuật khác
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 thuộc 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 để biết 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.