Next.js 实战笔记 2.0:深入 App Router 高阶特性与布局解构

发布于:2025-07-09 ⋅ 阅读:(20) ⋅ 点赞:(0)

Next.js 实战笔记 2.0:深入 App Router 高阶特性与布局解构

上一篇笔记:

上篇笔记主要回顾了一些 Next12 到 Next15 的一些变化,这里继续学习/复习一些已有或者是新的变化

turbo 的补充

在实际运行的过程当中,我发现使用 yarn dev --turbo 运行,编译并不稳定——不确定是因为我的 Mac 还是 intel 的原因,毕竟现在很多的优化都是针对 M 芯片做的,总之目前还是 fallback 到了默认的开发模式……

其他保留页面

除了 page.jslayout.js 之外,NextJS 还有其他两个保留页面

报错页面

也就是 error.js,大体的实现如下:

"use client";
import React from "react";

const MealsErrorPage = () => {
  return (
    <main className="error">
      <h1>An Error Occurred!</h1>
      <p>Fail to fetch meal data. Please try again later.</p>
    </main>
  );
};

export default MealsErrorPage;

需要注意的是, error.js 必须要使用 use client,因为这个页面即会处理 server end 的异常,也会处理 client end 的异常

它的作用与 layout 类似,在当前/兄弟姐妹/子页面出现异常后,会渲染当前页面

not found

大体实现如下:

import React from "react";

const NotFoundPage = () => {
  return (
    <main className="not-found">
      <h1>Not Found</h1>
      <p>Could not find the page you are looking for.</p>
    </main>
  );
};

export default NotFoundPage;

error.js 类似,不过在组件内调用 notFound(); 也可以重定向到当前页面

表单

其实这部分不完全是 NextJS 的内容,更多的是 React 19 提出的新功能。这里会基于 NextJS 中的实现进行讨论,React 的话,等到 NextJS 的内容过完了后,重新过一遍 React18 和 19 的新特性

提交表单

之前在使用 React 的表单时,提交事件其实不由 action 触发,而是通过 onClick + preventDefault() 可以绕过 action 进行实现。不过目前 NextJS 目前则可以直接通过 action 在 server end 完成表单的提交,并且将表单中有的数据包成 formData 作为参数

下面是一个简单的实现:

export default function ShareMealPage() {
  const shareMeal = async (formData) => {
    // use server must be an async function
    "use server";

    const meal = {
      creator: formData.get("name"),
      creator_email: formData.get("email"),
      title: formData.get("title"),
      summary: formData.get("summary"),
      instructions: formData.get("instructions"),
      image: formData.get("image"),
    };

    console.log(meal);
  };

  return (
    <>
      <header className={classes.header}>
        <h1>
          Share your <span className={classes.highlight}>favorite meal</span>
        </h1>
        <p>Or any other meal you feel needs sharing!</p>
      </header>
      <main className={classes.main}>
        <form className={classes.form} action={shareMeal}></form>
      </main>
    </>
  );
}

服务端输出的结果:

这里需要注意的是,如果组件本身使用了 use client,那么在方法内使用 use server 就会报错……

useFormStatus

这里简单的提一下使用方法,就是一个返回的 pending 可以更灵活的运用

const { pending, data, method, action } = useFormStatus();

具体的使用案例如下:

"use client";

import React from "react";
import { useFormStatus } from "react-dom";

const MealsFormSubmit = () => {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>{pending ? "Submitting" : "Share Meal"}</button>
  );
};

export default MealsFormSubmit;

我这里是单独拆了一个组件出来使用,这个方法和官方提供的使用方法类似:

import { useFormStatus } from "react-dom";
import action from "./actions";

function Submit() {
  const status = useFormStatus();
  return <button disabled={status.pending}>Submit</button>;
}

export default function App() {
  return (
    <form action={action}>
      <Submit />
    </form>
  );
}

具体的操作,React 在内部已经实现了,只要通过 action 进行触发,就可以顺利地监听到表单的状态变化

useFormState

目前 React 官方是把 useFormState 重命名成了 useActionState,并且用法是一样的——除了后者是从 react 中导入,前者是 react-dom 中导入:

