最新Next 14快速上手基础部分
最新的NEXT快速上手文档,2023.10.27 英文官网同步,版本Next14.0.0
本项目案例:GitHub地址,可以根据git回滚代码到对应知识,若有错误,欢迎指正!
一、介绍
1.什么是Next.js
Next.js是一个用于构建全栈Web应用程序的React框架。你可以使用React组件来构建用户界面,使用Next.js来实现额外的功能和优化。
在引擎盖下,Next.js还抽象并自动配置React所需的工具,如捆绑,编译等。这使您可以专注于构建应用程序,而不是花费时间进行配置。
无论您是个人开发人员还是大型团队的一员,Next.js都可以帮助您构建交互式,动态和快速的React应用程序。
2. 主要特点
Next.js的特点主要包括:
特点 | 描述 |
---|---|
路由 | 基于文件系统的路由器,构建在服务器组件之上,支持布局、嵌套路由、加载状态、错误处理等。 |
渲染 | 使用客户端和服务器组件进行客户端和服务器端渲染。通过Next.js在服务器上使用静态和动态渲染进一步优化。Edge和Node.js运行时上的流媒体。 |
数据获取 | 简化了服务器组件中的数据获取,并扩展了fetch API,用于请求存储,数据缓存和重新验证。 |
样式 | 支持您首选的样式化方法,包括CSS Modules、Tailwind CSS和CSS-in-JS |
优化项 | 图像、字体和脚本优化,以改善应用程序的核心Web关键点和用户体验。 |
TypeScript支持 | 改进了对TypeScript的支持,具有更好的类型检查和更有效的编译,以及自定义TypeScript插件和类型检查器。 |
二、安装Next
Node版本要求在18.17以上,建议使用nvm切换
1. 安装步骤
-
打开终端(官网建议使用
create-next-app
创建Next应用)npx create-next-app@latest
-
接下来将看到如下提示:根据自己的习惯进行选择,这里我全选Yes,最后回车
What is your project named? my-next-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to usesrc/
directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias (@/)? No / Yes
What import alias would you like configured? @/注意:选择使用项目根目录中的
src
目录将应用程序代码与配置文件分开。这和我选择的方式是一致的
2. 项目结构
下面将介绍我们主要关注的几个目录
-
顶级目录文件夹
public
服务的静态资产 src
应用程序源文件夹,在这个文件夹下编写应用代码 -
src
文件夹- 在
src
文件夹中的app
目录就是我们选择的App Router
,在app文件夹创建文件夹及相关文件将对应相应的路由,后面将详细说明 - 在
src
下,按照习惯,- 创建
components
文件夹,用于放置自定义的组件 - 创建
styles
文件夹,用于放置样式文件,当前使用的是CSS in JS方式 - 创建
lib
文件夹,用于放置自定义的方法工具等
······
- 创建
- 在
三、构建应用程序
推荐使用路由器==(App Router )方式==
Next.js使用基于文件系统的路由器。App Router概览
路由目录对应的文件规则:
文件名(后缀.js .jsx .tsx) 描述 layout
路由及其子路由的共享UI page
路由的唯一UI并使路由可公开访问 loading
路由加载及其子路由加载的UI not-found
找不到路由及其子路由的UI error
Error UI for a segment and its children段及其子段的错误UI global-error
全局错误UI,在app(根)目录下 route
服务器端API端点 template
专门的重新渲染布局UI default
并行路由的回退UI
I. 定义路由
Next.js使用基于文件系统的路由器,其中文件夹用于定义路由。
每个文件夹代表一个映射到URL段的路由段。要创建嵌套路由,可以将文件夹嵌套在彼此内部。
特殊的page.js
文件用于使路由可公开访问。(主要后缀js本文用tsx)
例如,要创建第一个页面,在src/app
目录下添加page.tsx文件,并导出React组件:
export default function Page() {
return <h1>Hello, Next.js!</h1>
}
执行命令npm run dev
,访问:http://localhost:3000/
,页面如下:
II. 页面和布局
Next.js 13中的App Router引入了新的文件约定,可以轻松创建页面、共享布局和模板。本篇将指导您如何在Next.js应用程序中使用这些特殊文件。
-
页面
页面是路由所特有的UI。可以通过从
page.tsx
文件导出组件来定义页面。使用嵌套文件夹定义路由和page.js
文件以使路由可公开访问上一节,我们已经在
src/app
下添加了page.tsx
文件作为首页,我们更新这个文件:// `app/page.tsx` is the UI for the `/` URL export default function Page() { return <h1>Hello, Home page!</h1> }
接下来我们将在
src/app
下添加dashboard
目录,并且在这个新增目录下添加page.tsx
// `app/dashboard/page.tsx` is the UI for the `/dashboard` URL export default function Page() { return <h1>Hello, Dashboard Page!</h1> }
当我们访问对应路由
/
或/dashboard
的时候,就会分别展示对应的page/tsx
中的UI,对应目录和路由如下:总结:要使路由可公开访问,需要使用
page.js
文件。 -
布局
布局是在多个页面之间共享的UI。在导航时,布局将保留状态,保持交互性,并且不会重新呈现。布局也可以==嵌套==。
我们可以通过从
layout.js
文件默认(default)导出React组件来定义布局。该组件应该接受一个children
prop,该prop将在呈现过程中填充子布局(如果存在)或子页面。
最顶层的布局称为根布局,即app
目录下的layout.tsx
。该文件是必须存在的,且在应用程序中的所有页面之间共享。根布局必须包含html
和body
标签。
app/layout.tsx
根布局如下(也可以自定义):export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ) }
为了演示这个效果,我们单独封装了个简单的共享组件,当然后面也会详细说明在Next中的路由跳转。
首先,在
src/components
下新建文件夹links
并在目录下创建文件index.tsx
:'use client' import { usePathname } from 'next/navigation' import Link from 'next/link' type Props = { linkList: string[] } export function Links({ linkList }: Props) { const pathname = usePathname() return ( <nav> <ul style={{ display: 'flex', listStyle: 'none' }}> {linkList.map((link:string) => { return ( <li key={link} style={{ margin: '0 20px' }}> <Link className={`${pathname === link ? 'active' : ''}`} href={link === 'home' ? '/' : '/' + link}> {link?.toUpperCase()} </Link> </li> ) })} </ul> </nav> ) }
接下来,我们将按如下目录创建文件:
在
src/app/dashboard
下创建layout.tsx
文件:import { Links } from '../../components/links' export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( <section> {/* Include shared UI here e.g. a header or sidebar */} <Links linkList={['dashboard', 'dashboard/settings']} /> {children} </section> ) }
在
src/app/dashboard/settings
下创建page.tsx
文件:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传export default function Page() { return <h1>settings</h1> }
效果如下:
嵌套布局,在文件夹(例如
app/dashboard/layout.js
)中定义的布局适用于特定的路由(例如acme.com/dashboard
),并在这些路由处于活动状态时进行渲染。默认情况下,文件层次结构中的布局是嵌套的,这意味着它们通过其children
属性包装子布局。
-
模板Templates(目前先简单了解)
模板类似于布局,因为它们包装每个子布局或页面。与跨路径持久化并保持状态的布局不同,模板在导航上为其每个子项创建一个新实例。这意味着,当用户在共享模板的路由之间导航时,将挂载组件的新实例,重新创建DOM元素,不保留状态,并重新同步效果。
在某些情况下,您可能需要这些特定的行为,而模板将是比布局更合适的选择。例如:
- 依赖于
useEffect
(例如记录页面浏览量)和useState
(例如每页反馈表单)的功能。 - 更改默认框架行为。例如,布局内的Suspense Bouncement仅在首次加载布局时显示回退,而在切换页面时不显示。对于模板,回退显示在每个导航中。
模板可以通过从
template.js
文件导出默认的React组件来定义。该组件应接受children
prop。如src/app/template.tsx
:export default function Template({ children }: { children: React.ReactNode }) { return <div>{children}</div> }
- 依赖于
-
修改
<head>
在
app
目录中,您可以使用内置的SEO支持修改<head>
HTML元素,例如title
和meta
。元数据即
html
文件中head
标签下的内容,可以在layout.tsx
或page.tsx
中导出metadata
对象或generateMetadata
函数来定义,如src/app/page.tsx
:import { Metadata } from 'next' export const metadata: Metadata = { title: 'Next.js', } export default function Page() { return '...' }
然后访问路由
/
时,标签页的名就会变为Next.js
III. 链接和导航
在Next.js中有两种方法可以在路由之间导航:
- 使用
Link
组件- 使用
useRouter
Hook
-
<Link>
组件<Link>
是一个内置组件,用于扩展 HTML<a>
标记以提供路由之间的预取和客户端导航。这是在 Next.js 中的路由之间导航的主要方式。可以通过从
next/link
导入<Link>
并将href
传递给组件来使用它,使用例子,如前面添加的
src/components/index.tsx
,href
属性传入跳转的对应路由,此外还可以以对象方式传入,Link
组件具体使用'use client' import { usePathname } from 'next/navigation' import Link from 'next/link' type Props = { linkList: string[] } export function Links({ linkList }: Props) { const pathname = usePathname() return ( <nav> <ul style={{ display: 'flex', listStyle: 'none' }}> {linkList.map((link:string) => { return ( <li key={link} style={{ margin: '0 20px' }}> <Link className={`${pathname === link ? 'active' : ''}`} href={link === 'home' ? '/' : '/' + link}> {link?.toUpperCase()} </Link> </li> ) })} </ul> </nav> ) }
-
useRouter()
勾子此钩子只能在客户端组件中使用,并从
next/navigation
导入。'use client' import { useRouter } from 'next/navigation' export default function Page() { const router = useRouter() return ( <button type="button" onClick={() => router.push('/dashboard')}> Dashboard </button> ) }
有关
useRouter
方法的完整列表,请参阅API参考。
IV. 路由分组
在
app
目录中,嵌套文件夹通常映射到 URL 路径。但是,您可以将文件夹标记为路由组,以防止该文件夹包含在路由的 URL 路径中。这允许您将路由段和项目文件组织到逻辑组中,而不会影响 URL 路径结构。
-
路由分组的作用
-
将路线组织成组,例如按站点部分、意图或团队。
-
在同一路线段级别启用嵌套布局
- 在同一区段中创建多个嵌套布局,包括多个根布局
- 将布局添加到公共段中的路由子集
-
-
路由分组的使用:
可以通过将文件夹名称括在括号中来创建路由组:
(folderName)
-
在不影响 URL 路径的情况下组织路由
要在不影响 URL 的情况下组织路由,请创建一个组以将相关路由保持在一起。括号中的文件夹将从 URL 中省略(例如或(marketing)
(shop)
)。同时,即使路由内部
(marketing)
和(shop)
共享相同的 URL 层次结构,您也可以通过在文件夹内添加layout.js
文件来为每个组创建不同的布局。 -
创建多个根布局
要创建多个根布局,请移除顶级文件,然后在每个路由组内添加一个
layout.js
layout.js
文件。这对于将应用程序划分为具有完全不同的 UI 或体验的部分非常有用。<html>
需要将 和<body>
标记添加到每个根布局中。
V. 动态路由
如果您提前不知道确切的路由名称,并且想要根据动态数据创建路由,则可以使用在请求时填充或在构建时预呈现的动态路由。
可以通过将文件夹的名称括在方括号中来创建动态路由: [folderName]
。例如, [id]
或 [slug]
。
动态路由作为 params
prop
传递给 、 layout
route
、 page
和 generateMetadata
函数。
例如src/app/blog/[id]/page.tsx
:
export default function Page({ params }: { params: { id: string } }) {
return <div>My Post: {params.id}</div>
}
路由 | 示例网址 | params |
---|---|---|
app/blog/[id]/page.js | /blog/a | { id: 'a' } |
app/blog/[id]/page.js | /blog/b | { id: 'b' } |
app/blog/[id]/page.js | /blog/c | { id: 'c' } |
generateStaticParams
函数可与动态路由段结合使用,以在构建时静态生成路由,而不是在请求时按需生成路由。
例如src/app/blog/[id]/page.tsx
:
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.map((post) => ({
id: post.id,
}))
}
最简单的动态路由案例(博客)实现,步骤:
-
首先引入我们需要使用的样式文件,在
src/styles/utils.module.css
中写入代码:.heading2Xl { font-size: 2.5rem; line-height: 1.2; font-weight: 800; letter-spacing: -0.05rem; margin: 1rem 0; } .headingXl { font-size: 2rem; line-height: 1.3; font-weight: 800; letter-spacing: -0.05rem; margin: 1rem 0; } .headingLg { font-size: 1.5rem; line-height: 1.4; margin: 1rem 0; } .headingMd { font-size: 1.2rem; line-height: 1.5; } .borderCircle { border-radius: 9999px; } .colorInherit { color: inherit; } .padding1px { padding-top: 1px; } .list { list-style: none; padding: 0; margin: 0; } .listItem { margin: 0 0 1.25rem; } .lightText { color: #999; }
-
在
src/posts
文件夹下,准备两个Markdown文件-
pre-rendering.md
--- title: 'Two Forms of Pre-rendering' date: '2020-01-01' --- Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page. - **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request. - **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**. Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others.
-
ssg-ssr.md
--- title: 'When to Use Static Generation v.s. Server-side Rendering' date: '2020-01-02' --- We recommend using **Static Generation** (with and without data) whenever possible because your page can be built once and served by CDN, which makes it much faster than having a server render the page on every request. You can use Static Generation for many types of pages, including: - Marketing pages - Blog posts - E-commerce product listings - Help and documentation You should ask yourself: "Can I pre-render this page **ahead** of a user's request?" If the answer is yes, then you should choose Static Generation. On the other hand, Static Generation is **not** a good idea if you cannot pre-render a page ahead of a user's request. Maybe your page shows frequently updated data, and the page content changes on every request. In that case, you can use **Server-Side Rendering**. It will be slower, but the pre-rendered page will always be up-to-date. Or you can skip pre-rendering and use client-side JavaScript to populate data.
-
-
安装三个包
npm i gray-matter remark remark-html
-
在
src/lib/posts.ts
中,编写要用到的代码import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { remark } from 'remark' import html from 'remark-html' const postsDirectory = path.join(process.cwd(), 'src/posts') // 获取排序后的blog列表 export function getSortedPostsData() { // Get file names under /posts const fileNames = fs.readdirSync(postsDirectory) const allPostsData = fileNames.map(fileName => { // Remove ".md" from file name to get id const id = fileName.replace(/\.md$/, '') // Read markdown file as string const fullPath = path.join(postsDirectory, fileName) const fileContents = fs.readFileSync(fullPath, 'utf8') // Use gray-matter to parse the post metadata section const matterResult = matter(fileContents) // Combine the data with the id return { id, ...matterResult.data } }) return new Promise(function (resolve, reject) { //做一些异步操作 setTimeout(function () { resolve( // Sort posts by date allPostsData.sort((a: any, b: any) => { if (a.date < b.date) { return 1 } else { return -1 } }) ) }, 1000) }) } // 获取所有动态路由 export function getAllPostIds() { const fileNames = fs.readdirSync(postsDirectory) // Returns an array that looks like this: // [ // { // params: { // id: 'ssg-ssr' // } // }, // { // params: { // id: 'pre-rendering' // } // } // ] return new Promise(function (resolve, reject) { //做一些异步操作 setTimeout(function () { resolve( fileNames.map(fileName => { return { params: { id: fileName.replace(/\.md$/, '') } } }) ) }, 1000) }) } // 根据blog的ID获取博客内容 export async function getPostData(id: string) { const fullPath = path.join(postsDirectory, `${id}.md`) const fileContents = fs.readFileSync(fullPath, 'utf8') // Use gray-matter to parse the post metadata section const matterResult = matter(fileContents) console.log('matterResult', matterResult) // Use remark to convert markdown into HTML string const processedContent = await remark().use(html).process(matterResult.content) const contentHtml = processedContent.toString() // Combine the data with the id and contentHtml return { id, contentHtml, ...matterResult.data } }
-
在
src/app/blogs
目录下新建page.tsx
文件import { getSortedPostsData } from '@/lib/posts' import utilStyles from '../../styles/utils.module.css' import Link from 'next/link' export default async function Page() { // 获取按日期排序好的博客大纲 const allPostsData: any = await getSortedPostsData() // console.log('allPostsData', allPostsData) return ( <section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}> <h2 className={utilStyles.headingLg}>Blogs</h2> <ul className={utilStyles.list}> {/* 渲染博客列表 */} {allPostsData.map(({ id, date, title }: { id: string; date: string; title: string }) => ( <li className={utilStyles.listItem} key={id}> <Link href={`/blogs/${id}`}>{title}</Link> <br /> <small className={utilStyles.lightText}>{date}</small> </li> ))} </ul> </section> ) }
接下来,访问:http://localhost:3000/blogs
你将看到如下页面
-
在
src/app/blogs/[id]
目录下新建page.tsx
文件import Head from 'next/head' import { getAllPostIds, getPostData } from '../../../lib/posts' import utilStyles from '../../../styles/utils.module.css' import Link from 'next/link' // params中的属性对应文件夹[id] type pathProps = [{ params: { id: string } }] // generateStaticParams函数可以与动态路由段结合使用,以便在构建时静态生成路由,而不是在请求时按需生成路由。 // 若是无generateStaticParams函数不影响动态路由使用 // 静态生成的params参数数组,用于构建动态路由 export async function generateStaticParams() { const paths = (await getAllPostIds()) as pathProps console.log('paths', paths) return paths } type pageParams = { params: { // 此处id对应动态路由文件夹 [id], 若是[slug]文件夹应该是 slug:string id: string } // 此处的searchParams对应浏览器的query参数,即?username=xzq&age=18这种 searchParams: {} } // 页面(默认导出),根据对应的动态路由渲染页面 export default async function Page({ params }: pageParams) { const postData: any = await getPostData(params.id) return ( <> <Head> <title>{postData.id}</title> </Head> <article> <h1 className={utilStyles.headingXl}>{postData?.id}</h1> <div className={utilStyles.lightText}>{postData?.date}</div> <div dangerouslySetInnerHTML={{ __html: postData?.contentHtml }} /> </article> <Link style={{ position: 'absolute', marginTop: 100 }} href={`/blogs`}> back blogs </Link> </> ) }
接下来,访问:http://localhost:3000/blogs/ssg-ssr
你就看到如下页面:
-
最终案例效果如下:
此外,还有两种动态路由,详情见Next官网
VI. 加载UI
特殊文件
loading.js
可帮助您使用 React Suspense 创建有意义的加载 UI。使用此约定,您可以在加载路由段的内容时显示来自服务器的即时加载状态。渲染完成后,新内容将自动交换。
-
立即加载状态
即时加载状态是导航时立即显示的回调 UI。您可以预渲染加载指示器,例如骨架和微调器,或者未来屏幕的一小部分但有意义的部分,例如封面照片、标题等。这有助于用户了解应用正在响应,并提供更好的用户体验。
通过在文件夹中添加
loading.js
文件来创建加载状态。在
src/app/dashboard
下新建文件loading.tsx
export default function Loading() { // You can add any UI inside Loading, including a Skeleton. return <>加载中...</> }
在同一个文件夹中,
loading.js
将嵌套在layout.js
.它会自动将page.js
文件和下面的任何子项包装在<Suspense>
边界中。 -
流式处理相关,见Next官网
VII. 错误处理
普通错误
文件 error.js
约定允许您正常处理嵌套路由中的意外运行时错误。
- 自动将路由段及其嵌套子级包装在 React 错误边界中。
- 使用文件系统层次结构创建针对特定段定制的错误 UI,以调整粒度。
- 将错误隔离到受影响的段,同时保持应用程序的其余部分正常运行。
- 添加功能以尝试从错误中恢复,而无需重新加载整个页面。
通过在路由段中添加 error.js
文件并导出 React 组件来创建错误 UI:
在src/app/dashboard
下新建文件error.tsx
'use client' // Error components must be Client Components
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
)
}
注意:错误处理组件必须是一个客户端组件
error.tsx
的工作原理
error.js
自动创建一个 React 错误边界,用于包装嵌套的子段或page.js
组件。- 从
error.js
文件导出的 React 组件用作回退组件。 - 如果在错误边界内引发错误,则会包含该错误,并呈现回退组件。
- 当回退错误组件处于活动状态时,错误边界上方的布局将保持其状态并保持交互性,并且错误组件可以显示从错误中恢复的功能。
处理嵌套路由错误
通过特殊文件创建的 React 组件呈现在特定的嵌套层次结构中。
例如,具有两个包含 layout.js
和 error.js
文件的段的嵌套路由在以下简化的组件层次结构中呈现:
嵌套组件层次结构对嵌套路由中的 error.js
文件行为有影响:
- 错误冒泡到最近的父错误边界。这意味着
error.js
文件将处理其所有嵌套子段的错误。通过将文件放置在error.js
路由的嵌套文件夹中的不同级别,可以实现或多或少的粒度错误 UI。 - 错误处理
error.js
不会处理同一段中layout.js
组件中引发的错误,因为错误边界error.js
嵌套在该布局layout.js
的中。
处理布局中的错误
error.js
边界不会捕获抛出 layout.js
的错误或 template.js
同一段的组件。这种有意的层次结构使在发生错误时在同级路由(如导航)之间共享的重要 UI 可见且正常运行。
要处理特定布局或模板中的错误,请将 error.js
文件放在布局父段中。
处理根布局中的错误
根错误边界 app/error.js
不会捕获根布局app/layout.js
或模板 app/template.js
组件中引发的错误。
要处理根布局或模板中的错误,请使用命名的 error.js
变体:global-error.js
。
与根错误边界error.js
不同, global-error.js
错误边界包装整个应用程序,其回退组件在活动时替换根 error.js
布局。因此,重要的是要注意必须 global-error.js
定义自己的 <html>
和 <body>
标签。
global-error.js
是最精细的错误 UI,可被视为整个应用程序的“全部捕获”错误处理。它不太可能经常触发,因为根组件通常不太动态,其他 error.js
边界将捕获大多数错误。
即使定义了 , global-error.js
仍建议定义一个根,其回退组件将在根 error.js
布局中呈现,其中包括全局共享的 UI 和品牌。
src/app/global-error.tsx
如下
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
处理服务器错误
如果在服务器组件中抛出错误,Next.js 会将一个 Error
对象(在生产中去除敏感错误信息)转发到最近的 error.js
文件作为 error
prop。
保护敏感错误信息,在生产过程中,转发到客户端的 Error
对象仅包含泛型 message
和 digest
属性。这是一种安全预防措施,可避免将错误中包含的潜在敏感详细信息泄露给客户端。
该属性包含有关错误的通用消息,该 message
digest
属性包含自动生成的错误哈希,可用于匹配服务器端日志中的相应错误。
在开发过程中,转发到客户端 Error
的对象将被序列化,并包含原始错误的 , message
以便于调试。
VIII. 并行路由
并行路由允许您在同一布局中同时或有条件地呈现一个或多个页面。对于应用的高度动态部分(例如社交网站上的仪表板和源),并行路由可用于实现复杂的路由模式。
例如,您可以同时呈现团队和分析页面。
并行路由允许您为每个路由定义独立的错误和加载状态,因为它们正在独立流式传输。
并行路由还允许您根据特定条件(如身份验证状态)有条件地呈现槽。这将在同一 URL 上启用完全分离的代码。
并行路由使用规则
并行路由是使用命名槽创建的。插槽是按照 @folder
约定定义的,并作为props
传递到同一级别的布局。
槽不是路由段,不会影响 URL 结构。可在路由
/members
访问文件路径/@team/members
。
例如,以下文件结构定义了两个显式插槽: @analytics
和 @team
。
上面的文件夹结构意味着 app/layout.js
组件中现在接受 @analytics
和 @team
插槽 props
,并且可以将它们与 children
并行渲染:
src/app/layout.tsx
export default function RootLayout({
children,
team,
analytics
}: {
children: React.ReactNode
team: React.ReactNode
analytics: React.ReactNode
}) {
return (
<html lang="en">
<body>
<>
{children}
{team}
{analytics}
</>
</body>
</html>
)
}
提示: children
道具是一个隐式插槽,不需要映射到文件夹。这意味着 app/page.js
等效于 app/@children/page.js
。
在src/app/@team/page.tsx
中
export default function Page() {
return <h1>Parallel Route Team </h1>
}
src/app/@analytics/page.tsx
类似
最终我们访问:http://localhost:3000/
未被匹配的路由
==默认情况下,==插槽内渲染的内容将与当前 URL 匹配。
对于不匹配的插槽,Next.js 呈现的内容因路由技术和文件夹结构而异。
default.tsx
您可以定义一个文件default.tsx
,以便在 Next.js 无法根据当前 URL 恢复槽的活动状态时渲染。
- 在导航时,Next.js 将呈现槽以前处于活动状态的状态,即使它与当前 URL 不匹配。
- 重新加载时,Next.js 将首先尝试渲染不匹配的插槽的
default.tsx
的文件。如果default.tsx
不存在,则呈现 404。
用于不匹配路由的 404 有助于确保您不会意外渲染不应并行渲染的路由。
useSelectedLayoutSegment(s)
useSelectedLayoutSegment
和 useSelectedLayoutSegments
接受 parallelRoutesKey
,这允许您读取该插槽内的活动路由段,不包括并行路由内部的路由。
为了更好的演示,并行路由的以上情况,我们实现了一个案例来更好的解释,尤其是在未被匹配路由的情况下,以及重新加载时**default.tsx
**的效果。(注意本操作,基于前面的项目)
-
首先新建两个并行路由目录
src/app/@team
和src/app/@analytics
在
@team
目录下-
新建
page.tsx
export default function Page() { return <h1>Parallel Route Team </h1> }
-
新建
default.js
export default function Page() { return ( <h1> Parallel Route Team <span style={{ color: 'red' }}>Default</span> </h1> ) }
-
新建目录
settings
,在这个目录下新建page.tsx
export default function Page() { return <h1>Parallel Route Team Settings </h1> }
在
@analytics
目录下-
新建
page.tsx
export default function Page() { return <h1>Parallel Route Analytics </h1> }
-
新建
default.js
export default function Page() { return ( <h1> Parallel Route Analytics <span style={{ color: 'yellow' }}>Default</span> </h1> ) }
-
-
然后更新
src/app/page.tsx
和src/app/layout.tsx
-
src/app/page.tsx
import { Links } from '@/components/links' import { Metadata } from 'next' export const metadata: Metadata = { title: 'Next.js' } // `app/page.tsx` is the UI for the `/` URL export default function Page() { return ( <> <Links linkList={['dashboard', 'settings']} /> </> ) }
-
src/app/layout.tsx
'use client' import Link from 'next/link' import { useSelectedLayoutSegment, useSelectedLayoutSegments } from 'next/navigation' export default function RootLayout({ children, team, analytics }: { children: React.ReactNode team: React.ReactNode analytics: React.ReactNode }) { const allSegments = useSelectedLayoutSegments() console.log('allSegments', allSegments) return ( <html lang="en"> <body> <> {children} {team} {analytics} <Link style={{ position: 'absolute', marginTop: 100 }} href={`/`}> back index </Link> </> </body> </html> ) }
-
-
新建
src/app/default.tsx
export default function Page() { return ( <h1> App <span style={{ color: 'blue' }}>Default</span>{' '} </h1> ) }
完成好的目录结构如下
启动项目访问:http://localhost:3000/,注意观察路由、重新加载页面已经控制台信息,结合上面提到的情况
最终效果如下
可以看到,从首页导航进入/dashboard
,页面渲染还是原来的并行路由,接着重新加载(刷新)页面,两个并行路由,分别渲染了对应的default.tsx
;当从首页导航进入/settings
时,其实这里访问的是/@team/settings
的文件,页面渲染还是原来的并行路由,接着重新加载(刷新)页面,重新加载页面,因为当前路由是在/settings
所以第一个并行路由渲染的是src/app/@team/settings/page.tsx
,第二个并行路由则渲染自己的default.tsx
,此外原来路由对应渲染的children
,路由发生变化时,重新加载,也会使用自身的default.tsx
注意:前面说到的404页面,在当前项目下,当你将
/@team/default.tsx
删除后,进入/dashboard
,刷新页面,因为此时有一个并行路由找不到对应的default.tsx
,所以会渲染404页面,这个可以选择去尝试一下
另有关登录Modal模态框及条件路由的相关使用见Next官网
IX. 拦截路由
拦截路由允许您从当前布局中应用程序的另一部分加载路由。当您希望在用户不切换到其他上下文的情况下显示路由的内容时,此路由范式非常有用。
例如,单击源中的照片时,可以以Modal模态框显示照片,覆盖源。在这种情况下,Next.js 会截获 /photo/123
路由,屏蔽 URL,并将其覆盖 /feed
在 上。
但是,当通过单击可共享的 URL 或刷新页面导航到照片时,应呈现整个照片页面而不是模式。不应发生路由拦截。
拦截路由的定义方式
拦截路由可以使用指定规则定义,该规则类似于相对路径 (..)
约定 ../
,但适用于路由。
可以使用:
(.)
匹配同一级别的路由段(..)
匹配上一级的路由段(..)(..)
匹配上两级的路由(...)
匹配根app
目录中的路由段
接下来,我们实现一个Modal框的小案例(建议将前面的记录用commit
提交,后续方便回滚查看),我们将用到动态路由、并行路由和拦截路由的相关知识
首先,添加src/lib/photos.ts
,代码如下
import { StaticImageData } from 'next/image'
import imgTemp from '@/assets/images/opengraph-image.jpg'
export type Photo = {
id: string
name: string
href: string
username: string
imageSrc: StaticImageData
}
const photos: Photo[] = [
{
id: '1',
name: 'Kevin Canlas',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp,
username: '@kvncnls'
},
{
id: '2',
name: 'Pedro Duarte',
username: '@peduarte',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '3',
name: 'Ahmad Awais',
username: '@MrAhmadAwais',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '4',
name: 'Leandro Soengas',
username: '@lsoengas',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '5',
name: 'Samina',
username: '@saminacodes',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '6',
name: 'lafond.eth',
username: '@laf0nd',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '7',
name: '山岸和利💛',
username: '@ykzts',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '8',
name: 'Altngelo',
username: '@AfterDarkAngelo',
href: 'https://wallhaven.cc/w/gp1j9l',
imageSrc: imgTemp
},
{
id: '9',
name: 'Matias Baldanza',
href: 'https://twitter.com/matiasbaldanza/status/1404834163203715073',
username: '@matiasbaldanza',
imageSrc: imgTemp
}
]
export default photos
新建src/assets/images
文件夹,并放入一张自己喜欢的图片,我们这里命名为opengraph-image.jpg
,此外我们将src/styles
移动到src/assets
中完善项目文件结构,其中的globals.css
如下
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans,
Helvetica Neue, sans-serif;
line-height: 1.6;
font-size: 18px;
}
* {
box-sizing: border-box;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
display: block;
}
新增两个组件在src/components
-
modal/Modal.tsx
'use client' import { useCallback, useRef, useEffect, MouseEventHandler } from 'react' import { useRouter } from 'next/navigation' export default function Modal({ children }: { children: React.ReactNode }) { const overlay = useRef(null) const wrapper = useRef(null) const router = useRouter() const onDismiss = useCallback(() => { router.back() }, [router]) const onClick: MouseEventHandler = useCallback( e => { if (e.target === overlay.current || e.target === wrapper.current) { if (onDismiss) onDismiss() } }, [onDismiss, overlay, wrapper] ) const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') onDismiss() }, [onDismiss] ) useEffect(() => { document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [onKeyDown]) return ( <div ref={overlay} className="fixed z-10 left-0 right-0 top-0 bottom-0 mx-auto bg-black/60" onClick={onClick}> <div ref={wrapper} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full sm:w-10/12 md:w-8/12 lg:w-1/3 p-6" > {children} </div> </div> ) }
-
frame/Frame.tsx
import Image from 'next/image' import { Photo } from '../../lib/photos' export default function Frame({ photo }: { photo: Photo }) { return ( <> <Image alt="" src={photo.imageSrc} height={600} width={600} className="w-full object-cover aspect-square col-span-2" /> <div className="bg-white p-4 px-6"> <h3>{photo.name}</h3> <p>Taken by {photo.username}</p> </div> </> ) }
更新src/app/page.tsx
import Link from 'next/link'
import swagPhotos from '../lib/photos'
import Image from 'next/image'
export default function Home() {
const photos = swagPhotos
return (
<main className="container mx-auto">
<h1 className="text-center text-4xl font-bold m-10">Parallel routing and route interception achieve Modal</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 auto-rows-max gap-6 m-10">
{photos.map(({ id, imageSrc }: { id: string; imageSrc: any }) => (
<Link key={id} href={`/photos/${id}`}>
<Image alt="" src={imageSrc} height={500} width={500} className="w-full object-cover aspect-square" />
</Link>
))}
</div>
</main>
)
}
更新src/app/layout.tsx
'use client'
import '@/assets/styles/globals.css'
import Link from 'next/link'
import { useSelectedLayoutSegment, useSelectedLayoutSegments } from 'next/navigation'
export default function RootLayout({
children,
team,
analytics,
modal
}: {
children: React.ReactNode
team: React.ReactNode
analytics: React.ReactNode
modal: React.ReactNode
}) {
const allSegments = useSelectedLayoutSegments()
console.log('allSegments', allSegments)
return (
<html lang="en">
<body>
<>
{children}
{modal}
<Link style={{ position: 'absolute', marginTop: 100 }} href={`/`}>
back index
</Link>
</>
</body>
</html>
)
}
更新src/app/default.tsx
// app default
export default function Page() {
return null
}
新建文件夹src/app/@modal
-
该文件夹下新建
default.tsx
,和app/default.tsx
返回null
防止在并行路由刷新页面404的情况 -
该文件夹下新建
(.)photos/[id]/page.tsx
import Frame from '../../../../components/frame/Frame' import Modal from '../../../../components/modal/Modal' import swagPhotos, { Photo } from '../../../../lib/photos' export default function PhotoModal({ params: { id: photoId } }: { params: { id: string } }) { const photos = swagPhotos const photo: Photo = photos.find(p => p.id === photoId)! return ( <Modal> <Frame photo={photo} /> </Modal> ) }
新建src/app/photos/[id]/page.tsx
,该动态路由的作用是当对应的路由被拦截后,刷新页面展示到这个路由页面
import Frame from '../../../components/frame/Frame'
import swagPhotos, { Photo } from '../../../lib/photos'
export default function PhotoPage({ params: { id } }: { params: { id: string } }) {
const photo: Photo = swagPhotos.find(p => p.id === id)!
return (
<div className="container mx-auto my-10">
<div className="w-1/2 mx-auto border border-gray-700">
<Frame photo={photo} />
</div>
</div>
)
}
新建src/app/photos/default.tsx
,和前面的一样返回null
就行
最终的效果