简介这份NEXT.JS思维导图面向正在学习或准备上手Next.js的前端开发者尤其适合已掌握JavaScript与React基础、希望系统梳理框架知识体系的人群。内容围绕Next.js核心概念展开涵盖应用创建命令、/app目录下routes、components、lib、ui、public、scripts等模块划分以及next.config.js配置、占位数据、Prisma与TypeScript集成、全局样式、TailwindCSS、CSS模块、clsx样式开关、字体与图片优化等知识点帮助读者建立清晰的知识脉络。资源为1个PDF文件压缩包约846KB以思维导图形式呈现便于快速查阅与复习。目前已有105人学习适合作为日常开发中的速查手册与学习路线参考。1. 从一张思维导图到能跑的全栈应用Next.js 这套东西到底怎么落地很多人第一次接触 Next.js是因为看到一张把 App Router、数据获取、流式渲染全画在一起的思维导图觉得结构清晰、知识点全但真到自己动手时卡在npx create-next-app之后不知道下一步该干嘛。这份「NEXT.JS思维导图」资源的价值就在这——它不是零散的知识点罗列而是按真实项目推进顺序把创建工程、目录约定、样式方案、字体图片优化、数据库接入、数据获取与渲染模式、搜索分页这条主线串成了一条可执行的路径。它适合两类人一是刚学完 React、想找一个完整项目把服务端组件和客户端组件边界搞清楚的开发者二是已经用过 Next.js 但目录结构混乱、数据获取到处写、缓存行为靠猜的从业者。下面我按自己拆项目的顺序把这份资源里真正能抄作业的部分拆开讲。2. 工程初始化与目录约定为什么app下要分lib、ui、scripts2.1 创建命令与依赖安装的实际差异资源里给的创建命令是npx create-next-app appName --use-npm这里--use-npm是显式指定包管理器避免机器上同时装了 pnpm、yarn 时交互式询问卡住。实际执行时还会被问到是否用 TypeScript、是否用 ESLint、是否用 Tailwind、是否用src/目录、是否用 App Router。我的习惯是TypeScript 开、ESLint 开、Tailwind 开、不用src/、App Router 开。原因后面会讲因为资源里所有路径都基于/app根目录如果选了src/所有/app/...的别名都要改对新手是纯增负担。npx create-next-app appName --use-npm cd appName npm i npm run devnpm i装的是package.json里的依赖npm run dev起的是开发服务器默认监听http://localhost:3000。这里有个容易忽略的点create-next-app生成的package.json里dev脚本通常是next dev不带--turbo。如果你机器 Node 版本在 18.17 以下某些新特性会报错先node -v确认一下。2.2 目录分层背后的职责边界资源把/app拆成routes、components、lib、ui、public、scripts这套分法不是随便定的。/app本身承载路由每个文件夹映射一个 URL 段/app/lib放数据获取函数和工具函数比如data.ts、utils.ts/app/ui放纯展示组件比如卡片、表格、表单/public放静态资源图片字体都在这/scripts放种子脚本比如seed.js用来往数据库灌初始数据。我一般会额外加一条约定/app/lib/definitions.ts专门放 TypeScript 类型定义比如User、Invoice、Revenue这样数据层和 UI 层引用类型时不会互相 import 组件文件避免循环依赖。资源里queryUser返回PromiseUser[]这个User类型就适合抽到definitions.ts里统一维护。2.3 占位数据的正确用法与退出时机资源里提到的 placeholder data典型写法是在/app/lib/placeholder-data.js里导出一个数组const invoices [ { customer_id: customers[0].id, amount: 15795, status: pending, date: 2022-12-06, }, { customer_id: customers[1].id, amount: 20348, status: pending, date: 2022-11-14, }, ];这段数据的意义是在数据库还没接好、API 还没通的时候让页面先有内容可渲染方便调样式和布局。但很多人会犯一个错——把占位数据直接 import 进页面组件等真数据接进来时忘了删结果线上页面混着假数据。我的做法是占位数据只允许被seed脚本引用页面组件一律走fetch或sql查询从源头断掉误用路径。3. 样式、字体与图片Tailwind、CSS Modules 和next/image怎么选3.1 全局样式与 Tailwind 的配合方式资源里全局样式走/app/ui/global.css在/app/layout.tsx里import /app/ui/global.css。这个/别名指向项目根是tsconfig.json里paths配的不是 Webpack 魔法。Tailwind 的引入通常是在global.css顶部写tailwind base; tailwind components; tailwind utilities;然后所有组件里直接用 utility class。import AcmeLogo from /app/ui/acme-logo; import { ArrowRightIcon } from heroicons/react/24/outline; import Link from next/link; export default function Page() { return ( main classNameflex min-h-screen flex-col p-6 div classNameflex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52 {/* ... */} /div /main ); }这里flex、min-h-screen、md:h-52都是 Tailwind 的响应式前缀写法md:表示中等屏幕以上生效。资源里还提到用clsx做条件类名切换这个在状态驱动的样式里非常实用import clsx from clsx; export default function InvoiceStatus({ status }: { status: string }) { return ( span className{clsx( inline-flex items-center rounded-full px-2 py-1 text-sm, { bg-gray-100 text-gray-500: status pending, bg-green-500 text-white: status paid, }, )} {/* ... */} /span ); }clsx的第一个参数是始终生效的类名第二个对象参数的 key 是类名、value 是布尔条件。这样写比模板字符串拼status pending ? bg-gray-100 : 可读性高很多也不会出现多余空格。3.2 CSS Modules 的适用场景资源里给了/app/ui/home.module.css的例子.shape { height: 0; width: 0; border-bottom: 30px solid black; border-left: 20px solid transparent; border-right: 20px solid transparent; }然后在组件里import styles from /app/ui/home.module.css用className{styles.shape}。CSS Modules 的核心价值是自动生成唯一类名把作用域限制在组件内。我的判断标准是纯布局和间距用 Tailwind需要复杂选择器、伪元素、动画关键帧的用 CSS Modules。两者不冲突同一个组件里可以混用。3.3 字体与图片优化的实际收益资源里用next/font/google引入 Interimport { Inter } from next/font/google; export const inter Inter({ subsets: [latin] });然后在layout.tsx里body className{${inter.className} antialiased}。这个做法的关键点是字体文件在构建时下载并自托管用户访问页面时不会再去 Google 的域名请求字体既省了一次 DNS 和 TLS 握手也避免了字体加载闪烁。subsets: [latin]是只取拉丁字符集减小字体文件体积如果你的页面有中文需要额外配subsets: [latin]加中文字体方案或者用系统字体栈。图片方面next/image的Image组件相比原生img多了几件事自动防止布局偏移因为要求写width和height、按视口调整尺寸、默认懒加载、支持 WebP/AVIF。资源里的用法Image src/hero-desktop.png width{1000} height{760} classNamehidden md:block altScreenshots of the dashboard project showing desktop version /classNamehidden md:block是移动端隐藏、桌面端显示配合另一张classNameblock md:hidden的移动端图就能做到按屏幕切换素材。注意src写的是/hero-desktop.png对应/public/hero-desktop.png不要写成相对路径./。4. 数据库接入与数据获取Prisma、Vercel Postgres 和Promise.all的取舍4.1 Prisma 的 schema 定义与迁移流程资源里用 Prisma 做 ORM安装命令是npm i prisma prisma/client。然后在项目根建.envDATABASE_URLpostgresql://postgres:passwordlocalhost:5432/postgres?schemapublic再建schema.prismadatasource db { provider postgresql url env(DATABASE_URL) } model user { id Int id default(autoincrement()) name String unique pass_word String create_time DateTime default(now()) } generator client { provider prisma-client-js }这里id default(autoincrement())是自增主键unique是唯一约束default(now())是插入时自动填当前时间。定义完跑npx prisma generate生成客户端类型再跑npx prisma migrate dev --name init建表并生成迁移文件。--name init是迁移记录的名字后面每次改 schema 都要重新跑一次 migrate名字换成有意义的描述。4.2 Server Actions 里的 CRUD 写法资源在/app/actions/actions.tsx里定义操作函数文件顶部use server;是关键它告诉 Next.js 这些函数只在服务端执行可以被客户端组件直接调用而不暴露数据库连接。use server; import { PrismaClient } from prisma/client; const prisma new PrismaClient(); export type User { id: number; name: string; pass_word: string; create_time: Date; }; export async function queryUser(): PromiseUser[] { const res await prisma.user.findMany(); return res; } export async function addUser(name: string, passWord: string) { const res await prisma.user.create({ data: { name: name, pass_word: passWord, }, }); return res; } export async function deleteUser(id: number) { const res await prisma.user.delete({ where: { id } }); return res; }findMany不带参数就是查全表create的data字段对应 schema 里的列名delete的where是条件。这里有个坑new PrismaClient()每次调用都新建连接在开发环境热更新时容易把连接池打满。常见做法是抽一个prisma.ts单例用globalThis缓存实例。4.3 并行数据获取与瀑布流的区别资源里重点对比了串行和并行。串行写法const revenue await fetchRevenue(); const latestInvoices await fetchLatestInvoices(); const { numberOfInvoices, numberOfCustomers } await fetchCardData();三个请求依次等待总耗时是三者之和。并行写法用Promise.allexport async function fetchCardData() { const invoiceCountPromise sqlSELECT COUNT(*) FROM invoices; const customerCountPromise sqlSELECT COUNT(*) FROM customers; const invoiceStatusPromise sqlSELECT SUM(CASE WHEN status paid THEN amount ELSE 0 END) AS paid, SUM(CASE WHEN status pending THEN amount ELSE 0 END) AS pending FROM invoices; const data await Promise.all([ invoiceCountPromise, customerCountPromise, invoiceStatusPromise, ]); // ... }Promise.all是三个请求同时发出总耗时取决于最慢的那个。但要注意Promise.all只要有一个 reject整体就 reject另外两个的结果拿不到。如果某个请求失败不影响其他数据展示用Promise.allSettled更稳它返回每个 Promise 的状态和值不会因为一个失败全盘皆输。4.4 静态渲染与动态渲染的切换开关资源里用unstable_noStore退出静态渲染import { unstable_noStore as noStore } from next/cache; export async function fetchRevenue() { noStore(); // ... }默认情况下Next.js 会尽量静态渲染页面在构建时取一次数据然后缓存。但仪表盘这类页面数据实时变化必须每次请求都重新取。noStore()等价于fetch(..., { cache: no-store })告诉框架这个函数不能被缓存。我的经验是凡是依赖 cookies、searchParams、或者数据时效性要求高的查询都在数据函数第一行加noStore()别等到发现页面数据不更新再回头排查。5. 流式渲染与搜索分页Suspense、loading.tsx和 URL 参数联动5.1 流式渲染的两种落地方式资源里给了两条路一是loading.tsx在page.tsx同级目录放一个export default function Loading() { return divLoading.../div; }或者用骨架屏import DashboardSkeleton from /app/ui/skeletons; export default function Loading() { return DashboardSkeleton /; }。二是Suspense包裹慢组件Suspense fallback{RevenueChartSkeleton /} RevenueChart / /Suspenseloading.tsx是整页级别的加载态Suspense是组件级别的。我的用法是页面框架和快数据直接渲染慢的图表用Suspense单独包这样用户先看到卡片数字图表区域显示骨架屏等数据到了再替换。资源里还提到用(overview)路由组把loading.tsx和page.tsx放一起带括号的文件夹不会出现在 URL 里这个技巧在需要给某个子路由单独加 loading 又不影响路径时很好用。5.2 搜索与分页的 URL 参数同步资源里搜索的实现分两步。第一步捕获输入use client; import { MagnifyingGlassIcon } from heroicons/react/24/outline; export default function Search({ placeholder }: { placeholder: string }) { function handleSearch(term: string) { console.log(term); } return ( input placeholder{placeholder} onChange{(e) { handleSearch(e.target.value); }} / ); }第二步用useSearchParams、usePathname、useRouter更新 URLuse client; import { useSearchParams, usePathname, useRouter } from next/navigation; export default function Search() { const searchParams useSearchParams(); const pathname usePathname(); const { replace } useRouter(); function handleSearch(term: string) { const params new URLSearchParams(searchParams); if (term) { params.set(query, term); } else { params.delete(query); } replace(${pathname}?${params.toString()}); } }URLSearchParams是浏览器原生 APIparams.set会覆盖同名参数params.delete是移除。replace而不是push是为了不往历史记录里塞一堆中间状态用户按返回键不会一步步退回每个字符。分页同理把page参数写进 URL服务端组件从searchParams里读出来传给 SQL 的LIMIT和OFFSET。5.3 客户端组件与服务端组件的边界这里有个高频翻车点useSearchParams、usePathname、useRouter都只能在客户端组件里用所以Search组件顶部必须写use client。但父组件如果是服务端组件可以直接 import 这个客户端组件没问题。反过来客户端组件里不能直接await数据查询得通过 Server Action 或者 API 路由。资源里actions.tsx的use server和search.tsx的use client就是这条边界的两个方向写的时候先想清楚这个组件需不需要交互、需不需要访问浏览器 API再决定放哪边。6. 避坑与排查五个我实际踩过的坑6.1 页面数据不更新改了数据库刷新还是旧值现象往数据库插了一条记录刷新页面看不到重启npm run dev才出现。原因数据获取函数被静态渲染缓存了构建时取了一次就固定住。解决在数据函数里加noStore()或者确认页面是否依赖了cookies()、searchParams这类动态 API如果都没有框架默认走静态。6.2useSearchParams报错说必须包在Suspense里现象页面编译报错提示useSearchParams() should be wrapped in a suspense boundary。原因useSearchParams在静态渲染时会导致整个页面退出静态模式Next.js 要求你显式用Suspense包一层来声明加载态。解决把用到useSearchParams的组件用Suspense fallback{...}包起来或者把该组件标记为动态渲染。6.3 Prisma 迁移后类型没更新TS 报属性不存在现象schema.prisma加了新字段npx prisma migrate dev也跑了但代码里访问新字段 TS 报错。原因prisma generate没跑或者跑了但编辑器用的还是旧的类型缓存。解决每次改完 schema 先npx prisma generate再重启 TS 服务VS Code 里CtrlShiftP→TypeScript: Restart TS Server。6.4next/image的src写相对路径导致 404现象图片在/public下但页面显示裂图控制台 404。原因src写成了./hero.png或../public/hero.png。解决/public下的资源直接用/开头比如/hero.png框架会自动映射到public目录。6.5Promise.all里一个查询失败导致整页白屏现象仪表盘三个统计卡片其中一个 SQL 写错整页报错什么都看不到。原因Promise.all的失败传播机制一个 reject 全部 reject。解决对独立性强的查询改用Promise.allSettled拿到结果后判断status fulfilled再取值失败的给默认值或错误提示不让一个查询拖垮整页。7. 进阶技巧用unstable_noStore和Suspense组合出可预测的加载体验把这份资源跑通之后我最大的习惯变化是不再靠感觉判断页面是静态还是动态而是每个数据函数都显式声明缓存意图。具体做法是建一个data.ts每个导出函数第一行根据业务决定加不加noStore()然后在页面里用Suspense按数据快慢分组。快的卡片数据直接await慢的图表用Suspense包住骨架屏尺寸和真实组件对齐避免数据到达时布局跳动。验证方法很简单npm run build看输出静态路由会标○动态路由标λ。如果某个你认为是动态的页面标了○说明noStore()没生效或者被上层缓存覆盖了。另一个验证点是打开浏览器 Network 面板刷新页面看 HTML 文档的响应时间——流式渲染的页面会先返回一部分 HTML剩余部分通过 chunk 陆续到达而不是等所有数据齐了一次性返回。// 一个可复用的模式快数据直接取慢数据交给 Suspense export default async function Page() { const latestInvoices await fetchLatestInvoices(); const { numberOfInvoices, numberOfCustomers } await fetchCardData(); return ( main Card titleTotal Invoices value{numberOfInvoices} / Card titleTotal Customers value{numberOfCustomers} / Suspense fallback{RevenueChartSkeleton /} RevenueChart / /Suspense LatestInvoices latestInvoices{latestInvoices} / /main ); }从那以后我每次接新项目第一件事就是把data.ts里每个函数的缓存策略过一遍再决定页面里哪些部分用Suspense包。这个习惯帮我省掉了大量「为什么线上数据不对」的排查时间。希望帮到你。本文还有配套的精品资源点击获取