Micro-frontend Learning
Giao tiếp và trạng thái

Routing và navigation

Bắt đầu từ những vấn đề khi nhiều frontend dùng chung URL, rồi chọn cách chia route, phối hợp điều hướng và hỗ trợ mở link trực tiếp.

Khi tách một frontend thành nhiều microfrontend, người dùng vẫn mở một website, nhìn một thanh địa chỉ và bấm cùng các nút Back/Forward. Nhưng phía sau, mỗi app có những màn hình riêng và có thể được phát triển, deploy độc lập. Vậy ai quyết định URL hiện tại thuộc app nào? Nếu một app thêm trang mới, những app còn lại có phải sửa theo không?

Bài này đi từ vấn đề cần giải quyết, sau đó mới đến giải pháp và code. Bạn chưa cần biết BrowserRouter, memory router hay browser history để đọc phần đầu.

Hai tên gọi dùng trong bài

Shell là app bao ngoài, giữ khung giao diện và ghép các microfrontend vào trang. Remote là microfrontend được ghép vào đó. Ví dụ xuyên suốt là một shop có Catalog để xem sản phẩm và Cart để xem giỏ hàng. Đây là ứng dụng giả định, không phải tính năng có sẵn trong repository tài liệu.

Mở Source Explorer: đối chiếu các file trong bài

Mục lục

Các vấn đề cần giải quyết

Trước khi tách app, bạn có thể khai báo tất cả màn hình ở một nơi:

direction: right
catalog: /catalog
product: /catalog/products/42
cart: /cart
list: Danh sách sản phẩm
detail: Chi tiết sản phẩm 42
basket: Giỏ hàng
catalog -> list: Hiển thị
product -> detail: Hiển thị
cart -> basket: Hiển thị
/catalog/catalog/products/42/cartDanh sách sản phẩmChi tiết sản phẩm 42Giỏ hàng Hiển thị Hiển thị Hiển thị
/catalog/catalog/products/42/cartDanh sách sản phẩmChi tiết sản phẩm 42Giỏ hàng Hiển thị Hiển thị Hiển thị
URL và màn hình trước khi tách app

Một route là quy tắc nối đường dẫn với nội dung cần hiển thị. Routing là việc tìm và hiển thị nội dung theo URL hiện tại. Navigation là hành động chuyển sang một URL khác, chẳng hạn click vào sản phẩm để mở trang chi tiết.

Khi tách Catalog và Cart thành hai app, các URL trên không nhất thiết phải đổi. Điều thay đổi là trách nhiệm xử lý chúng được chia cho nhiều app. Từ đó xuất hiện những vấn đề dưới đây.

Ai chọn app và ai chọn màn hình

Bạn mở /catalog/products/42. Shell có thể tải Catalog, nhưng riêng việc tải Catalog chưa nói được cần hiển thị danh sách hay chi tiết sản phẩm.

Có hai quyết định khác nhau:

  1. URL này cần app Catalog, không phải Cart.
  2. Bên trong Catalog, URL này cần trang chi tiết sản phẩm 42, không phải danh sách.

Nếu không phân chia hai việc đó, shell dễ phải biết mọi màn hình của mọi app. Ngược lại, nếu chỉ tải remote rồi bỏ mặc URL, remote có thể luôn mở trang mặc định dù người dùng đã truy cập một link chi tiết.

App thêm trang có phải sửa shell

Giả sử shell đang liệt kê từng đường dẫn:

direction: right
list: /catalog
product: /catalog/products/:id
basket: /cart
catalog: Catalog
cart: Cart
list -> catalog: Shell chọn app
product -> catalog: Shell chọn app
basket -> cart: Shell chọn app
/catalog/catalog/products/:id/cartCatalogCart Shell chọn app Shell chọn app Shell chọn app
/catalog/catalog/products/:id/cartCatalogCart Shell chọn app Shell chọn app Shell chọn app
Shell liệt kê từng route cụ thể

Catalog thêm trang /catalog/search. Nếu shell chỉ nhận những đường dẫn đã liệt kê, Catalog đã có trang mới nhưng shell vẫn chưa biết cần tải nó.

Lúc này, thêm một màn hình trong Catalog lại đòi hỏi sửa và deploy cả shell. Hai app có repository riêng nhưng vẫn phụ thuộc vào nhau trong một thay đổi nội bộ.

Vấn đề cần giải quyết: shell phải nhận ra những URL thuộc Catalog mà không cần biết Catalog có bao nhiêu màn hình.

Nhiều app cùng điều hướng thì ai đổi URL

Menu của shell có nút “Giỏ hàng”. Trang sản phẩm trong Catalog cũng có nút “Xem giỏ hàng”. Cả hai đều có thể đưa người dùng đến /cart.

Nếu mỗi bên tự đổi URL theo cách riêng mà không thông báo cho phần còn lại, bạn có thể gặp tình trạng thanh địa chỉ đã là /cart, nhưng vùng nội dung vẫn hiển thị sản phẩm. Hoặc bấm Back thì URL quay lại sản phẩm, còn màn hình vẫn đứng ở giỏ hàng.

