NextJs基础
目录
一、创建项目
二、导航和路由
2.1、布局和模板
2.2、usePathname
2.3、链接和导航
2.3.1、动态路由参数
2.3.2、查询参数 (Search Params)
2.5、路由组(Route Groups)
2.6、动态路由案例
2.7、动态Metadata(generateMetadata)
2.8、并行路由(平行路由)
2.9、拦截路由
三、数据渲染
3.1、传递动态Props
3.2、集成lowdb的增删改查
3.3、GET缓存与缓存退出
3.4、Middleware中间件
3.5、Middleware的登录、退出和拦截
一、创建项目
官网文档:https://nextjs.org/docs
创建项目:npx create-next-app@latest
指定版本:npx create-next-app@14.0.0 my-next-app安装插件:Tailwind CSS IntelliSense 提示工具
Tailwind官网:border-width - Borders - Tailwind CSS
二、导航和路由
案例:
"use client";
import Link from "next/link";
import { useState } from "react";
import { usePathname } from "next/navigation";
const linkData = [{ name: "About", path: "/dashboard/about" },{ name: "Settings", path: "/dashboard/settings" },
];
export default function DashboardLayout({children,
}: Readonly<{children: React.ReactNode;
}>) {const [count, setCount] = useState(0);const pathname = usePathname();return (<div className="border-2 border-dashed border-black p-4 w-1/2 mx-auto"><div className="flex gap-4 font-bold text-lg mb-4">{linkData.map((link) => (<Linkkey={link.path}className={pathname === link.path ? "text-purple-500" : ""}href={link.path}>{link.name}</Link>))}</div><h2>Dashboard-Layout-{count}</h2><buttonclassName="bg-black text-white my-4 p-2 rounded-md"onClick={() => setCount(count + 1)}>increment</button>{children}</div>);
}
2.1、布局和模板
特性 | 布局(layout) | 模板(template) |
定义方式 | app/layout.tsx | app/template.tsx |
复用性 | 全局共享(导航栏+页脚) | 针对特定页面组复用 |
状态保持 | 切换路由时状态保留 | 切换路由时重新挂载 |
用途 | 全局结构(如<html><body>标签) | 动画过渡、强制刷新子组件 |
2.2、usePathname
-
功能:获取当前URL的路径部分(不含查询参数和域名)
-
案例上实现了高亮当前导航项和根据路径动态渲染内容功能
2.3、链接和导航
特性 | 链接(<Link>) | 编程式导航(useRouter) |
实现方式 | 声明式 | 命令式(js函数调用) |
预加载 | 自动预加载 | 手动调用router.push() |
SEO优化 | 保持SPA特性,不刷新页面 |
2.3.1、动态路由参数
// 传递
<Link href="/user/123">User</Link>
router.push('/user/123');// 接收参数页面:app/user/[id]/page.tsx
export default function User({ params }: { params: { id: string } }) {return <div>User ID: {params.id}</div>; // 输出: User ID: 123
}
2.3.2、查询参数 (Search Params)
// 传递
<Link href="/search?q=keyword">Search</Link>
router.push('/search?q=keyword');// 接收参数页面: app/search/page.tsx
import { useSearchParams } from 'next/navigation';
export default function Search() {const searchParams = useSearchParams();const query = searchParams.get('q'); // "keyword"return <div>Search: {query}</div>;
}
2.5、路由组(Route Groups)
路由组的作用
1、逻辑分组:将功能相关的路由组织在一起(如认证、仪表板)
2、避免URL影响:组名不会出现在最终 URL 中(如 (auth)/login → /login)
3、共享布局:可在组内创建共享的 layout.js
4、选择性嵌套:允许部分路由继承布局,部分不继承
注意事项
1、组名约定:
必须用括号包裹:(groupName);不能是路由段保留字(如 page.js, layout.js)
2、URL 影响:
组名不会出现在 URL 中;无法通过 usePathname() 获取组名
3、SEO 友好:
适合组织管理后台等无需SEO的路由;建议使用清晰的一级路径(如 /blog)
2.6、动态路由案例
// blog文件夹下的page.tsx
"use client";
import React from "react";
import { Avatar, List } from "antd";
import Link from "next/link";
import { data } from "@/data/index";
export default function Page() {return (<div><ListitemLayout="horizontal"dataSource={data}renderItem={(item, index) => (<List.Item><List.Item.Metaavatar={<Avatarsrc={`https://api.dicebear.com/7.x/miniavs/svg?seed=${index}`}/>}title={<Link href={`/blog/${item.id}`}>{item.title}</Link>}description={item.body}/></List.Item>)}/></div>);
}
// blog文件夹下的[id]文件夹下的page.tsx
import React from "react";
import { Card } from "antd";
import { data } from "@/data/index";
export default function page({params}:{params:{id:string}}) {const item = data.find((item) => item.id === +params.id);return (<div>{" "}<Card title={item?.title} variant="borderless" style={{ width: 300 }}><p>{item?.body}</p></Card></div>);
}
2.7、动态Metadata(generateMetadata)
// 静态元数据: 向xx路由添加标题
import type { Metadata } from "next";
export const metadata: Metadata = {title: "博客列表",description: "博客列表",
};
// 详情页面动态渲染
interface IProps {params: { id: string };
}
export async function generateMetadata({ params }: IProps) {return {title: `博客详情 - ${params.id}`,};
}
2.8、并行路由(平行路由)
并行路由:允许在同一个页面(layout)中同时渲染多个独立的路由内容,类似于 Vue/React 中的
<slot>
或命名插槽机制。备注:app目录下,使用@前缀的文件夹定义插槽,在layout.tsx 中通过props接收并渲染。
如案例所示:Home路由下是第一张图,Visitors路由下是第二张图,,此时@analytics文件夹下的page.tsx和visitors下的page.tsx属于并行路由,刷新后为了防止404,在app文件夹和@team文件夹下建立default.tsx。
2.9、拦截路由
拦截路由:在不离开当前页面的情况下,动态加载并覆盖部分路由内容。例如:
点击相册图片,在当前页弹出大图预览(URL 变化但背景保留)
表单编辑弹窗(覆盖当前列表页)
"use client"
import React from 'react'
import { photos } from "@/data/index";
import Image from "next/image";
import {useRouter} from 'next/navigation'
export default function page({params}:{params:{id:string}}) {const photo = photos.find(item => item.id === params.id)!//非空断言idconst router=useRouter()return (<div className='flex justify-center items-center fixed inset-0 bg-gray-500/[.8]' onClick={router.back}><Image src={photo.src} alt={photo.alt} width={400} height={400} className='rounded-lg' onClick={(e) => e.stopPropagation()}/></div>)
}
并行路由 vs 拦截路由
特性 | 并行路由 | 拦截路由 |
用途 | 同时渲染多个独立内容区 | 临时覆盖当前页面部分内容 |
URL内容 | 无(隐式加载) | 有(URL变化但UI叠加) |
场景 | 多面板布局 | 模态框、临时编辑弹窗 |
语法 | @slot文件夹 | (..)或(.)文件夹 拦截语法 |
三、数据渲染
3.1、传递动态Props
// 不同父组件
import React from "react";
import homeSrc from "@/public/bg1.jpg";
import Cover from "@/components/cover";
export default function Page() {return (<Cover imgUrl={homeSrc} altTxt="Performance" content="Welcome to Performance" />);
}
// 子组件cover.tsx接收
import Image, { StaticImageData } from "next/image";
import React from "react";
interface CoverProps {imgUrl: StaticImageData;//静态文件图片altTxt: string;content: string;
}
export default function Cover(props: CoverProps) {return (<div className="h-screen relative"><div className="absolute inset-0 -z-10"><Imagesrc={props.imgUrl}fillstyle={{ objectFit: "cover" }}alt={props.altTxt}></Image><div className="absolute inset-0 bg-gradient-to-r from-gray-900"></div></div><div className="flex justify-center pt-48"><h1 className="text-white text-6xl">{props.content}</h1></div></div>);
}
3.2、集成lowdb的增删改查
第一步:npm i lowdb(https://github.com/typicode/lowdb)
第二步:建立db.ts
import { JSONFilePreset } from "lowdb/node";
// 同级目录下创建 db.json
const defaultData: { posts: { id: string; title: string; content: string }[] } ={ posts: [] };
const db = await JSONFilePreset("db.json", defaultData);
export default db;
第三步:创建接口url
//============================api\articles\route.ts
import { NextRequest, NextResponse } from "next/server";
import db from "@/db";
// 分页查询 GET=> /api/articles
export async function GET(request: NextRequest) {const searchParams = request.nextUrl.searchParams;const pagenum = Number(searchParams.get("pageNum")) || 1;const pagesize = Number(searchParams.get("pageSize")) || 2;const query = searchParams.get("query") || "";const data = db.data.posts;let filterData = query? data.filter((item) => {const { id, ...rest } = item;return Object.values(rest).some((value) =>String(value).toLowerCase().includes(query.toLowerCase()));}): data;const total = filterData.length;const startIndex = (pagenum - 1) * pagesize;const endIndex = Math.min(startIndex + pagesize, total);filterData =startIndex >= total ? [] : filterData.slice(startIndex, endIndex);return NextResponse.json({code: 200,message: 'success',data: {total,list: filterData}});
}
// 添加 POST=> /api/articles
export async function POST(request: Request) {const data = await request.json();await db.update(({ posts }) =>posts.unshift({id: Math.random().toString(36).slice(-8),...data,}));return NextResponse.json({code: 200,message: "添加成功",data,});
}
//============================api\articles\[id]\route.ts
import { NextResponse } from "next/server";
import db from "@/db";
// 指定删除 DELETE=> /api/articles/:id
interface IParams {params: { id: string };
}
export async function DELETE(request: Request, { params }: IParams) {await db.update(({ posts }) => {const idx = posts.findIndex((item) => item.id === params.id);posts.splice(idx, 1);});return NextResponse.json({code: 200,message: "删除成功",});
}
// 修改 PATCH=> /api/articles/:id
export async function PATCH(request: Request, { params }: IParams) {const data = await request.json();await db.update(({ posts }) => {const idx = posts.findIndex((item) => item.id === params.id);if (idx !== -1) {posts[idx] = { ...posts[idx], ...data };}});return NextResponse.json({code: 200,message: "修改成功",data});
}
// 查找 GET=> /api/articles/:id
export async function GET(request: Request, { params }: IParams) {const data = db.data.posts.find((item) => item.id === params.id);return NextResponse.json({code: 200,message: "查询成功",data,});
}
第四步:封装接口
// lib/api.ts
export async function fetchArticles(query = "", pageNum = 1, pageSize = 5) {const res = await fetch(`/api/articles?query=${query}&pageNum=${pageNum}&pageSize=${pageSize}`,{ cache: "no-store", next: { revalidate: 0 } } // SSR 时不要缓存);return res.json();
}
export async function addArticle(data: any) {const res = await fetch("/api/articles", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify(data),});return res.json();
}
export async function updateArticle(id: string, data: any) {const res = await fetch(`/api/articles/${id}`, {method: "PATCH",headers: { "Content-Type": "application/json" },body: JSON.stringify(data),});return res.json();
}
export async function deleteArticle(id: string) {const res = await fetch(`/api/articles/${id}`, {method: "DELETE",});return res.json();
}
export async function fetchArticleById(id: string) {const res = await fetch(`/api/articles/${id}`);return res.json();
}
第五步:以编辑页面的信息查询和编辑提交为例
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { fetchArticleById, updateArticle } from "@/lib/api";
export default function EditArticlePage() {const { id } = useParams();const router = useRouter();const [form, setForm] = useState({ title: "", content: "" });useEffect(() => {const load = async () => {const res = await fetchArticleById(id as string);setForm(res.data || {});};load();}, [id]);const handleSubmit = async (e: React.FormEvent) => {e.preventDefault();await updateArticle(id as string, form);router.push("/articles");router.refresh();};return (<form onSubmit={handleSubmit} className="p-4 space-y-4"><h1 className="text-xl font-bold">编辑文章</h1><inputtype="text"value={form.title}onChange={(e) => setForm({ ...form, title: e.target.value })}className="border p-2 w-full"/><textareavalue={form.content}onChange={(e) => setForm({ ...form, content: e.target.value })}className="border p-2 w-full h-40"/><buttontype="submit"className="bg-green-500 text-white px-4 py-2 rounded">保存修改</button></form>);
}
3.3、GET缓存与缓存退出
默认生产环境下,GET请求返回的数据有可能被缓存(开发环境不会),例如在GET请求种返回该日期对象时间部分的字符串,此时在频繁请求时结果可能不会及时刷新
通过以下情况可以将其从静态渲染API变成动态API:
1、使用Request对象,从Request获取了前端传递的动态query,此时会触发缓存退出
2、用POST代替GET,因为POST请求往往用于改变数据
3、使用像cookies、headers这样的的动态函数,这些数据只有当请求的时候才知道具体的值,所以不适合缓存
4、路由段配置项手动声明为动态模式
import { headers } from "next/headers";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {const headersList = headers();const referer = headersList.get("referer");request.cookies.get('token')return Response.json({ data: new Date().toLocaleTimeString(), referer });
}
3.4、Middleware中间件
Middleware 在请求完成前运行,可以修改请求和响应,一般请求都会经过它
用export const config或者使用条件语句控制 middleware 对哪些路径产生效果
import { NextRequest, NextResponse } from "next/server";
export default function middleware(request: NextRequest) {console.log(request.nextUrl.pathname, "@@@@");if (request.nextUrl.pathname.startsWith("/about")) {return NextResponse.rewrite(new URL("/about-2", request.url));}
}
export const config = {matcher: "/about",// matcher: ["/about", "/dashboard"],// matcher: ["/about/:path*", "/dashboard/:path*"],
};
3.5、Middleware的登录、退出和拦截
登录时接收用户名和密码,并保存token到cookies里,在退出时将其删除
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {const { login, password } = await request.json();const r = await fetch("https://api.zhihur.com/admin/auth/sign_in", {method: "POST",headers: {"Content-Type": "application/json",},body: JSON.stringify({login,password,}),});const data = await r.json();const response = NextResponse.json({success: true,msg:data.message});response.cookies.set("token", data.data.token, {path: "/",maxAge: 60 * 60 * 24 * 7,httpOnly: true,});return response;
}
退出:
import { NextResponse } from "next/server";
export async function DELETE() {// 设置过期时间为 0 来删除 cookie// cookies().set('token', '', { maxAge: 0 });const responses = NextResponse.json({success: true,msg: "登出成功",});responses.cookies.set("token", "", { maxAge: 0 });return responses;
}
登录验证拦截:
除了 /login
页面以外,如果用户访问其他页面时没有携带有效的 token
(登录凭证,存在 Cookie 中),就自动跳转到 /login
登录页
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {if (request.nextUrl.pathname !== "/login") {const token = request.cookies.get("token")?.value;if (!token) {return NextResponse.redirect(new URL("/login", request.url));}}
}
export const config = {matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)",],
};