Đang tải…
Đang tải…
Đọc searchParams ở server là cách nhanh nhất biến một trang prerender thành trang render theo yêu cầu. Đây là cách giữ được cả hai.

Trang danh sách nào rồi cũng cần bộ lọc. Blog này lọc theo tag qua ?tag=react, trang thuật toán lọc theo nhóm qua ?group=sorting. Cách hiển nhiên trong App Router là nhận searchParams ngay ở page component rồi lọc trên server.
Bộ lọc chạy đúng. Nhưng cả trang chậm hẳn đi, và phải nhìn vào bảng route sau khi build mới thấy vì sao.
searchParams là thứ chỉ tồn tại khi có request thật. Page component chạm vào nó thì Next không thể dựng sẵn HTML lúc build, đơn giản vì lúc build chưa có request nào cả. Route rơi sang chế độ render theo yêu cầu, và ký hiệu đứng trước tên nó trong output đổi theo:
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
Từ ● sang ƒ. Với một trang danh mục, đó là khác biệt giữa việc trả về một file HTML nằm sẵn và việc mở kết nối tới database mỗi lần có người bấm vào link trong navbar. Bảng route này in ra sau mỗi lần build, và nó là chỗ đáng liếc qua nhất trước khi deploy.
Lối thứ nhất: bỏ searchParams, dùng hook useSearchParams() phía client. Hook này buộc toàn bộ cây component tính từ ranh giới Suspense gần nhất phải render ở client. Route giữ được nhãn tĩnh, nhưng nội dung bên trong biến mất khỏi HTML. Máy tìm kiếm nhận về một cái khung rỗng, còn người đọc thấy một nhịp trắng trước khi danh sách hiện ra. Bạn đổi một vấn đề hiệu năng lấy một vấn đề SEO.
Lối thứ hai: đọc window.location.search trong useEffect rồi setState. Cách này chạy được. React Compiler sẽ báo lint vì nó tạo ra một lượt render nối đuôi ngay sau lượt đầu, và kể cả khi tắt lint đi thì bạn vẫn trả giá bằng đúng nhịp nháy đó.
Cách React chính thức khuyến nghị để đọc trạng thái chỉ có ở trình duyệt là useSyncExternalStore. Nó nhận ba đối số, và đối số thứ ba mới là thứ quan trọng ở đây: một hàm trả về giá trị dùng khi render trên server.
function subscribe(callback) {
// Nút back/forward đổi query string mà không remount component.
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
}
const getSnapshot = () => window.location.search;
const getServerSnapshot = () => "";
export function useUrlSearch() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function useUrlParam(key) {
return new URLSearchParams(useUrlSearch()).get(key);
}
Server trả chuỗi rỗng. Lượt paint đầu tiên ở client cũng trả chuỗi rỗng. Hai bên khớp nhau nên không có hydration mismatch, rồi ngay sau đó hook đổi sang giá trị thật và component render lại. Điểm mấu chốt: component vẫn được render trên server, nên nội dung của nó vẫn nằm nguyên trong HTML tĩnh.
subscribe lắng nghe popstate vì đó là sự kiện phát ra khi người dùng bấm back hoặc forward. Không có nó, bấm back sẽ đổi thanh địa chỉ mà danh sách đứng yên.
Có một đánh đổi thật, và nó nằm đúng ở dòng getServerSnapshot trả về chuỗi rỗng: HTML tĩnh luôn là bản chưa lọc. Ai mở thẳng /blog?tag=react sẽ thấy toàn bộ danh sách trong khoảnh khắc đầu tiên, rồi nó co lại còn các bài React sau khi JavaScript chạy xong.
Với bộ lọc thì đổi này đáng: danh sách đầy đủ là một trạng thái hợp lệ, người đọc thấy nó trong chớp mắt rồi trang tự thu hẹp. Với thứ khác thì không đáng chút nào. Đừng dùng cách này cho ?token= ở trang đặt lại mật khẩu, cho giỏ hàng, hay cho bất kỳ trang nào mà nội dung chính vô nghĩa nếu thiếu query param. Ở đó bạn cần render động thật, và nhãn ƒ mới là nhãn đúng.
Còn một ràng buộc kèm theo mà mình từng quên: đọc URL ở client thì cũng phải ghi URL ở client. Gọi router.push khi người dùng bấm một tag là một lượt điều hướng thật, và nó vứt đi đúng cái trang tĩnh vừa cố công giữ lại. Thứ cần dùng là history.replaceState: link vẫn chia sẻ được, mà không rời khỏi trang.
Chưa có bình luận nào — hãy là người đầu tiên!