Vấn đề không nằm ở số lượng nút hay số lượng app. Các bên phải thống nhất cách thay đổi URL và cách cập nhật màn hình khi URL thay đổi.

Vì sao URL đổi nhưng router chưa cập nhật

Bạn có thể thắc mắc: “URL đã đổi rồi thì router phải tự biết chứ?”. Với các BrowserRouter độc lập chưa có cơ chế đồng bộ, điều đó không tự động xảy ra. Cần phân biệt hai thứ:

  • URL và browser history thật: do trình duyệt quản lý; khi pushState thành công, thanh địa chỉ và history entry hiện tại được cập nhật cùng nhau.
  • Location trong router: thông tin thư viện router đang giữ để chọn và render màn hình. Nó cần nhận được thông báo thay đổi thì mới cập nhật.

Hai router dùng browser history trong cùng document không tạo ra hai lịch sử browser riêng. Chúng dùng chung lịch sử thật, nhưng có thể giữ location và subscription riêng.

Giả sử shell và Catalog có hai router độc lập, đều đang nhận location /catalog. Catalog dùng router của nó để chuyển tới /cart. Nếu chưa có cơ chế thông báo cho shell, bạn có thể thấy:

direction: down
navigation: Catalog điều hướng tới /cart
browser: Browser {
  address: "URL trên thanh địa chỉ = /cart"
  history: "History entry hiện tại = /cart"
}
catalog: "Location trong router Catalog = /cart"
shell: "Location trong router shell = /catalog\nChưa được cập nhật" {
  style.stroke: "#b45309"
}
navigation -> browser: pushState thành công
navigation -> catalog: Router Catalog cập nhật
browser -> shell: "Không tự phát popstate\nShell chưa được thông báo" {style.stroke-dash: 4}
Catalog điều hướng tới /cartBrowserLocation trong router Catalog = /cartLocation trong router shell = /catalogChưa được cập nhậtURL trên thanh địa chỉ = /cartHistory entry hiện tại = /cart pushState thành công Router Catalog cập nhật Không tự phát popstateShell chưa được thông báo
Catalog điều hướng tới /cartBrowserLocation trong router Catalog = /cartLocation trong router shell = /catalogChưa được cập nhậtURL trên thanh địa chỉ = /cartHistory entry hiện tại = /cart pushState thành công Router Catalog cập nhật Không tự phát popstateShell chưa được thông báo
Sau khi Catalog điều hướng: browser đã đổi, shell chưa nhận thông báo

URL và browser history đã khớp. Thứ chưa khớp là location trong router shell, nên shell có thể vẫn hiển thị Catalog thay vì Cart.

Nếu shell đọc trực tiếp window.location.pathname lúc này, nó vẫn nhận /cart, giống Catalog. Nhưng useLocation() trong router shell có thể vẫn trả pathname /catalog. Đọc biến của browser không giống việc router nhận thông báo rồi cập nhật state và render lại.

BrowserRouter thông thường không liên tục kiểm tra thanh địa chỉ. Nó cập nhật qua các tín hiệu mà nó theo dõi:

Hành độngRouter của shell có nhận được thay đổi không?
Navigation thành công qua navigate() hoặc Link của router shellCó; cơ chế navigation của chính router cập nhật location
Người dùng Back/Forward giữa các history entry trong cùng documentCó; browser phát popstate, router lắng nghe để cập nhật
Router độc lập của Catalog đổi URL bằng pushState()Không tự động; pushState không phát popstate để báo cho router shell
Có bridge thông báo và áp dụng location mới vào router shellCó; do cơ chế đồng bộ đã được thiết kế

replaceState() cũng không tự phát popstate. Đây là lý do đổi URL từ bên ngoài router không bảo đảm router biết thay đổi đó, dù URL browser đã đúng. Bạn có thể xem hành vi event này trong tài liệu History API của MDN.

BrowserRouter vẫn hỗ trợ Back và Forward

Không phải router chỉ cập nhật khi chính nó navigate: Back/Forward cũng cung cấp tín hiệu để cập nhật. Điểm cần nhớ là router phải nhận được thông báo; chỉ thay đổi URL từ một router độc lập khác chưa đủ.

Vì vậy, hai hướng giải quyết là dùng router chung, để các component cùng đi qua một cơ chế navigation, hoặc phối hợp router riêng bằng thông báo location. Dùng hai router độc lập không nhất thiết gây lỗi, nhưng phải có cơ chế phối hợp đó.

Một trang cần nhiều microfrontend

Trang sản phẩm có thể gồm phần chi tiết do Catalog cung cấp và khối gợi ý do Recommendations cung cấp. Khi đó, URL /catalog/products/42 cần hiển thị cả hai microfrontend cùng lúc.

