Next.js 實戰筆記 2.0:深入 App Router 高階特性與布局解構
上一篇筆記:
- Next.js 實戰筆記 1.0:架構重構與 App Router 核心機制詳解
上篇筆記主要回顧了一些 Next12 到 Next15 的一些變化,這里繼續學習/復習一些已有或者是新的變化
turbo 的補充
在實際運行的過程當中,我發現使用 yarn dev --turbo
運行,編譯并不穩定——不確定是因為我的 Mac 還是 intel 的原因,畢竟現在很多的優化都是針對 M 芯片做的,總之目前還是 fallback 到了默認的開發模式……
其他保留頁面
除了 page.js
和 layout.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
作為第一個參數
💡:我個人覺得,將 useFormState
和 useFormStatus
封裝成一個通用的 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;
這種情況下, archive
和 latest
的內容會被并排渲染:
parallel routes + 動態路由
現在總體來說,需求還是比較明確的:
- archive 顯示按照年月分類的文檔
- latest 顯示最近的幾個文檔
按照 NextJS 的結構,那么文檔目錄就應該是現在這個樣子的:
不過這就造成了一個問題:
這是因為,parallel routes 中的路徑存在不匹配的情況—— @archive
下有 [year]
,但是 @latest
下面沒有,NextJS 沒有辦法完美匹配路徑,因此就拋出了異常
這種情況下解決方式有兩種:
-
@latest
下也創立對應的[year]
結構缺點就是語意不明確,而且會增加很多無意義的結構
在當前的業務情況下,
@latest
默認只會顯示最近的幾條數據,并不需要根據 年/月 進行搜索 -
使用
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]]
本身就攔截了所有的路徑——前面也提到過了
最終效果如下: