学习Next.js中的客户端路由

2,332 阅读14分钟

网络诞生以来,超链接一直是网络的瑰宝之一。根据MDN的说法,超链接是使网络成为网络的原因*。*虽然用于文件之间的链接等目的,但其主要用途是引用不同的网页,可通过一个独特的网址或URL来识别。

路由是每个网络应用的一个重要方面,就像超链接对网络一样。它是一种机制,通过它将请求路由到处理它们的代码。在路由方面,Next.js页面是通过一个独特的URL路径来引用和识别的。如果网络是由超链接相互连接的导航 网页组成的,那么每个Next.js应用程序是由路由器相互连接的可路由的页面(路由处理程序或路由)组成的。

Next.js内置了对路由的支持,这可能会让人难以理解,尤其是在考虑渲染和数据获取的时候。作为理解Next.js中客户端路由的前提,有必要对Next.js中的路由、渲染和数据获取等概念有一个概述。

这篇文章对熟悉Next.js并想了解它如何处理路由的React开发者来说是有益的。你需要对React和Next.js有一定的了解,才能从这篇文章中获得最大的收获,这篇文章只涉及客户端路由和Next.js的相关概念。

路由和渲染

路由和渲染是相辅相成的,在本文中会起到很大的作用。我喜欢[Gaurav对]它们的[解释]。

路由是指用户被导航到网站上不同页面的过程。

渲染是将这些页面放在用户界面上的过程。每次你请求路由到一个特定的页面时,你也在渲染该页面,但不是每次渲染都是路由的结果。

关于Next.js的渲染,你需要了解的是,每一个页面都是提前预渲染的,同时还需要最小的JavaScript代码,以便通过一个被称为 "水合 "的过程实现完全交互。Next.js如何做到这一点,在很大程度上取决于预渲染的形式静态生成服务器端渲染,它们都与所使用的数据获取技术高度耦合,并与页面的HTML生成时间分开。

根据你的数据获取要求,你可能会发现自己在使用内置的数据获取功能,如getStaticProps,getStaticPaths, 或getServerSideProps, 客户端数据获取工具,如SWR, react-query, 或传统的数据获取方法,如fetch-on-render,fetch-then-render,render-as-you-fetch(withSuspense) 。

预渲染(在渲染之前--到用户界面)是对Routing的补充,并且与数据获取高度耦合--在Next.js中是一个完整的主题。因此,虽然这些概念要么是互补的,要么是密切相关的,但本文将只关注页面之间的单纯导航(Routing),并在必要时引用相关概念。

说完这些,让我们从基本要点开始。Next.js有一个基于文件系统的路由器,建立在页面的概念上。

页面

Next.js中的页面是React组件,可自动作为路由使用。它们作为默认导出文件从pages目录中导出,支持的文件扩展名为.js.jsx.ts.tsx

一个典型的Next.js应用程序将有一个文件夹结构,其中有顶级目录,如页面公共样式。

next-app
├── node_modules
├── pages
│   ├── index.js // path: base-url (/)
│   ├── books.jsx // path: /books
│   └── book.ts // path: /book
├── public
├── styles
├── .gitignore
├── package.json
└── README.md

每个页面都是一个React组件。

// pages/books.js — `base-url/book`
export default function Book() {
  return Books
}

注意:请记住,页面也可以被称为 "路由处理程序"。

自定义页面

这些是驻留在pages目录中的特殊页面,但不参与路由。它们的前缀是下划线符号,如:_app.js ,和_document.js

  • _app.js
    这是一个自定义组件,驻留在pages文件夹中。Next.js使用这个组件来初始化页面。
  • _document.js
    _app.js_document.js ,这是一个自定义组件,Next.js使用它来增强你的应用程序<html><body> 标签。这是必要的,因为Next.js的页面跳过了对周围文档标记的定义。
next-app
├── node_modules
├── pages
│   ├── _app.js // ⚠️ Custom page (unavailable as a route)
│   ├── _document.jsx // ⚠️ Custom page (unavailable as a route)
│   └── index.ts // path: base-url (/)
├── public
├── styles
├── .gitignore
├── package.json
└── README.md

页面之间的链接

Next.js从next/link API中公开了一个Link 组件,可用于在页面之间执行客户端路由转换。

// Import the <Link/> component
import Link from "next/link";

// This could be a page component
export default function TopNav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/">Publications</Link>
      <Link href="/">About</Link>
    </nav>
  )
}

// This could be a non-page component
export default function Publications() {
  return (
    <section>
      <TopNav/>
      {/* ... */}
    </section>
  )
}

Link 组件可以在任何组件内使用,无论是否是页面。当以最基本的形式使用时,如上面的例子,Link 组件转化为一个带有href 属性的超链接。(在下面的下一个/链接部分会有更多关于Link 的内容)。

路由

Next.js基于文件的路由系统可用于定义最常见的路由模式。为了适应这些模式,每个路由都是根据其定义分开的*。*

索引路由

默认情况下,在你的Next.js应用程序中,初始/默认路由是pages/index.js ,它自动作为你的应用程序的起点,即/ 。由于基本的URL是localhost:3000 ,这个索引路由可以在浏览器中的应用程序的基本URL级别被访问。

索引路由自动作为每个目录的默认路由,可以消除命名上的冗余。下面的目录结构暴露了两个路由路径://home

next-app
└── pages
    ├── index.js // path: base-url (/)
    └── home.js // path: /home

嵌套路由的消除作用更为明显。

嵌套路由

pages/book 这样的路由是一个级别的深度。更深入的是创建嵌套路由,这需要一个嵌套的文件夹结构。有了一个https://www.smashingmagazine.com 的base-url,你可以通过创建一个类似于下面的文件夹结构来访问路由https://www.smashingmagazine.com/printed-books/printed-books

next-app
└── pages
    ├── index.js // top index route
    └── printed-books // nested route
        └── printed-books.js // path: /printed-books/printed-books

或者用索引路由消除路径冗余,访问印刷书籍的路由:https://www.smashingmagazine.com/printed-books

next-app
└── pages
    ├── index.js // top index route
    └── printed-books // nested route
        └── index.js // path: /printed-books

动态路由在消除冗余方面也发挥了重要作用。

动态路由

在前面的例子中,我们使用索引路由来访问所有印刷书籍。要访问个别书籍,需要为每本书创建不同的路由,比如。

// ⚠️ Don't do this.
next-app
└── pages
    ├── index.js // top index route
    └── printed-books // nested route
        ├── index.js // path: /printed-books
        ├── typesript-in-50-lessons.js // path: /printed-books/typesript-in-50-lessons
        ├── checklist-cards.js // path: /printed-books/checklist-cards
        ├── ethical-design-handbook.js // path: /printed-books/ethical-design-handbook
        ├── inclusive-components.js // path: /printed-books/inclusive-components
        └── click.js // path: /printed-books/click

这是高度冗余的,不可扩展的,可以用动态路由来补救,比如。

// ✅ Do this instead.
next-app
└── pages
    ├── index.js // top index route
    └── printed-books
        ├── index.js // path: /printed-books
        └── [book-id].js // path: /printed-books/:book-id

括号内的语法--[book-id] --是动态段,并不只限于文件。它也可以像下面的例子那样与文件夹一起使用,使作者可以在路由/printed-books/:book-id/author

next-app
└── pages
    ├── index.js // top index route
    └── printed-books
        ├── index.js // path: /printed-books
        └── [book-id]
            └── author.js // path: /printed-books/:book-id/author

路由的动态段被暴露为一个查询参数,可以通过useRouter() 钩子的query 对象访问参与路由的任何连接组件--(更多内容在下一节/路由器API部分)。

// printed-books/:book-id
import { useRouter } from 'next/router';

export default function Book() {
  const { query } = useRouter();

  return (
    <div>
      <h1>
        book-id <em>{query['book-id']}</em>
      </h1>
    </div>
  );
}
// /printed-books/:book-id/author
import { useRouter } from 'next/router';

export default function Author() {
  const { query } = useRouter();

  return (
    <div>
      <h1>
        Fetch author with book-id <em>{query['book-id']}</em>
      </h1>
    </div>
  );
}

