Next.js 作為一款卓越的前端框架,有效解決了服務(wù)端渲染問題,顯著提升網(wǎng)站性能,有利于SEO搜索引擎爬取。
然而,在使用 Next.js 的 App Router 構(gòu)建 SPA(單頁應(yīng)用)時,如果未能妥善處理其 React Server Components(RSC)的加載機制,頁面切換時容易出現(xiàn)延遲。
當(dāng)開發(fā)者在構(gòu)建 SPA 過程中執(zhí)行router.push操作時,頁面會向服務(wù)端發(fā)起請求。
這是因為它會抓取服務(wù)端的 RSC 片段,盡管這些片段的加載時間可能僅為幾十到幾百毫秒,但對于單頁應(yīng)用(SPA)而言,頻繁的此類請求會嚴(yán)重影響用戶體驗,導(dǎo)致界面有明顯的卡頓感。

解題思路
參考 Next.js 的緩存模式相關(guān)文檔App Router Caching in Next.js可知,無論采用何種緩存模式,都會發(fā)起請求以進(jìn)行緩存操作,即使請求的是緩存內(nèi)容。
再查閱Routing: Linking and Navigating文檔,其中提到 Next.js 允許使用原生的window.history.pushState和window.history.replaceState方法來更新瀏覽器的歷史記錄棧,且無需重新加載頁面。
我們可以借助window.history API 進(jìn)行狀態(tài)推送,從而避免加載過程,防止網(wǎng)頁重新加載。
Next.js 路由代碼架構(gòu)
在實際的文件結(jié)構(gòu)設(shè)計中,大部分頁面可采用常規(guī)的服務(wù)端渲染方式,但在特定路由下,可將路由控制權(quán)完全交由自定義邏輯處理。
對于 SPA 路由,我們可在 App Router 內(nèi)指定一個路由專門處理單頁應(yīng)用的網(wǎng)址變化,以此繞開 Next.js 默認(rèn)的路由行為。
文件結(jié)構(gòu)如下:
app
├── page1
│ └── page.tsx # 服務(wù)端渲染的正常網(wǎng)頁
├── page2
│ └── page.tsx
└── spa
└── […spaSlugs]
└── spaRouter.tsx # 自定義一個 SPA router.tsx
└── page.tsx # 接管 SPA 應(yīng)用內(nèi)的所有路由網(wǎng)址變化,內(nèi)部路由不再使用 App Router 編寫更多 page.tsx
接著在app/spa/[...spaSlugs]/page.tsx中,我們將會要接管自定義一個自己的一個單頁應(yīng)用的客戶端路由器。
接管客戶端路由器 (Router)
在構(gòu)建自定義的單頁應(yīng)用路由器時,可以考慮引入第三方成熟的前端路由組件,如React Router或Wouter。
不過,為簡化操作,這里使用 Express 的path-to-regexp package來實現(xiàn)。
app/spa/[...spaSlugs]/spaRouter.tsx代碼如下:
import { match } from 'path-to-regexp';
import { notFound } from 'next/navigation';
// 定義路由項的類型
interface ISpaceRoute {
path: string;
component: (props: { params: Partial<Record<string, string | string[]>> }) => React.JSX.Element;
}
// 路由映射數(shù)組
const spaceRoutes: ISpaceRoute[] = [
{
path: '/spa/users',
component: () => <div>Users List</div>,
},
{
path: '/spa/users/:id',
component: ({ params }) => <div>用戶 ID: {params.id}</div>,
},
];
// 處理路由匹配的函數(shù),記得外部 useMemo 緩存
export const handleRoute = (url: string): React.JSX.Element => {
for (const route of spaceRoutes) {
const matchFn = match(route.path);
const matchResult = matchFn(url);
if (matchResult) {
const params = matchResult.params;
return <route.component params={params} />;
}
}
// throw 404 page
notFound();
};
依據(jù) Next.js 文檔 Single-Page Applications with Next.js: Shallow routing on the client,usePathname 和 useSearchParams 這兩個 API 可用于客戶端動態(tài)獲取當(dāng)前網(wǎng)址。
app/spa/[...spaSlugs]/spaRouter.tsx代碼如下:
'use client';
import { handleRoute } from './spaRouter';
import { usePathname } from 'next/navigation';
import React from 'react';
export default function SpaceRouterEntryPage() {
const pathname = usePathname();
const com = React.useMemo(() => handleRoute(pathname), [pathname]);
return com;
}
需要注意的是,useParams 不能在該場景下使用,因為它不會隨window.history.pushState客戶端網(wǎng)址的變化而更新,其值是固定的,因此避免使用 useParams。
路由跳轉(zhuǎn) (Link)
經(jīng)過上述處理,我們已經(jīng)定義好了路由。但要注意,跳轉(zhuǎn)到該定義的路由不能使用 NextJS 原生的useRouter,因為這會引發(fā)問題。
可以使用如下代碼進(jìn)行跳轉(zhuǎn):
window.history.pushState(null, '', '/spa/users');
為優(yōu)化用戶體驗,我是建議采用如下鏈接形式:
<a onClick={(e) => {
e.preventDefault();
window.history.pushState(null, '', '/spa/users');
}} href="/asdfdsf">
Your Link
</a>
同時設(shè)置onClick和href屬性,這樣當(dāng)用戶鼠標(biāo)滑過鏈接時,會顯示鏈接的 URL 詳情,點擊后不會真正刷新網(wǎng)頁,而僅是執(zhí)行window.history.pushState操作。
總結(jié)
通過上述一系列優(yōu)化舉措,成功攻克了在開發(fā) Bika.ai 過程中,Next.js 構(gòu)建 SPA 時頁面切換延遲的難題。
優(yōu)化后的系統(tǒng),既保留了服務(wù)端渲染在 SEO 方面的顯著優(yōu)勢,確保網(wǎng)站易于被搜索引擎抓取和索引;又賦予了SPA單頁應(yīng)用模式時,快速響應(yīng)、操作敏捷的特性,極大提升了用戶交互體驗,實現(xiàn)了兩者的完美融合。
此外,本次優(yōu)化還實現(xiàn)了路由與邏輯代碼的分離和解耦。
這一成果意義重大,意味著我們能夠?qū)?SPA 應(yīng)用獨立拆解出來,形成一個完全獨立的程序。
例如,可以另行創(chuàng)建一個基于 Next.js的工程,或者新建一個基于 Vite 框架的工程,達(dá)成一套代碼在兩個不同框架中同時運行的目標(biāo)。
以我實際操作經(jīng)驗為例,在常規(guī)網(wǎng)頁服務(wù)端,我們采用 Next.js 框架,而在打包 PC 客戶端時,借助 Tauri(或 Electron)框架,輸出獨立的HTML+JS文件,使其能夠脫離服務(wù)器獨立運行,進(jìn)一步拓展了應(yīng)用的部署和使用場景。
如果您對我們同時兼容 Next.js 與 Vite 這兩套框架的獨特支持方式頗感興趣,歡迎留言與Kelly我分享,后續(xù),我會在接下來的文章里詳細(xì)闡釋其運作方法和內(nèi)在原理。