nextjs是一个服务器端渲染框架
官网地址 https://nextjs.org
创建项目 nextjs-learning
npx create-next-app@latest nextjs-learning
#按默认项创建
#测试项目源码地址:
https://gitee.com/galen.zhang/vue3-demo/tree/master/nextjs-learning
cd nextjs-learning
npm run dev
#访问 http://localhost:3000/
修改首页 app/page.tsx
export default function Home() {
return (
这是一个nextjs项目
)
}
全局样式 globals.css
只保留一点样式
@tailwind base;
@tailwind components;
@tailwind utilities;
Next.js是一个文件系统基础的路由系统,在文件夹中放一个文件 page.tsx
这将会自动成为一个可访问的路由
例如首页 app/page.tsx
export default function Home() {
return (
这是一个nextjs项目
)
}
新建文件 app/demo/page.tsx
export default function Demo() {
return (
Demo page
)
}
在文件夹中放一个 page.tsx
文件,路由为文件夹的名称
http://localhost:3000/demo
在 page.tsx
同目录下创建文件 layout.tsx
(每个目录下可以有一个layout.tsx文件)
app/demo/layout.tsx
export default function DemoLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
Demo layout
{children}
>
)
}
在demo及其子路由页面,会应用这个样式
新建文件 app/dashboard/settings/page.tsx
在每个目录下会放一个page页面
export default function Page() {
return hello world
}
访问地址
http://localhost:3000/dashboard/settings
在目录下创建文件 layout.tsx
(每个目录下可以有一个layout.tsx文件)
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
dashboard layout
{children}
>
)
}
在demo文件中,创建文件 app/demo/[id]/page.tsx
import React from "react"
function Detail({ params }: { params: { id: string } }) {
return (
Detail page -- {params.id}
)
}
export default Detail
访问地址
http://localhost:3000/demo/123
http://localhost:3000/demo/abc
创建文件
(admin)/list/page.tsx
(admin)/about/page.tsx
(admin)/layout.tsx
在分组 (admin)
中可以配置一个共用的页面样式 layout.tsx
分组路径在实际访问时不需要指定
http://localhost:3000/list
http://localhost:3000/about
页面标题Metadata的固定写法
import React from "react"
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '这是一个List页面',
description: '这是一个nextjs构建的List页面',
keywords: 'React、Nextjs'
}
function List() {
return (
List page
)
}
export default List
也可以动态设置页面标题
import React from "react"
type Props = {
params: { id: string }
searchParams: any
}
// 可以动态的进行metadata的生成
export async function generateMetadata({ params, searchParams }: Props) {
return {
title: '这是详情页 -- ' + params.id + ' -- ' + searchParams.name
}
}
function Detail({ params, searchParams }: Props) {
return (
Detail page -- {params.id} --, query -- {searchParams.name}
)
}
export default Detail
访问地址
http://localhost:3000/demo/123?name=123abc
新建文件 api/goods/route.ts
在文件夹中要有一个固定的文件 route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({
success: true,
errorMessage: '获取数据成功',
data: [{
id: 1,
name: '张三'
}, {
id: 2,
name: "李四"
}]
})
}
// 插入数据
export const POST = async (req: NextRequest) => {
const data = await req.json()
return NextResponse.json({
success: true,
errorMessage: '创建成功',
data
})
}
访问api接口
http://localhost:3000/api/goods
新建文件 api/goods/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest, { params }: any) {
return NextResponse.json({
success: true,
errorMessage: '获取单条数据' + params.id,
data: {}
})
}
访问地址
http://localhost:3000/api/goods/123
创建组件 app\list\_components\List.tsx
'use client'
import React, { useEffect, useState } from "react"
type Item = {
id: number
name: string
}
function List() {
const [data, setData] = useState- ([])
useEffect(() => {
fetch('/api/goods').then(res => res.json()).then(res => setData(res.data))
}, [])
return (
{data.map((item) => (
- {item.name}
))}
)
}
export default List
在 app\list\page.tsx
中使用组件
import React from "react"
import { Metadata } from 'next'
import List from "./_components/List"
export const metadata: Metadata = {
title: '这是一个List页面',
description: '这是一个nextjs构建的List页面',
keywords: 'React、Nextjs'
}
function ListPage() {
return (
List page
)
}
export default ListPage
注意:如果是在服务端渲染调用api,则需要使用全路径(客户端页面必须在文件开头添加’use client’,不添加默认为服务端渲染页面)
import { Metadata } from 'next'
import React from 'react'
export const metadata: Metadata = {
title: "这是一个列表页",
description: "设置SEO信息",
keywords: "nextjs,react"
}
async function getData() {
const res = await fetch('http://localhost:3000/api/goods')
if (!res.ok) {
throw new Error('Failed to fetch data')
}
return res.json()
}
type Item = {
id: number,
name: String
}
async function ListPage() {
const data = await getData()
console.log('getData for goods: ', data)
return (
{
data.data.map((item: Item) => (
- {item.name}
))
}
)
}
export default ListPage
页面链接跳转 blog\PostList.tsx
import Link from "next/link"
export default function Page({ posts }) {
return (
<>
{
posts.map((post) => (
-
{post.title}
))
}
>
)
}
页面路由跳转
'use client'
import Link from "next/link"
import { useRouter } from "next/navigation"
import PostList from './blog/PostList'
export default function Home() {
const router = useRouter()
const postData = [
{ id: 1, slug: "aaa", title: "aaa" },
{ id: 2, slug: "bbb", title: "bbb" }
]
return (
<>
Home Page
Dashboard Link
>
)
}
使用 (文件夹)
的形式组织文件
在访问页面时,路由路径中可以不包含括号中的文件夹名称
在文件夹中也可以创建页面布局模板 layout.tsx
app\(shop)\account\page.tsx
http://localhost:3000/account
[文件夹]
[[文件夹]]
//可选路径
// app/blog/[slug]/page.jsx
export default function Page({ params }: { params: { slug: string }}) {
return (
blog slug: {params.slug}
)
}
http://localhost:3000/blog/abc
app/shop/[…slug]/page.js
http://localhost:3000/shop/abc
http://localhost:3000/shop/abc/123
loading.js
export default function Loading() {
return (
Loading
)
}
在 layout.js
中引用文件
import { Suspense } from 'react'
import Loading from './loading'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
}>
dashboard layout
{children}
>
)
}
error.js
'use client'
export default function Error({ error, reset }: {
error: Error,
reset: () => void
}) {
return (
出错啦
)
}
安装Prisma
npm config set registry https://registry.npmmirror.com/
npm install prisma --save-dev
#初始化sqlite数据库
npx prisma init --datasource-provider sqlite
修改数据表结构映射关系 prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Goods {
id String @id @unique @default(uuid())
name String
desc String @default("")
content String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("products")
}
执行更新表结构
npx prisma db push
创建工具类数据库连接 db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
服务端api接口中查询表中数据 api/goods/route.ts
import { prisma } from '@/db'
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
const data = await prisma.goods.findMany({
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json({
success: true,
errorMessage: '获取数据成功',
data
})
}
// 插入数据
export const POST = async (req: NextRequest) => {
const data = await req.json()
await prisma.goods.create({
data
})
return NextResponse.json({
success: true,
errorMessage: '创建成功',
data
})
}
POST http://localhost:3000/api/goods
{“name”: “华为手机”}
GET http://localhost:3000/api/goods
Antd
做后台管理页面安装 Antd
npm install antd --save
npm install @ant-design/icons --save
tailwind
与 Antd
样式有冲突
修改文件 tailwind.config.js
plugins: [],
// 不要覆盖原有样式,禁止设置初始值
corePlugins: {
preflight: false
}
配置引入 Antd
的组件 admin\_components\AntdContainer.tsx
'use client'
import React from 'react'
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
function AntdContainer({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}
export default AntdContainer
配置公共布局样式 admin\layout.tsx
import React from 'react'
import AntdContainer from './_components/AntdContainer'
function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}
export default AdminLayout
客户端登录页面,使用 Form
表单提交数据
admin\login\page.tsx
'use client'
import { Card, Button, Form, Input } from "antd"
import { useRouter } from "next/navigation"
function LoginPage() {
const nav = useRouter()
return (
)
}
export default LoginPage
服务端登录api接口 api/admin/login/route.ts
import { NextRequest, NextResponse } from "next/server"
export const POST = async (req: NextRequest) => {
const data = await req.json()
console.log('server login param', data)
// 需要自己添加处理登录逻辑
return NextResponse.json({
success: true,
errorMessage: '登录成功'
}, {
headers: {
'Set-Cookie': 'admin-token=123;Path=/'
}
})
}
中间页做登录判断 middleware.ts
每次请求时都会执行
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const url = request.nextUrl.pathname
if (url.startsWith('/admin') && !url.startsWith('/admin/login')) {
if (request.cookies.get('admin-token')) {
} else {
return NextResponse.redirect(new URL('/admin/login', request.url))
}
}
}
包含左侧导航栏,右侧内容显示区域
admin\_components\AntdAdmin.ts
'use client'
import React, { useState } from 'react'
import { Layout, Menu, theme, Button } from 'antd';
import 'antd/dist/reset.css'
import { UploadOutlined, UserOutlined, MenuUnfoldOutlined, MenuFoldOutlined, DashboardOutlined } from '@ant-design/icons';
const { Header, Content, Footer, Sider } = Layout;
import { useRouter } from 'next/navigation';
function AntdAdmin({
children,
}: {
children: React.ReactNode
}) {
const nav = useRouter()
const [collapsed, setCollapsed] = useState(false);
const {
token: { colorBgContainer },
} = theme.useToken();
return (
: }
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '16px',
width: 64,
height: 64,
}}
/>
{children}
)
}
export default AntdAdmin
Card卡片页面样式 admin\_components\PageContainer.ts
'use client'
import { Card } from 'antd'
function PageContainer({
children, title
}: {
children: React.ReactNode, title: string
}) {
return (
{children}
)
}
export default PageContainer
admin后台样式分组
admin\(admin-layout)\layout.ts
import React from 'react'
import AntdAdmin from '../_components/AntdAdmin'
function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}
export default AdminLayout
测试样式页面
admin\(admin-layout)\dashboard\page.tsx
import React from 'react'
import PageContainer from '../../_components/PageContainer'
function DashboardPage() {
return (
任务看板
)
}
export default DashboardPage
添加列表、Form页面
admin\(admin-layout)\user\page.tsx
'use client'
import React from 'react'
import { Form, Table, Input, Button, Card } from 'antd'
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
function UserPage() {
return (
} type='primary' />>}>
} type='primary'>
)
}
export default UserPage
新建数据表 prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Goods {
id String @id @unique @default(uuid())
name String
desc String @default("")
content String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("products")
}
model Article {
id String @id @unique @default(uuid())
title String
desc String? @default("")
content String? @default("")
image String? @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("article")
}
更新数据模型
npx prisma db push
api\admin\article\route.ts
import { prisma } from '@/db'
import { NextRequest, NextResponse } from 'next/server'
// 查询列表带分页功能
export async function GET(req: NextRequest) {
// pageNum 页码
// pageSize 每页条数
const pageNum = (req.nextUrl.searchParams.get('pageNum') as any) * 1 || 1
const pageSize = (req.nextUrl.searchParams.get('pageSize') as any) * 1 || 10
const title = (req.nextUrl.searchParams.get('title') as string) || ''
const data = await prisma.article.findMany({
where: {
title: {
contains: title // 模糊查询
}
},
orderBy: {
createdAt: 'desc'
},
take: pageSize, // 取多少条数据
skip: (pageNum - 1) * pageSize // 跳过多少条
})
const total = await prisma.article.count({
where: {
title: {
contains: title // 模糊查询
}
}
})
return NextResponse.json({
success: true,
errorMessage: '获取数据成功',
data: {
list: data,
pages: Math.ceil(total / pageSize),
total
}
})
}
// 新增
export const POST = async (req: NextRequest) => {
const data = await req.json()
await prisma.article.create({
data
})
return NextResponse.json({
success: true,
errorMessage: '创建成功',
data
})
}
更新、删除数据接口
api\admin\article\[id]\route.tsx
import { prisma } from '@/db'
import { NextRequest, NextResponse } from 'next/server'
export const PUT = async (req: NextRequest, { params }: any) => {
const data = await req.json()
const { id } = params
await prisma.article.update({
where: {
id
},
data
})
return NextResponse.json({
success: true,
errorMessage: '修改成功',
})
}
export const DELETE = async (req: NextRequest, { params }: any) => {
const { id } = params
await prisma.article.delete({
where: {
id
},
})
return NextResponse.json({
success: true,
errorMessage: '删除成功',
})
}
文件上传接口
api\common\upload\route.tsx
import { NextRequest, NextResponse } from "next/server"
import dayjs from "dayjs"
import path from "path"
import fs from 'fs'
import { randomUUID } from "crypto"
const saveFile = async (blob: File) => {
const dirName = '/upload/' + dayjs().format('YYYY-MM-DD')
const uploadDir = path.join(process.cwd(), 'public' + dirName)
fs.mkdirSync(uploadDir, {
recursive: true
})
const fileName = randomUUID() + '.png'
const arrayBuffer = await blob.arrayBuffer()
fs.writeFileSync(uploadDir + '/' + fileName, new DataView(arrayBuffer))
return dirName + "/" + fileName
}
export const POST = async (req: NextRequest) => {
const data = await req.formData()
const fileName = await saveFile(data.get('file') as File)
return NextResponse.json({
success: true,
errorMessoge: '文件上传成功',
data: fileName
})
}
富文本编辑器文件上传
api\common\wang_editor\upload\route.tsx
import { NextRequest, NextResponse } from "next/server"
import dayjs from "dayjs"
import path from "path"
import fs from 'fs'
import { randomUUID } from "crypto"
const saveFile = async (blob: File) => {
const dirName = '/upload/' + dayjs().format('YYYY-MM-DD')
const uploadDir = path.join(process.cwd(), 'public' + dirName)
fs.mkdirSync(uploadDir, {
recursive: true
})
const fileName = randomUUID() + '.png'
const arrayBuffer = await blob.arrayBuffer()
fs.writeFileSync(uploadDir + '/' + fileName, new DataView(arrayBuffer))
return dirName + "/" + fileName
}
export const POST = async (req: NextRequest) => {
const data = await req.formData()
const fileName = await saveFile(data.get('file') as File)
return NextResponse.json({
"errno": 0, // 注意:值是数字,不能是字符串
"data": {
"url": fileName, // 图片 src ,必须
// "alt": "yyy", // 图片描述文字,非必须
// "href": "zzz" // 图片的链接,非必须
}
})
}
npm install @wangeditor/editor
npm install @wangeditor/editor-for-react
npm install dayjs
https://www.wangeditor.com/
开源 Web 富文本编辑器
admin\(admin-layout)\article\page.tsx
'use client'
import { useState, useEffect } from 'react'
import { Card, Form, Input, Button, Table, Modal, Space, Popconfirm } from 'antd'
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import MyUpload from '../../_components/MyUpload';
import dynamic from 'next/dynamic';
// 只在客户端中引入富文本编辑器,不在编译的时候做处理
// import MyEditor from '../../_components/MyEditor';
const MyEditor = dynamic(() => import('../../_components/MyEditor'), {
ssr: false
})
type Article = {
id: string,
title: string,
desc: string,
image: string,
content: string,
}
function ArticlePage() {
const [open, setOpen] = useState(false)
const [list, setList] = useState([])
const [query, setQuery] = useState({
pageNum: 1,
pageSize: 10,
title: ''
}) // 查询条件
const [currentId, setCurrentId] = useState('') // 使用一个当前id,表示是新增还是修改
const [total, setTotal] = useState(0) // 总条数
const [imageUrl, setImageUrl] = useState(''); // 上传图片
// 编辑器内容
const [html, setHtml] = useState('')
const [myForm] = Form.useForm() // 获取Form组件
useEffect(() => {
fetch(`/api/admin/article?pageNum=${query.pageNum}&pageSize=${query.pageSize}&title=${query.title}`).then(res => res.json()).then(res => {
setList(res.data.list)
setTotal(res.data.total)
})
}, [query])
useEffect(() => {
if (!open) {
setCurrentId('')
setImageUrl('')
setHtml('')
}
}, [open])
return (
} type='primary' onClick={() => setOpen(true)}>>}>
} type='primary' htmlType='submit'>
}
}, {
title: '操作',
render(v, r) {
return
} type='primary' onClick={() => {
setOpen(true)
setCurrentId(r.id)
setImageUrl(r.image)
setHtml(r.content)
myForm.setFieldsValue(r)
}}>
{
await fetch(`/api/admin/article/${r.id}`, {
method: 'DELETE',
}).then(res => res.json())
setQuery({
...query,
pageNum: 1,
pageSize: 10
})
}}>
} type='primary' danger>
},
}
]}>
setOpen(false)}
onOk={() => {
myForm.submit()
}}>
)
}
export default ArticlePage
admin\_components\MyEditor.tsx
'use client'
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import React, { useState, useEffect } from 'react'
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
function MyEditor({ html, setHtml }: { html: string, setHtml: any }) {
// editor 实例
const [editor, setEditor] = useState(null) // TS 语法
// const [editor, setEditor] = useState(null) // JS 语法
// 模拟 ajax 请求,异步设置 html
useEffect(() => {
// setTimeout(() => {
// setHtml('hello world
')
// }, 1500)
}, [])
// 工具栏配置
const toolbarConfig: Partial = {} // TS 语法
// const toolbarConfig = { } // JS 语法
// 编辑器配置
const editorConfig: Partial = { // TS 语法
// const editorConfig = { // JS 语法
placeholder: '请输入内容...',
MENU_CONF: {
'uploadImage': {
server: '/api/common/wang_editor/upload',
fieldName: 'file'
},
}
}
// 及时销毁 editor ,重要!
useEffect(() => {
return () => {
if (editor == null) return
editor.destroy()
setEditor(null)
}
}, [editor])
return (
<>
setHtml(editor.getHtml())}
mode="default"
style={{ height: '500px', overflowY: 'hidden' }}
/>
{/*
{html}
*/}
>
)
}
export default MyEditor
admin\_components\MyUpload.tsx
import React, { useState } from 'react';
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import { message, Upload } from 'antd';
import type { UploadChangeParam } from 'antd/es/upload';
import type { RcFile, UploadFile, UploadProps } from 'antd/es/upload/interface';
// const getBase64 = (img: RcFile, callback: (url: string) => void) => {
// const reader = new FileReader();
// reader.addEventListener('load', () => callback(reader.result as string));
// reader.readAsDataURL(img);
// };
const beforeUpload = (file: RcFile) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('You can only upload JPG/PNG file!');
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('Image must smaller than 2MB!');
}
return isJpgOrPng && isLt2M;
};
// 组件接收的属性
type Props = {
imageUrl: string;
setImageUrl: any
}
const MyUpload = ({ imageUrl, setImageUrl }: Props) => {
const [loading, setLoading] = useState(false);
const handleChange: UploadProps['onChange'] = (info: UploadChangeParam) => {
if (info.file.status === 'uploading') {
setLoading(true);
return;
}
if (info.file.status === 'done') {
// Get this url from response in real world.
// getBase64(info.file.originFileObj as RcFile, (url) => {
// setLoading(false);
// setImageUrl(url);
// });
console.log('path', info.file.response.data)
setImageUrl(info.file.response.data)
}
};
const uploadButton = (
{loading ? : }
Upload
);
return (
<>
{imageUrl ? : uploadButton}
>
);
};
export default MyUpload;
npm run build
npm start
动态导入:只在客户端中引入,不做服务器内容生成(避免编译打包时报错)
// 只在客户端中引入富文本编辑器,不在编译的时候做处理
// import MyEditor from '../../_components/MyEditor';
const MyEditor = dynamic(() => import('../../_components/MyEditor'), {
ssr: false
})
使用pm2管理进程
https://pm2.fenxianglu.cn/
NODE.JS进程管理工具
npm install pm2@latest -g
#pm2 start app.js
pm2 start npm --name next-app -- start
pm2 ls
pm2 del 0 #删除项目
nginx服务器做代理
# 配置一下nginx服务器,提供静态资源如图片的访问
server {
listen 3031;
location / {
proxy_pass http://localhost:3000;
}
# 配置静态资源目录
location /upload {
alias 你的路径;
}
}
注意:上边的项目代码中包含sqlite本地数据库,是不能在vercel站点中使用的
在vercel中开始部署项目
https://vercel.com/new
关联github账号并选择要部署的项目,按提示一步步操作
国内不能访问vercel提供的域名
使用自己购买的国内域名
CNAME cname.vercel-dns.com
使用vercel提供的Postgres存储数据
第三方PlanetScale已限制国内区域访问了
https://blog.csdn.net/w_monster/article/details/131680662
免费的云数据库:探索PlanetScale,划分分支的MySQL Serverless平台
https://blog.csdn.net/jascl/article/details/131304307
使用 Vercel Edge 上的 PlanetScale 和 Prisma 向我的 Astro 博客添加评论
https://juejin.cn/post/7252951745012727868
如何使用 Next.js、Prisma 和 Vercel Postgres 构建全栈应用程序