用Catch All Routes扩展动态路由段

你已经看到了动态路由段的括号语法,如前面的例子中的[book-id].js 。这种语法的好处是,它通过Catch-All Routes使事情更进一步。你可以从名字中推断出它的作用:捕捉所有路由。

当我们看动态例子时,我们了解到它是如何帮助消除文件创建的冗余,让一个路由以其ID访问多个书籍。但是,我们还可以做一些别的事情。

具体来说,我们的路径是/printed-books/:book-id ,有一个目录结构。

next-app
└── pages
    ├── index.js
    └── printed-books
        ├── index.js
        └── [book-id].js

如果我们更新路径,使其有更多的段,如类别,我们最终可能会有这样的结果。/printed-books/design/:book-id,/printed-books/engineering/:book-id, 或者更好的是/printed-books/:category/:book-id

让我们添加发布年份:/printed-books/:category/:release-year/:book-id 。你能看到一个模式吗?目录结构变成了。

next-app
└── pages
    ├── index.js
    └── printed-books
        └── [category]
            └── [release-year]
                └── [book-id].js

我们用命名的文件代替了动态路由,但不知何故还是以另一种形式的冗余而告终。好吧,有一个解决办法。Catch All Routes可以消除对深度嵌套路由的需求。

next-app
└── pages
    ├── index.js
    └── printed-books
        └── [...slug].js

它使用相同的括号语法,只是前缀为三个点。想想看,这些点就像JavaScript的扩散语法。你可能想知道。如果我使用catch-all路线,我如何访问类别([category]),和发布年份([release-year])。有两种方法。

  1. 在印刷书的例子中,最终的目标是书,每本书的信息都会有它的元数据附加,或者
  2. slug "段作为一个查询参数数组被返回。
import { useRouter } from 'next/router';

export default function Book() {
  const { query } = useRouter();
  // There's a brief moment where slug is undefined
  // so we use the Optional Chaining (?.) and Nullish coalescing operator (??)
  // to check if slug is undefined, then fall back to an empty array
  const [category, releaseYear, bookId] = query?.slug ?? [];

  return (
    <table>
      <tbody>
        <tr>
          <th>Book Id</th>
          <td>{bookId}</td>
        </tr>
        <tr>
          <th>Category</th>
          <td>{category}</td>
        </tr>
        <tr>
          <th>Release Year</th>
          <td>{releaseYear}</td>
        </tr>
      </tbody>
    </table>
  );
}

下面是更多关于路由的例子/printed-books/[…slug]

路径查询参数
/printed-books/click.js{ "slug":["click"] }
/printed-books/2020/click.js{ "slug":["2020", "点击" ] }
/printed-books/design/2020/click.js{ "slug":["设计", "2020", "点击" ] }

与catch-all路由一样,除非你提供一个后备索引路由,否则路由/printed-books ,会抛出404错误。

next-app
└── pages
    ├── index.js
    └── printed-books
        ├── index.js // path: /printed-books
        └── [...slug].js

这是因为catch-all路由是 "严格 "的。它要么匹配一个slug,要么抛出一个错误。如果你想避免在catch-all路由旁边创建索引路由,你可以使用可选的catch-all路由代替。

用可选的catch-all路由来扩展动态路由段

语法与catch-all-routes相同,但用双方括号代替。

next-app
└── pages
    ├── index.js
    └── printed-books
        └── [[...slug]].js

在这种情况下,catch-all路由(slug)是可选的,如果不可用,则回落到路径/printed-books ,用[[…slug]].js 路由处理程序渲染,没有任何查询参数。

将catch-all与索引路由一起使用,或者单独使用可选的catch-all路由。避免同时使用 catch-all 和可选的 catch-all 路由。

路由的优先级

能够定义最常见的路由模式的能力可能是一个 "黑天鹅"。路由冲突的可能性是一个迫在眉睫的威胁,特别是当你开始使用动态路由的时候。

当这样做有意义时,Next.js会以错误的形式让你知道路由冲突的情况。如果不这样做,它就会根据路由的特殊性对其进行优先处理。

例如,在同一层次上有多个动态路由是一个错误。

