Published on

Nextjs文件目录

Authors
  • avatar
    Name
    游戏人生
    Twitter

Nextjs 路由方案

Nextjs 又两套路由解决方案:

  • Pages Router
  • App Router

从 v13.4 版本起,App Router 已成为默认的路由方案,不在建议使用 Pages Router。

创建 Nextjs 项目

最快捷的创建 Next.js 项目的方式是使用 create-next-app 脚手架:

npx create-next-app@latest
  What is your project named? … next-router-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 use `src/` directory? … No / Yes
  ✔ Would you like to use App Router? (recommended) … No / Yes
  ✔ Would you like to customize the default import alias (@ / *)? … No / Yes

项目创建成功后,目录结构如下:

  next-router-app
    |- app
      |- page.tsx
      |- layout.tsx
      |- globals.css
      |- favicon.ico
    |- public
    |- tailwind.config.js
    |- next.config.js
    |- package.json

目录结构介绍:

  • app: Next.js 的新路由系统,用于创建静态页面、动态路由、SSR 等。
  • public: 用于存放静态资源,如图片、字体等。
  • tailwind.config.js: Tailwind CSS 的配置文件。
  • next.config.js: Next.js 的配置文件。
  • package.json: 项目依赖包管理文件。

文件系统

Nextjs 的路由是根据文件系统自动生成的,所以文件目录结构决定了路由。

在 App Router 模式下,在 app 目录下创建一个名为 about 的文件夹,并在该文件夹下创建一个名为 page.tsx 的文件,那么该页面的路由就是 /about。app 目录下的 page.tsx 对应的路由是 /:

  app
  |─ about
    |─ page.tsx
  |─ page.tsx

在 Pages Router 模式下,pages 目录下的文件,对应的文件名即路由,index.js 对应的路由为根目录 /;about.js 对应的路由为 /about

App Router

定义路由及页面

App Router 的文件目录结构决定了路由,app 目录下的 page.tsx 对应的路由是 /app/personalCenter/page.tsx 对应的路由是 /personalCenter

注意:路由下的页面组件必须名为 page.(js/jsx/tsx)。

  app
  |─ personalCenter
    |─ page.tsx
  |─ page.tsx

定义布局(layout)

布局是用于多个页面共享的组件,会保留状态、保持可交互性,且不会重新渲染如。例如头部、侧边栏、底部等。布局需要命名为 layout.js:

  app
  |─ personalCenter
    |- layout.tsx
    |─ page.tsx
  |─ page.tsx

代码如下:


  export default function CenteredLayout(props: { children: React.ReactNode }) {

    return (
      <>
        <nav>nav</nav>
        <div className="flex min-h-screen items-center justify-center">
          {props.children}
        </div>
      </>
    );
  }

该组件接收一个 children prop,chidren 表示子布局或者子页面。

app 目录下的 layout.js 表示根布局,它会应用于所有的路由。根布局的默认代码如下:

  import type { Metadata } from "next";
  import { Inter } from "next/font/google";
  import "./globals.css";

  const inter = Inter({ subsets: ["latin"] });

  export const metadata: Metadata = {
    title: "Create Next App",
    description: "Generated by create next app",
  };

  export default function RootLayout({
    children,
  }: Readonly<{
    children: React.ReactNode;
  }>) {
    return (
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    );
  }

其特点如下:

  • app 目录必须包含根布局,即 app/layout.js 这个文件是必需的
  • 根布局必须包含 html 和 body标签,其他布局不能包含这些标签
  • 根布局是服务端组件,不能设置为客户端组件
  • 可以基于路由组创建多个根布局

比如,针对已登录和未登录的路由组,创建两个根布局:

  app
  | (auth)
    |- personalCenter
      |- page.tsx
    |- layout.tsx
  | (unauth)
    |- about
      |- page.tsx
    |- layout.tsx
    |- page.tsx
  |─ global-error.tsx

定义模板(template)

模板与布局类似,也会传入每个子页面,但不会维持状态,在路由切换时会为每一个 children 创建一个实例,当在共享模板的路由间进行切换时,将会重新挂载组件实例。

模板需要命名为 template.js:

  app
  |- template.tsx
  |- layout.tsx
  |─ page.tsx

当 layout 与 template 同时存在时,template 会覆盖 layout。类似于

  <Layout>
    {/* 模板需要唯一的 key */}
    <Template key={routeParam}>{children}</Template>
  </Layout>

template 与 layout 对比

layout 不会重新挂载,一直保持状态,template 会重新挂载。因此,当切换共用 template 的路由时,template 会重置组件状态。组件内的变量等都会重置,但 layout 会一直保持不变。

