Skip to content

NextJs教程式笔记

Next.js 全栈开发

https://nextjs.org 是一个服务端渲染框架

创建项目

1
2
npx create-next-app@latest . # 在当前空文件夹中创建
# 除了最后一项全选择 True 即可

安装插件ES7+ React/Redux/React-Native snippetstsx中键入rfce快速生成代码片。

在app下任意创建一个文件夹就是一个页面的路由,比如app/demo。在文件夹中可以创建page.tsxlayout.tsx分别为页面和模版,如果没有创建模版页面也能通过路由访问,也可以创建模版将页面作为children嵌入其中。

page.tsx是固定的名字,每一个路由下都要有它且名字不能改变。

创建一个demo/list/page.tsx可以看到demo模版依然作为顶层模版。list下也可以创建一个layout.tsx

layout页面一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react'

function DemoLayout({children}: any) {
return (
<div className='demo'>
<h2>这是demo页面的模版...</h2>
<hr />
{children}
</div>
)
}

export default DemoLayout

如果要创建动态路由,创建app/demo/list/[id]/page.tsx就可以,访问http://localhost:3000/demo/list/1就出来了。

获取params的方式:

1
2
3
4
5
6
7
8
9
import React from 'react'

function ListDetailPage({params}) {
return (
<div>ListDetailPage -- {params.id}</div>
)
}

export default ListDetailPage

如果只需要一个idparams就可以用ts显示地申明出来:

1
2
3
4
5
function ListDetailPage({params}: { params: {id: string} }) {
return (
<div>ListDetailPage -- {params.id}</div>
)
}

分组

app/demo下创建:

  • (admin)/layout.tsx
  • (admin)/goods/page.tsx
  • (admin)/us/page.tsx

这相当于是将这几个页面分组在一起,(admin)并不会体现在路由当中,仍然可以通过/demo/us来访问我们的页面。但(admin)/layout.tsx却会作用在我们所有的分组页面当中。

比如我们的layout.tsx

1
2
3
4
5
6
7
8
9
10
11
import React, { ReactNode } from 'react'

function AdminLayout({children}: {children: ReactNode}) {
return (
<div className='demo-admin p-8 bg-rose-400 text-white'>
<h4>这是一个admin页面中的内容</h4>
{children}</div>
)
}

export default AdminLayout

可以看到玫瑰色的layout现在分组中的页面

metadata

如需更改页面的 metadata,在对应页面的page.tsx添加:

1
2
3
4
5
6
7
import { Metadata } from 'next'

export const metadata: Metadata = {
title: "这是一个列表页",
description: "这是一个用nextJs输出的列表页",
keywords: "next.js,react"
}

title会发生变化,常用于SEO优化。

要定义一个动态的metadata,比如根据list/[id]来动态改变页面的title可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React from "react";
import { Metadata } from "next"; // 引入Metadata

// 定义Ts类型
type Props = {
params: { id: string };
searchParams: any;
};

// 暴露动态的metadata定义
export async function generateMetadata({
params,
searchParams,
}: Props): Promise<Metadata> {
return {
title: "这是详情页--" + params.id,
};
}

function ListDetailPage({ params }: { params: { id: string } }) {
return <div>ListDetailPage -- {params.id}</div>;
}

export default ListDetailPage;

关于searchParams在代码中的接收方法,修改[id]/page.tsx中这一部分:

1
2
3
function ListDetailPage({ params, searchParams }: Props) {
return <div>ListDetailPage -- {params.id}, Query -- {searchParams.name}</div>;
}

接着可以通过http://localhost:3000/demo/list/2342asdf?name=老刘发现页面中包含文本:

1
2
3
4
5
6
这是demo页面的模版...
---------------------------
这是list页面的模版
---------------------------

ListDetailPage -- 2342asdf, Query -- 老刘

需要说明的是,组件中接受到的searchParamsparams, searchParamsgenerateMetadata()中接受到的是一样的,都是路由的信息。

编写 API

服务器端渲染

创建文件夹app/api用于存放我们的后端 api,建立一个api/goods文件夹,并创建goods/route.ts

一定要是route.ts不能是其他名字,如page.tsx一个原理。

1
2
3
4
5
6
7
8
import { NextResponse } from "next/server" // nestJs 请求对象