// ❌ This is an error
// Failed to reload dynamic routes: Error: You cannot use different slug names for the // same dynamic path ('book-id' !== 'id').
next-app
└── pages
    ├── index.js
    └── printed-books
        ├── [book-id].js
        └── [id].js

如果你仔细观察下面定义的路由,你会发现有可能发生冲突。

// Directory structure flattened for simplicity
next-app
└── pages
    ├── index.js // index route (also a predefined route)
    └── printed-books
        ├── index.js
        ├── tags.js // predefined route
        ├── [book-id].js // handles dynamic route
        └── [...slug].js // handles catch all route

例如,试着回答这个问题:什么路由处理路径/printed-books/inclusive-components?

  • /printed-books/[book-id].js, 或
  • /printed-books/[…slug].js.

答案就在于路由处理程序的 "特殊性"。预定义路由排在第一位,其次是动态路由,然后是全能路由。你可以把路由请求/处理模型看作是一个伪代码,有以下步骤。

  1. 是否有一个预定义的路由处理程序可以处理路由?
    • true - 处理路由请求。
    • false - 转到2。
  2. 是否有一个动态路由处理程序可以处理该路由?
    • true - 处理路由请求。
    • false - 转到3。
  3. 是否有一个可以处理该路由的总括性路由处理程序
    • true - 处理路由请求。
    • false - 抛出一个404页面未找到。

因此,/printed-books/[book-id].js 赢了。

这里有更多的例子。

路由路由处理程序途径的类型
/printed-books/printed-books索引途径
/printed-books/tags/printed-books/tags.js预定义的路由
/printed-books/inclusive-components/printed-books/[book-id].js动态途径
/printed-books/design/inclusive-components/printed-books/[...slug].js全能型路由

next/link API

next/link API公开了Link 组件,作为执行客户端路由转换的声明性方式。

import Link from 'next/link'

function TopNav() {
  return (
    <nav>
      <Link href="/">Smashing Magazine</Link>
      <Link href="/articles">Articles</Link>
      <Link href="/guides">Guides</Link>
      <Link href="/printed-books">Books</Link>
    </nav>
  )
}

Link 组件将被解析为一个常规的HTML超链接。也就是说,<Link href="/">Smashing Magazine</Link> 将解析为<a href="/">Smashing Magazine</a>

href 道具是Link 组件的唯一必要道具。关于Link 组件上可用的道具的完整列表,请参阅文档

Link 组件的其他机制也需要注意。

具有动态段的路由

在Next.js 9.5.3之前,Linking到动态路由意味着你必须提供hrefas 两个道具给Link 作为in。

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href="/printed-books/[printed-book-id]"
      as={`/printed-books/${printedBook.id}`}
    >
      {printedBook.name}
    </Link>
  ));
}

虽然这允许Next.js为动态参数插值href,但这是很繁琐的,容易出错,而且有些势在必行,现在随着Next.js 10的发布,对大多数的使用情况都进行了修复。

这个修正也是向后兼容的。如果你一直在使用ashref ,就不会有任何问题。要采用新的语法,请丢弃href 这个道具及其值,并将as 这个道具重命名为href ,如下例所示。

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link href={/printed-books/${printedBook.id}}>{printedBook.name}</Link>
  ));
}

关于passHref 的用例

仔细看一下下面的片段。

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

// Say this has some sort of base styling attached
function CustomLink({ href, name }) {
  return <a href={href}>{name}</a>;
}

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link href={/printed-books/${printedBook.id}} passHref>
      <CustomLink name={printedBook.name} />
    </Link>
  ));
}

passHref 道具迫使Link 组件将href 道具传递给CustomLink 子组件。如果Link 组件包裹着一个返回超链接<a> 标签的组件,这就是强制性的。你的用例可能是因为你正在使用一个像styled-components这样的库,或者你需要向Link 组件传递多个子组件,因为它只期望有一个子组件。

URL对象

Link 组件的href 托词也可以是一个 URL 对象,其属性与query 一样,被自动格式化为一个 URL 字符串。

使用printedBooks 对象,下面的例子将链接到。

  1. /printed-books/ethical-design?name=Ethical+Design
  2. /printed-books/design-systems?name=Design+Systems.
