文章目录
前言
对于那些对Next.js一无所知的前端伙伴来说,最佳的快速入门方法无疑是官方提供的交互式入门教程文档。尽管如此,由于官方教程至今未提供中文版本,这无疑增加了中文用户的学习难度。为了帮助大家节省宝贵的时间,我决定亲自深入学习这些材料,并将我所学到的关键信息整理成文。通过阅读我的文章,你也能迅速掌握Next.js的核心要点。
当然学Next.js之前你必须会CRA创建的React项目。
官方入门文档地址:https://nextjs.org/learn/dashboard-app/getting-started
最近又发现了一个国人翻译的,质量很高:https://qufei1993.github.io/nextjs-learn-cn
文章如果有错误欢迎评论区指正!
目录结构
如何去初始化一个Next.js项目我就不记录了,跟着官方文档做很简单,不是此次的重点,我们还是先看目录结构吧。
初始化工程的目录为:
/app
这个文件夹里放的是我们开发中大部分手写的代码。内部还可以继续细化为:
/app/lib
放js文件,例如一些工具函数,API文件等/app/ui
放组件,例如公共组件、业务组件等
/public
放静态资源,例如图片
/script
放一些数据有关的脚本
/next.config.mjs
在里头可以添加或者修改Webpack设置
其他文件咱们下面讲到对应章节再补充说明~
样式方案
Next对样式的编写提供和推荐了一些方案。
正常引入样式文件
Next给默认设置了路径别名(在我们初始化一个Next工程的时候,会有选项询问我们是不是要主动帮忙添加路径别名),所以你只需要这样引入样式文件:
import '@/app/ui/global.css';
Tailwind方案
这个库有多火就不说了,不是文章重点学习部分,大概意思就是这个库帮你编写了很多类名的css样式,例如你想要某个h1标签的文字颜色为蓝色且字重为500,那么你就可以这样写:
<h1 className="text-blue-500">I'm blue!</h1>
这样就会自动加上css规则。
感兴趣的自己去看吧,官方文档地址为:https://tailwindcss.com/
CSS Modules方案
这个是CRA创建时默认的CSS隔离方案,就不多说了。
clsx方案
这个库就是方便我们在写class的时候做一些条件判断,例如文档中的例子:
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',
},
)}
>
// ...
)}
是不是比写什么三元表达式清晰很多。感兴趣的可以查看官方文档:https://www.npmjs.com/package/clsx
综上所述,在Next应用中,使用这些样式方案基本能满足我们的需求了。
文字和图片优化
Next自动的对文字和图片做了很好的优化,减少我们平时对这方面的处理工作。
文字
假如我们需要用自定义的字体,那么会有两个问题:
- 页面第一次加载静态资源的时候,需要拉取和加载自定义字体文件。
- 一旦文字文件被解析,页面将经历一个文字替换的过程。然而,由于自定义字体的行高、尺寸和字重与默认字体不同,从默认字体切换到自定义字体时,页面的布局可能会出现偏移。
Next帮我们处理了上述的问题,并且能够做到第一次加载页面不会拉取字体文件的同时,页面直接显示的就是我们的自定义字体。
做法是,首先创建一个文件/app/ui/fonts.ts
来引入教程给我们预装好的字体文件:
import { Inter, Lusitana } from 'next/font/google'; // 如果是我们自己的字体如何引入?这点我后续有空了尝试下,先按照教材的理解即可
export const inter = Inter({ subsets: ['latin'] }); // 这个意思是从这个字体中提取latin这一个模块的字体
export const lusitana = Lusitana({ // 还可以设定一些属性
weight: ['400', '700'],
subsets: ['latin'],
});
然后我们给全局的文字替换成我们自定义的latin字体(好像是替换英文和阿拉伯数字),只需要在/app/layout.tsx
中引入并使用:
import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} antialiased`}>{children}</body>
</html>
);
}
同时官方还推荐使用Tailwind的antialiased类名,让文字看起来更加平滑清晰。在其他子组件使用也是一样的方式。
字体属性的设定有很多,具体可以参考官方文档:https://nextjs.org/docs/app/api-reference/components/font#font-function-arguments
还有文档中提到好像有些属性对谷歌某些字体是没有用的,具体要上谷歌字体官网上去查看(需翻墙):https://fonts.google.com/
图片
首先图片资源官方要求放在/public
目录里。Next提供了Image组件供我们使用,例如:
import Image from 'next/image';
export default function Page() {
return (
// ...
<div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
<Image
src="/hero-desktop.png"
width={1000}
height={760}
className="hidden md:block"
alt="Screenshots of the dashboard project showing desktop version"
/>
</div>
//...
);
}
通过这种使用方式,Next会帮我们做好几件事:
- 图片从无到加载出来的过程,页面布局不会出现变化
- 能够适配不同视口的大小
- 自带懒加载
- 如果浏览器支持webp,自动提供webp格式
不过我看这个设备屏幕大小适配的问题是通过Tailwind的hidden md:block
去解决的?默认隐藏,当视口达到设定的md大小就显示。这点我以后有空验证下。
Pages和Layout的机制
Pages
Next设置了一个由文件结构决定路由地址的机制。例如文章中给的例子:
这个路由会顺着文件夹里的invoices中找到page.ts
文件,文件里面就写组件代码即可,会自动的渲染到页面上。
所以当页面创建多了后,关系参考如下:
看明白了吧~
Layout
这个组件的作用就是可以把page组件嵌套在里面,你可以这么理解,Next的路由文件查找机制其实是这样的:
- 首先沿着路由地址一路过一遍文件夹,例如url=
/dashboard/invoices
- 如果一路都是page.ts,那么找invoices里的page.tsx当做对应的页面。
- 如果路上有layout.tsx,那么其实是把layout.tsx当做对应的父级页面,然后layout.tsx里的children props变量就是子层级文件夹里page.tsx这个组件。这样page.tsx被嵌在layout.tsx显示在页面上。
- 如果是url=
/dashboard
那么layout.tsx里的children props变量就是同层级page.tsx这个组件。
来看看layout.tsx代码官方的示例:
import SideNav from '@/app/ui/dashboard/sidenav';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
<div className="w-full flex-none md:w-64">
<SideNav /> // 公共组件
</div>
<div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div> // = page.tsx
</div>
);
}
同理其他子页面也是一样的,梳理他们之间的关系如下:
注意!官方这张图的dashboard少了自己的page.tsx,记住了有layout.tsx必有page.tsx.
采用这种方式渲染页面可以实现当子组件更新时,父组件无需更新的效果。
所以我的理解是,当你一个页面需要嵌套子组件的时候,就适合用layout组件,当然最大的layout组件在/app
里,和他平级的page.ts应该是嵌套在layout组件里(有空时我去验证下)。
感觉这种机制的好处还有:强制规范了组件文件结构的摆放~,这样就不会有一个项目一个文件规范的情况出现。
通过Link组件改变路由并且拆分打包
Next给我们提供了Link组件去修改路由地址,使用方式:
import Link from 'next/link';
// ...
export default function NavLinks() {
return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className="..."
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}
这个组件的好处是:Next打包的时候,会自动的通过路由关系去拆分文件,并且不会一下子全部拉取。如果当前页面有Link组件,那么会在背后偷偷预拉取对应的组件代码,等用户点击后直接渲染。
我举个例子,假设页面跳转到com/a
,那么此时是显示a页面,这个页面上用link链接到两个组件(b,c),可以通过点击link链接来回切换bc组件。
当我跳转到a页面时,后台会请求到这两个组件的文件。当我通过点击b组件链接显示b组件时,就会直接渲染刚拉到的b组件文件,c组件也是同理。但!当我从c组件切换回b组件时,b组件的文件就会重新去拉取,同理,之后的bc组件切换都是即时拉去的形式了!
注意:href属性要给完整的地址,例如/dashboard/invoices
提供Hooks
Next提供了很多hooks供我们使用,例如与路由有关的示例:
'use client'; // 这个意思好像是说这个文件只在浏览器端加载,不作为SSR
import { usePathname } from 'next/navigation';
export default function NavLinks() {
const pathname = usePathname(); // 获取路由地址
// ...
}
// ...
通过Vercel部署Next应用
首先我们要知道什么是Vercel,这里我找AI介绍了下:
Vercel 是一个平台,提供静态和 Jamstack网站的托管服务。它支持多种前端框架,如Next.js、Nuxt.js、Gatsby等,并且能够实现快速部署、服务器端渲染和自动化的性能优化。Vercel 通过其全球分布式的边缘网络(Edge Network)来加速内容的交付,确保用户能够快速加载网站内容。
Vercel 还提供了集成的连续部署功能,允许开发者在代码推送至GitHub、GitLab或Bitbucket等版本控制系统后自动构建和部署网站。此外,Vercel 还提供了一些高级功能,如路由、重写规则、环境变量管理以及HTTPS证书等,使得开发者可以轻松管理他们的网站。
咱们这个Next.js也是属于Vercel下的!
首先咱们把我们的Next应用推到自己的github仓库。
然后去Vercel网站创建账号,把github上的仓库关联到Vercel中,然后选中我们的Next应用仓库,导入:
接着部署:
他会自动安装依赖,打包…成功后会有一个这样的页面:
如果出现部署失败,基本上问题就是要去修改一个文件的配置tsconfig.json
:
"baseUrl": ".",
"paths": {
"@/*" : ["./*"],
"@/app/*": ["app/*"]
}
然后就可以访问了。注意!访问需要科学上网!
今后只要github上的主分支一更新代码,Vercel就会自动重新部署~
通过Vercel创建数据库
点进你的项目,然后选择图中的按钮,无脑下一步:
然后如图依次点击,把连接数据库的信息复制下:
然后在next工程的根目录中创建文件.env
把内容复制进去。
同时别忘了git忽略这个文件的上传,因为这个文件只是供我们开发环境使用的:
接着在我们的Next应用中需要安装连接Vercel数据库的依赖:
npm i @vercel/postgres
npm install dotenv
这样我们就可以在本地开发中对数据库做一些操作,官方的演示项目是给我们提供了一个脚本文件./scripts/seed.js
,我们只要执行他就能自动创建数据库添加一些数据。不过这里就暂时先不细看他这个例子里面脚本具体要怎么写,感兴趣的可以先去看看原文件。
我给一个我写的简单版本:
const { db } = require("@vercel/postgres");
const revenue = [
{
month: "1",
revenue: "4",
},
];
async function seedRevenue(client) {
try {
// Create the "revenue" table if it doesn't exist
const createTable = await client.sql`
CREATE TABLE IF NOT EXISTS revenue (
month VARCHAR(4) NOT NULL UNIQUE,
revenue INT NOT NULL
);
`;
console.log(`Created "revenue" table`);
// Insert data into the "revenue" table
const insertedRevenue = await Promise.all(
revenue.map(
(rev) => client.sql`
INSERT INTO revenue (month, revenue)
VALUES (${rev.month}, ${rev.revenue})
ON CONFLICT (month) DO NOTHING;
`
)
);
console.log(`Seeded ${insertedRevenue.length} revenue`);
return {
createTable,
revenue: insertedRevenue,
};
} catch (error) {
console.error("Error seeding revenue:", error);
throw error;
}
}
async function main() {
const client = await db.connect();
await seedRevenue(client);
await client.end();
}
main().catch((err) => {
console.error(
"An error occurred while attempting to seed the database:",
err
);
});
推荐执行这些数据脚本文件用指令的方式去执行,例如官方执行这个./scripts/seed.js
,会去给package.json中添加script指令:
"seed": "node -r dotenv/config ./scripts/seed.js"
回到Vercel页面,按照如下步骤就可以看到刚刚脚本添加的数据表了:
点击数据表下方就会出现表里的内容,也可以通过旁边的Query按钮通过SQL语句去查询。
请求数据
在Next工程中写的组件,其实准确的来说叫服务端组件,他是在服务端先构建执行的,然后请求需要的数据,填充到组件后,再返回给浏览器,这种也就是服务端渲染。
这种方式的好处是:
- 前端开发能够知道请求的数据到底来自哪个表,对后端的业务多少了解一些
- Next把数据填充完后会编译后再给到浏览器端,所以代码安全问题得到保证
- 浏览器不需要向以前一样承担所有的数据获取和渲染工作
我们来看看具体怎么做,同理我还是拿官方的例子稍微简化下,在/app/lib/data.ts
文件中,主要写获取表数据的逻辑:
import { sql } from '@vercel/postgres';
import { unstable_noStore as noStore } from 'next/cache';
type Revenue = {
month: string;
revenue: number;
};
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
noStore();
try {
// Artificially delay a response for demo purposes.
// Don't do this in production :)
// console.log('Fetching revenue data...');
// await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql<Revenue>`SELECT * FROM revenue`; // 这种传递参数的方式可以百度下
// console.log('Data fetch completed after 3 seconds.');
return data.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
然后我们在组件中就可以通过调用导出的函数直接获取数据。
import { fetchRevenue } from '@/app/lib/data';
export default async function Page() {
const revenue = await fetchRevenue();
// ...
}
如果请求的函数比较多,且想要一次性拿到所有结果,那么可以在请求函数里使用Promise.all,官方也给出了例子:
export async function fetchCardData() {
noStore();
try {
// You can probably combine these into a single SQL query
// However, we are intentionally splitting them to demonstrate
// how to initialize multiple queries in parallel with JS.
const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = sql`SELECT
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,
]);
const numberOfInvoices = Number(data[0].rows[0].count ?? '0');
const numberOfCustomers = Number(data[1].rows[0].count ?? '0');
const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0');
const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0');
return {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
};
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch card data.');
}
}
静态渲染和动态渲染
上面提到的服务端渲染,其实有两个渲染方式,静态渲染和动态渲染。我和官方文档反着来介绍,先说说动态渲染。
动态渲染
当我们采用动态渲染的方式时,在项目重新部署/构建或者页面点击切换到相对应的内容的情况下,服务端会重新获取数据并渲染组件,给我们浏览器返回最新的静态资源。
具体做法如同上面的请求数据代码例子中,请求方法开头加个noStore()
,表明不需要缓存,一切都是即时的,也就是动态的:
import { sql } from '@vercel/postgres';
import { unstable_noStore as noStore } from 'next/cache';
// ...
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
noStore();
// ...
}
这样就能够保证我们虽然服务端渲染方式,但用户总能看到的最新数据。
静态渲染
当我们采用静态渲染的方式时,项目在重新部署/构建或者重新请求对应页面内容(例如清空缓存的刷新)的情况下,服务端会重新获取数据渲染组件。我们有余力的话,还可以把这些渲染后的结果资源分配到各个CDN中缓存。
如果在非 “项目在重新部署/构建或者重新请求对应页面内容(例如清空缓存的刷新)的情况下” ,Next会给浏览器返回之前的缓存文件。
这样做的优点是:
- 用户通过CDN快速访问资源,拿到的还是可以直接显示的最终结果,各地区用户体验很好。
- 公司服务器和各个CDN服务器压力减小很多,拿的都是缓存资源。
- 对SEO非常友好,我们的最终结果什么信息都在上面渲染好了,爬虫就容易爬了,提高搜索引擎的排名。不像之前的CRA方式构建的项目,只有一个div id为app的index.html文件。
但这种静态渲染只适合那种不怎么变化、大家看的都一样的页面。
当有新的部署构建或数据更新,如果我们页面不手动刷新,在页面里怎么跳转都不会去拿最新的静态资源,显示的还是旧内容。
做法就是把noStore()
去掉即可。
生产环境和开发环境的不同表现
以上静态和动态渲染的表现形式都是我在开发环境上测试的,到了生产环境情况有点不一样了。
在静态渲染的情况下,只有重新部署的情况下,缓存的文件资源才会重新更新,用户才能展示最新数据。
在动态渲染的情况下,只有重新部署和页面普通刷新的情况下,缓存的文件资源才会重新更新,用户才能展示最新数据。 (后面发现有可能是重新部署完后浏览器或者服务器那边有缓存的情况,过段时间后的表现就和上面加粗字总结的一样了)
加载动画
页面阻塞
官方特意说明了,开发环境下静态或动态渲染中,如果接口请求时间过久,会导致页面第一次去获取文件的时候,页面等待时间过久的情况,看起来页面就像是卡住了一样。要避免!
例如可以这样模拟,在请求方法中加个异步阻塞:
import { sql } from "@vercel/postgres";
import { unstable_noStore as noStore } from "next/cache";
type Revenue = {
month: string;
revenue: number;
};
export async function fetchRevenue() {
noStore();
try {
console.log('Fetching revenue data...');
await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql<Revenue>`SELECT * FROM revenue`; // 这种传递参数的方式可以百度下
console.log('Data fetch completed after 3 seconds.');
return data.rows;
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch revenue data.");
}
}
结合上一小节的结论,生产环境下,对动态渲染影响更大。
加loading组件
Next给我们提供了添加loading组件的机制,去解决请求时间过长时的等待。
给路由组件加
以下说的父子组件都是说路由组件。
方法一:给父组件page.tsx和子组件page.tsx统一同一个loading.tsx组件。
做法很简单,给父组件page.tsx的同级新建一个loading.tsx组件就ok了,组件内容自己写,可以是转圈圈,也可以是骨架图。
这样父子组件都共用了一个loading.tsx组件。
例子:
方法二:只给父组件与子组件各有独立的loading.tsx组件
有时候呢,我们父子组件要展示不同的loading效果,那么可以这样做。在父组件文件夹中创建一个(overview)
文件夹,把父组件的loading组件和page组件都放进去。然后子组件文件夹创建一个自己的loading组件。这样一来,父子组件都有各自的loading效果了。
例子:
这种路由加载有个缺点,如果组件中有一个小组件请求很慢,就会影响整个路由组件的加载时间。
给非路由组件加
以下说的组件指代普通的组件。
说白了就是用React的Suspense组件,使用步骤第一步,子组件需要的请求方法写在子组件里面。
第二步,在父组件中引用这个子组件:
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
// ...
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
// ...
子组件还没请求完就会用写好的骨架屏组件RevenueChartSkeleton先显示。
官方叫这种方式为流式传输。意思就是一个页面的展示,先渲染好的先渲染好的先展示,后渲染好的后展示。
官方还建议了下,如果一个大区域中需要显示有很多这种有单独加载动画的子组件,例如很多卡片组件。那么建议把这些卡片组件再封装成一个大的组件中,然后把卡片组件loading组件去掉,统一成这个大组件的loading组件。
这样做的好处就是:
- 刷新页面,这些组件能同时加载,是一个大区域的加载动画。
- 官方说了这句话 “虽然你可以为每个单独的卡片获取数据,但这可能会导致卡片加载时出现弹出效果,这对用户来说可能是视觉上的冲击。” 这个还不太明白意思,以后尝试研究下。
路由组件和非路由组件的结合
前面说了路由组件有个缺点,如果组件中有一个小组件请求很慢,就会影响整个路由组件的加载时间。那么我们可以把一个路由组件去掉loading组件,把路由组件内容用非路由组件加载的方式来写。就能解决这个问题。
局部预渲染(实现性质)
官方的文档看的不是特别懂,我的理解是,官方提出了一个实现性质的功能叫局部预渲染,为了解决流式传输中一个没解决的场景。
假设页面上有一个区域大组件,大组件里面有个小组件,两者内部都需要自己请求数据显示,那么当小组件先在服务端渲染完,还要等大组件渲染完才能一起在浏览器端上显示。
Next就提供了一个实验功能,能够让小组件先在页面上显示,然后给大组件没显示的地方留区域占位。
当然我理解可能也不对,欢迎大家指出~
未完待续…
感想
了解完Next.js的皮毛后,我对网上说的 “Web又倒退重回MVC时代” 的调侃类观点有了自己的看法。
我认为从MVC到MVVC的进化,最主要的原因是当时Web2.0时代发展迅速,页面的交互变得越来越复杂,MVC模式已经渐渐支撑不了,所以再转向MVVC模式。
到了如今类似Next这样的技术框架出现,已经能够在满足复杂页面的开发的同时使用MVC模式,确切的来说是MVC和MVVC的混合模式。集成了各自的优点,这不是一种倒退,而是进步。