iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 20

Day 20 - Next.js 13 App Router 路由切換:<Link> & useRouter

  • 分享至 

  • xImage
  •  

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 Component

<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

Link Component 細節設定

除了 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=true
    假如 scroll 設為 false 時,進到新頁面滾輪會維持在相同位置,不會回到最上方。

    import Link from 'next/link';
    
    export default function Page() {
      return (
        <Link href='/dashboard' scroll={false}>
          Dashboard
        </Link>
      );
    }
    

    scroll=false

  • 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 的內容也提前下載了。
    https://ithelp.ithome.com.tw/upload/images/20230920/20161853tey6FTpMF0.png

    假如不希望 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。提供給有遇到類似問題的讀者參考。

主動路由切換 - useRouter

如果想主動切換路由,比如說我希望用戶輸入「回到首頁」後,會 redirect 到首頁。這時可以使用 useRouterpush 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()。詳細要帶什麼參數,篇幅考量這邊就不細談,可以參考官方文件

這邊做幾個小提醒:

  1. 假如使用 App Router,useRouter 要從 next/navigation 而不是 next/router import,不然會跳 NextRouter was not mounted. 的報錯。
  2. useRouter 預設不會 prefetch,假如希望 pretch 要使用 router.prefetch()
  3. 假如頁面含有動態渲染元素 ( 需要依照 request 渲染 html ),只有 layout 和 loading UI 會被 prefetched。

淺談 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)
  })
}

簡單來說:

  1. function 最後會 return 一個 Promise。假如 prefetch 成功,會 resolve,發生錯誤則 reject。
  2. 首先判斷是否已經載入要 prefetch 對象的資源,是的話則 return reslove();還沒的話則建一個 <link> element。
  3. 接著給 <link> 幾個 attributes。其中透過 rel 來提示 user agent,後續 navigation 會用到這些資源,麻煩先 prefetch 和 cache 它們。
  4. 再透過 onload 代表資源載入完成,則 resolve;反之 onerror 則 reject。
  5. 最後透過 href 提供對應資源的 URL

Next 內建的 Navigation 性能優化

除了 prefetch 以外,Next 也針對 navigation 做了幾個性能上的優化:

  1. Prefetch 的 segments 會存快取,減少 client 向 server 發 request 的次數。
  2. 只有發生改變的 route segments 會 re-render。舉例來說,從 /dashboard/settings/dashboard/analytics,/dashboard 的 layout 並不會 re-render。
    https://ithelp.ithome.com.tw/upload/images/20230920/20161853mSnVkzSuKU.png
    ( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating )

至於什麼時候該使用 <Link>,而什麼時候該使用 useRouter 呢?官方給了一個很簡單的建議:能用 <Link> 就用 <Link>,不能的時候再用 useRouter

Recommendation: Use the <Link> component to navigate between routes unless you have a specific requirement for using useRouter.

Next.js - Linking and Navigating

意外小發現

最後跟大家分享,有天好奇心作祟,在嘗試讀 <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。

看完後統整幾個重點,希望可以幫大家防一點點雷:

  1. <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>
  );
}
  1. <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>
  );
}
  1. Next.js 13 後 <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 提供的便利的「功能性」路由。

謝謝大家耐心的閱讀,我們明天見!


上一篇
Day 19 - Next.js 13 App Router 動態路由 Dynamic Routes & getStaticParams()
下一篇
Day 21 - 功能性路由 ( 一 ):Route Group
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
mihuartuanr
iT邦新手 4 級 ‧ 2023-11-23 23:51:41

您好,通过文章学习了很多,作为nextjs新手,有个问题想咨询一下:

  1. web完全重新渲染时,页面请求拿到的是document文档类型,浏览器右键查看源码可以看到完整的html结构;
  2. 若使用LinkuseRouter进行导航,目标页面不管是React Server Component(Page)还是React Client Component(Page),从chrome devtoolsnetwork面板就看不到document文档类型的请求,能看到route segment相关的js资源请求,浏览器右键查看源码仍然可以看到完整的html结构;

对于2感到疑惑:

  1. RSC传输给浏览器端的不是html?
  2. 请求的响应头Content-type: text/x-component是做什么的?难道是hydrate?
  3. 请求到的资源不是html,浏览器右键查看源码为什么能看到完整的html结构?

期待您的解答~

S.C iT邦新手 4 級 ‧ 2023-11-27 22:19:15 檢舉

您好,很開心我的文章能幫助到您!針對您提的問題:

  1. 您可能有些搞混 RSC 與預渲染 ( pre-rendering )。RSC 經過渲染後並不是產生一份 HTML,而是一份特殊的檔案格式 RSC Payload,記載 RSC 渲染的結果、Client Components 要渲染後要塞入的位置,和要傳給 Client Components 的 props 等資訊,這時網頁初始的 HTML 還沒產生。

  等 Server & Client Components 都渲染完後, Next.js 才會藉由 RSC Payload 和 Client Components 渲染的結果來產出網頁初始的 HTML 檔案,這個步驟稱為預渲染,所以我猜您想問的是 Next 預渲染後傳給瀏覽器的是不是 HTML 檔案對嗎?

  1. 其實您在 DevTools Network 面板看到 route segment 的 document 即是該 route segment 的 HTML,document 是更廣義的分類,HTML 是 document ( 文件 ) 的一種。假如您打開 route segment 的 document,應該可以看到 response 最開頭是 <!DOCTYPE html>,就是告訴您這份文件類型為 HTML。

  您也可在 next build 後 ( ex: npm run build ),到/.next/server/app 中,應該可以看到檔名為 route segment 的 html 文件。

  1. Content-type: text/x-component 我就不確定了,假如您有找到答案也歡迎跟我分享!

感谢您的答复,根据您的答复我又提炼了一下问题。

目前做了一个nextjs学习demo,结构如下
nextjs学习demo

查看了一下/.next/server/app,确实有对应route segmenthtml 文件,只不过不理解这些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片段吧?
x-component

关于Content-type: text/x-component,搜索的资源和dymatic html相关,如何在nextjs环境使用,还没有找到确切的内容。

我要留言

立即登入留言