Client-side navigation 一直是 SPA ( Single Page Application ) 的一大特色。可以讓使用者拜訪其他頁面時,瀏覽器不須向 server 發 request,重新整理,因此頁面載入速度較快,且可保留 state 的值,使用者體驗較佳。
在 React 專案中,我們可以使用像是 React Router 等 libraries 來設定路由和做 client-side navigation,那在 Next.js App Router 的架構底下,該怎麼實現 client-side navigation 的效果呢?
我們可以使用官方 API 提供的 <Link>
component 和 useRouter()
hook:
<Link>
的概念類似 html 裡面的 <a>
tag,但額外增加了 prefetching 和 client-side navigation 的功能。
這樣做的優點是什麼呢?在支援 JavaScript ( JS ) 的環境,Next 可以利用 JS 更新 DOM 來達成 soft navigation 的頁面跳轉效果;在無法使用 JS 的環境,像是部分搜尋引擎爬蟲,則可以透過 <a>
tag 的 href
來跳轉頁面。可以同時兼顧 SEO 和使用者體驗。
<Link>
使用方法很簡單,只需從 next/link
import <Link>
並在 href
prop 中帶入目的地的路徑:
import Link from 'next/link';
export default function Page() {
return <Link href='/dashboard'>Dashboard</Link>;
}
href
中也可以帶 template literals 來連接動態路由:
import Link from 'next/link'
export default function PostList({ posts }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
)
}
路由切換時,頁面滾輪位置預設會是在最上方。假如我們希望路由切換時,頁面自動可以滾到某個 element 的位置,我們可以比照 <a>
tag 的做法,在 href
的 path 後加入 #[id]
:
<Link href="/dashboard#settings">Settings</Link>
假如網址要帶 query string,我們可以傳一個 object 到 href:
'use client';
import Link from 'next/link';
export default function Test() {
return (
<Link
href={{
pathname: '/about',
query: { name: 'test' },
}}
>
Visit About
</Link>
);
}
這時點擊 Visit About,就會拜訪 /about?name=test
。
除了 href
以外,<Link>
還有另外三個 props 可以讓你做 navigation 的細節設定。
replace
決定路由變換後,要不要取代目前瀏覽紀錄。 預設為 false,意思是拜訪新頁面時,next/link
會在瀏覽器的 history stack 中加一個新的 URL;假如設為 true,則會取代掉目前的 URL。
好像技術名詞有點多,一樣舉個例子:
假如 replace 為 false,我們從 /about 進到 /profile,再進到 /settings,打開瀏覽紀錄,會看到多兩筆紀錄,網址分別指向 /profile 和 /settings;但假如 replace 為 true 時,你只會看到一筆紀錄,網址指向 /settings。
import Link from 'next/link';
export default function Page() {
return (
<Link href='/dashboard' replace>
Dashboard
</Link>
);
}
scroll
決定路由變換後頁面滾輪的位置。 預設為 true,意思是進到一個新頁面時,滾輪會回到最上方。假如使用回到上下頁,則會在上次滾到的位置。
假如 scroll 設為 false 時,進到新頁面滾輪會維持在相同位置,不會回到最上方。
import Link from 'next/link';
export default function Page() {
return (
<Link href='/dashboard' scroll={false}>
Dashboard
</Link>
);
}
prefetch
決定 <Link>
連結的頁面要不要 prefecth。 預設為 true,但只有在 production stage 會有效果。
App Router 架構下,Next 會自動在 server-side 依據 route segments 做 code splitting,所以當使用者進到某個頁面時,server 只會回傳該頁面相關的程式碼。假如頁面有使用到 <Link>
,Next 預設會 prefetch <Link>
連結的 route segments,提前載入該 route segments 的內容,降低使用者在切換 route 時的延遲。
舉例來說,我的首頁包含兩個 <Link>
,分別連到 /shop 和 /profile:
import Link from 'next/link';
export default function Home() {
return (
<div>
<Link href='/shop'>Go to shop</Link>
<Link href='/profile'>Go to profile</Link>
</div>
);
}
部署後,進到首頁打開開發者工具的 Network tab,可以發現 /shop 和 /profile 的內容也提前下載了。
假如不希望 prefetch,將 Link 的 prefetch props 設為 false 即可:
import Link from 'next/link';
export default function Home() {
return (
<div>
<Link href='/shop' prefetch={false}>Go to shop</Link>
<Link href='/profile'>Go to profile</Link>
</div>
);
}
補充一個踩雷經驗,曾經碰到一個案例是,首頁 side bar viewport 約一次會顯示 20 個 <Link>
,每個<Link>
前往的頁面都有一個 middleware 會在 request 完成前打一支 API 處理 cookies。
原本應該是點擊 <Link>
,路由切換時才會打這支 API,但因為 <Link>
預設 prefetching,所以使用者一進到首頁就會打 20 次 API,往下滑 side bar 就會繼續一直打。這時就要將 prefetch
改為 false。提供給有遇到類似問題的讀者參考。
如果想主動切換路由,比如說我希望用戶輸入「回到首頁」後,會 redirect 到首頁。這時可以使用 useRouter
的 push
method:
'use client';
import { useRouter } from 'next/navigation';
export default function Page() {
const router = useRouter();
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
const inputValue = e.target.value;
inputValue === '回到首頁' && router.push('/');
}
return <input onChange={handleInput} />;
}
除了 push 以外,假如想調整歷史紀錄和 prefetch 設定,可以使用 router.replace()
和 roouter.prefetch()
兩個 methods;假如想回到上 / 下頁,可以用 router.back()
和 router.forward()
。詳細要帶什麼參數,篇幅考量這邊就不細談,可以參考官方文件。
這邊做幾個小提醒:
next/navigation
而不是 next/router
import,不然會跳 NextRouter was not mounted. 的報錯。router.prefetch()
。讀到這邊我不禁好奇,Next 是怎麼做到 prefetch 的呢?我們來看一點點 source code:
主要讀的原始碼是 route-loader.tsx
這份檔案,附上連結
我們可以從第 116 行 prefetchViaDom
這個 function,大致了解 prefetch 背後的原理是什麼:
function prefetchViaDom(
href: string,
as: string,
link?: HTMLLinkElement
): Promise<any> {
return new Promise<void>((resolve, reject) => {
const selector = `
link[rel="prefetch"][href^="${href}"],
link[rel="preload"][href^="${href}"],
script[src^="${href}"]`
// 判斷是否已經載入要 prefetch 對象的資源
if (document.querySelector(selector)) {
return resolve()
}
// 假如還沒載入,就在 HTML 中加入一個 <link> element
link = document.createElement('link')
// The order of property assignment here is intentional:
if (as) link!.as = as
// 告訴 user agent 要 prefetch
link!.rel = `prefetch`
link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
link!.onload = resolve as any
link!.onerror = () =>
reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))
// `href` should always be last:
link!.href = href
document.head.appendChild(link)
})
}
簡單來說:
<link>
element。<link>
幾個 attributes。其中透過 rel
來提示 user agent,後續 navigation 會用到這些資源,麻煩先 prefetch 和 cache 它們。onload
代表資源載入完成,則 resolve;反之 onerror
則 reject。href
提供對應資源的 URL除了 prefetch 以外,Next 也針對 navigation 做了幾個性能上的優化:
/dashboard/settings
到 /dashboard/analytics
,/dashboard 的 layout 並不會 re-render。至於什麼時候該使用 <Link>
,而什麼時候該使用 useRouter
呢?官方給了一個很簡單的建議:能用 <Link>
就用 <Link>
,不能的時候再用 useRouter
。
Recommendation: Use the
<Link>
component to navigate between routes unless you have a specific requirement for using useRouter.
最後跟大家分享,有天好奇心作祟,在嘗試讀 <Link>
相關的 source code,意外看到的一些小發現。
主要讀的源始碼是 link.tsx
這個檔案,連結在這
我們來從 source code 嘗試推敲 <Link>
的原理。先看到最後 723 - 759 行:
if (isAbsoluteUrl(as)) {
childProps.href = as
} else if (
!legacyBehavior ||
passHref ||
(child.type === 'a' && !('href' in child.props))
) {
...
childProps.href =
localeDomain ||
addBasePath(addLocale(as, curLocale, pagesRouter?.defaultLocale))
}
return legacyBehavior ? (
React.cloneElement(child, childProps)
) : (
<a {...restProps} {...childProps}>
{children}
</a>
)
}
)
legacyBehavior
我猜是 Next 13 以前的用法。推測根據是,假如我今天讓 <Link>
的子元素為 <a>
:
<Link>
<a>Back Home</a>
</Link>
就會出現:Invalid <Link> with <a> child. Please remove <a> or use <Link legacyBehavior>.
的報錯。
假如看官方文件,可以知道 Next 13 以後,<Link>
本身會是一個 <a>
,所以子元素不能是一個 <a>
。假如要這樣做,要帶一個 LegacyBehavior
的 prop。
在原始碼第 484 - 518 行也可以看到,假如 legacyBehavior 為 false,children 是 <a>
,則報剛剛的錯誤:
if (legacyBehavior) {
...
} else {
if (process.env.NODE_ENV === 'development') {
if ((children as any)?.type === 'a') {
throw new Error(
'Invalid <Link> with <a> child. Please remove <a> or use <Link legacyBehavior>.\nLearn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor'
)
}
}
}
假如看原始碼 294 行,legacyBehavior 的定義為:legacyBehavior = Boolean(process.env.__NEXT_NEW_LINK_BEHAVIOR) !== true
,__NEXT_NEW_LINK_BEHAVIOR 這個命名的確也蠻符合我們的推測。
所以 legacyBehavior 應該代表 <Link>
有帶 LegacyBehavior
prop,表示使用 Next 13 以前 <Link>
的用法。
因為我們是用 Next.js 13,legacyBehavior 應該是 false,所以可以大概推測,<Link>
最後會return 一個 <a>
,並讓它帶有 childProps
中定義的 props、childProps
以外有使用的 props,和 744 行加入的 href。
那 childProps
是什麼呢?來看到 608 行:
const childProps: {
onTouchStart: React.TouchEventHandler<HTMLAnchorElement>
onMouseEnter: React.MouseEventHandler<HTMLAnchorElement>
onClick: React.MouseEventHandler<HTMLAnchorElement>
href?: string
ref?: any
...
} = {
onClick(e) {
if (!legacyBehavior && typeof onClick === 'function') {
onClick(e);
}
if (e.defaultPrevented) {
return
}
linkClicked(
...
);
},
}
看起來是一個存多個 props 的 object。其中值得注意的是 onClick 這邊。假如本來就有 onClick 這個 prop,而且是一個 function,那我們就先執行 onClick 的事件,接著假如沒有 e.preventDefault()
,再執行 linkClicked
。
看完 childProps 的定義後,可以大致推敲出,假如 <Link>
有帶 onClick,Next 會改寫它。怎麼改寫?大致上為先執行這個 onClick,再執行 linkClicked
。
那 linkClicked
是做什麼的呢?於是我往上找到了 193 行:
function linkClicked(
...
): void {
...
e.preventDefault()
const navigate = () => {
// If the router is an NextRouter instance it will have `beforePopState`
const routerScroll = scroll ?? true
if ('beforePopState' in router) {
router[replace ? 'replace' : 'push'](href, as, {
shallow,
locale,
scroll: routerScroll,
})
} else {
router[replace ? 'replace' : 'push'](as || href, {
forceOptimisticNavigation: !prefetchEnabled,
scroll: routerScroll,
})
}
}
if (isAppRouter) {
React.startTransition(navigate)
} else {
navigate()
}
}
簡單來說,假如目的地是其他網站,那就直接跳轉;假如是站內跳轉,則會先 e.preventDefalut()
掉 <a>
預設的事件,再呼叫負責 navigation 的 method,所以路由跳轉時不會重新整理。
我在 <Link>
加入 onClick 事件,讓它簡單 console.log('click'),也的確會先 console.log 完才跳轉頁面;假如在 onClick callback 中加入 e.preventDefault()
,也如預期不會跳轉了;同時也可以在 <Link>
加入 childprops
以外的其他 props,像是 onMouseOver。
看完後統整幾個重點,希望可以幫大家防一點點雷:
<Link>
可以帶 onClick,而且 onClick 的事件會在切換路由前觸發。所以假如 onClick 中使用了 window.location.href
,會改 hard navigate 到 window.location.href
的連結。export default function Page() {
// 點按鈕後會拜訪 Google
return (
<div>
<Link
href='profile'
onClick={() => (window.location.href = 'https://www.google.com')}
>
Go to Profile
</Link>
</div>
);
}
<Link>
的路由跳轉只有在 onClick 沒有 e.preventDefault()
時才會觸發,所以假如不小心在子元素 onClick 寫了 e.preventDefault()
可能會讓 navigation 失效:export default function Page() {
// 什麼事都不會發生
return (
<div>
<Link href='profile' onClick={(e) => e.preventDefault()}>
Go to Profile
</Link>
</div>
);
}
<Link>
會是一個 <a>
,所以子元素不能帶 <a>
。同時 Next 也給了 <a>
onClick 事件,並讓 onClick 中先 e.preventDefault()
,再執行跳轉。所以在 JavaScript 可以執行的環境,可以達到 soft navigation 的效果,假如 JS 無法執行的環境 ( ex: 部分搜尋引擎爬蟲 ),還是可以透過 <a>
的 href 來換頁。篇幅考量,這邊就先不設計實驗,有興趣的讀者可以自己玩玩看!
以上是要如何在 Next.js App Router 中做 client-side navigation 的介紹。學會了靜態和動態路由的架構,以及使用 <Link>
和 useRouter 切換路由,接下來兩天會和大家分享幾個,不會影響到 URL,但可以享受 Next 針對 route segment 提供的便利的「功能性」路由。
謝謝大家耐心的閱讀,我們明天見!
您好,通过文章学习了很多,作为nextjs
新手,有个问题想咨询一下:
document
文档类型,浏览器右键查看源码可以看到完整的html结构;Link
或useRouter
进行导航,目标页面不管是React Server Component(Page)
还是React Client Component(Page)
,从chrome devtools
的network
面板就看不到document
文档类型的请求,能看到route segment
相关的js资源请求,浏览器右键查看源码仍然可以看到完整的html结构;对于2感到疑惑:
Content-type: text/x-component
是做什么的?难道是hydrate
?html
,浏览器右键查看源码为什么能看到完整的html结构?期待您的解答~
您好,很開心我的文章能幫助到您!針對您提的問題:
等 Server & Client Components 都渲染完後, Next.js 才會藉由 RSC Payload 和 Client Components 渲染的結果來產出網頁初始的 HTML 檔案,這個步驟稱為預渲染,所以我猜您想問的是 Next 預渲染後傳給瀏覽器的是不是 HTML 檔案對嗎?
<!DOCTYPE html>
,就是告訴您這份文件類型為 HTML。 您也可在 next build 後 ( ex: npm run build ),到/.next/server/app
中,應該可以看到檔名為 route segment 的 html 文件。
Content-type: text/x-component
我就不確定了,假如您有找到答案也歡迎跟我分享!感谢您的答复,根据您的答复我又提炼了一下问题。
目前做了一个nextjs学习demo,结构如下
查看了一下/.next/server/app
,确实有对应route segment
的 html
文件,只不过不理解这些html
文件何时使用的。
问题点1:
rsc软路由跳转的话,浏览器请求的是html还是rsc payload,如果是rsc payload的话,和ssr有什么区别,都是页面初始化/刷新时生成html,软路由仍旧是浏览器端react hydrate。
问题点2:
在next build && next start
后,能看到后续路由的rsc paylod的预加载,软路由跳转后并无html
网络请求(如上图从/
跳转至/carts
),但,source code
中能看到完整的html片段,此时的html
片段是如何产生的。如果是浏览器的runtime react hydrate
的话,应该看不到完整的html
片段吧?
关于Content-type: text/x-component
,搜索的资源和dymatic html相关,如何在nextjs
环境使用,还没有找到确切的内容。