Bài 19.4: Code splitting với React.lazy

Chào mừng bạn quay trở lại với hành trình khám phá thế giới lập trình web cùng FullhouseDev!

Ở các bài trước, chúng ta đã xây dựng các component React và kết hợp chúng lại để tạo nên giao diện người dùng. Tuy nhiên, khi ứng dụng của bạn ngày càng lớn, số lượng component, thư viện và logic xử lý tăng lên, kích thước của file JavaScript cuối cùng được tải xuống trình duyệt cũng tăng lên đáng kể. Điều này có thể khiến thời gian tải trang ban đầu trở nên chậm chạp, ảnh hưởng nghiêm trọng đến trải nghiệm người dùng và thậm chí là thứ hạng SEO.

Làm thế nào để giải quyết vấn đề này? Code Splitting (Chia nhỏ mã nguồn) chính là câu trả lời! Đây là một kỹ thuật giúp chia nhỏ gói mã JavaScript lớn thành nhiều gói nhỏ hơn ("chunks"). Khi người dùng truy cập vào ứng dụng, trình duyệt chỉ cần tải xuống những gói mã cần thiết cho phần giao diện họ đang xem, thay vì tải toàn bộ ứng dụng cùng một lúc. Các phần còn lại sẽ được tải theo yêu cầu khi cần.

Và React cung cấp cho chúng ta một công cụ tuyệt vời để thực hiện Code Splitting một cách dễ dànghiệu quả: đó chính là React.lazy kết hợp với React.Suspense.

Tại sao Code Splitting lại quan trọng?

Hãy tưởng tượng ứng dụng web của bạn như một cửa hàng bách hóa tổng hợp. Khi một khách hàng chỉ muốn mua một gói kẹo (tức là chỉ xem một trang cụ thể), bạn không bắt họ phải mang toàn bộ cửa hàng về nhà. Thay vào đó, bạn chỉ đưa cho họ gói kẹo họ cần. Code Splitting làm điều tương tự với mã nguồn của bạn.

Lợi ích chính của Code Splitting bao gồm:

  1. Tải trang ban đầu nhanh hơn: Chỉ những đoạn mã cần thiết cho giao diện ban đầu mới được tải, giảm thời gian chờ đợi cho người dùng.
  2. Giảm thiểu việc sử dụng tài nguyên: Trình duyệt chỉ cần xử lý và thực thi lượng mã ít hơn lúc ban đầu.
  3. Cải thiện trải nghiệm người dùng: Ứng dụng cảm thấy "nhẹ" và phản hồi nhanh hơn.
  4. Tối ưu băng thông: Đặc biệt quan trọng với người dùng sử dụng mạng chậm hoặc dữ liệu di động.

Trước đây, để làm Code Splitting trong React, chúng ta thường phải dùng các thư viện của bên thứ ba hoặc cấu hình Webpack phức tạp. Nhưng với React.lazy, mọi thứ đã trở nên đơn giản hơn bao giờ hết!

React.lazy: Load component theo yêu cầu

React.lazy là một hàm cho phép bạn render một import động (dynamic import) như một component thông thường.

Cú pháp rất đơn giản:

const TenComponentCuaBan = React.lazy(() => import('./duongDanDenComponent'));

React.lazy nhận một đối số là một hàm trả về một Promise. Promise này sẽ phân giải (resolve) thành một module có thuộc tính default export là component React mà bạn muốn tải.

Ví dụ cơ bản:

Giả sử bạn có một component tên là HeavyComponent ở trong file HeavyComponent.js. Component này rất lớn hoặc chứa logic phức tạp mà không phải lúc nào cũng cần thiết.

Thay vì import thông thường:

// import thông thường (sẽ được đóng gói vào bundle chính)
import HeavyComponent from './HeavyComponent';

function App() {
  return (
    <div>
      <h1>Ứng dụng chính</h1>
      {/* HeavyComponent luôn được load dù có hiển thị hay không */}
      <HeavyComponent />
    </div>
  );
}

Chúng ta sẽ sử dụng React.lazy:

// import động với React.lazy
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h1>Ứng dụng chính</h1>
      {/* HeavyComponent sẽ chỉ được load khi nó được render */}
      <HeavyComponent />
    </div>
  );
}