import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href={{
        pathname: `/printed-books/${printedBook.id}`,
        query: { name: `${printedBook.name}` },
      }}
    >
      {printedBook.name}
    </Link>
  ));
}

如果你在pathname 中包含一个动态段,那么你也必须在查询对象中把它作为一个属性,以确保查询在pathname 中被插值。

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

// In this case the dynamic segment `[book-id]` in pathname
// maps directly to the query param `book-id`
export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href={{
        pathname: `/printed-books/[book-id]`,
        query: { 'book-id': `${printedBook.id}` },
      }}
    >
      {printedBook.name}
    </Link>
  ));
}

上面的例子有路径。

  1. /printed-books/ethical-design, 和
  2. /printed-books/design-systems.

如果你在VSCode中检查href 属性,你会发现LinkProps 的类型,href 属性是Url 的类型,如前所述,它是stringUrlObject

进一步检查UrlObject ,就可以看到带有属性的界面。

你可以在Node.js URL模块文档中了解更多关于这些属性的信息。

哈希值的一个用例是链接到一个页面中的特定部分。

import Link from 'next/link';

const printedBooks = [{ name: 'Ethical Design', id: 'ethical-design' }];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href={{
        pathname: /printed-books/${printedBook.id},
        hash: 'faq',
      }}
    >
      {printedBook.name}
    </Link>
  ));
}

该超链接将解析为/printed-books/ethical-design#faq

next/router API

如果next/link 是声明性的,那么next/router 是强制性的。它暴露了一个useRouter 钩子,允许访问 router对象的任何函数组件内。你可以使用这个钩子来手动执行路由,最特别的是在某些场景下,next/link 是不够的,或者你需要 "钩 "到路由。

import { useRouter } from 'next/router';

export default function Home() {
  const router = useRouter();

  function handleClick(e) {
    e.preventDefault();
    router.push(href);
  }

  return (
    <button type="button" onClick={handleClick}>Click me</button>
  )
}

useRouter 是一个React钩子,不能与类一起使用。在类组件中需要 对象?使用 。router withRouter

import { withRouter } from 'next/router';

function Home({router}) {
  function handleClick(e) {
    e.preventDefault();
    router.push(href);
  }

  return (
    <button type="button" onClick={handleClick}>Click me</button>
  )
}

export default withRouter(Home);

router 对象

useRouter 钩子和withRouter 高阶组件,都返回一个路由器对象,其属性如pathname,query,asPath, 和basePath ,给你关于当前页面的URL状态的信息,locale,locales, 和defaultLocale ,给你关于活动的、支持的或当前默认的语言的信息。

路由器对象也有一些方法,如push ,通过在历史堆栈中添加一个新的URL条目来导航到一个新的URL,replace ,类似于push,但是替换了当前的URL,而不是在历史堆栈中添加一个新的URL条目。

自定义路由配置next.config.js

这是一个常规的Node.js模块,可以用来配置某些Next.js行为。

module.exports = {
  // configuration options
}

记住,无论何时更新next.config.js ,都要重新启动服务器。

基本路径

前面提到,Next.js的初始/默认路径是pages/index.js ,路径是/ 。这是可以配置的,你可以让你的默认路由成为该域的子路径。

module.exports = {
  // old default path: /
  // new default path: /dashboard
  basePath: '/dashboard',
};

这些变化将在你的应用程序中自动生效,所有/ 的路径都被路由到/dashboard

这个功能只能在Next.js 9.5及以上版本中使用。

后面的斜杠

默认情况下,每个URL的末尾都不会有尾部斜杠。然而,你可以用这个来切换。

module.exports = {
  trailingSlash: true
};
# trailingSlash: false
/printed-books/ethical-design#faq
# trailingSlash: true
/printed-books/ethical-design/#faq

总结

路由是Next.js应用程序中最重要的部分之一,它反映在基于文件系统的路由器中,该路由器建立在页面的概念上。页面可以用来定义最常见的路由模式。路由和渲染的概念是密切相关的。当你建立自己的Next.js应用或在Next.js代码库上工作时,请带着本文的教训。