因此,template 可以用于记录页面访问次数,示例如下:

  // template.js
  'use client';

  import Link from "next/link";
  import { useEffect } from "react";

  export default function RootLayout({ children }) {

    useEffect(() => {
      console.log('page view');
    }, []);

    return (
      <div className="p-5">
        <nav className="flex items-center justify-center">
          <Link href="/login">Login</Link>
          <Link href="/about">About</Link>
        </nav>
        {children}
      </div>
    );
  }

当每次切换 Login 或者 About 页面时,控制台都会打印 page view。但是在 layout 中,只会触发最开始的一次。

template 也可以用于更改框架的默认行为,比如 Suspense。当将 Suspense 放到 Layout 中时,只有首次加载页面会显示 Loading,但是当放到 Template 中时,每次切换路由都会显示 Loading。

示例如下:

  'use client'

  import { Suspense, useState, useEffect } from "react";

  function Loading() {
    return <div>Loading...</div>;
  }

  const sleep = timer => new Promise(res => setTimeout(res, timer));

  async function CommonComp() {
    await sleep(1000);
    return <div>Template Loaded</div>;
  }

  export default function Template({ children }) {

    useEffect(() => {
      console.log('page view');
    }, [])

    return (
      <div>
        <Suspense fallback={<Loading />}>
          <CommonComp />
        </Suspense>
        
        {children}
      </div>
    );
  }

定义加载页面(loading)

App Router 也提供了用于展示加载页面的 loading.js,其实现原理借助了 React 的Suspense API。实现的效果即当发生路由变化的时候,立刻展示 fallback UI,等加载完成后,展示真实的页面数据。

使用方法如下:

  // 在 ListPage 组件处于加载阶段时显示 Loading
  <Suspense fallback={<Loading />}>
    <ListPage />
  </Suspense>

其基本原理ListPage 会 throw 一个数据加载的 promise,Suspense 会捕获这个 promise,追加一个 then 函数,then 函数中实现替换 fallback UI 。当数据加载完毕,promise 进入 resolve 状态,then 函数执行,更新替换 fallback UI。

使用 loading.js,loading.js 与 template、layout 在同层级:

  app
  |- template.tsx
  |- layout.tsx
  |- loading.tsx
  |─ page.tsx

loading.js 代码如下:

  export default function Loading() {
    return <>loading...</>;
  }

page.js 代码如下:

  async function getData() {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return {
      message: 'Page Loaded!',
    };
  }

  export default async function RootPage(props) {
    const { message } = await getData();
    return <h1>{ message }</h1>;
  }

刷新页面,即可发现显示 loading...。

使用 loading 组件,也可以不使用 async 函数,改为使用 React 的 use 函数。

  import { use } from 'react';

  async function getData() {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return {
      message: 'Page Loaded!',
    };
  }

  export default function RootPage(props) {
    const { message } = use(getData());
    return <h1>{ message }</h1>;
  }

当同时存在 layout.js、template.js、loading.js 时,它们的层级关系如下:

  <Layout>
    <Template>
      <ErrorBoundry fallback={<Error />}>
        <Suspense fallback={<Loading />}>
          <ErrorBoundry fallback={<NotFound />}>
            <Page />
          </ErrorBoundry>
        </Suspense>
      </ErrorBoundry>
    </Template>
  </Layout>

定义错误处理(Error Handling)

error.js 用于创建发生错误时展示的 UI,其基于 React 的 ErrorBoundry API 实现。

  // ListPage 页面发生错误时展示的 Error UI
  <ErrorBoundry fallback={<Error />}>
    <ListPage />
  </ErrorBoundry>

使用方法如下,在 layout 同层级创建 error.js,内容如下:

  'use client'; // 错误处理组件必须是客户端组件

  import { useEffect } from 'react';
  
  export default function Error({ error, reset }) {
    useEffect(() => {
      console.error(error);
    }, [error]);
  
    return (
      <div>
        <h2>Something went wrong!</h2>
        <button
          onClick={() => reset()}
        >
          重试
        </button>
      </div>
    );
  }

page.js 内容修改为如下,用于触发页面错误:

  "use client";

  import React, { useState } from "react";

  export default function Page() {
    const [error, setError] = useState(false);

    return (
      <>{error ? Error() : <button onClick={() => setError(true)}> Error </button>}</>
    );
  }

当点击 Error 按钮时,页面会展示 Error UI 的内容。

但是,基于层级关系来看,error.js 不能处理同级的layout.js 和 template.js 的错误。只能交给上级的 error.js 来处理。如果是顶层的错误处理,nextjs 提供了 global-error.js。

