Nâng cao điểm hiệu suất trên Lighthouse cho ứng dụng React

Nâng cao điểm hiệu suất trên Lighthouse cho ứng dụng React
Photo by Nathan Jennings from Unsplash

Lighthouse là một công cụ mã nguồn mở, hiện tại đã được tích hợp vào các trình duyệt dùng nhân Chromium, dùng để đánh giá hiệu suất hoạt động của các trang web. Lighthouse cung cấp nhiều thông tin giá trị cho những lập trình viên web như:

  • Điểm số: trong khoảng 0~100 dựa vào các chỉ số đánh giá hiệu suất của trang web.
  • Một đánh giá chi tiết về các vấn đề và tips để nâng cao hiệu suất.

Trong bài viết này, tôi sẽ trình bày những kinh nghiệm của mình trong việc nâng cao điểm số cho một ứng dụng viết bằng React.

Mở đầu

Trước khi đi nội dung chính, hãy nói qua một chút về hiệu suất trang web. Hiệu suất trang web là một yếu tố rất quan trọng, liên quan trực tiếp đến trải nghiệm người dùng. Và khi trải nghiệm người dùng tăng lên, cơ hội người dùng sử dụng dịch vụ sẽ cao hơn. Theo như chia sẻ của Pinterest, họ đã làm lại trang web để tối ưu hiệu suất, giảm 40% thời gian chờ. Nhờ vậy mà họ đã tăng 15% SEO traffic, và tăng thêm 15% conversion rate.

Với Lighthouse, bất cứ lập trình viên nào cũng có thể dễ dàng đánh giá hiệu suất trang web mình đang viết, tối ưu và đưa ra một trải nghiệm tốt hơn cho người dùng. Không biết dòng đời đưa đẩy thế nào, tôi được giao nhiệm vụ tối ưu hiệu suất của trang web chúng tôi đang phát triển. Team tôi có 5 lập trình viên nhưng không hiểu sao tôi lại được chọn. Nhưng người ta đã chọn, tôi phải cố hết sức thôi (không làm được bị lay off thì toang), mặc dù tôi cũng không biết mình phải làm những gì 😂.

Khi tôi chạy Lighthouse để đánh giá trang web của chúng tôi, điểm số tôi nhận được là 17 (đỏ chót 😬). Nói qua một chút, trang web của chúng tôi là một ứng dụng dạng SPA viết bằng React (TypeScript), khởi tạo bằng Create React App, nên hoạt động của nó khá nặng (vì phụ thuộc hoàn toàn vào JavaScript). Vẫn biết rằng không thể hy vọng điểm số cao cho một ứng dụng như thế, nhưng chỉ có 17 điểm thì thực sự quá thấp.

point
Ảnh chụp màn hình

Đánh giá của Lighthouse bao gồm rất nhiều tiêu chí quan trọng (chỉ số có giá trị càng thấp điểm số càng cao):

  • First Contentful Paint: thời gian để text hoặc ảnh đầu tiên được hiển thị.
  • Speed Index: thời gian để trang web hiển thị toàn bộ nội dung.
  • Largest Contentful Paint: thời gian để phần nội dung nặng nhất của trang web được hiển thị.
  • Time to Interactive: thời gian để trang web có thể tương tác đầy đủ với người dùng.
  • Total Blocking Time: tổng thời gian trang web bị block (thời gian để chạy các tác vụ nặng trên 0.05 giây trong khoảng giữa First Contentful Paint và Time to Interactive).

Cũng từ đánh giá của Lighthouse, có nhiều lời khuyên khác nhau được đưa ra để giải quyết những vấn đề về hiệu suất. Trong các phần tiếp theo, tôi sẽ trình bày những gì mình đã thực hiện.

Kiểm tra build đã dùng production mode chưa

Khi build một ứng dụng React, thông thường các module bundler như webpack sẽ có development mode và production mode. Sự khác biệt của hai chế độ này là việc sử dụng các plugin dùng để nén và tối ưu các file bundle. Kích thước file bundle là một yếu tố rất quan trọng, liên quan đến việc tải trang của người dùng.

Trang web của chúng tôi sử dụng Create React App, nên cấu hình mặc định đã dùng production mode mỗi khi build rồi. Tôi kiểm tra lại phía server cũng đã sử dụng gzip để nén file.

Tối ưu CSS bằng purgeCSS

purgeCSS là một công cụ để loại bỏ những code không dùng của CSS. Theo như đánh giá của Lighthouse, file CSS trên trang web có tới 99% nội dung không cần thiết. Tỉ lệ 99% thực tế sẽ thấp hơn, vì file CSS được dùng chung cho tất cả các file. Nhưng dù sao, CSS đang rất cần được tối ưu.

Sau khi suy nghĩ, tôi quyết định sử dụng purgeCSS. Tôi biết đến công cụ này khi dùng tailwindCSS. Công cụ này có thể dùng như một plugin cho webpack nhưng việc cấu hình nó vào Create React App phức tạp và tôi không làm được. Tôi chọn phương án đơn giản hơn là cài đặt và sử dụng purgeCSS cli. Để purgeCSS chạy tự động mỗi lần build, tôi thêm vào scripts như sau:

{
    "scripts": {
        "các scripts khác": "...",
        "postbuild": "purgecss --config ./purgecss.config.js && gzipper compress ./build/static/css"
    }
}

Trong script trên, gzipper là công cụ phải bổ sung để nén các file bundle theo định dạng gzip (mặc định mỗi file CSS (và cả JS) luôn có một file gzip đi kèm). Trang web của chúng tôi sử dụng Ant Design, và tôi làm theo hướng dẫn này để cấu hình purgeCSS cho phù hợp.

Sau khi thêm purgeCSS, file bundle CSS (gzip) đã giảm từ hơn 60kB xuống còn khoảng 20kB. Giảm rất mạnh (còn khoảng 1/3) nhưng tôi chưa hoàn toàn hài lòng vì phần lớn CSS của Ant Design bắt buộc phải giữ lại mà không thể tối ưu được.

Loại bỏ toàn bộ các hằng số, hàm không dùng đến

Công việc này tương đối đơn giản. Sử dụng eslint và tôi biết ngay được những hằng số/hàm nào được định nghĩa mà không được sử dụng. Việc còn lại chỉ đơn giản là xóa chúng đi là xong.

Sử dụng React.lazy để chia nhỏ code

React.lazy là một công cụ của chính React giúp chia nhỏ code khi bundle để tối ưu quá trình tải trang.

Khi tôi bắt đầu quá trình tối ưu, tôi đọc và làm theo hướng dẫn về việc chia nhỏ code của chính React ở đây, nhưng hiện tại, tài liệu này đã được đánh dấu là legacy 😢.

Hướng dẫn từ React hay những gì tôi tìm được đều chung chung. Tôi hiểu được đại ý rằng, React.lazy sẽ giúp tải component chỉ khi nào cần phải render. Nhờ đó mà giảm tải được file bundle cũng như nâng cao hiệu suất của trang web. Thế nhưng, vấn đề là với dự án tôi đang làm, số lượng component lên tới hơn 100, tôi phải dùng React.lazy cho component nào đây? Đó là điều mà tôi không tìm được một hướng dẫn nào nói cụ thể cả.

Tôi cũng không rõ là thần linh nào đã mách bảo, nhưng cuối cùng, tôi lựa chọn sử dụng React.lazy ở 2 nơi:

  • Dùng React.lazy khi dùng react-router. Mỗi route tương ứng với một trang khác nhau và sẽ gọi một component. Component này sẽ gọi tiếp các component khác. Vì vậy dùng React.lazy ở đây (theo tôi) là cực kỳ hợp lý. Không cần thiết phải dùng React.lazy ở các component con vì chỉ cần component lớn nhất được load lazy là đủ. Thực thế, tôi cũng đã thử và đúng là nó không mang lại hiệu quả (thậm chí còn bị phản tác dụng).
  • Dùng React.lazy ở Root component. Đây là component lớn nhất, ở đó sẽ gọi react-router cũng như nhiều component dùng chung cho tất cả các trang khác (như các component thông báo, loading, v.v…)

Loại bỏ các file index.ts

Dự án của tôi có một phong cách tổ chức code mà cá nhân tôi không thích lắm. Trong mỗi thư mục, luôn có file index.ts để export toàn bộ nội dung của thư mục đó (cú pháp kiểu export * from ...).

Cú pháp này rất tiện trong lúc lập trình, cần import cái gì chỉ cần import từ đường dẫn đến thư mục là xong. Trong thư mục còn có thể phân cấp nhiều lần với file và thư mục con. Lúc đó, sự tiện lợi tăng lên vài lần.

Nhưng sự tiện lợi này cũng có những mặt trái nhất định. Khi chỉ cần import 2-3 hàm chẳng hạn, việc import từ toàn bộ thư mục sẽ khiến code của toàn bộ thư mục đó được thực thi. Điều này vô tình khiến cho file bundle sẽ tăng kích thước đáng kể mà không cần thiết. Thậm chí, code của những file, những module không còn được sử dụng cũng được đóng gói cùng luôn (việc tối ưu những thành phần này sẽ được nói đến ở phần sau).