export const GET = () => {
return NextResponse.json({ // 返回一个json格式
success: true,
errorMessage: ''
})
}

直接用http://localhost:3000/api/goods就能访问我们的api了。

同样也可以使用动态路由,比如需要访问http://localhost:3000/api/goods/123

api/goods/[id]下创建route.ts

1
2
3
4
5
6
7
8
import { NextResponse } from "next/server" // nestJs 请求对象

export const GET = () => {
return NextResponse.json({ // 返回一个json格式
success: true,
errorMessage: '获取单条记录',
data: {}
})

如果要获取params:

1
2
3
4
5
6
7
8
9
10
11
import { NextResponse } from "next/server"; // nestJs 请求对象

// req 是前端传过来的请求,第二个参数是一个上下文,我们可以解析上下文props获得其中的params参数,比如goods/1231中的1231就是一个params
export const GET = (req: NextResponse, { params }: any) => {
return NextResponse.json({
// 返回一个json格式
success: true,
errorMessage: "获取单条记录:" + params.id,
data: {},
});
};

关键在于: export const GET = (req: NextResponse, { params }: any) => {}

客户端调用接口

如果直接在客户端组件中使用useEffect会报错。

我们可以在list下创建一个_components/List.tsx来单独封装我们的列表渲染组件,组件中代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
"use client"
import React, {useEffect, useState} from 'react'

type Item = {
id: number,
name: string
}

function List() {
// 也可以是 [age, setAge],相当于定义变量和更新变量的手段
// 这种数组的结构是 React中定义组件状态的常用手法
const [data, setData] = useState<Item[]>([])
// <Item[]> 用于定义这个状态(State)的数据类型,并初始化为([])
// useEffect 勾子 用于初始化的时候执行
useEffect(() => {
// 注意这里是/api/goods而不是 api/goods,差一个slash有天壤之别
fetch('/api/goods').then(
res => res.json() // 从 api/goods中拿到json数据
).then(res => setData(res.data)) // 用setData更新状态为取到的数据

}, [])
return (
<div className='list'>
<ul className='bg-sky-500 text-white'>
{data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
)
}

export default List

一般来说我们将客户端组件可以单独封装并放在_components下供服务端组件调用。

为了有数据可以用,可以修改api/goods/route.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { NextResponse } from "next/server" // nestJs 请求对象

export const GET = () => {
return NextResponse.json({ // 返回一个json格式
success: true,
errorMessage: '',
data: [
{
id: 0,
name: '手推车'
},
{
id: 1,
name: '小李子'
}
]
})
}

这样就完成了接口的调用了。其中这里的useState是React语法,可以在React文档查看具体的用法。

数据库 Prisma

安装

1
2
npm install prisma --save-dev # 安装
npx prisma init --datasource-provider sqlite # 初始化为sqlite

初始化完成后在根目录多了prisma文件夹,可以在其中定义我们的数据库模型。

定义Goods表的模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}

datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}

model Goods {
// id 类型 设置为主键 设置为unique 默认值
id String @id @unique @default(uuid())
name String
desc String @default("")
content String @default("")
// 用 @map 起别名
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

// 表的别名
@@map("products")
}

完成后生成一下数据表:

1
npx prisma db push

prisma/dev.db就是我们的数据库了。

对于数据库的连接在 prisma 的官方文档中有想尽的说明,我们只需要在文档中搜索:next.js就能找到。

实则不然,非常难找,但我还是找到了,见文档

1
2
3
4
5
6
7
8
9
// lib/prisma.ts
// 使用相对路径导入生成在 `src/generated/prisma` 的 Prisma Client
import { PrismaClient } from "./generated/prisma";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

将这段代码粘贴到src/db.ts中,官网给的示例似乎有点问题,这里是用Capilot生成的能跑的连接代码。

要查询数据和增加数据,修改api/goods/route.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { NextRequest, NextResponse } from "next/server" // nestJs 请求对象
import { prisma } from "@/db"