global-error.js 需要放在 app 目录下,其会包裹整个应用,而且当它触发的时候,它会替换掉根布局的内容。所以,global-error.js 中也要定义 <html><body /> 标签。

内容示例如下:

  'use client';

  import Error from 'next/error';

  export default function GlobalError(props: {
    error: Error & { digest?: string };
    params: { locale: string };
  }) {

    return (
      <html lang={props.params.locale}>
        <body>
          <Error statusCode={undefined as any} />
        </body>
      </html>
    );
  }

定义 404 页面

当访问的路由不存在时,nextjs 会自动展示 not-found.js 即 404 页面。

在 app 目录下新增 not-found.js,内容如下:

  'use client';

  import Link from 'next/link';

  export default function NotFound() {
    return (
      <div>
        <h2>404 Not Found</h2>
        <Link href="/">返回主页</Link>
      </div>
    );
  }

它由两种情况触发:

  • 当组件抛出 notFound 函数的时候
  • 当路由地址不匹配的时候

not-found.js 用于修改默认的 404 页面。但是如果 not-found.js 放到了子文件夹下,它只能由 notFound 函数手动触发。示例如下

  // /personalCenter/page.js
  import { notFound } from 'next/navigation';

  export default function Page() {
    notFound();
    return <></>;
  }

当执行 notFound 函数时,会由最近的 not-found.js 来处理。但如果直接访问不存在的路由,则都是由 app/not-found.js 来处理。

在实际开发中,当请求一个用户的数据时或是请求一篇文章的数据时,如果该数据不存在,就可以直接丢出 notFound 函数,渲染自定义的 not-found.js 界面。

示例如下:

  // app/blog/[id]/page.js
  import { notFound } from 'next/navigation';
  
  async function getUserDetail(id) {
    const res = await fetch('https://url...');
    if (!res.success) return null;
    return res.json();
  }
  
  export default async function Page({ params }) {
    const user = await getUserDetail(params.id);
  
    if (!user) {
      notFound();
    }

    return <>{user?.name}</>;
  }

路由定义方式

动态路由(Dynamic Routes)

[name]

当用中括号包住文件夹的名字时,路由的名字会作为 params prop 传给布局、页面、路由处理程序 以及 generateMetadata 函数。

例如,新建 /user/[id] 文件夹,在文件夹里面创建 page.js,内容如下:

  // app/user/[id]/page.js
  export default function Page({ params }) {
    return <>{params.id}</>;
  }

当访问 /user/1 时,params 参数为 { id: '1' }

[...name]

表示捕获后面所有的路由片段。

例如,新建 /user/[...id] 文件夹,在文件夹里面创建 page.js,内容如下:

  // app/user/[...id]/page.js
  export default function Page({ params }) {
    return <>{params.id}</>;
  }
  • 当访问 /user/1 ,params 的值为 { id: ['1'] }
  • 当访问 /user/1/2 ,params 的值为 { id: ['1', '2'] }

[[...name]]

表示可选的捕获后面所有的路由片段。与 [...name] 类似,但是也会匹配不带参数的路由/user

示例如下,新建 /user/[[...id]] 文件夹,在文件夹里面创建 page.js,内容如下:

  // app/user/[[...id]]/page.js
  export default function Page({ params }) {
    return <>{params.id}</>;
  }
  • 当访问 /user ,params 的值为 { }
  • 当访问 /user/1 ,params 的值为 { id: ['1'] }
  • 当访问 /user/1/2 ,params 的值为 { id: ['1', '2'] }

路由组(Route groups)

当给文件夹名增加括号时,比如(unauth),即将该文件夹标记成路由组,路由组会阻止文件夹名被映射到页面路由中。

可以借助路由组创建多个根布局,创建时,需要删除掉 app/layout.js 文件,然后在每组都创建一个 layout.js 文件。创建的时候要注意,因为是根布局,所以要有 <html><body> 标签。

路由组特点

  • 路由组的命名除了用于组织之外并无特殊意义,不会影响 URL 路径
  • 不同路由组下的路径名不能相同,比如 (unauth)/about/page.js(auth)/about/page.js 都会解析为 /about,会导致报错
  • 创建多个根布局的时候,因为删除了顶层的 app/layout.js 文件,访问 / 会报错,所以 app/page.js 需要定义在其中一个路由组中
  • 跨根布局导航会导致页面完全重新加载,比如使用 app/(unauth)/layout.js 根布局的 /mine 跳转到使用 app/(auth)/layout.js 根布局的 /setting 会导致页面重新加载

注意

当定义多个根布局的时候,使用 app/not-found.js 会出现问题,可参考 Next.js v14 如何为多个根布局自定义不同的 404 页面

平行路由(Parallel Routes)

