这一节,将学习 Next.js 中的路由工作原理以及可以使用的渲染方法,以充分利用 Next.js 的特性。然后,我们将学习如何配置每个页面的布局,使应用程序看起来和感觉像一个单页应用程序。
Next.js 有一个基于文件系统的路由机制,其中每个页面文件代表一个页面。页面存放于 pages
文件夹中,其结构如下:
const Page = () => <div>My page</div>;
export default Page;
由于路由是基于文件系统的,路由将由页面文件的命名方式决定。如,指向根路由的页面应该在 src/pages/index.tsx
文件中定义。如果我们想要 About
页面,我们可以在 src/pages/about.tsx
中定义它。
对于任何具有动态数据的复杂应用程序,仅创建预定义页面是不够的。如,假设有一个社交网络应用程序,可以访问用户个人资料,个人资料应该通过用户 ID 加载。由于为每个用户创建页面文件太过重复,因此需要将页面设置为动态页面,如下所示:
// src/pages/users/[userId].tsx
import { useRouter } from "next/router";
const UserProfile = () => {
const router = useRouter();
const { userId } = router.query;
return <div>User Profile: {userId}</div>;
};
export default UserProfile;
为了动态获取 ID 并加载数据,我们可以在 pages/users/[userId].tsx
中定义一个通用的用户个人资料页面,其中 userId
将被动态地注入到页面中。例如,访问 /users/123
将显示用户个人资料页面,并通过 router.query
将 123
作为 userId
传递。
Next.js 支持四种渲染策略:
比如前面用户个人资料页面的例子,可以按以下方式编写页面以执行客户端渲染:
// src/pages/users/[userId].tsx
import { useRouter } from "next/router";
import { useUser } from "./api";
const UserProfile = () => {
const router = useRouter();
const { userId } = router.query;
const { user, isLoading, isError } = useUser(userId);
if (!user && isLoading) return <div>Loading...</div>;
if (isError) return <div>Error!</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
};
export default UserProfile;
这种方法在不考虑 SEO 和初始页面加载性能时是可以接受的。在这里,我们必须等待初始页面加载完成,然后再获取用户数据。对于不应该公开的数据(例如管理员看板),这种方法完全有效。
但是,对于公开页面,最好启用服务器返回实际的页面以使搜索引擎更容易爬取和索引我们的页面,可以通过在服务器端呈现页面来实现这一点。
// src/pages/users/[userId].tsx
import { getUser } from "./api";
const UserProfile = ({ user }) => {
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
};
export const getServerSideProps = async ({ params }) => {
const userId = params.userId;
const user = await getUser(userId);
return {
props: {
user,
},
};
};
正如我们在这里看到的,除了页面组件,还导出了 getServerSideProps
函数,该函数在服务端执行。它的返回值可以包含 props
属性,这些将传递给组件的 props
。
我们需要记住的是,并没有适用于所有情况的完美渲染策略;因此,必须权衡利弊并根据需求选择使用哪种策略。使用 Next.js 的好处在于它允许我们在每个页面上使用不同的渲染策略,因此我们可以组合它们以最佳方式适应应用程序的需求。
为了优化页面的 SEO,可以添加一些 meta
标签和页面的标题,并将它们注入到页面中。这可以通过 Next.js 提供的 Head
组件来实现。
对于应用程序,最好有一个专门的组件来添加页面的标题,比如创建 src/components/seo/seo.tsx
文件并添加以下内容:
import Head from "next/head";
interface SeoProps {
title: string;
description?: string;
keywords?: string;
}
export const Seo = ({ title, description, keywords }: SeoProps) => {
return (
<Head>
<title>{title}</title>
{description && <meta name="description" content={description} />}
{keywords && <meta name="keywords" content={keywords} />}
</Head>
);
};
然后,可以在页面组件中使用它:
// src/pages/users/[userId].tsx
import { Seo } from "@/components/seo/seo";
const UserProfile = ({ user }) => {
if (!user) return <div>User not found</div>;
return (
<div>
<Seo title={user.name} description={user.bio} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
};
在开发具有多个视图或页面的应用程序时,我们需要考虑布局的可复用性。
可以创建一个 src/components/layout/layout.tsx
文件,其中包含应用程序的布局:
import { ReactNode } from "react";
import Header from "@/components/header/header";
import Footer from "@/components/footer/footer";
interface LayoutProps {
children: ReactNode;
}
export const Layout = ({ children }: LayoutProps) => {
return (
<div>
<Header />
<main>{children}</main>
<Footer />
</div>
);
};
然后,可以在页面组件中使用它:
// src/pages/users/[userId].tsx
import { Layout } from "@/components/layout/layout";
const UserProfile = ({ user }) => {
return (
<Layout>
{user ? (
<>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</>
) : (
<div>User not found</div>
)}
</Layout>
);
};
这种方式处理 Next.js 应用程序中的布局对于一些简单的情况是可以的。然而,它也有一些缺点:
Layout
组件跟踪一些内部状态,当页面更改时会丢失它Layout
中对于我们的应用程序,我们将使用一种更好的方式来处理每个页面的布局,即将它们附加到所有页面组件(即 page component)上。
可以在 src/pages/_app.tsx
实现这一点:
// src/pages/_app.tsx
import { ReactElement, ReactNode } from "react";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { AppProvider } from "@/providers/app";
type NextPageWithLayout = NextPage & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
const App = ({ Component, pageProps }: AppPropsWithLayout) => {
const getLayout = Component.getLayout ?? ((page) => page);
const pageCotent = getLayout(<Component {...pageProps} />);
return <AppProvider>{pageCotent}</AppProvider>;
};
export default App;
页面组件需要附加 getLayout
静态属性,该属性将在 _app.tsx
中渲染组件时用于包装整个组件。得益于 React 的优化,当在具有相同布局的页面之间导航时,所有布局组件状态都将继续保持。
我们已经构建了布局组件,现在只需要将它们添加到我们的页面中即可。
现在我们已经了解了 Next.js 页面的工作原理,并准备好了 Seo
组件和布局设置,接下来让我们实现应用程序的页面。我们将实现以下页面:
// src/pages/organizations/[organizationId]/index.tsx
import { ReactElement } from "react";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { Heading, Stack } from "@chakra-ui/react";
import { NotFound } from "@/components/not-found";
import { Seo } from "@/components/seo/seo";
import { JobsList } from "@/features/jobs";
import type { Job } from "@/features/jobs";
import { OrganizationInfo } from "@/features/organizations";
import { PublicLayout } from "@/layouts/public-layout";
import { getJobs, getOrganization } from "@/testing/test-data";
type PublicOrganizationPageProps = InferGetServerSidePropsType<typeof getServerSideProps>;
const PublicOrganizationPage = ({ organization, jobs }: PublicOrganizationPageProps) => {
if (!organization) {
return <NotFound />;
}
return (
<>
<Seo title={organization.name} />
<Stack spacing="4" w="full" maxW="container.lg" mx="auto" mt="12" p="4">
<OrganizationInfo organization={organization} />
<Heading size="md" my="6">
Open Jobs
</Heading>
<JobsList jobs={jobs} organizationId={organization.id} type="public" />
</Stack>
</>
);
};
PublicOrganizationPage.getLayout = function getLayout(page: ReactElement) {
return <PublicLayout>{page}</PublicLayout>;
};
export default PublicOrganizationPage;
export const getServerSideProps = async ({ params }: GetServerSidePropsContext) => {
const organizationId = params?.organizationId as string;
const [organization, jobs] = await Promise.all([
getOrganization(organizationId).catch(() => null),
getJobs(organizationId).catch(() => [] as Job[]),
]);
return {
props: {
organization,
jobs,
},
};
};
其他代码细节可以在 GitHub (opens new window) 提交查看。
启动服务后,访问 http://localhost:3000/organizations/amYXmIyT9mD9GyO6CCr
,可以看到如下页面:
import { ReactElement } from "react";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { Stack, Button } from "@chakra-ui/react";
import { NotFound } from "@/components/not-found";
import { Seo } from "@/components/seo";
import { PublicJobInfo } from "@/features/jobs";
import { PublicLayout } from "@/layouts/public-layout";
import { getJob, getOrganization } from "@/testing/test-data";
type PublicJobPageProps = InferGetServerSidePropsType<typeof getServerSideProps>;
export const PublicJobPage = ({ organization, job }: PublicJobPageProps) => {
const isInvalid = !job || !organization || job.organizationId !== organization.id;
if (isInvalid) {
return <NotFound />;
}
return (
<>
<Seo title={`${job.position} | ${job.location}`} />
<Stack w="full">
<PublicJobInfo job={job} />
<Button
bg="primary"
color="primaryAccent"
_hover={{
opacity: "0.9",
}}
as="a"
href={`mailto:${organization.email}?subject=Application for ${job.position} position`}
target="_blank"
>
Apply
</Button>
</Stack>
</>
);
};
PublicJobPage.getLayout = (page: ReactElement) => <PublicLayout>{page}</PublicLayout>;
export default PublicJobPage;
export const getServerSideProps = async ({ params }: GetServerSidePropsContext) => {
const organizationId = params?.organizationId as string;
const jobId = params?.jobId as string;
const [organization, job] = await Promise.all([
getOrganization(organizationId).catch(() => null),
getJob(jobId).catch(() => null),
]);
return {
props: {
organization,
job,
},
};
};
访问 http://localhost:3000/organizations/amYXmIyT9mD9GyO6CCr/jobs/wS6UeppUQoiXGTzAI6XrM
,可以看到如下页面:
更多代码细节可以通过 GitHub (opens new window) 提交查看。
一些 SSR 的缺点,主要包括:
getServerSideProps
执行时间可能会阻塞整个应用程序因此,我们只希望在合适的情况下使用 SSR,比如需要对 SEO 友好的公开页面,以及它们的内容可能更频繁变化。
// pages/dashboard/jobs/index.tsx
import { ReactElement } from "react";
import { Heading, HStack } from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons";
import { Link } from "@/components/link";
import { Loading } from "@/components/loading";
import { Seo } from "@/components/seo";
import { JobsList } from "@/features/jobs";
import { DashboardLayout } from "@/layouts/dashboard-layout";
import { useJobs, useUser } from "@/testing/test-data";
const DashboardJobsPage = () => {
const user = useUser();
const jobs = useJobs(user.data?.organizationId ?? "");
if (jobs.isLoading) {
return <Loading />;
}
if (!user.data) return null;
return (
<>
<Seo title="Jobs" />
<HStack mb="8" justify="space-between" align="center">
<Heading>Jobs</Heading>
<Link href={`/dashboard/jobs/create`} icon={<PlusSquareIcon />} variant="solid">
Create Job
</Link>
</HStack>
<JobsList
jobs={jobs.data || []}
isLoading={jobs.isLoading}
organizationId={user.data.organizationId}
type="dashboard"
/>
</>
);
};
DashboardJobsPage.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;
export default DashboardJobsPage;
访问 http://localhost:3000/dashboard/jobs
,可以看到如下页面:
更多代码细节可以通过 GitHub (opens new window) 提交查看。
// pages/dashboard/jobs/[jobId].tsx
import { ReactElement } from "react";
import { useRouter } from "next/router";
import { Loading } from "@/components/loading";
import { NotFound } from "@/components/not-found";
import { Seo } from "@/components/seo";
import { DashboardJobInfo } from "@/features/jobs";
import { DashboardLayout } from "@/layouts/dashboard-layout";
import { useJob } from "@/testing/test-data";
const DashboardJobPage = () => {
const router = useRouter();
const jobId = router.query.jobId as string;
const job = useJob(jobId);
if (job.isLoading) {
return <Loading />;
}
if (!job.data) {
return <NotFound />;
}
return (
<>
<Seo title={`${job.data.position} | ${job.data.location}`} />
<DashboardJobInfo job={job.data} />
</>
);
};
DashboardJobPage.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;
export default DashboardJobPage;
访问 http://localhost:3000/dashboard/jobs/wS6UeppUQoiXGTzAI6XrM
,可以看到如下页面:
更多代码细节可以通过 GitHub (opens new window) 提交查看。
// pages/dashboard/jobs/create.tsx
import { ReactElement } from "react";
import { useRouter } from "next/router";
import { Heading } from "@chakra-ui/react";
import { Seo } from "@/components/seo";
import { CreateJobForm } from "@/features/jobs";
import { DashboardLayout } from "@/layouts/dashboard-layout";
const DashboardCreateJobPage = () => {
const router = useRouter();
const onSuccess = () => {
router.push("/dashboard/jobs");
};
return (
<>
<Seo title="Create Job" />
<Heading mb="8">Create Job</Heading>
<CreateJobForm onSuccess={onSuccess} />
</>
);
};
DashboardCreateJobPage.getLayout = (page: ReactElement) => (
<DashboardLayout>{page}</DashboardLayout>
);
export default DashboardCreateJobPage;
访问 http://localhost:3000/dashboard/jobs/create
,可以看到如下页面:
更多代码细节可以通过 GitHub (opens new window) 提交查看。
// pages/404.tsx
import { Center } from "@chakra-ui/react";
import { Link } from "@/components/link";
import { NotFound } from "@/components/not-found";
const NotFoundPage = () => {
return (
<>
<NotFound />
<Center>
<Link href="/">Go back home</Link>
</Center>
</>
);
};
export default NotFoundPage;
pages
文件夹中的 404.tsx
文件是一个特殊的页面,每当用户访问未知页面时,它就会显示出来。
访问 http://localhost:3000/somepage
,可以看到如下页面:
更多代码细节可以通过 GitHub (opens new window) 提交查看。