Giải thích code:

  • const HeavyComponent = React.lazy(() => import('./HeavyComponent')); : Dòng này không tải ngay lập tức file HeavyComponent.js. Thay vào đó, nó tạo ra một "component lười" (lazy component). Khi React lần đầu tiên cố gắng render <HeavyComponent />, hàm import('./HeavyComponent') mới được gọi. Hàm import() này là cú pháp chuẩn của JavaScript (ES dynamic imports) và sẽ báo cho bundler (như Webpack hoặc Parcel) biết rằng đây là một điểm chia tách mã. Bundler sẽ tạo ra một file JavaScript riêng cho HeavyComponent và các dependencies của nó.

Tuy nhiên, có một vấn đề nhỏ. Khi HeavyComponent đang được tải, React cần biết phải hiển thị cái gì trong lúc chờ đợi. Đây chính là lúc React.Suspense phát huy tác dụng!

React.Suspense: Xử lý trạng thái chờ đợi

React.lazy phải được sử dụng bên trong React.Suspense. Suspense là một component của React cho phép bạn "đình chỉ" việc render một phần của cây component và hiển thị một giao diện dự phòng (fallback) trong khi đợi điều kiện (trong trường hợp này là tải mã) hoàn thành.

Suspense nhận một prop bắt buộc là fallback. Prop này chứa bất kỳ phần tử React nào bạn muốn hiển thị trong khi các component "lười" bên trong nó đang được tải.

Kết hợp React.lazyReact.Suspense:

import React, { Suspense } from 'react';

// import động với React.lazy
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h1>Ứng dụng chính</h1>

      {/* Sử dụng Suspense để bọc component lười */}
      <Suspense fallback={<div>Đang tải HeavyComponent...</div>}>
        {/* Component lười */}
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

Giải thích code:

  • import React, { Suspense } from 'react';: Chúng ta cần import Suspense từ thư viện react.
  • <Suspense fallback={<div>Đang tải HeavyComponent...</div>}>: Đây là boundary của Suspense. Bất kỳ component con nào bên trong Suspense (bao gồm cả HeavyComponent) mà "đình chỉ" việc render (ví dụ: do đang chờ mã được tải) sẽ khiến Suspense hiển thị nội dung của prop fallback (<div>Đang tải HeavyComponent...</div>).
  • Khi HeavyComponent được tải xong, Suspense sẽ tự động chuyển sang hiển thị HeavyComponent thực tế.

Bạn có thể đặt nhiều component lazy bên trong cùng một boundary Suspense.

import React, { Suspense } from 'react';

const ComponentA = React.lazy(() => import('./ComponentA'));
const ComponentB = React.lazy(() => import('./ComponentB'));

function App() {
  return (
    <div>
      <h1>Ứng dụng chính</h1>

      <Suspense fallback={<div>Đang tải các component...</div>}>
        <ComponentA />
        <ComponentB /> {/* Nếu ComponentA hoặc ComponentB đang tải, fallback sẽ hiển thị */}
      </Suspense>
    </div>
  );
}

Nếu cả ComponentAComponentB đều là component lười và được render lần đầu tiên cùng lúc, React sẽ cố gắng tải cả hai file mã nguồn cùng lúc. Trong khi cả hai chưa sẵn sàng, fallback của Suspense sẽ được hiển thị.

Các trường hợp sử dụng phổ biến

Code Splitting đặc biệt hiệu quả trong một số trường hợp cụ thể:

1. Chia nhỏ theo Route (Route-based splitting)

Đây là cách phổ biến và hiệu quả nhất để áp dụng Code Splitting. Thông thường, người dùng chỉ xem một hoặc vài trang (route) của ứng dụng tại một thời điểm. Chúng ta có thể chia nhỏ mã nguồn sao cho mỗi route có gói mã riêng. Khi người dùng điều hướng đến một route, chỉ mã nguồn cần thiết cho route đó mới được tải.

Sử dụng với một thư viện định tuyến như react-router-dom:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';