export const GET = async () => {
// 查询数据,根据创建时间倒序排列
const data = await prisma.goods.findMany({
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json({ // 返回一个json格式
success: true,
errorMessage: '获取数据成功',
data: data
})
}

// 添加 GOODS
export const POST = async (req: NextRequest) => {
const data = await req.json() // 获取请求体中传递的json数据
// data是请求创建的json格式{ name: '商品名' }
await prisma.goods.create({ // 利用prisma.create插入一个新的数据项
data,
})
return NextResponse.json({ // 返回一个json格式
success: true,
errorMessage: '创建成功',
data: {}
})
}

完事儿后我们就写好了可以创建商品的API了,打开PostMan添加一个POST请求:http://localhost:3000/api/goods,写入请求体(格式为raw):

1
2
3
{
"name": "测试POst添加商品"
}

请求成功:

1
2
3
4
5
{
"success": true,
"errorMessage": "创建成功",
"data": {}
}

antd组件库与后台

安装antd组件库:

1
npm i antd

要创建一个管理后台,我们可以先创建一个app/admin用于后台页面,接下来继续创建:

  • _components/AntContainer.tsx
  • app/admin/login/page.tsx
  • app/admin/layout.tsx

layout.tsxpage.tsx中用rfce创建基本的代码框架。

使用antd开发,只需要在其官网复制粘贴就好:https://ant.design/docs/react/introduce-cn

由于 antd 的更新,现在已经可以直接在 Next.js 很方便地使用。见文档:https://ant.design/docs/react/use-with-next-cn

比如创建一个按钮组件_components/Button.tsx

1
2
3
4
5
6
7
8
9
10
11
'use client'
import React from 'react';
import { Button } from 'antd';

const MyButton = () => (
<div className="App">
<Button type="primary">Button</Button>
</div>
);

export default MyButton;

完成后记得为layout.tsx添加children,类型申明为any即可。然后在page.tsx中引入Button将能被看到:

1
2
3
4
5
6
7
8
9
10
11
import React from 'react'
import MyButton from '../_components/Button'
function LoginPage() {
return (
<div>
<MyButton />
</div>
)
}

export default LoginPage

页面上会出现一个蓝色的小按钮。

一个简单的后台页面

我们来加一个登陆页面,修改admin/login.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
'use client'
import React from 'react'
import { Card, Form, Button, Input } from 'antd'
// import MyButton from '../_components/Button'
// 这里的 Form.Item 实际上是 Form 对象的一个属性
// Form 对象下有一个 Form.Item 对象,本身是一个 React 组件
function LoginPage() {
return (
<div>
{/* <MyButton /> */}
<Card title="Next.js 全栈管理后台">
<Form.Item label="用户名">
<Input placeholder='请输入用户名'></Input>
</Form.Item>
<Form.Item label="密码">
<Input.Password placeholder='请输入密码'></Input.Password>
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit'>登陆</Button>
</Form.Item>
</Card>
</div>
)
}

export default LoginPage

完成后会出现表单,但样式似乎有点奇怪,这是因为 Tailwindcss 和 Ant 产生了一些冲突,我们可以修改 Tailwindcss 的配置tailwind.config.ts来解决。

这里发现我似乎没有原教程中这个问题,省略处理过程。

我们修改一下上述页面,让它具有获取表单内容的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
"use client";
import React from "react";
import { Card, Form, Button, Input } from "antd";
// import MyButton from '../_components/Button'
// 这里的 Form.Item 实际上是 Form 对象的一个属性
// Form 对象下有一个 Form.Item 对象,本身是一个 React 组件
function LoginPage() {
return (
<div className="pt-20 flex justify-center items-center h-screen">
{/* <MyButton /> */}
<Card title="Next.js 全栈管理后台" className="w-4/5">
<Form labelCol={{span: 3}} onFinish={(v) => {
console.log(v);
}} >
{/* labelCol对齐表单元素 */}
<Form.Item name="username" label="用户名">
<Input placeholder="请输入用户名"></Input>
</Form.Item>
<Form.Item name="password" label="密码">
<Input.Password placeholder="请输入密码"></Input.Password>
</Form.Item>
<Form.Item>
{/* block 用于让按钮撑满宽度 */}
<Button block type="primary" htmlType="submit">
登陆
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}

export default LoginPage;

这里为Form.Item添加了name属性,并通过onFinish={(v) => {}} >来打印获取表单数据。

中间件做登陆判断

我们创建一个app/admin/dashboard文件夹,并创建:

  • admin/dashboard/_components/PageContainer.tsx
  • admin/dashboard/page.tsx

这样就有了看版页面。

看板组件dashbaord/_components/PageContainer.tsx中:

1
2
3
4
5
6
7
8
9
10
11
'use client'

import { Card } from "antd"

function PageContainer({children, title}: any) {
return (
<Card title={title}>{children}</Card>
)
}

export default PageContainer

我们需要一个中间件来判断是否登陆,检测到未登陆则重定向到login.tsx页。

创建一个文件:src/middleware.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
console.log('中间件执行力!');
if (request.nextUrl.pathname.startsWith('/admin')) {
// 如果是admin下的路由
if (!request.nextUrl.pathname.startsWith('/admin/login')) {
// 并且不是登陆页面
if (request.cookies.get('admin-token')) {
// 已经登陆了,啥都不做
} else {
return NextResponse.redirect(new URL('/admin/login', request.url));
// 重定向到登陆页面
}
}
}

这时我们会被强制拉到login页面了。

登陆接口

创建app/api/admin/login/route.ts用于登陆接口:

1
2
3
4
5
6
7
8
9
10
11
12
import { NextRequest, NextResponse } from "next/server";

export const POST = (req: NextRequest) => {
return NextResponse.json({
success: true,
errorMessage: '登陆成功'
}, {
headers: {
'Set-Cookie': 'admin-token=123;Path=/'
}
})
}

安装Vscode插件:Thunder Client便于直接测试接口。

安装完成后打开 Thunder Client 面板,new一个新的 POST 接口,直接发送一个localhost:3000/api/admin/login请求,可以看到成功发送了。

1
2
3
4
{
"success": true,
"errorMessage": "登陆成功"
}

并且可以在Response Headers中看到 set-cookie 被设置为:admin-token=123;Path=/

完成后我们在客户端加上发送登陆请求的代码:

只需要修改关键部分:

1
2
3
4
import { useRouter } from "next/navigation";

function LoginPage() {
const nav = useRouter()
1
2
3
4
5
6
7
8
<Form labelCol={{span: 3}} onFinish={async (v) => {
const res = await fetch('/api/admin/login', {
method: 'POST',
body: JSON.stringify(v)
}).then(res=>res.json())
console.log(res);
nav.push('/admin/dashboard')
}} >

完成后就可以登陆跳转到看板了。

接下来我们来做一个后台面板,修改admin下的文件结构,将layout.tsx和dashboard单独合并在一起。

修改admin/(admin-layout)/layout.tsx中的代码,将ant中的带有侧边导航的布局面板加入其中:

1
2
3
4
5
6
7
8
9
10
import React from 'react';
import AntAdmin from '../_components/AntAdmin';

function AdminLayout({children}: any) {
return (
<AntAdmin>{children}</AntAdmin>
)
}

export default AdminLayout

并对login/page.tsx页面做适当的修改(主要是样式的调整):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
"use client";
import React from "react";
import { Card, Form, Button, Input } from "antd";
import { useRouter } from "next/navigation";
// import MyButton from '../_components/Button'
// 这里的 Form.Item 实际上是 Form 对象的一个属性
// Form 对象下有一个 Form.Item 对象,本身是一个 React 组件
function LoginPage() {
const nav = useRouter()
return (
<div className="pt-20 flex justify-center items-center h-full">
{/* <MyButton /> */}
<Card title="Next.js 全栈管理后台" className="w-[500px]">
<Form labelCol={{span: 3}} onFinish={async (v) => {
const res = await fetch('/api/admin/login', {
method: 'POST',
body: JSON.stringify(v)
}).then(res=>res.json())
console.log(res);
nav.push('/admin/dashboard')
}} >
{/* labelCol对齐表单元素 */}
<Form.Item name="username" label="用户名">
<Input placeholder="请输入用户名"></Input>
</Form.Item>
<Form.Item name="password" label="密码">
<Input.Password placeholder="请输入密码"></Input.Password>
</Form.Item>
<Form.Item>
{/* block 用于让按钮撑满宽度 */}
<Button block type="primary" htmlType="submit">
登陆
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}

export default LoginPage;

到这里项目代码开始有点复杂了,不太方便记录在笔记中。从下面开始只介绍一些新的概念。

About this Post

This post is written by Sy, licensed under CC BY-NC 4.0.