Để tối ưu hiệu suất, việc tôi cần làm (và cũng muốn làm từ lâu) là xóa bỏ toàn bộ các file index.ts này. Việc này mất rất nhiều thời gian và công sức, vì sẽ phải sửa lại toàn bộ code liên quan đến import. Thế nhưng kết quả thu được cực kỳ xứng đáng. File bundle đã nhẹ hơn đáng kể, đặc biệt việc này còn tự động sửa một số lỗi import chéo khiến cho code không chạy được.

Loại bỏ các export không cần thiết

Sau khi xóa bỏ các file index.ts, công việc tiếp theo là xóa bỏ những export không cần thiết, sẽ giúp file bundle nhẹ hơn, đồng thời logic cũng đơn giản hơn. Để làm được việc này, công cụ như eslint là không đủ. Tôi cần đến sự trợ giúp của một extensions dành cho vscode, đó là Find unused exports.

Extension này sẽ giúp tìm kiếm những export không được sử dụng ở đâu. Việc của tôi là cần phải xóa bỏ chúng đi. Phần lớn chúng là những hàm/hằng số không còn được sử dụng nữa, có thể xóa bỏ hoàn toàn. Nhưng một phần nhỏ là những hàm/hằng số dùng nội bộ trong module nên chỉ xóa từ khóa export là được.

Kết hợp extension này và eslint sẽ cho hiệu quả tốt nhất. Một công cụ để tìm ra những export không cần thiết, một công cụ tìm ra những thành phần không được sử dụng (sau khi xóa từ khóa export).

Một lưu ý nhỏ là bước này nên được làm sau khi đã xóa bỏ tất cả index.ts. Vì extension “Find unused exports” không hoạt động được với cú pháp export * from ....

Sau khi thực hiện bước này, tôi mới nhận ra, chúng tôi đang giữ quá nhiều “rác” trong code của dự án. Rất nhiều hàm tiện ích, các hằng số được định nghĩa sẵn, thỉnh thoảng là cả một file không còn được sử dụng nữa mà không ai xóa đi. May mà cuối cùng tôi cũng đã có cơ hội để “dọn dẹp” chúng.

Tối ưu script bên thứ ba

Các script bên thứ ba như affiliate tag, các công cụ tracking, phân tích cũng ảnh hưởng một phần không nhỏ đến hiệu suất của trang web. Theo đánh giá của Lighthouse, những script kiểu này block thread chính của chúng tôi hơn 1 giây. Vì vậy, tôi phải tìm cách làm giảm ảnh hưởng của chúng (không thể bỏ bớt đi được).

Những script này lại có đặc điểm là được nhúng trực tiếp vào HTML bằng thẻ <script> (nội dung thì copy-paste). Vì vậy chúng sẽ được thực thi ngay khi tải trang. Điểm quan trọng ở đây là phải tìm cách để chúng chạy bất đồng bộ, sau khi nội dung của trang đã tải xong. Dưới đây là cách tôi đã áp dụng:

window.onload = () => {
    // copy-paste script bên thứ ba
};

Thêm nội dung cho template HTML

Đây chỉ là một thủ thuật nhỏ để tăng điểm số, chứ nó không thực sự mang lại hiệu suất tốt hơn cho trang web.

Trang web của chúng tôi sử dụng React, phần template HTML hoàn toàn không có nội dung. Nội dung chỉ được hiển thị sau khi React được thực thi và render.

Vì vậy, (theo ý kiến cá nhân của tôi) First Contentful Paint của các trang web dùng React kiểu này sẽ rất cao. Tôi tìm cách giảm thời gian này xuống bằng cách thêm vào template nội dung đơn giản như sau. Để tránh ảnh hưởng đến trải nghiệm người dùng, tôi đặt màu trắng cho text (sẽ không nhìn được khi mở trên trình duyệt).

<div id="root">
    <div style="color: #fff;">Loading</div>
</div>

Cách này không phải do tôi tự nghĩ ra, tôi “tham khảo” từ những framework pre-rendering hoặc server-side rendering như Next.js mà thôi. Sau khi áp dụng thủ thuật này, First Contentful Paint của trang web đã giảm xuống chỉ còn 1 giây.

Tổng kết