平行路由可以实现在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽)。使用方式只需要在文件夹名称前增加@。示例如下:

  app
  |- @chart
    |- page.js
  |- @table
    |- page.js
  |- layout.js
  |- page.js

在 layout.js 中,使用对应的组件:

  export default function Layout(props) {
    return (
      <>
        {props.children}
        {props.chart}
        {props.table}
      </>
    );
  }

平行路由也可以与路由组一样,增加子路由,也不影响页面路由地址,比如:

  app
  |- @chart
    |- layout.js
    |- detail
      |- page.js
    |- list
      |- page.js
  |- @table
    |- page.js
  |- layout.js
  |- page.js

/@chart/detail/page.js 对应的地址是 detail/@chart/list/page.js 对应的地址是 /list

平行路由的特点:

  • 使用平行路由可以将单个布局拆分为多个插槽,使代码更易于管理,尤其适用于团队协作的时候
  • 每个插槽都可以定义自己的加载界面和错误状态,比如某个插槽加载速度比较慢,就可以加一个loading效果,加载期间,不会影响其他插槽的渲染和交互。当出现错误的时候,也只会在具体的插槽上出现错误提示,而不会影响页面其他部分,能有效改善用户体验
  • 每个插槽都可以有自己独立的导航和状态管理,这使得插槽的功能更加丰富

default.js

当使用平行路由时,需要在平行路由层内和相同层级之间增加 default.js,因为当页面路由访问/detail时,页面不仅会加载 app/@chart/detail/page.js,也会加载 app/@table/detail/page.jsapp/detail/page.js

但是项目中并没有 app/@table/detail/page.jsapp/detail/page.js 这两个文件,因此 Nextjs 提供了 default.js,当访问页面时,Nextjs 会为不匹配的插槽呈现 default.js 的内容,如果没有定义 default.js,才会渲染 404 错误。

拦截路由

拦截路由允许在当前路由拦截其他路由地址并在当前路由中展示内容。实现的效果类似于从列表页点击详情进入详情页,改为将详情页放到当前页面展示,比如放入弹窗内。

拦截路由需要在文件夹名前添加(..):

  • (.) 表示匹配同一层级
  • (..) 表示匹配上一层级
  • (..)(..) 表示匹配上上层级
  • (...) 表示匹配根目录

匹配的是路由层级而不是文件夹路径的层级,就比如路由组、平行路由这些不会影响页面路径的文件夹就不会被计算层级。

示例:

app
|- layout.js
|- page.js
|- data.js
|- default.js
|- @modal
  |- default.js
  |- (.)photo
    |- [id]
    |-page.js
|- photo
  |- [id]
    |- page.js

app/data.js 代码如下:

export const photos = [
  { id: "1", src: "http://placekitten.com/210/210" },
  { id: "2", src: "http://placekitten.com/330/330" },
  { id: "3", src: "http://placekitten.com/220/220" },
  { id: "4", src: "http://placekitten.com/240/240" },
  { id: "5", src: "http://placekitten.com/250/250" },
  { id: "6", src: "http://placekitten.com/300/300" },
  { id: "7", src: "http://placekitten.com/500/500" },
];

app/page.js代码如下:

import Link from "next/link";
import { photos } from "./data";

export default function Home() {
  return (
    <main className="flex flex-row flex-wrap">
      {photos.map(({ id, src }) => (
        <Link key={id} href={`/photo/${id}`}>
          <img width="200" src={src} className="m-1" />
        </Link>
      ))}
    </main>
  );
}

app/layout.js 代码如下:

import "./globals.css";

export default function Layout({ children, modal }) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

新建 app/photo/[id]/page.js,代码如下:

import { photos } from "../../data";

export default function PhotoPage({ params: { id } }) {
  const photo = photos.find((p) => p.id === id);
  return <img className="block w-1/4 mx-auto mt-10" src={photo.src} />;
}

实现拦截路由,为了和单独访问图片地址时的样式区分,声明另一种样式效果。app/@modal/(.)photo/[id]/page.js 代码如下:

import { photos } from "../../../data";

export default function PhotoModal({ params: { id } }) {
  const photo = photos.find((p) => p.id === id)
  return (
    <div className="flex h-60 justify-center items-center fixed bottom-0 bg-slate-300 w-full">
      <img className="w-52" src={photo.src} />
    </div>
  )
}

用到了平行路由,需要设置 default.js。app/default.jsapp/@modal/default.js 的代码都是:

export default function Default() {
  return null
}

最终的效果,在 / 路由下,访问 /photo/5,即点击图片,路由会被拦截,并使用 @modal/(.)photo/[id]/page.js 的样式。

参考链接

Nextjs 开发指南