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ục lục
- Các vấn đề cần giải quyết
- Các giải pháp
- Nên chọn cách nào trước
- Ví dụ React tối thiểu
- Navigation contract cho remote cô lập
- Các lưu ý sau khi routing cơ bản đã chạy
- Tự kiểm tra thiết kế
- Đọc tiếp
- Tài liệu tham khảo
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:
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:
- URL này cần app Catalog, không phải Cart.
- 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:
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
pushStatethà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:
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 động | Router của shell có nhận được thay đổi không? |
|---|---|
Navigation thành công qua navigate() hoặc Link của router shell | Có; 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 document | Có; 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 shell | Có; 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.
Mở link trực tiếp khác gì click trong app
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ể:
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:
Đâ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:
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:
- Người dùng bấm “Xem giỏ hàng” trong Catalog.
- Catalog gửi yêu cầu đến shell: “Đi tới Cart”.
- Shell thực hiện navigation đến
/cart. - 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.
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:
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:
Đâ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:
| Request | Server cần xử lý thế nào |
|---|---|
Document /catalog/products/42 | Trả shell HTML để frontend dựng màn hình |
| JavaScript/CSS có thật | Trả đúng file và MIME type |
| JavaScript/CSS không tồn tại | Trả lỗi asset, không trả shell HTML |
| API request | Chuyể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 shell | Router chung; shell nhận prefix, remote khai báo route con |
| Remote khác framework hoặc mount trong root riêng | Shell đ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ập | Link thông thường; chấp nhận full-page navigation |
| Một màn hình ghép nhiều MFE | Shell 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
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ể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
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>
);
}Catalog chỉ khai báo phần phía sau điểm gắn /catalog:
indexlà màn hình mặc định tại/catalog.products/:productIdnhận/catalog/products/42, vớiproductIdbằng"42".searchnhậ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:
- Shell chọn
CatalogRoutes; Catalog chọnProductListqua routeindex. - Bạn click “Xem sản phẩm 42”.
Linkyêu cầu router chung chuyển tới/catalog/products/42. - Shell vẫn chọn Catalog vì URL vẫn thuộc
/catalog/*; Catalog chuyển sangProductDetail. - Bạn click “Giỏ hàng” ở menu. Router chuyển tới
/cart, shell thay vùng nội dung bằngCartPage. - 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.
Navigation contract cho remote cô lập
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.
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'>;
}| Thành phần | Remote 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 |
revision | Phâ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.
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:
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 | /shop | Toàn bộ app chạy dưới prefix này |
| Điểm gắn remote | /catalog | Shell giao nhánh này cho Catalog |
| Asset base URL | https://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ác | Kết quả cần thấy |
|---|---|
Mở /catalog/products/42 trong tab mới | Shell chọn Catalog, Catalog mở sản phẩm 42 |
| Refresh URL sản phẩm | Không bị server 404, không mất điểm đến |
Catalog thêm một màn hình dưới /catalog | Không phải thêm route con đó vào shell |
| Sản phẩm → Cart → Back → Forward | URL và màn hình luôn khớp nhau |
Mở /catalog/unknown | Catalog hiển thị not found của mình |
Mở /unknown | Shell hiển thị not found cấp ứng dụng |
| Catalog tải chậm, người dùng chuyển Cart | Kết quả tải cũ không thay màn hình Cart |
| Request một chunk không tồn tại | Server 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
State ownership
Phân biệt URL state đã áp dụng với local state và dữ liệu đang nhập.
Hợp đồng giao tiếp
Thiết kế API giữa shell và remote mà không lộ toàn bộ implementation.
Authentication và session
Giữ điểm đến sau login và phối hợp route guard với session.
Thực hành routing và auth
Áp dụng routing, navigation port và auth trong shop giả định.
Tài liệu tham khảo
- React Router — Routing: khai báo route, nested routes, splat và Link.
- React Router — BrowserRouter: router dùng browser history và basename.
- React Router — MemoryRouter: router giữ history trong memory và ý nghĩa của initial entries.
- MDN — Working with the History API: push, replace và khôi phục nội dung khi Back/Forward.
- single-spa — Layout Definition: ghép route, DOM layout và nhiều application trên cùng trang.
State ownership
Phân định local state, server state và shared state; giữ một owner cho mỗi miền dữ liệu thay vì để global store điều khiển mọi remote.
Data fetching và cache
Phân định API ownership, chọn phạm vi query cache, deduplicate request và invalidation đúng domain mà không buộc các remote dùng chung mọi implementation.