NextJs:react开发者的全栈最佳选择(从0-1的react全栈入门指南)
目录
- 前言
- 学习路线
- 随笔
前言
该指南面向vue转react的同学和想学习react的同学,本指南本质上是我个人的学习随笔记录,但也可以用作学习参考。
学习路线
1.TS速成
在当下ts已经是前端必会语言,无论是Vuer还是Reacter,如果你有ts基础可以忽略这一条,如果没有,可以去速成一下:
【20分钟学会TypeScript 无废话速成TS 学不会你来评论区】https://www.bilibili.com/video/BV1gX4y177Kf?vd_source=0d0d6b12377aa593bc3a34f0884de98a
2.React速成视频入门(四个板块)
【30分钟学会React18核心语法 可能是你学会React最好的机会 前端开发必会框架 无废话精品视频】https://www.bilibili.com/video/BV1pF411m7wV?vd_source=0d0d6b12377aa593bc3a34f0884de98a
可以结合下面博客食用:
3.React官方文档
根据自身基础选看即可
4.学习React常用hooks
https://youtu.be/6wf5dIrryoQ?si=CaMTqsMXloKUk9cl
附带一个测验(很简单可做可不做,下面附上链接):
https://quiz.greatstack.dev/rhks
5.学习tailwindcss
由于tailwind在国外且在react上使用广泛,所以tailwindcss是reacter的必修课
【【tailwind】tailwind使用入门】https://www.bilibili.com/video/BV1nV4y1y7ec?vd_source=0d0d6b12377aa593bc3a34f0884de98a
下面附上tailwindcss随笔
6.做一个小项目:One Thing
【绝对React初学者的项目(一件事应用程序)】https://www.bilibili.com/video/BV1qd4y197Ep?vd_source=0d0d6b12377aa593bc3a34f0884de98a
比较可惜的是该教程没有用ts,下面附上one-thing项目随笔
7.学习NextJS14
https://youtu.be/GowPe3iiqTs?si=SeUrE7ogGPeUF47l
教程中的项目会用到MongoDB和next-auth,所以建议去补一下。
教程中的项目源码地址:
Mebius1916/nextjs-demo: next初学者必学的小demo (github.com)
8.学习MongoDB
个人感觉比mysql好用
https://youtu.be/soprdrmpO3M?si=WH7iaP7O-jnSOzgU
9.学习AuthJS/Next-Auth
该教程可以算作上面nexjs14教程的进阶版。
学习视频:
https://youtu.be/soprdrmpO3M?si=ZkKppA30G8Nn0yli
参考文章:
NextJS - 使用 next-auth 配置 JWT token - 炎黄子孙,龙的传人 - 博客园 (cnblogs.com)
视频项目NextAuth-advanced地址:
Mebius1916/NextAuth-advanced (github.com)
下面附上该项目随笔:
NextAuth-advanced项目随笔
10.巩固NextJS
学习视频:【[ Nextjs ] 关于NextJS你需要知道的12个概念 - ByteGrad - 管子版本】https://www.bilibili.com/video/BV1TC411b7H8?vd_source=0d0d6b12377aa593bc3a34f0884de98a
11.NextJS实战:HaloChat
技术栈:
- 前端:React+Roast+TypeScript+MaterialUI+
- 后端:NextJS+NextAuth+MongoDB+BcryptJS
学习视频:
https://youtu.be/2Zv8YYq1ymo?si=WF90w6Hq82UNlmnI
ts重构版代码地址:
Mebius1916/HaloChat — Mebius1916/HaloChat (github.com)
实战可自行挑选,我选择该项目的原因是我打算从0-1写一个集合了各种功能的聊天工具,然后该项目用到了NextJS, Next Auth, MongoDB, Tailwindcss,与之前学习的技术栈相符。
随笔
资源随笔
1.web学习网站推荐:
我一般用来搜css,能实时编辑查看css的效果这点我比较喜欢。
vscode随笔
插件
-
AI插件
选择你喜欢的AI插件即可,我用的是字节的
MarsCode
。 -
Material Icon Theme
文件/文件夹的图标主题,好用好看。
设置
-
缩进设置:
原因:vscode的默认缩进是4,代码繁琐的话会导致代码结构不清晰,推荐设置成2。
操作:打开setting,并输入:
tabsize
然后回车搜索。
React随笔
注意事项
-
组件名必须以大写字母开头
-
可以在组件中使用其它组件但不要定义组件
-
组件导入分为具名导入和默认导入
语法 导出语句 导入语句 默认 export default function Button() {}
import Button from './Button.js';
具名 export function Button() {}
import { Button } from './Button.js';
-
函数传递与函数调用的区别
- 传递:thisFunction
- 调用:thisFunction()
-
useState与useReducer可实现相同功能:
React hooks
1.useState
为什么要用useState?
用于解决修改变量值后由于不重新渲染导致页面仍保留渲染完成后的旧值(如ref),那么使用useState即可在改变变量的值后使页面重新渲染。
const [state, setState] = useState(0);
const [state, setState] = useState([]);
const [state, setState] = useState({});
state为绑定元素,setState用于对state进行操作
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text:string) => {
const newTodo = {
id:Date.now(),
text,
completed:false
}
setTodos([...todos,newTodo])//合并
}
}
更新state中的数组注意事项
避免使用 (会改变原始数组) | 推荐使用 (会返回一个新数组) | |
---|---|---|
添加元素 | push ,unshift | concat ,[...arr] 展开语法(例子) |
删除元素 | pop ,shift ,splice | filter ,slice (例子) |
替换元素 | splice ,arr[i] = ... 赋值 | map (例子) |
排序 | reverse ,sort | 先将数组复制一份(例子) |
2.useEffect
与vue中的watch类似,可通过封装⇒watch功能
无依赖项:
useEffect(()⇒{})
每次组件渲染时都会触发。
空依赖项数组:
useEffect(()⇒{},[])
只在组件初次渲染后执行一次。
有依赖项:
useEffect(()⇒{...},[count])
组件第一次加载且每当count变化时触发。
3.useRef
const ref = useRef(initialValue)
与useState相似之处在于都可以定义一个变量并对其操作,最大的不同点在于useRef的修改不会出发页面重新渲染,而useState的修改会出发页面重新渲染。
1、改变变量不重新渲染。
2、绑定dom元素:给dom元素绑定“别名”,通过别名对dom元素进行操作。
4.useMemo
const cachedValue = useMemo(calculateValue, dependencies)
为了防止state改变数据造成页面重新渲染从而导致的重复计算(复杂计算),useMemo 会在依赖项变化时重新计算值,而在依赖项没有变化时,返回上一次计算的结果,从而避免不必要的计算。
5.useCallback
const cachedFn = useCallback(fn, dependencies)
与useMemo类似,为了防止state改变数据造成页面重新渲染从而导致组件的重复渲染。
6.useContext
用于管理react中的全局数据,类似vue中的provide/inject
const value = useContext(SomeContext)
如何使用自行参考文档或video,由于好理解且代码量大,所以不在此处展示,个人感觉没有provide/inject好用。
7.useReducer
通过抽离重复逻辑来减少代码量,常见的用法是控制变量加减
// @ts-nocheck
import {useReducer, useState} from "react"
//定义逻辑
function countReducer(state,action){
switch(action.type){
case "increment":
return state + 1
case "decrement:":
return state - 1
default:
throw new Error()
}
}
export default function App() {
const [state,dispatch] = useReducer(countReducer,0)//放入逻辑和初始值
const handleIncrement = () => dispatch({type:"increment"})
const handleDecrement = () => dispatch({type:"decrement"})
return(
<div style={{padding:10}}>
<button onClick={handleIncrement}>-</button>
<span>{count}</span>
<button onClick={handleDecrement}>+</button>
</div>
)
}
8.useLayoutEffect
与useEffect功能相同,区别是useEffect在dom元素渲染后调用,而useLayoutEffect在dom元素渲染前调用。
无依赖项:
useLayoutEffect(()⇒{})
每次组件渲染时都会触发。
空依赖项数组:
useLayoutEffect(()⇒{},[])
只在组件初次渲染后执行一次副作用函数。
有依赖项:
useLayoutEffect(()⇒{...},[count])
组件第一次加载且每当count变化时触发。
9.usePathname
作用是获取到当前路径名
const pathname = usePathname()
react快捷键
rafce
import React from 'react'
const page = () => {
return (
<div>page</div>
)
}
export default page
rfce
import React from 'react'
function page() {
return (
<div>page</div>
)
}
export default page
tailwindcss随笔
盒模型相关
1.w-xx宽度||h-xx高度||bg-xx背景||min/max-w/h-xx最小宽度
2.p-xx表示padding||m-xx表示margin : xylrtbse
3.border-xx表示border||xx是长度或者颜色||rounded-xx表示圆角||shadow阴影
4.位置absolute||top-xx||left-xx||right-xx||z-xx(z-index)
文字相关
- 颜色大小与对齐 text-xxx
- 行距leading
- 字粗font-xx
flex/grid相关
【用在父容器】
- flex=display:flex;
- flex-[row/col-reverse]方向
- flex-wrap/nowrap溢出
- justify-xx横向瓦片排布 content-xx纵向瓦片排布
- justify-item-xx瓦片内dom横向排布item-xx瓦片内dom纵向排布
【用在item】
- flex-1 flex-auto自动扩缩
- basis-xx grow/shrink[-0] 手动设置三个参数
- justify-self-xx self-xx 调整自己这个瓦片的排布
One Thing项目随笔
一个很不错的入门项目,美中不足的是没有用ts,通过这个项目你能学会:
- react的组件化思想
- react的动态绑定
- react隐藏组件思想(v-if效果,不是v-show)
- tailwindcss的入门使用
- heroicons图表库的使用
- js-confetti五彩纸屑库的使用
- 用vite搭建react项目(个人觉得vite是优于webpack的)
我在原有代码的基础上进行了一点点的修改来符合我的审美,下面附上源码github链接:
Mebius1916/One-thing (github.com)
Mongoose随笔
Creating Model
import mongoose from "mongoose";
const moviesSchema = new mongoose.Schema({
name:{
type:String,
required:true,
trim:true
},
ratings:{
type:Number,
required:true,
min:1,
max:5
},
money:{
// @ts-ignore
type:mongoose.Decimal128,
required:true,
validate:v => v>=10,
},
genre:{
type:Array,
},
isActive:{
type:Boolean,
default:true
},
comments:[{
value:{type:String},
published:{type:Date,default:Date.now}
}],
})
const movieModel = mongoose.model('movies',moviesSchema);
insert
.save()
const result = await MovieModel.insertMany([]);
find
const result = await MovieModel.find();
//all data
const result = await MovieModel.find({});
const result = await MovieModel.findById();
update
const result = await MovieModel.updateOne({});
const result = await MovieModel.updateMany({});
const result = await MovieModel.findByIdAndUpdate({});
delete
const result = await MovieModel.deleteOne();
const result = await MovieModel.deleteMany();
const result = await MovieModel.findByIdAndDelete();
ObjectId
type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User'}]
通常与populate
结合使用:
.populate({
path: "members",
model: User,
})
根据_id
匹配对应的User
模型对象
正则搜索
$ regex
操作符用于执行正则表达式搜索,query
变量中的用户输入被用作搜索模式。$ options: "i"
表示搜索应不区分大小写。
const searchedContacts = await User.find({
$or: [
{ username: { $regex: query, $options: "i" } },
{ email: { $regex: query, $options: "i" } }
]
})
在NextAuth-advanced项目中的使用
NextJS随笔
use server/client
- 如有体积较大的依赖引入,可以选择"use server",这样就不用在客户端进行渲染,提高客户端性能。
- 存在react hook的页面必须实用"use client"。
Image组件
nextjs中有自己的Image组件,实用性远强于普通的img的图片,其中功能包括但不限于以下几点:
- 自动优化: 支持导入’.webp’图片,并且它会即时压缩优化图像,减少图像大小而不会显著损失质量,如果浏览器不支持压缩后的格式还会自动回退。
- 懒加载: 图像默认采用懒加载方式。这意味着图像会在滚动到视口时才加载,有助于加快页面的初始加载速度。
- SrcSet 支持: 它会自动生成每个图像的多个版本,适配不同的屏幕分辨率和设备,并通过
srcset
属性提供适当的版本。 - 占位符支持: 你可以使用
placeholder
属性指定占位符的处理方式。例如,设置placeholder="blur"
会在图像加载时提供一个模糊的版本,改善感知性能。
布局选项:layout 属性控制图像的调整行为。常见的值包括fill
、fixed
、intrinsic
、responsive
和raw
。
基本使用:
import Image from 'next/image'
export default function Page() {
return (
<Image
src="/profile.png"
width={500}
height={500}
alt="Picture of the author"
/>
)
}
结合 width
和height
属性,用于确定图像的纵横比,浏览器使用该纵横比在加载图像之前为图像预留空间。- 固有大小并不总是意味着浏览器中呈现的大小,这将由父容器确定。例如,如果父容器小于固有大小,则图像将按比例缩小以适合容器。
- 当宽度和高度未知时,可以使用 fill 属性。
动态路由
-
[folderName]
folderName仅为一个名字,在url中携带的[folderName]部分会作为值进行传递。
举个例子,假设路径为:
/app/[id]/page.tsx
,url为:http://localhost:3000/14
,那么此时id=14
就作为参数传入/app/[id]/page.tsx
。 -
[…folderName]
在命名文件夹的时候,如果你在方括号内添加省略号,比如
[...folderName]
,这表示捕获所有后面所有的路由片段。也就是说,
app/shop/[...slug]/page.js
会匹配/shop/clothes
,也会匹配/shop/clothes/tops
、/shop/clothes/tops/t-shirts
等等。举个例子,
app/shop/[...slug]/page.js
的代码如下:// app/shop/[...slug]/page.js export default function Page({ params }) { return <div>My Shop: {JSON.stringify(params)}</div> }
路由组
-
按逻辑分组
将路由按逻辑分组,但不影响 URL 路径:
你会发现,最终的 URL 中省略了带括号的文件夹(上图中的
(marketing)
和(shop)
)。 -
创建不同布局
借助路由组,即便在同一层级,也可以创建不同的布局:
在这个例子中,
/account
、/cart
、/checkout
都在同一层级。但是/account
和/cart
使用的是/app/(shop)/layout.js
布局和app/layout.js
布局,/checkout
使用的是app/layout.js
-
创建多个根布局
创建多个根布局:
创建多个根布局,你需要删除掉
app/layout.js
文件,然后在每组都创建一个layout.js
文件。创建的时候要注意,因为是根布局,所以要有<html>
和<body>
标签。这个功能很实用,比如你将前台购买页面和后台管理页面都放在一个项目里,一个 C 端,一个 B 端,两个项目的布局肯定不一样,借助路由组,就可以轻松实现区分。
AuthJS随笔
大体鉴权流程
1. 用户登录请求
- 用户输入凭据: 用户通过客户端(如浏览器或移动应用)提交登录请求,输入凭据(如用户名和密码,或通过 OAuth 提供的访问令牌)。
- 请求发送到服务器: 客户端将用户凭据发送到服务器端的 Auth.js 认证端点(API)。
2. 凭据验证
- 验证用户凭据: 服务器使用 Auth.js 验证用户凭据是否正确。如果使用的是 OAuth 等第三方认证方式,Auth.js 会与对应的服务(如 Google、Facebook)进行通信来验证令牌。
- 验证通过: 如果用户凭据正确,Auth.js 生成认证令牌(如 JWT)或创建一个会话来标识用户身份。
- 验证失败: 如果凭据错误或无效,Auth.js 返回相应的错误信息(如 401 未授权)。
3. 生成和返回认证令牌
- 创建会话或令牌: 一旦验证成功,Auth.js 会创建一个会话或生成一个认证令牌(例如 JWT),其中包含用户的身份信息和可能的权限信息。
- 发送令牌给客户端: 服务器将会话 ID 或令牌返回给客户端。令牌通常包含在响应的
Authorization
头中,或者作为一个持久化的 cookie。
4. 客户端存储令牌
- 存储方式: 客户端将接收到的令牌存储在安全的地方,如浏览器的
localStorage
、sessionStorage
或作为一个 HttpOnly 的 cookie,避免 XSS 攻击风险。 - 自动附加令牌: 客户端在后续的每个请求中,将自动在请求头中附加令牌,以证明用户的身份。
5. 访问受保护资源
- 发送带令牌的请求: 用户请求受保护的资源时,客户端将存储的令牌附加到请求头中,并发送到服务器。
- 服务器验证令牌: Auth.js 在服务器端接收到请求后,解析并验证令牌的有效性(例如签名是否正确、令牌是否过期、用户是否有权限访问请求的资源)。
6. 权限验证
- 基于角色或权限验证: 如果令牌验证通过,Auth.js 进一步检查用户是否具备访问请求资源的权限。通常,Auth.js 会基于用户的角色或自定义权限策略来进行验证。
- 授权通过或拒绝: 如果用户有权限访问,服务器返回请求的资源。如果没有权限,服务器返回
403 Forbidden
。
7. 会话管理和令牌刷新
- 会话保持或刷新: 如果 Auth.js 使用了会话机制,会定期刷新会话保持活跃。如果使用的是短期有效的 JWT,Auth.js 可能会提供刷新令牌的机制,允许客户端在 JWT 过期前获取一个新的令牌,而不需要用户重新登录。
- 处理会话过期: 如果会话或令牌过期,Auth.js 会要求用户重新登录以获取新的认证信息。
8. 用户注销
- 注销请求: 当用户选择注销时,客户端会发送注销请求到服务器。
- 销毁会话或令牌: Auth.js 接收到注销请求后,销毁服务器端的会话或标记令牌为无效。客户端同时清除本地存储的令牌。
- 确认注销: 服务器向客户端确认注销成功,用户被重定向到登录页面或首页。
JWT
NextAuth
默认使用 JWT 来管理用户的会话,它通过内置的机制自动生成和处理 JWT。
生成过程:
- 用户登录: 当用户通过任何一种认证提供者(如 GitHub、Google 或自定义的凭证登录)成功登录时,
NextAuth
会生成一个包含用户信息的 JWT。 - JWT 的创建: 在
NextAuth
中,JWT 的创建和签名是自动完成的。NextAuth
使用内部的jsonwebtoken
库来生成 JWT。每当用户成功登录时,NextAuth
会创建一个新的 JWT,并将一些基础信息(如用户 ID、邮箱等)存储在这个 JWT 中。 - JWT 回调函数: 回调函数在 JWT 生成或更新时被调用。这里,你可以在 JWT 中添加自定义的字段,如用户角色
role
。当用户登录成功后,user
对象会传递到这个回调中,你可以将用户角色附加到token
上。 - JWT 的签名和存储:
NextAuth
会使用在配置中设置的NEXTAUTH_SECRET
环境变量对 JWT 进行签名。这个密钥用于确保 JWT 的安全性,防止未授权的修改。生成的 JWT 被发送到客户端并保存在客户端的 cookie 中。 - JWT 的验证: 每次用户发送请求时,这个 JWT 会被自动附加到请求中,服务器会验证这个 JWT。如果验证成功,用户会话就会被恢复。如果 JWT 无效或过期,用户可能需要重新登录。
// 创建 NextAuth 配置对象
export const { handlers, signIn, signOut, auth } = NextAuth({
// 配置使用的认证提供者列表
providers: [
// 配置 Github 认证提供者,使用环境变量中的客户端 ID 和客户端密钥
Github({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
// 配置 Google 认证提供者,使用环境变量中的客户端 ID 和客户端密钥
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
// 凭证
Credentials({
name: "Credentials",
// 定义登录表单的字段
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
// 通过凭证信息来授权
authorize: async (credentials) => {
// 提取用户提供的邮箱和密码
const email = credentials.email as string | undefined;
const password = credentials.password as string | undefined;
// 如果邮箱或密码为空,抛出错误
if (!email ||!password) {
throw new CredentialsSignin("Please provide both email & password");
}
// 连接数据库
await connectDB();
// 在数据库中查找具有给定邮箱的用户,并包含密码和角色字段
const user = await User.findOne({ email }).select("+password +role");
// 如果没有找到用户,抛出错误
if (!user) {
throw new Error("Invalid email or password");
}
// 如果用户存在但没有设置密码(可能使用了第三方登录),抛出错误
if (!user.password) {
throw new Error("Invalid email or password");
}
// 比较用户提供的密码和数据库中存储的密码哈希值是否匹配
const isMatched = await compare(password, user.password);
// 如果密码不匹配,抛出错误
if (!isMatched) {
throw new Error("Password did not matched");
}
// 密码匹配成功,返回用户数据用于构建会话
const userData = {
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
id: user._id,
};
return userData;
},
}),
],
// 配置登录页面的 URL
pages: {
signIn: "/login",
},
// 定义在验证过程中将会话和令牌进行处理的回调函数
callbacks: {
// 登录后更新缓存
async session({ session, token }) {
if (token?.sub && token?.role) {
session.user.id = token.sub;
//@ts-ignore
session.user.role = token.role;
}
return session;
},
// 更新 JWT 令牌对象
async jwt({ token, user }) {
if (user) {
//@ts-ignore
token.role = user.role;
}
return token;
},
// 处理登录成功后的回调函数
signIn: async ({ user, account }) => {
if (account?.provider === "google") {
try {
// 从登录用户信息中提取必要属性
const { email, name, image, id } = user;
// 连接数据库
await connectDB();
// 查询数据库中是否已经存在具有给定邮箱的用户
const alreadyUser = await User.findOne({ email });
// 如果不存在,则创建新用户
if (!alreadyUser) {
await User.create({ email, name, image, authProviderId: id });
} else {
// 如果用户已经存在,直接返回 true 表示登录成功
return true;
}
} catch (error) {
// 如果在处理过程中发生任何错误,抛出错误信息
throw new Error("Error while creating user");
}
}
// 如果是通过用户名和密码登录,则直接返回 true 表示登录成功
if (account?.provider === "credentials") {
return true;
} else {
// 其他情况返回 false,表示登录失败
return false;
}
},
},
});
以上面代码为例:
jwt
** 回调函数**:这个回调函数在每次 JWT 生成或更新时调用。它接收一个token
对象和一个user
对象作为参数。token
:包含当前的 JWT 信息。user
:包含从authorize
函数返回的用户信息(即用户的role
等信息)。
- 在
authorize
函数中,当用户成功通过凭证(例如电子邮件和密码)验证后,会返回一个包含用户信息的userData
对象。这些信息(如role
)会被存储在 JWT 中,通过jwt
回调函数传递给客户端。
NextAuth-advanced项目随笔
目录讲解
为什么用cache存储session?
import { auth } from "@/auth";
import { cache } from "react";
export const getSession = cache(async () => {
const session = await auth();
return session;
});
维护session,提高性能。
- session调用频繁,使用cache能减少负载,提高响应速度。
- cache读取速度快,性能好。
- session易丢失,使用cache能有效缓存会话数据。
html表格标签
<tr>
表示表格的一行。<td>
表示表格的数据单元格。-
<th>
表示表格的表头单元格。 <table>
作为最外层的容器,包含<thead>
和<tbody>
。<thead>
通常位于<table>
的顶部,包含<tr>
元素。<tr>
中的<th>
元素表示列标题。<tbody>
位于<thead>
之后,包含<tr>
元素。<tr>
中的<td>
元素表示行数据。
mongoose数据库使用
**.env
**环境配置
MONGO_URI='mongodb://127.0.0.1:27017/nextAuth'
AUTH_SECRET=klsgjcsr6ku987123kjdvlksadfadf0243
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
**/models/User.ts
**导出User模型
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, required: true },
password: { type: String, select: false },
role: { type: String, default: "user" },
image: { type: String },
authProviderId: { type: String },
});
export const User = mongoose.models?.User || mongoose.model("User", userSchema);
/lib/db.ts
抽离连接数据库函数⇒自定义hook
import mongoose from "mongoose";
const connectDB = async () => {
try {
await mongoose.connect(process.env.MONGO_URI!);
console.log(`Successfully connected to mongoDB 🥂`);
} catch (error: any) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
};
export default connectDB;
/action/user.ts
使用数据库
"use server";
import connectDB from "@/lib/db";
import { User } from "@/models/User";
import { redirect } from "next/navigation";
import { hash } from "bcryptjs";
import { CredentialsSignin } from "next-auth";
import { signIn } from "@/auth";
const login = async (formData: FormData) => {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
await signIn("credentials", {
redirect: false,
callbackUrl: "/",
email,
password,
});
} catch (error) {
const someError = error as CredentialsSignin;
return someError.cause;
}
redirect("/");
};
const register = async (formData: FormData) => {
const firstName = formData.get("firstname") as string;
const lastName = formData.get("lastname") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!firstName || !lastName || !email || !password) {
throw new Error("Please fill all fields");
}
await connectDB();
// existing user
const existingUser = await User.findOne({ email });
if (existingUser) throw new Error("User already exists");
const hashedPassword = await hash(password, 12);
await User.create({ firstName, lastName, email, password: hashedPassword });
console.log(`User created successfully 🥂`);
redirect("/login");
};
const fetchAllUsers = async () => {
await connectDB();
const users = await User.find({});
return users;
};
export { register, login, fetchAllUsers };
auth.ts
在next-auth配置中使用数据库
// 创建 NextAuth 配置对象
export const { handlers, signIn, signOut, auth } = NextAuth({
// 配置使用的认证提供者列表
providers: [
// 配置 Github 认证提供者,使用环境变量中的客户端 ID 和客户端密钥
Github({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
// 配置 Google 认证提供者,使用环境变量中的客户端 ID 和客户端密钥
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
// 凭证
Credentials({
name: "Credentials",
// 定义登录表单的字段
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
// 通过凭证信息来授权
authorize: async (credentials) => {
// 提取用户提供的邮箱和密码
const email = credentials.email as string | undefined;
const password = credentials.password as string | undefined;
// 如果邮箱或密码为空,抛出错误
if (!email ||!password) {
throw new CredentialsSignin("Please provide both email & password");
}
// 连接数据库
await connectDB();
// 在数据库中查找具有给定邮箱的用户,并包含密码和角色字段
const user = await User.findOne({ email }).select("+password +role");
// 如果没有找到用户,抛出错误
if (!user) {
throw new Error("Invalid email or password");
}
// 如果用户存在但没有设置密码(可能使用了第三方登录),抛出错误
if (!user.password) {
throw new Error("Invalid email or password");
}
// 比较用户提供的密码和数据库中存储的密码哈希值是否匹配
const isMatched = await compare(password, user.password);
// 如果密码不匹配,抛出错误
if (!isMatched) {
throw new Error("Password did not matched");
}
// 密码匹配成功,返回用户数据用于构建会话
const userData = {
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
id: user._id,
};
return userData;
},
}),
],
// 配置登录页面的 URL
pages: {
signIn: "/login",
},
// 定义在验证过程中将会话和令牌进行处理的回调函数
callbacks: {
// 登录后更新缓存
async session({ session, token }) {
if (token?.sub && token?.role) {
session.user.id = token.sub;
//@ts-ignore
session.user.role = token.role;
}
return session;
},
// 更新 JWT 令牌对象
async jwt({ token, user }) {
if (user) {
//@ts-ignore
token.role = user.role;
}
return token;
},
// 处理登录成功后的回调函数
signIn: async ({ user, account }) => {
if (account?.provider === "google") {
try {
// 从登录用户信息中提取必要属性
const { email, name, image, id } = user;
// 连接数据库
await connectDB();
// 查询数据库中是否已经存在具有给定邮箱的用户
const alreadyUser = await User.findOne({ email });
// 如果不存在,则创建新用户
if (!alreadyUser) {
await User.create({ email, name, image, authProviderId: id });
} else {
// 如果用户已经存在,直接返回 true 表示登录成功
return true;
}
} catch (error) {
// 如果在处理过程中发生任何错误,抛出错误信息
throw new Error("Error while creating user");
}
}
// 如果是通过用户名和密码登录,则直接返回 true 表示登录成功
if (account?.provider === "credentials") {
return true;
} else {
// 其他情况返回 false,表示登录失败
return false;
}
},
},
});
HaloChat实战项目重构随笔
重构部分
- js→ts
- Next-Auth v4→v5
- img→Image
前言
我在学习项目的同时将项目重构为了ts版本,这是一次很不错的经历,因为我实际上并没有ts的实战经验,基本上都是跟着视频学习没有真正上手过,这次重构确实对我的ts代码能力有很大提升,ts版本代码地址:
关于这个项目我想吐槽的一点是,这个项目几乎全是客户端渲染,我觉得这实际上和Next-auth是冲突的,虽然Next-auth客户端和服务端都能用,但是Next-auth其实更偏向于服务端渲染,在重构我选择保留了原视频客户端渲染的登录与注册,但我重构后发现以下问题:
- 如果用"use client":可以用react-hook-form,但是无法使用Next-auth的provider below也就是github、google等第三方登录。
- 如果用"use server":可以用Next-auth的provider below但是无法使用react-hook-form。
权衡下来其实我更偏向于服务端渲染登录注册页面,所以我推荐看了本文章的同学们可以尝试用服务端渲染重构此项目并加入其它的provider below,具体参考上面的NextAuth-advanced就好啦,我重构本项目的时候参考了很多NextAuth-advanced里的写法,不过登录注册页面由于服务端、客户端渲染冲突,为了保住react-hook-form就不了了之,回看下来其实登录注册对表单的应用挺简单的,不用react-hook-form自己写表单都行。
上面是关于登录注册页面的吐槽,而这里我想吐槽一下这个教程视频的结构和逻辑很混乱,得自己去看源码结构细品,然后视频中用authjs v4版本中的中间件来进行路由保护,而我重构为了v5版本,我自己尝试用中间件来进行路由保护会有bug所以就没加路由保护,前面NextAuth-advanced
项目中有用session进行路由保护,可以参考参考。
随笔
-
cloudinary上传修改图片
const uploadPhoto = (result:any) => { setValue("profileImage", result?.info?.secure_url); }; <img src={ watch("profileImage") || user?.profileImage || "/assets/person.jpg" } alt="profile" className="w-40 h-40 rounded-full" /> <CldUploadButton options={{ maxFiles: 1 }} onUpload={uploadPhoto} uploadPreset="kdm7bzdm" > <p className="text-body-bold">Upload new photo</p> </CldUploadButton>
- 用户通过
<CldUploadButton>
上传新的图片后,uploadPhoto
函数会被触发,它通过setValue
更新profileImage
的值为新的图片 URL。 - 由于
watch("profileImage")
监听了profileImage
,一旦值更新,<img>
标签的src
也会实时更新,显示新上传的图片。 - 一开始没有
"profileImage"
字段,通过uploadPhoto
中的setValue
给表单添加上"profileImage"
字段,然后触发watch("profileImage")
ps:推荐用于img,不推荐用于Image。我用Image来加载cloudinary,即使设置了next.config.mjs
也会报错,而且此处为外部加载资源所以Image也提供不了多少优化。
- 用户通过
-
关于profile(上传图片)页面的**
loading
**时机- 首次渲染页面时
loading
为默认true及 展示loading
组件(可能时间过短看不见),随后触发useEffect
,等待session.user
获取到后通过setLoading(false)
将loading
设置为false
及展示页面内容。 updateUser
上传数据时,先将loading
初始化为true
,等待请求完成后通过setLoading(false)
将loading
设置为false
表示加载完成展示页面内容。
- 首次渲染页面时
-
**
Contacts.tsx
**页面 搜索好友 逻辑-
在输入框输入内容时触发
setSearch
函数const [search, setSearch] = useState("");
<input placeholder="Search contact..." className="input-search" value={search} onChange={(e) => setSearch(e.target.value)} />
-
setSearch
函数改变search
的值后通过触发useEffect
触发getContacts
改变contacts
useEffect(() => { if (currentUser) getContacts(); }, [currentUser, search]); const getContacts = async () => { try { //从数据库搜索出匹配项 const res = await fetch( search !== "" ? `/api/users/searchContact/${search}` : "/api/users" ); const data = await res.json(); //在所有信息里剔除自己的信息 setContacts(data.filter((contact:SessionData) => contact._id !== currentUser._id)); setLoading(false); } catch (err) { console.log(err); } };
-
通过
map
函数动态渲染contacts
组件{contacts.map((user:SessionData, index) => ( <div key={index} className="contact" onClick={() => handleSelect(user as never)} > {selectedContacts.find((item) => item === user) ? ( <CheckCircle sx={{ color: "red" }} /> ) : ( <RadioButtonUnchecked /> )} <img src={user.profileImage || "/assets/person.jpg"} alt="profile" className="profilePhoto" /> <p className="text-base-bold">{user.username}</p> </div> ))}
-
-
**
Contacts.tsx
**页面 选择成员 逻辑-
主体部分
//map将所有用户拆分成每一个current个体 {contacts.map((current:SessionData, index) => ( //渲染除自己外所有用户 <div key={index} className="contact" onClick={() => handleSelect(current as never)} > //selectedContacts为选中元素的数组 {selectedContacts.find((item) => item === current) ? ( //如果当前元速在选中数组里 <CheckCircle sx={{ color: "red" }} /> ) : ( //如果当前元速不在选中数组里 <RadioButtonUnchecked /> )} <img src={current.profileImage || "/assets/person.jpg"} alt="profile" className="profilePhoto" /> <p className="text-base-bold">{current.username}</p> </div> ))}
handleSelect
传入的
current
为当前点击的用户,之后进行判断:如果选中用户数组selectedContacts
里没有当前点击用户则触发else
将当前点击用户加入进去;如果没有,则从选中用户数组selectedContacts
中去除当前点击用户。const [selectedContacts, setSelectedContacts] = useState([]);
const handleSelect = (current: never) => { //取消选中 if (selectedContacts.includes(current)) { setSelectedContacts((prevSelectedContacts) => prevSelectedContacts.filter((item) => item !== current) ); } else { //选中 setSelectedContacts((Contacts) => [ ...Contacts,//之前所选 current,//当前所选 ]); } };
-
-
**
Contacts.tsx
页面 群聊取名与成员标签 **逻辑const [name, setName] = useState("");
{isGroup && ( <> <div className="flex flex-col gap-3"> <p className="text-body-bold">Group Chat Name</p> <input placeholder="Enter group chat name..." className="input-group-name" value={name} //实时修改name onChange={(e) => setName(e.target.value)} /> </div> <div className="flex flex-col gap-3"> <p className="text-body-bold">Members</p> <div className="flex flex-wrap gap-3"> //将选中数组 selectedContacts渲染出来,键值为index {selectedContacts.map((contact:SessionData, index) => ( <p className="selected-contact" key={index}> {contact.username} </p> ))} </div> </div> </> )}
-
**
Contacts.tsx
页面 创建群聊 **逻辑- 前端逻辑
const createChat = async () => { const res = await fetch("/api/chats", { method: "POST", body: JSON.stringify({ //当前用户_id currentUserId: currentUser._id, //选中成员的_id数组 members: selectedContacts.map((contact:SessionData) => contact._id), //是否是群组 isGroup, //群组名称 name, }), }); const chat = await res.json(); if (res.ok) { router.push(`/chats/${chat._id}`); } }; <button className="btn" onClick={createChat} disabled={selectedContacts.length === 0} >
- 后端逻辑
import { User } from "@/models/User"; import { connectToDB } from "@/mongodb"; import { pusherServer } from "@/lib/pusher"; import { NextRequest, NextResponse } from 'next/server'; import Chat from "@/models/Chat"; import { ObjectId } from "mongoose"; export const POST = async (req:NextRequest) => { try { await connectToDB(); const body = await req.json(); const { currentUserId, members, isGroup, name, groupPhoto } = body; //构建查询对象 const query = isGroup //群组 ? { isGroup, name, groupPhoto, members: [currentUserId,...members] } //个人 : { members: { $all: [currentUserId,...members], $size: 2 } }; let chat = await Chat.findOne(query); //如果不存在则创建新群聊 if (!chat) { chat = await new Chat( isGroup? query : { members: [currentUserId,...members] } ); await chat.save(); //chat为新建的群组||个人,members为成员_id //用当前chat中成员_id进行查找,给每个成员(User对象)的chats中添加上当前群聊的_id const updateAllMembers = chat.members.map(async (currentId) => { await User.findByIdAndUpdate( currentId, { $addToSet: { chats: chat._id }, }, { new: true } ); }); // 并发地执行所有更新操作 Promise.all(updateAllMembers); // 为每个成员触发一个实时事件推送,通知他们有新的聊天记录 chat.members.map(async (member:{_id:ObjectId}) => { await pusherServer.trigger(member._id.toString(), "new-chat", chat); }); } return new Response(JSON.stringify(chat), { status: 200 }); } catch (err) { console.error(err); return new Response("Failed to create a new chat", { status: 500 }); } };
- 前端逻辑
-
Contact.tsx
页面 整体 逻辑 -
数据库模型详解
User
模型就不说了,说一下Chat
和Message
模型首先是
Chat
模型:members
:群成员_id
集合数组,通过populate
将_id
替换为User
个体。messages
:群消息_id
集合数组,通过populate
将_id
替换为Message
个体。
然后是Message
模型:chat
:该消息所属群组的_id
。sender
:该消息的发送人。text
:消息本体。seenBy
:该消息订阅者的_id
数组(包括自己),在项目中好像只用于判断该消息是否是当前用户发送的,那用sender
不就行了?为什么还要整一个seenBy
?个人猜测是一种规范,在更为复杂的项目中可能会用到。
这个项目其实还有很多地方可以探究,但是我打算通过一个基于nextron的实时通讯实战来巩固所学知识点(我学习这个项目就是为了做一个自己的ChatApp),就不浪费时间去写随笔了。