// Import các component route một cách lười biếng
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <Router>
      <nav>
        <ul>
          <li><Link to="/">Trang chủ</Link></li>
          <li><Link to="/about">Giới thiệu</Link></li>
          <li><Link to="/dashboard">Dashboard</Link></li>
        </ul>
      </nav>

      {/* Suspense bọc toàn bộ Routes để xử lý trạng thái tải của các route lười */}
      <Suspense fallback={<div>Đang tải trang...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

export default App;

Giải thích code:

  • const Home = lazy(() => import('./pages/Home'));: Component Home được import lười. Tương tự với AboutDashboard.
  • <Suspense fallback={<div>Đang tải trang...</div>}>: Một boundary Suspense được đặt bên ngoài <Routes>. Bất cứ khi nào react-router-dom cố gắng render một Route mà component của route đó đang được tải (do nó là component lười), Suspense sẽ hiển thị fallback ("Đang tải trang...").
  • Khi người dùng click vào link, react-router-dom sẽ chuyển route, React sẽ cố gắng render component tương ứng. Nếu component đó là lazy và chưa được tải, nó sẽ "đình chỉ", và fallback của Suspense sẽ xuất hiện cho đến khi mã nguồn được tải xong.

Cách này giúp giảm đáng kể kích thước bundle ban đầu vì mã nguồn của AboutDashboard chỉ được tải khi người dùng truy cập vào các trang đó.

2. Chia nhỏ theo Component (Component-based splitting)

Bạn cũng có thể áp dụng Code Splitting cho các component không phải là route, đặc biệt là những component chỉ hiển thị trong một số điều kiện nhất định (ví dụ: modal, popup, phần nội dung mở rộng khi click nút).

Ví dụ: Tải modal chỉ khi người dùng click vào nút mở modal

import React, { useState, Suspense, lazy } from 'react';

// Component Modal được import lười
const LazyModal = lazy(() => import('./components/Modal'));

function App() {
  const [showModal, setShowModal] = useState(false);

  const handleOpenModal = () => {
    setShowModal(true);
  };

  const handleCloseModal = () => {
    setShowModal(false);
  };

  return (
    <div>
      <h1>Ứng dụng</h1>
      <button onClick={handleOpenModal}>Mở Modal</button>

      {showModal && (
        {/* Chỉ render Suspense và LazyModal khi showModal là true */}
        <Suspense fallback={<div>Đang tải Modal...</div>}>
          <LazyModal onClose={handleCloseModal} />
        </Suspense>
      )}
    </div>
  );
}

export default App;

Giải thích code:

  • const LazyModal = lazy(() => import('./components/Modal'));: Định nghĩa LazyModal là một component lười. Mã nguồn của Modal.js sẽ không được tải ngay.
  • const [showModal, setShowModal] = useState(false);: Sử dụng state để kiểm soát việc hiển thị modal.
  • handleOpenModalhandleCloseModal: Hàm để cập nhật state showModal.
  • {showModal && (...) }: Đây là kỹ thuật render có điều kiện. Chỉ khi showModaltrue, phần code bên trong && mới được thực thi.
  • <Suspense fallback={<div>Đang tải Modal...</div>}>: Boundary Suspense chỉ được render khi showModaltrue.
  • Khi người dùng click nút "Mở Modal", setShowModal(true) được gọi. React re-render và thấy showModaltrue, nên nó cố gắng render <Suspense><LazyModal>. Vì LazyModal là component lười và có thể chưa được tải, nó "đình chỉ", và Suspense hiển thị fallback. Khi Modal.js và các dependencies của nó tải xong, Suspense sẽ hiển thị LazyModal thực tế.

Cách này rất hiệu quả cho các thành phần giao diện lớn, phức tạp, hoặc ít được sử dụng như modal, popover, trình soạn thảo rich text, v.v. Mã nguồn của chúng sẽ chỉ được tải khi người dùng thực sự cần đến.

Hiển thị trạng thái tải (Loading States)

Prop fallback của Suspense có thể là bất kỳ element React nào. Bạn có thể hiển thị một dòng chữ đơn giản, một spinner loading, hoặc thậm chí là một skeleton screen (phiên bản đơn giản hóa của giao diện cuối cùng) để cải thiện trải nghiệm người dùng trong lúc chờ đợi.

Ví dụ với Spinner Component:

Giả sử bạn có một component Spinner đơn giản:

// components/Spinner.js
import React from 'react';

function Spinner() {
  return (
    <div className="spinner">
      {/* CSS để tạo hình ảnh spinner */}
      Loading...
    </div>
  );
}

export default Spinner;

Bạn có thể sử dụng nó làm fallback:

import React, { Suspense, lazy } from 'react';
import Spinner from './components/Spinner'; // Import component Spinner

const PageContent = lazy(() => import('./components/PageContent'));

function MyPage() {
  return (
    <div>
      <h2>Nội dung trang</h2>
      <Suspense fallback={<Spinner />}> {/* Sử dụng component Spinner làm fallback */}
        <PageContent />
      </Suspense>
    </div>
  );
}

Giải thích code:

  • Thay vì sử dụng một div đơn giản, chúng ta import và sử dụng component Spinner làm giá trị cho prop fallback. Điều này giúp giao diện chờ đợi trở nên chuyên nghiệp và hấp dẫn hơn.

Xử lý lỗi tải (Error Handling)

Mặc dù Suspense xử lý trạng thái tải, nó không xử lý các lỗi xảy ra trong quá trình tải mã nguồn (ví dụ: lỗi mạng, file không tồn tại). Nếu xảy ra lỗi, ứng dụng của bạn có thể bị crash.

Để xử lý các lỗi tải này, bạn cần sử dụng Error Boundaries. Error Boundaries là các component React class sử dụng các lifecycle method như static getDerivedStateFromError() hoặc componentDidCatch() để bắt lỗi JavaScript ở bất kỳ đâu trong cây component con của chúng.

Bạn có thể bọc boundary Suspense của mình trong một Error Boundary để xử lý cả trạng thái tải lỗi tải.

Ví dụ khái niệm (sử dụng Error Boundary cơ bản):

import React, { useState, Suspense, lazy } from 'react';

// Giả sử bạn đã tạo ErrorBoundary component
// Xem thêm về Error Boundaries trong tài liệu React
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Cập nhật state để lần render tiếp theo sẽ hiển thị giao diện fallback.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // Bạn cũng có thể log lỗi đến một dịch vụ báo cáo lỗi
    console.error("Lỗi tải component:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Bạn có thể render bất kỳ giao diện fallback tùy chỉnh nào
      return <h1>Đã xảy ra lỗi khi tải thành phần này.</h1>;
    }

    return this.props.children;
  }
}