Vì vậy, quy tắc “một URL chỉ được hiển thị một MFE” là quá hạn chế. Shell còn cần biết app nào xuất hiện ở vùng nào trên trang.

Tuy nhiên, khối gợi ý không nhất thiết có trang riêng. Nó có thể chỉ nhận product ID và hiển thị nội dung. Đừng mặc định rằng cứ là microfrontend thì phải có router.

Bạn vào trang chủ, click tới sản phẩm và thấy mọi thứ chạy đúng. Nhưng khi gửi /catalog/products/42 cho người khác, họ mở link và nhận 404.

Hai thao tác này đi qua hai đường khác nhau:

  • Click trong một SPA đang chạy: frontend đã được tải, router có thể chuyển màn hình mà không tải lại toàn bộ document.
  • Mở link trực tiếp hoặc refresh: browser gửi request đến server cho chính đường dẫn đó, trước khi frontend có cơ hội chạy.

Vì thế, khai báo route ở frontend chưa đủ. Server cũng phải phục vụ được URL mà người dùng mở.

Các giải pháp

Những vấn đề trên không phải các loại routing để chọn một và bỏ những loại còn lại. Một ứng dụng thường cần giải quyết nhiều vấn đề cùng lúc. Mình bắt đầu bằng cách chia route, rồi chọn cơ chế phối hợp router theo cách các app được tích hợp.

Chia route cấp app và route bên trong app

Giải pháp cho hai vấn đề đầu là để shell nhận nhóm đường dẫn, còn remote nhận màn hình cụ thể:

direction: down
shell: Shell {
  catalog_paths: /catalog và /catalog/*
  cart_paths: /cart và /cart/*
}
catalog: Catalog {
  list_path: /catalog
  product_path: /catalog/products/:id
  search_path: /catalog/search
  list: Danh sách sản phẩm
  product: Chi tiết sản phẩm
  search: Tìm kiếm
  list_path -> list: Hiển thị
  product_path -> product: Hiển thị
  search_path -> search: Hiển thị
}
cart: Cart
shell.catalog_paths -> catalog: Chọn app
shell.cart_paths -> cart: Chọn app
ShellCatalogCart/catalog và /catalog/*/cart và /cart/*/catalog/catalog/products/:id/catalog/searchDanh sách sản phẩmChi tiết sản phẩmTìm kiếm Hiển thị Hiển thị Hiển thị Chọn app Chọn app
ShellCatalogCart/catalog và /catalog/*/cart và /cart/*/catalog/catalog/products/:id/catalog/searchDanh sách sản phẩmChi tiết sản phẩmTìm kiếm Hiển thị Hiển thị Hiển thị Chọn app Chọn app
Shell chọn app theo prefix; Catalog chọn màn hình

Trong bản đồ trên, * biểu diễn phần đường dẫn phía sau prefix. Với React Router, điểm gắn /catalog/* cũng nhận URL /catalog; không cần khai báo thêm một route trùng cho nó.

Bây giờ Catalog thêm /catalog/favorites, shell vẫn nhận ra URL thuộc nhóm /catalog. Chỉ Catalog cần thêm màn hình; shell không cần thêm từng route con.

Một lần mở URL diễn ra như sau:

direction: right
n0: "/catalog/products/42"
n1: "Shell chọn Catalog theo /catalog"
n2: "Catalog chọn products/:id"
n3: "Trang chi tiết nhận id = 42"
n0 -> n1 -> n2 -> n3
/catalog/products/42Shell chọn Catalog theo /catalogCatalog chọn products/:idTrang chi tiết nhận id = 42
/catalog/products/42Shell chọn Catalog theo /catalogCatalog chọn products/:idTrang chi tiết nhận id = 42
Chia route cấp app và route bên trong app

Đây là hai cấp trách nhiệm, không có nghĩa phải tạo hai router độc lập. Cách thực hiện phụ thuộc vào mô hình tích hợp ở hai phần tiếp theo.

Có một giới hạn cần nhớ: thêm trang dưới prefix đã giao khác với đổi prefix. Nếu Catalog đổi từ /catalog sang /products, shell cần cập nhật ranh giới đó. Link cũ đã được bookmark hoặc gửi qua email cũng cần redirect hay chính sách migration; URL đã công bố là một contract với người dùng.

Shell xử lý URL không thuộc app nào, chẳng hạn /unknown. Catalog xử lý URL thuộc vùng của mình nhưng không có màn hình, chẳng hạn /catalog/unknown. Còn /catalog/products/42 có route hợp lệ nhưng sản phẩm 42 không tồn tại là lỗi dữ liệu trên trang chi tiết, không phải lỗi chọn app.

Dùng router chung khi các app cùng cây React

Nếu shell render các remote như component trong cùng một cây React, lựa chọn đơn giản là một router chung:

direction: down
router: Router shell
catalog: "Component route Catalog"
cart: "Component route Cart"
router -> catalog: "/catalog/*"
router -> cart: "/cart/*"
Router shellComponent route CatalogComponent route Cart /catalog/* /cart/*
Router shellComponent route CatalogComponent route Cart /catalog/* /cart/*
Dùng router chung khi các app cùng cây React

Shell tạo router. Catalog và Cart cung cấp những nhánh route của mình, đồng thời dùng Link hoặc useNavigate từ router chung để điều hướng.

Nhờ vậy, click ở menu shell hay click trong Catalog đều đi qua cùng cơ chế. Khi người dùng bấm Back, router chung nhận URL mới và chọn lại nội dung tương ứng.

Catalog vẫn sở hữu danh sách màn hình của nó. Dùng chung router không có nghĩa shell phải chứa toàn bộ route con của Catalog. Phần code bên dưới minh họa chính cách này.

Không lồng thêm BrowserRouter

Khi remote đã render dưới router của shell và dùng cùng router context, không bọc remote bằng một BrowserRouter khác. Cũng không tự gọi history.pushState từ remote để bỏ qua router chung. Các app cần thống nhất dependency và router API mà chúng dùng.

Phối hợp router riêng khi các app chạy độc lập

Nếu shell dùng React còn Catalog dùng Vue, chúng không thể dùng chung React Router. Ngay cả khi cùng React, remote gọi createRoot riêng cũng không tự nhận router context từ shell.

Trong trường hợp đó, mỗi app có thể có router riêng để chọn màn hình, nhưng cần một cơ chế phối hợp. Mình mặc định để shell điều phối URL công khai của trang:

  1. Người dùng bấm “Xem giỏ hàng” trong Catalog.
  2. Catalog gửi yêu cầu đến shell: “Đi tới Cart”.
  3. Shell thực hiện navigation đến /cart.
  4. Shell cập nhật app và màn hình cần hiển thị theo URL mới.

Chiều ngược lại cũng cần có: khi người dùng bấm Back, shell nhận URL mới rồi thông báo cho remote đang hiển thị. Nếu đó là một màn hình khác trong Catalog, Catalog phải chọn lại đúng màn hình.

direction: right
remote: Remote
shell: Shell
remote -> shell: "Yêu cầu chuyển trang"
shell -> remote: "URL hiện tại sau khi đổi"
RemoteShell Yêu cầu chuyển trang URL hiện tại sau khi đổi
RemoteShell Yêu cầu chuyển trang URL hiện tại sau khi đổi
Phối hợp router riêng khi các app chạy độc lập

Remote có thể dùng memory router, tức router giữ đường đi trong bộ nhớ thay vì tự đổi thanh địa chỉ. Nhưng dùng memory router thôi chưa đủ: nó vẫn cần nhận URL ban đầu và các thay đổi từ shell để deep link và Back/Forward hoạt động.

Mô hình này giúp các app độc lập về framework hoặc cách mount, nhưng cần viết adapter và kiểm thử đồng bộ. Nếu các app đã dùng được router chung, mình không thêm bridge chỉ để mỗi app có một router riêng. Contract mẫu nằm ở phần Navigation contract cho remote cô lập.

Nếu mỗi frontend là một website/document độc lập, bạn cũng có thể dùng link thật như <a href="/cart">Giỏ hàng</a> và để browser tải trang tiếp theo. Cách này không cần bridge SPA, nhưng local state của trang cũ có thể mất khi rời trang. Với iframe, cần thiết kế quan hệ giữa URL trang cha và trang con; xem Tích hợp bằng iframe.

Ghép nhiều microfrontend theo layout

Để một URL hiển thị nhiều MFE, shell chọn cấu trúc trang, không chỉ chọn một app duy nhất:

direction: down
shell: "Shell: /catalog/products/42"
header: Header
catalog: "Vùng chính: Catalog\nChi tiết sản phẩm 42"
recommend: "Vùng gợi ý: Recommendations\nGợi ý cho sản phẩm 42"
shell -> header
shell -> catalog
shell -> recommend
Shell: /catalog/products/42HeaderVùng chính: CatalogChi tiết sản phẩm 42Vùng gợi ý: RecommendationsGợi ý cho sản phẩm 42
Shell: /catalog/products/42HeaderVùng chính: CatalogChi tiết sản phẩm 42Vùng gợi ý: RecommendationsGợi ý cho sản phẩm 42
Ghép nhiều microfrontend theo layout

Catalog dùng URL để chọn trang sản phẩm. Recommendations chỉ cần nhận productId = "42" để hiển thị gợi ý, nên không nhất thiết cần router riêng.

Nếu người dùng click một sản phẩm trong khối gợi ý, Recommendations yêu cầu navigation tới URL sản phẩm đó qua cơ chế đã thống nhất: router chung hoặc API điều hướng của shell. Nó không cần import toàn bộ cấu hình route bên trong Catalog.

Một widget có các tab “Liên quan” và “Đã xem” có thể giữ tab ở local state. Chỉ đưa tab vào URL khi cần chia sẻ link hoặc hỗ trợ Back/Forward qua từng tab. Nếu nhiều vùng cùng đưa state vào URL, hãy thống nhất path hoặc query nào thuộc mỗi vùng, tránh một vùng cập nhật làm mất state của vùng khác.

Cho server và frontend cùng hiểu URL

Với một static SPA, giải pháp cho mở link trực tiếp là để server trả document của shell cho những đường dẫn màn hình thuộc ứng dụng. Sau đó shell và remote dựng nội dung theo URL ban đầu:

direction: down
n0: "Browser mở /catalog/products/42"
n1: "Server trả HTML shell, giữ URL"
n2: "Shell nhận /catalog → tải\nCatalog"
n3: "Catalog nhận products/42 → hiển\nthị"
n0 -> n1 -> n2 -> n3
Browser mở /catalog/products/42Server trả HTML shell, giữ URLShell nhận /catalog → tảiCatalogCatalog nhận products/42 → hiểnthị
Browser mở /catalog/products/42Server trả HTML shell, giữ URLShell nhận /catalog → tảiCatalogCatalog nhận products/42 → hiểnthị
Cho server và frontend cùng hiểu URL

Đây thường là rewrite nội bộ, không phải redirect người dùng về /. Nếu redirect về trang chủ, bạn có thể làm mất điểm đến mà người dùng muốn mở.

Nhưng không rewrite mọi request thành HTML:

RequestServer cần xử lý thế nào
Document /catalog/products/42Trả shell HTML để frontend dựng màn hình
JavaScript/CSS có thậtTrả đúng file và MIME type
JavaScript/CSS không tồn tạiTrả lỗi asset, không trả shell HTML
API requestChuyển tới backend theo rule API

Nếu một chunk JavaScript bị thiếu mà server trả HTML với status 200, browser vẫn không chạy được chunk đó. Đây là lỗi phục vụ asset, không phải lỗi route màn hình.

Không áp dụng SPA fallback cho mọi framework

Phần này nói về static SPA, không phải cấu hình cho site Next.js đang chứa tài liệu. Với Next.js hoặc SSR, server có routing pipeline riêng và cần render đúng route trước hydration. Xem Next.js và SSR.

Nên chọn cách nào trước

Sau khi hiểu các vấn đề, bạn có thể chọn theo cách các app được tích hợp:

Tình huốngĐiểm bắt đầu mình chọn
Các remote React render dưới router của shellRouter chung; shell nhận prefix, remote khai báo route con
Remote khác framework hoặc mount trong root riêngShell điều phối URL; remote nhận location và gửi yêu cầu navigation
Các frontend là những document độc lậpLink thông thường; chấp nhận full-page navigation
Một màn hình ghép nhiều MFEShell ghép layout; chỉ app cần route mới có routing

Dòng cuối không phải một lựa chọn thay thế cho các dòng trên. Một màn hình ghép nhiều MFE vẫn có thể dùng router chung hoặc cơ chế phối hợp router riêng.

Module Federation giúp tải và chia sẻ module; nó không tự quyết định cách routing. Cùng dùng React cũng chưa đủ để chọn router chung: điều quan trọng là remote có render trong cùng cây React và nhận cùng router context hay không.

Ví dụ React tối thiểu

Ví dụ này minh họa mô hình router chung. Shell và Catalog render trong cùng cây React; code dùng API declarative của React Router. Các tên import như catalog/routes và cart/Page là module giả định; phần cấu hình tải remote, loading và error boundary được bỏ qua để tập trung vào routing.

Shell chỉ khai báo điểm gắn của Catalog

shell/App.tsx
import { BrowserRouter, Link, Route, Routes } from 'react-router';
import { CatalogRoutes } from 'catalog/routes';
import { CartPage } from 'cart/Page';

export function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/catalog">Sản phẩm</Link>
        {' | '}
        <Link to="/cart">Giỏ hàng</Link>
      </nav>

      <Routes>
        <Route path="/" element={<p>Trang chủ</p>} />
        <Route path="/catalog/*" element={<CatalogRoutes />} />
        <Route path="/cart" element={<CartPage />} />
        <Route path="*" element={<p>Không tìm thấy trang</p>} />
      </Routes>
    </BrowserRouter>
  );
}
Đối chiếu shell/App.tsx

Điểm cần nhìn là /catalog/*. Shell không khai báo /catalog/products/:id hay /catalog/search; nó giao cả nhánh đó cho CatalogRoutes. Cart trong ví dụ chỉ có một trang, nên shell gắn trực tiếp /cart. Khi Cart cần nhiều màn hình, nó có thể cung cấp CartRoutes dưới /cart/* theo cùng cách.

Catalog khai báo các màn hình của mình

catalog/routes.tsx
import { Link, Route, Routes, useParams } from 'react-router';

function ProductList() {
  return (
    <section>
      <h2>Danh sách sản phẩm</h2>
      <Link to="/catalog/products/42">Xem sản phẩm 42</Link>
    </section>
  );
}

function ProductDetail() {
  const { productId } = useParams();
  return <h2>Chi tiết sản phẩm {productId}</h2>;
}

export function CatalogRoutes() {
  return (
    <Routes>
      <Route index element={<ProductList />} />
      <Route path="products/:productId" element={<ProductDetail />} />
      <Route path="search" element={<p>Tìm kiếm sản phẩm</p>} />
      <Route path="*" element={<p>Không có trang này trong Catalog</p>} />
    </Routes>
  );
}
Đối chiếu catalog/routes.tsx

Catalog chỉ khai báo phần phía sau điểm gắn /catalog:

  • index là màn hình mặc định tại /catalog.
  • products/:productId nhận /catalog/products/42, với productId bằng "42".
  • search nhận /catalog/search.
  • * nhận các đường dẫn còn lại trong nhánh Catalog.

Routes là nơi khai báo và chọn các màn hình, không phải một BrowserRouter mới. Toàn bộ ví dụ vẫn dùng router được tạo ở shell.

Link tới sản phẩm dùng prefix /catalog đã thống nhất. Nếu điểm gắn có thể thay đổi, truyền mount path hoặc dùng route builder thay vì hard-code prefix trong remote. Khi chạy Catalog standalone để phát triển, dev harness có thể tạo router bên ngoài và gắn CatalogRoutes tại /catalog/*; component export vẫn không tự bọc router.

Theo dõi một lần điều hướng

Giả sử bạn đang ở /catalog:

  1. Shell chọn CatalogRoutes; Catalog chọn ProductList qua route index.
  2. Bạn click “Xem sản phẩm 42”. Link yêu cầu router chung chuyển tới /catalog/products/42.
  3. Shell vẫn chọn Catalog vì URL vẫn thuộc /catalog/*; Catalog chuyển sang ProductDetail.
  4. Bạn click “Giỏ hàng” ở menu. Router chuyển tới /cart, shell thay vùng nội dung bằng CartPage.
  5. Bạn bấm Back. Router nhận lại /catalog/products/42, shell chọn Catalog và Catalog chọn lại sản phẩm 42.

Shell không cần nhận một event riêng kiểu “Catalog vừa mở sản phẩm”. URL và router chung đã cung cấp thông tin để chọn nội dung.

Giới hạn của ví dụ

Mẫu này dùng descendant Routes, chưa có layout con hay data loaders/actions. Khi cần layout con, dùng Outlet để đặt vùng render của màn hình con. Khi dùng data router, ghép route objects theo API của data router thay vì giả định mẫu này tự cung cấp loader/action cho mọi route.

Phần này dành cho trường hợp không dùng được router chung. Nếu bạn đang học mô hình React ở trên, có thể bỏ qua phần này ở lần đọc đầu.

Một remote chạy độc lập cần trả lời ba câu hỏi: “URL hiện tại là gì?”, “làm sao biết URL vừa đổi?” và “làm sao yêu cầu chuyển trang?”. Thay vì đưa cả router instance của shell cho remote, shell có thể cung cấp một API nhỏ, gọi là navigation port.

Ví dụ contract dưới đây giữ các public destination của shop. Catalog yêu cầu tới destination cart, không cần biết Cart dùng thư viện router nào.

contracts/navigation.ts
export type LocationSnapshot = Readonly<{
  revision: number;
  pathname: string;
  search: string;
  hash: string;
}>;

export type Destination =
  | Readonly<{ name: 'catalog.list' }>
  | Readonly<{ name: 'catalog.product'; productId: string }>
  | Readonly<{ name: 'cart' }>;

export interface NavigationPort {
  getSnapshot(): LocationSnapshot;
  subscribe(listener: () => void): () => void;
  href(destination: Destination): string;
  navigate(
    destination: Destination,
    options?: Readonly<{ replace?: boolean }>,
  ): Promise<'committed' | 'cancelled'>;
}
Đối chiếu contracts/navigation.ts
Thành phầnRemote dùng để làm gì
getSnapshot()Đọc URL hiện tại, kể cả ngay khi vừa mount
subscribe()Nhận thông báo URL đổi do click, Back hoặc Forward; trả hàm unsubscribe
href()Dựng link thật để mở tab mới hoặc copy, không tự điều hướng
navigate()Yêu cầu shell chuyển tới một destination
revisionPhân biệt các lần shell công bố location, kể cả quay lại URL cũ

Trong contract này, pathname nằm trong không gian route của app, chưa có application basename. search gồm dấu ? khi có query; hash gồm dấu # khi có fragment. Shell adapter chuyển URL browser thành snapshot theo quy ước đó. href trả đường dẫn có application basename; nó và navigate dùng cùng route builder, validate tham số và encode product ID như một path segment.

Snapshot là bất biến, giữ cùng reference khi chưa đổi nếu dùng với useSyncExternalStore. subscribe không replay; consumer đăng ký rồi đọc snapshot hiện tại và unsubscribe khi unmount. navigate mặc định dùng push. Kết quả committed nghĩa shell đã chấp nhận và công bố location, không có nghĩa remote hay dữ liệu đã tải xong. cancelled dùng khi guard từ chối hoặc yêu cầu mới thay thế yêu cầu cũ trước commit; destination không hợp lệ hay lỗi adapter khiến promise reject.

Điểm dễ sai nhất là đồng bộ thành vòng lặp. Khi shell gửi URL mới do Back, remote cần cập nhật màn hình nhưng không gửi lại yêu cầu chuyển tới chính URL đó. Nếu gửi lại bằng push, Back vừa quay về thì app lại tạo một bước history mới.

direction: right
n0: "Người dùng click trong remote"
n1: "Remote gọi navigate"
n2: "Shell đổi URL và công bố\nsnapshot"
n3: "Remote áp dụng snapshot\nKhông gọi lại navigate"
n0 -> n1 -> n2 -> n3
Người dùng click trong remoteRemote gọi navigateShell đổi URL và công bốsnapshotRemote áp dụng snapshotKhông gọi lại navigate
Người dùng click trong remoteRemote gọi navigateShell đổi URL và công bốsnapshotRemote áp dụng snapshotKhông gọi lại navigate
Navigation contract cho remote cô lập

Nếu remote dùng memory router, adapter cần cập nhật router khi snapshot thay đổi, thường bằng replace cho location từ shell. initialEntries của MemoryRouter chỉ khởi tạo history; đổi prop này không phải cách đồng bộ location liên tục. Back cấp ứng dụng đi qua shell, không qua memory history riêng của remote. Với wizard không cần bookmark hay Back qua từng bước, local state hoặc memory routing nội bộ có thể là đủ.

Đây là contract mẫu, chưa phải implementation của adapter hay protocol cho iframe. Không truyền function trực tiếp qua cross-origin iframe; trường hợp đó cần message protocol và kiểm tra origin, source, payload.

Các lưu ý sau khi routing cơ bản đã chạy

Đến đây bạn đã có cách chọn app, chọn màn hình và phối hợp navigation. Những phần còn lại giúp hành vi đúng trong các tình huống thực tế; chúng không phải điều kiện để hiểu mô hình ban đầu.

Browser history và Back Forward

Browser history là chuỗi điểm đến mà browser dùng để xử lý Back/Forward. Hai thao tác hay gặp là:

  • Push: thêm một điểm đến mới, để Back có thể quay lại điểm trước.
  • Replace: thay điểm đến hiện tại, không thêm một bước mới.

Ví dụ, mở sản phẩm rồi mở giỏ hàng thường dùng push:

direction: right
catalog: /catalog
product: /catalog/products/42
cart: /cart
catalog -> product: Push mở sản phẩm
product -> cart: Push mở giỏ hàng
cart -> product: Back lần 1
product -> catalog: Back lần 2
/catalog/catalog/products/42/cart Push mở sản phẩm Push mở giỏ hàng Back lần 1 Back lần 2
/catalog/catalog/products/42/cart Push mở sản phẩm Push mở giỏ hàng Back lần 1 Back lần 2
Push tạo entry; Back quay về entry trước

Chuẩn hóa URL hoặc thay route cũ bằng route mới thường dùng replace, để Back không quay lại một điểm chỉ làm nhiệm vụ redirect. Khi browser đã chuyển entry do Back/Forward, app chỉ cập nhật UI; không push lại URL đó.

Mình để router thực hiện các thao tác này. Nếu tự viết history adapter, cần nhớ pushState và replaceState không tự phát popstate: adapter phải thông báo location sau push/replace, đồng thời nhận popstate khi browser Back/Forward. Đừng trộn router navigation với lệnh ghi history trực tiếp từ remote.

Nút “Về danh sách” là chuyển tới /catalog, không nhất thiết là Back. Người dùng mở sản phẩm từ email có thể không có trang danh sách trong history. Dữ liệu cần để mở lại màn hình cũng không nên chỉ nằm trong history.state, vì link mở ở tab mới không mang state object của entry cũ.

Basename không phải địa chỉ tải asset

Ba giá trị sau giải quyết ba việc khác nhau:

Giá trịVí dụÝ nghĩa
Application basename/shopToàn bộ app chạy dưới prefix này
Điểm gắn remote/catalogShell giao nhánh này cho Catalog
Asset base URLhttps://cdn.example/catalog/v123/Nơi tải JavaScript, CSS và asset

Nếu shell dùng basename="/shop", route vẫn là /catalog/*, còn URL browser là /shop/catalog/products/42. Đặt basename không cấu hình nơi tải JavaScript; đổi CDN cũng không đổi URL màn hình.

Với remote có router riêng, thống nhất location nhận được có chứa điểm gắn hay không. Nếu cả shell adapter và remote đều thêm /catalog, bạn có thể tạo nhầm /catalog/catalog/products/42.

Bộ lọc trong URL và dữ liệu đang nhập

Một URL như /catalog?sort=price-asc&page=2 có thể dựng lại bộ lọc khi refresh, Back hoặc mở ở tab mới. Vì vậy, Catalog nên đọc filter đã áp dụng từ URL thay vì chỉ giữ ở store.

Ngược lại, nội dung đang gõ nhưng chưa submit có thể là local state. Không nhất thiết phải thêm một history entry cho từng ký tự. Khi người dùng áp dụng một tìm kiếm mới, push thường giúp Back khôi phục tìm kiếm trước; nếu đưa draft lên URL liên tục, replace tránh tạo quá nhiều bước history.

Catalog định nghĩa và validate sort, page; khi cập nhật sort, chỉ sửa những query thuộc trách nhiệm của nó và giữ các tham số khác theo contract. Khi chuyển sang Cart, không sao chép toàn bộ query Catalog một cách máy móc. Không đưa token, mật khẩu hay dữ liệu nhạy cảm vào URL vì chúng có thể xuất hiện trong history, log và link được chia sẻ.

Điều hướng khi cần đăng nhập

Nếu người chưa login mở một URL được bảo vệ, giữ lại điểm đến hợp lệ để quay về sau login. Đừng luôn đưa họ về /, làm mất màn hình họ muốn mở.

Điểm đến sau login là input không tin cậy. Parse và kiểm tra cùng origin, route được phép; chỉ kiểm tra startsWith('/') không đủ vì //evil.example có thể dẫn tới host khác. Sau login, thường replace entry login để Back không quay lại màn hình login vừa hoàn tất.

Chờ kết quả kiểm tra session trước khi redirect; trạng thái “đang kiểm tra” không có nghĩa “chưa đăng nhập”. Người đã login nhưng thiếu quyền cần màn hình forbidden, không phải vòng lặp login. Route guard chỉ bảo vệ trải nghiệm UI; backend vẫn phải authorize API. Xem Authentication và session.

Với form chưa lưu, dùng navigation blocker phù hợp với router và kiểm thử riêng link, Back, reload. Blocker trong app không tự ngăn được mọi cách rời trang; nếu draft quan trọng, cần chính sách lưu draft.

Remote tải chậm hoặc tải lỗi

Giả sử shell bắt đầu tải Catalog, nhưng người dùng đã chuyển sang Cart trước khi Catalog tải xong. Kết quả tải Catalog đến muộn không được mount đè lên Cart.

Shell cần đánh dấu navigation hiện tại và kiểm tra nó sau các bước bất đồng bộ. Với dữ liệu sản phẩm cũng vậy: response của sản phẩm A đến sau khi đã chọn B không được thay nội dung của B. Hủy request khi có thể, nhưng vẫn phải bỏ qua kết quả đã lỗi thời.

Nếu remote tải lỗi, giữ URL, hiển thị lỗi và cho retry thay vì giả vờ URL không tồn tại rồi redirect về trang chủ. Khi unmount, dọn subscription/listener; instance mới phải đọc location hiện tại, không dùng bản chụp lúc bắt đầu tải remote.

Cuối cùng, kiểm tra navigation bằng bàn phím: link cần href thật để mở tab mới và copy; chuyển trang cần cập nhật title, focus và scroll phù hợp. Back/Forward thường cần khôi phục scroll thay vì luôn đưa người dùng lên đầu trang.

Tự kiểm tra thiết kế

Hãy bắt đầu bằng những thao tác sau trên môi trường deploy thật, không chỉ dev server:

Thao tácKết quả cần thấy
Mở /catalog/products/42 trong tab mớiShell chọn Catalog, Catalog mở sản phẩm 42
Refresh URL sản phẩmKhông bị server 404, không mất điểm đến
Catalog thêm một màn hình dưới /catalogKhông phải thêm route con đó vào shell
Sản phẩm → Cart → Back → ForwardURL và màn hình luôn khớp nhau
Mở /catalog/unknownCatalog hiển thị not found của mình
Mở /unknownShell hiển thị not found cấp ứng dụng
Catalog tải chậm, người dùng chuyển CartKết quả tải cũ không thay màn hình Cart
Request một chunk không tồn tạiServer trả lỗi asset, không trả shell HTML

Nếu click hoạt động nhưng refresh lỗi, kiểm tra server routing trước. Nếu URL đổi nhưng UI không đổi, kiểm tra cơ chế navigation và cập nhật location. Nếu mỗi lần Back lại bị đẩy tới trang vừa rời, kiểm tra remote có gửi lại navigation khi áp dụng location từ shell hay không.

Với ứng dụng của bạn, hãy vẽ hai bản đồ nhỏ: shell nhận những nhóm URL nào, và mỗi remote nhận những màn hình nào. Sau đó chọn một URL sâu và đi qua chuỗi mở tab mới → Cart → Back → refresh. Cách này giúp kiểm tra ranh giới routing trước khi thêm các remote khác.

Đọc tiếp

Tài liệu tham khảo

On this page