Sau khi thực hiện tất cả những bước ở trên (đó cũng là tất cả những gì tôi có thể làm), tôi đã tối ưu file bundle (gzip) của CSS từ 60kB còn 20kB, JS từ hơn 450kB còn hơn 150kB, với tỉ lệ không được sử dụng từ 99% giảm xuống chỉ còn hơn 60%.

Đây là đánh giá của Lighthouse sau khi tôi đã tìm mọi cách để tối ưu hiệu suất. Điểm số đã tăng 3.8 lần, Speed Index giảm từ 11.2 giây xuống còn 2.6 giây. Điểm số chưa phải cao, nhưng chuyển trạng thái từ đỏ sang cam đã ổn hơn rất nhiều.

point final
Ảnh chụp màn hình

Còn nữa

Điểm số tuy đã tăng lên, nhưng chỉ hơn trung bình một chút. Mức như vậy mới gọi là tạm chấp nhận được. Việc tối ưu này sẽ vẫn phải tiếp tục.

Tôi nghĩ đến một số phần có thể cải thiện như sau. Những phần này có ảnh hưởng mạnh nên vừa làm vừa phải kiểm tra rất cẩn thận.

  • Redux store: dự án tôi dùng redux (React-Redux kết hợp với Redux Toolkit) để lưu trữ dữ liệu. Mô hình redux rất thích hợp cho các ứng dụng React mà cần sử dụng dữ liệu của các trang khác nhau. Nhưng mô hình này chỉ sử dụng một store chung cho toàn bộ ứng dụng, khiến cho hiệu suất giảm đi kha khá. Đặc biệt, những trang đầu tiên (những trang cần đánh giá bằng Lighthouse), logic chưa nhiều, việc tải toàn bộ redux store cùng các slice là không cần thiết. Tôi tìm được hướng dẫn về việc chia code Redux ở đây nhưng tôi vẫn chưa thể áp dụng phương pháp này.
  • Hook: hook là một phần quan trọng của React. Chúng tôi đang có một vài hook (chủ yếu là useEffect) dùng chung cho toàn bộ các trang. Những hook này có logic phức tạp với các câu if ... else để thay đổi hoạt động tùy theo URL của trang web. Những trang cần Lighthouse tính điểm lại không cần đến những hook này nhưng chúng vẫn tồn tại, kèm theo đó là việc phải đóng gói logic liên quan vào bundle. Tôi mong muốn sửa lại toàn bộ những logic này, nhưng hiện tại ảnh hưởng quá lớn nên phải để lại sau.
  • Các thư viện bên thứ ba: Các thư viện (được cài thông qua yarn hoặc npm) cung cấp rất nhiều tiện ích trong lập trình. Nhưng trên khía cạnh hiệu suất, chúng là những thành phần làm chậm ứng dụng. Rất nhiều thư viện được cài đặt, nhưng chúng tôi chỉ sử dụng một vài hàm của chúng (nhưng cả thư viện vẫn được đóng gói vào file bundle). Trong tương lai, tôi sẽ tìm cách giảm bớt những thư viện này, dùng các hàm có sẵn của JavaScript hoặc sử dụng các phiên bản gọn nhẹ hơn.
  • CSS: CSS đã được rút gọn nhờ purgeCSS nhưng vẫn còn nặng. Hiện tại ứng dụng của chúng tôi sử dụng chung file CSS cho toàn bộ các trang. Nếu có thể chia nhỏ các file này, chỉ tải CSS cần thiết cho từng trang thì hiệu suất sẽ được cải thiện đáng kể.
  • rel=preconnect: đây là một thuộc tính có thể áp dụng với thẻ <link>. Sử dụng thuộc tính này, trình duyệt sẽ tự động thực hiện những truy vấn ban đầu (DNS, TCP. TLS), từ đó giảm thời gian tải trang. Có thể áp dụng thuộc tính này cho các file CSS để tăng hiệu suất.

Tối ưu hiệu suất là một công việc tốn nhiều thời gian và công sức. Không có một phương pháp hay một công thức cụ thể nào có thể áp dụng cho tất cả mọi ứng dụng. Cách làm duy nhất là thử đi thử lại các phương pháp khác nhau và kiểm tra hiệu quả của chúng mà thôi (chúng tôi gọi là try & error 😆).

Mục tiêu cuối cùng là làm sao các file JS, CSS càng gọn nhẹ càng tốt, chia nhỏ các file bundle, chỉ tải những thành phần cần sử dụng ngay là tốt nhất. Hy vọng bài viết cung cấp được phần nào thông tin cho những ai đang tìm cách tối ưu hiệu suất giống như tôi.

Happy coding!!

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.