// Component lười
const MyProblematicComponent = lazy(() => import('./components/MyProblematicComponent'));

function App() {
  return (
    <div>
      <h1>Ứng dụng</h1>

      {/* Bọc Suspense trong ErrorBoundary */}
      <ErrorBoundary>
        <Suspense fallback={<div>Đang tải component...</div>}>
          <MyProblematicComponent />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

export default App;

Giải thích code:

  • ErrorBoundary là một component class cơ bản bắt lỗi từ cây con.
  • <ErrorBoundary> được đặt ở lớp bên ngoài <Suspense>.
  • Nếu MyProblematicComponent gặp lỗi trong quá trình import (ví dụ: file không tìm thấy), Error Boundary sẽ bắt được lỗi đó và hiển thị giao diện fallback của nó (<h1>Đã xảy ra lỗi...</h1>).
  • Điều này đảm bảo rằng ứng dụng của bạn không bị crash hoàn toàn chỉ vì một phần nhỏ không tải được.

Trong thực tế, bạn nên sử dụng các thư viện Error Boundary đã được phát triển tốt như react-error-boundary để đơn giản hóa việc này.

Bundler hỗ trợ

Điều quan trọng cần lưu ý là React.lazyimport() động hoạt động được là nhờ sự hỗ trợ từ các công cụ đóng gói module (bundler) như Webpack, Parcel, hoặc Rollup. Các bundler hiện đại sẽ tự động nhận diện cú pháp import() động và tạo ra các file mã nguồn (chunks) riêng biệt cho các module được import theo cách này.

Khi bạn build ứng dụng cho production, bundler sẽ phân tích các điểm import() động và xuất ra nhiều file .js nhỏ thay vì một file .js khổng lồ duy nhất.

Một vài lưu ý khi sử dụng React.lazy và Suspense

  • Không sử dụng với Server-Side Rendering (SSR): Hiện tại, React.lazySuspense chủ yếu được thiết kế cho client-side rendering. Để sử dụng Code Splitting với SSR, bạn cần các giải pháp hoặc thư viện bổ sung (như Loadable Components hoặc cấu hình phức tạp hơn với Next.js/Gatsby). React đang phát triển các tính năng SSR tương thích với Suspense trong tương lai.
  • Vị trí của Suspense: Bạn có thể đặt boundary Suspense ở bất kỳ đâu trong cây component. Đặt nó càng cao càng tốt trong phần mà bạn muốn xử lý tải chung (ví dụ: bọc toàn bộ phần nội dung của route) để hiển thị fallback cho nhiều component lười cùng lúc. Đặt nó ở mức thấp hơn (gần component lười) nếu bạn muốn fallback chỉ ảnh hưởng đến một phần nhỏ của giao diện.
  • Đừng lạm dụng: Mặc dù Code Splitting rất hữu ích, việc chia nhỏ quá mức (quá nhiều file nhỏ) cũng có thể gây ra overhead do trình duyệt phải tạo nhiều kết nối HTTP hơn để tải các chunk. Hãy tập trung vào chia nhỏ các phần lớn, không cần thiết ngay lập tức của ứng dụng.

Comments

There are no comments at the moment.