In earlier React Canary versions, this API was part of React DOM and called useFormState.

但是我看了下,不知道为啥用 useActionState 会报错,用 useFormState 暂时没问题。介于我用的这个版本,useFormState 还没有被移除,因此暂时就使用了 useFormState

hook 的 signature 如下:

const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);

同理,因为是 hook,所以也需要使用 use client

具体使用方法如下:

"use client";

import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
import { shareMeal } from "@/lib/action";
import MealsFormSubmit from "@/components/meals/meals-form-submit";
import { useFormState } from "react-dom";

export default function ShareMealPage() {
  const [state, formAction] = useFormState(shareMeal, { message: null });
  return (
    <>
      <header className={classes.header}>
        <h1>
          Share your <span className={classes.highlight}>favorite meal</span>
        </h1>
        <p>Or any other meal you feel needs sharing!</p>
      </header>
      <main className={classes.main}>
        <form className={classes.form} action={formAction}>
          <p className={classes.actions}>
            {state.message && <p>{state.message}</p>}
          </p>
        </form>
      </main>
    </>
  );
}

shareMeal 的实现如下:

export const shareMeal = async (prevState, formData) => {
  const meal = {
    creator: formData.get("name"),
    creator_email: formData.get("email"),
    title: formData.get("title"),
    summary: formData.get("summary"),
    instructions: formData.get("instructions"),
    image: formData.get("image"),
  };

  if (
    isInvalidText(meal.title) ||
    isInvalidText(meal.summary) ||
    isInvalidText(meal.instructions) ||
    isValidEmail(meal.creator_email) ||
    isValidEmail(meal.creator) ||
    !meal.creator_email.includes("@") ||
    !meal.image ||
    meal.image.size === 0
  ) {
    return {
      message: "Invalid input",
    };
  }

  await saveMeal(meal);

  redirect("/meals/");
};

这部分其实没什么特别好深入挖掘的,使用方法和官方文档基本一致,属于跟着官方文档实现就好了,大体需要注意的地方有:

  • form 的 action 需要使用 useFormState 返回的第二个值,这样方便 React 进行监听
  • 原本的 action fn 第一个参数需要接受 initialState 作为第一个参数

💡:我个人觉得,将 useFormStateuseFormStatus 封装成一个通用的 custom hook,保证全局的 initialState 一致,这样处理起来可能会更加的高效,也可以更好地减少 boilerplate 代码

缓存

这部分主要是使用 revalidatePath() 这个方法,在进行重定向的时候,去清除 NextJS 中存在的缓存

说实话,这部分的内容可能真的是要多做一点 deploy 之后,才有更多的感觉。目前我有一个小项目是通过 NextJS+github actions 部署到 GH Pages 上的,我只能说似乎是因为 use client 的关系,页面还是会零零碎碎的去 fetch 一些小的 JS 文件。只不过因为页面整体的内容比较少,加载速度还是比较快——大概在 100-200ms 之间,因此目前我还没有花太多的时间和心力去研究 deploy 这部分的内容

dynamic metadata

metadata 的内容在 1.0 中已经提过了,这里讲的是动态的 metadata 的实现方式,主要是通过这个 generateMetadata 的方法自动生成的。 generateMetadata 也是一个保留词,具体使用方法如下:

export const generateMetadata = async ({ params }) => {
  const meal = await getMeal(params.mealSlug);

  return {
    title: meal.title,
    description: meal.summary,
  };
};

路由

这里再多提一些关于路由的内容,更多更完整的内容,还是可以到官方文档: **Project structure and organization** 中去去查找,并且自己测试试验,再根据项目需求判断是否需要

parallel routes

个人感觉,parallel routes 是一个更方便管理子组件的一种实现。官方文档中说了,parallel routes 的实现必须要依赖于 layout.js ,而且 parallel routes,也就是用 @folder 这种语法,会生成独立的 slot,但是不会生成独立的 URL

如下面这个案例:

@archive@latest 会作为两个独立的 slots,可以在 layout.js 中获取,但是它的路径还是在 localhost:3000/archive 下,单独访问 localhost:3000/archive/@archive 或是 localhost:3000/archive/@latest 会报错,因为 NextJS 内部并没有实现对应的路径

具体的排列方式如下:

import React from "react";

const ArchiveLayout = ({ archive, latest }) => {
  return (
    <div>
      <h1>News Archive</h1>
      <section id="archive-filter">{archive}</section>
      <section id="archive-latest">{latest}</section>
    </div>
  );
};

export default ArchiveLayout;

这种情况下, archivelatest 的内容会被并排渲染:

parallel routes + 动态路由

现在总体来说,需求还是比较明确的:

  • archive 显示按照年月分类的文档
  • latest 显示最近的几个文档

按照 NextJS 的结构,那么文档目录就应该是现在这个样子的:

不过这就造成了一个问题:

这是因为,parallel routes 中的路径存在不匹配的情况—— @archive 下有 [year],但是 @latest 下面没有,NextJS 没有办法完美匹配路径,因此就抛出了异常

这种情况下解决方式有两种:

  1. @latest 下也创立对应的 [year] 结构

    缺点就是语意不明确,而且会增加很多无意义的结构

    在当前的业务情况下,@latest 默认只会显示最近的几条数据,并不需要根据 年/月 进行搜索

  2. 使用 default.js

    default.js 是 parallel route 的 fallback 页面,具体实现如下:

    💡 这里的 default.js 中的内容和 page.js 完全一致,因此后期实现中将 page.js 删除了

最终渲染效果如下:

刚开始看到这个 @ 的用法还是不太理解,后面回顾了一下过去做的几个项目,发现这个 slots 还是可以比较好的解决过去项目中,我碰到的几个痛点:

  • 超大表单
    这个在填写付款方法、地址的时候经常碰上,不过我们那时候的业务场景更复杂一些,总体上来说大概会有 6-7 个 steps,每个 steps 的路径一致,但是表单不一样
  • 同一个路径中根据不同条件渲染不同内容

catch all route

其实 NextJS 还是提供了其他的不同实现方法,这个业务场景下,因为只有 年/月 的搜查,其实创建对应的文件夹结构也不是不行,而且对于 NotFound 的支持会更好一些。不过案例中选择用了 catch all route 这个也比较常见实现进行学习

组件部分的实现比较简单:

import NewsList from "@/app/_components/news-list";
import {
  getAvailableNewsMonths,
  getAvailableNewsYears,
  getNewsForYear,
  getNewsForYearAndMonth,
} from "@/app/_lib/news";
import Link from "next/link";
import React from "react";

const FilteredNewsPage = ({ params }) => {
  const filter = params.filter;
  const selectedYear = filter?.[0];
  const selectedMonth = filter?.[1];

  let news;
  let links = getAvailableNewsYears();

  if (selectedYear && !selectedMonth) {
    news = getNewsForYear(selectedYear);
    links = getAvailableNewsMonths(selectedYear);
  } else if (selectedYear && selectedMonth) {
    news = getNewsForYearAndMonth(selectedYear, selectedMonth);
    links = [];
  }

  let newsContent = <p>No news found for the selected period.</p>;

  if (news?.length) {
    newsContent = <NewsList news={news} />;
  }

  return (
    <>
      <header id="archive-header">
        <nav>
          <ul>
            {links.map((link) => {
              const href = selectedYear
                ? `/archive/${selectedYear}/${link}`
                : `/archive/${link}`;

              return (
                <li key={link}>
                  <Link href={href}>{link}</Link>
                </li>
              );
            })}
          </ul>
        </nav>
      </header>
      {newsContent}
    </>
  );
};

export default FilteredNewsPage;

这里需要注意的是 params 的返回值,从字符串变成了数组。这是 catch all 的特性,也就是拦截所有的 params

目录结构如下:

需要注意的是这种情况下, @archive 下的 page.js 就会导致冲突,因为 [[...filter]] 本身就拦截了所有的路径——前面也提到过了

最终效果如下:


网站公告

今日签到

点亮在社区的每一天
去签到