NextJs-react开发者的全栈最佳选择(从0-1的react全栈入门指南)

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

可以结合下面博客食用:

react速成指南-CSDN博客

3.React官方文档

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学习网站推荐:

Web 开发技术 | MDN (mozilla.org)

我一般用来搜css,能实时编辑查看css的效果这点我比较喜欢。

vscode随笔

插件
  1. AI插件

    选择你喜欢的AI插件即可,我用的是字节的MarsCode

  2. Material Icon Theme

    文件/文件夹的图标主题,好用好看。

设置
  1. 缩进设置

    原因: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之useState与useReducer-CSDN博客

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中的数组注意事项

避免使用 (会改变原始数组)推荐使用 (会返回一个新数组)
添加元素pushunshiftconcat[...arr] 展开语法(例子
删除元素popshiftsplicefilterslice例子
替换元素splicearr[i] = ... 赋值map例子
排序reversesort先将数组复制一份(例子

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)

useContext – React 中文文档

如何使用自行参考文档或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)

文字相关
  1. 颜色大小与对齐 text-xxx
  2. 行距leading
  3. 字粗font-xx
flex/grid相关

【用在父容器】

  1. flex=display:flex;
  2. flex-[row/col-reverse]方向
  3. flex-wrap/nowrap溢出
  4. justify-xx横向瓦片排布 content-xx纵向瓦片排布
  5. justify-item-xx瓦片内dom横向排布item-xx瓦片内dom纵向排布

【用在item】

  1. flex-1 flex-auto自动扩缩
  2. basis-xx grow/shrink[-0] 手动设置三个参数
  3. 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项目中的使用

作为Vuer的我如何快速通关React生态

NextJS随笔

use server/client

Next.js中的客户端渲染和服务端渲染-CSDN博客

  • 如有体积较大的依赖引入,可以选择"use server",这样就不用在客户端进行渲染,提高客户端性能。
  • 存在react hook的页面必须实用"use client"。
Image组件

nextjs中有自己的Image组件,实用性远强于普通的img的图片,其中功能包括但不限于以下几点:

  • 自动优化: 支持导入’.webp’图片,并且它会即时压缩优化图像,减少图像大小而不会显著损失质量,如果浏览器不支持压缩后的格式还会自动回退。
  • 懒加载: 图像默认采用懒加载方式。这意味着图像会在滚动到视口时才加载,有助于加快页面的初始加载速度。
  • SrcSet 支持: 它会自动生成每个图像的多个版本,适配不同的屏幕分辨率和设备,并通过srcset 属性提供适当的版本。
  • 占位符支持: 你可以使用placeholder 属性指定占位符的处理方式。例如,设置 placeholder="blur" 会在图像加载时提供一个模糊的版本,改善感知性能。
    布局选项:layout 属性控制图像的调整行为。常见的值包括 fillfixedintrinsicresponsive raw

基本使用:

import Image from 'next/image' 
export default function Page() {
  return (
    <Image
      src="/profile.png"
      width={500}
      height={500}
      alt="Picture of the author"
    />
  )
}
  • 结合 widthheight 属性,用于确定图像的纵横比,浏览器使用该纵横比在加载图像之前为图像预留空间。
  • 固有大小并不总是意味着浏览器中呈现的大小,这将由父容器确定。例如,如果父容器小于固有大小,则图像将按比例缩小以适合容器。
  • 当宽度和高度未知时,可以使用 fill 属性。
动态路由
  1. [folderName]

    folderName仅为一个名字,在url中携带的[folderName]部分会作为值进行传递。

    举个例子,假设路径为:/app/[id]/page.tsx,url为:http://localhost:3000/14,那么此时id=14就作为参数传入/app/[id]/page.tsx

  2. […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>
    }
    
    

路由组
  1. 按逻辑分组

    将路由按逻辑分组,但不影响 URL 路径:

    你会发现,最终的 URL 中省略了带括号的文件夹(上图中的(marketing)(shop))。

  2. 创建不同布局

    借助路由组,即便在同一层级,也可以创建不同的布局:

    在这个例子中,/account/cart/checkout 都在同一层级。但是 /account/cart使用的是 /app/(shop)/layout.js布局和app/layout.js布局,/checkout使用的是 app/layout.js

  3. 创建多个根布局

    创建多个根布局:

    创建多个根布局,你需要删除掉 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. 客户端存储令牌

  • 存储方式: 客户端将接收到的令牌存储在安全的地方,如浏览器的 localStoragesessionStorage 或作为一个 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。

生成过程:

  1. 用户登录: 当用户通过任何一种认证提供者(如 GitHub、Google 或自定义的凭证登录)成功登录时,NextAuth 会生成一个包含用户信息的 JWT。
  2. JWT 的创建:NextAuth 中,JWT 的创建和签名是自动完成的。NextAuth 使用内部的 jsonwebtoken 库来生成 JWT。每当用户成功登录时,NextAuth 会创建一个新的 JWT,并将一些基础信息(如用户 ID、邮箱等)存储在这个 JWT 中。
  3. JWT 回调函数: 回调函数在 JWT 生成或更新时被调用。这里,你可以在 JWT 中添加自定义的字段,如用户角色 role。当用户登录成功后,user 对象会传递到这个回调中,你可以将用户角色附加到 token 上。
  4. JWT 的签名和存储: NextAuth 会使用在配置中设置的 NEXTAUTH_SECRET 环境变量对 JWT 进行签名。这个密钥用于确保 JWT 的安全性,防止未授权的修改。生成的 JWT 被发送到客户端并保存在客户端的 cookie 中。
  5. 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进行路由保护,可以参考参考。

随笔
  1. 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也提供不了多少优化。
  2. 关于profile(上传图片)页面的**loading**时机

    • 首次渲染页面时loading为默认true及 展示loading组件(可能时间过短看不见),随后触发useEffect,等待session.user获取到后通过setLoading(false)loading设置为false及展示页面内容。
    • updateUser上传数据时,先将loading初始化为true,等待请求完成后通过setLoading(false)loading设置为false表示加载完成展示页面内容。
  3. **Contacts.tsx**页面 搜索好友 逻辑

    1. 在输入框输入内容时触发setSearch函数

      const [search, setSearch] = useState("");

            <input
              placeholder="Search contact..."
              className="input-search"
              value={search}
              onChange={(e) => setSearch(e.target.value)}
            />
      
    2. 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);
          }
        };
        
      
    3. 通过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>
                  ))}
      
  4. **Contacts.tsx**页面 选择成员 逻辑

    1. 主体部分

                  //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,//当前所选
            ]);
          }
        };
      
      
  5. **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>
                </>
              )}
    
  6. **Contacts.tsx页面 创建群聊 **逻辑

    1. 前端逻辑
              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}
                >
      
    2. 后端逻辑
      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 });
        }
      };
      
      
  7. Contact.tsx页面 整体 逻辑

    在这里插入图片描述

  8. 数据库模型详解

    User模型就不说了,说一下ChatMessage模型

    首先是Chat模型:

    • members:群成员_id集合数组,通过populate_id替换为User个体。
    • messages:群消息_id集合数组,通过populate_id替换为Message个体。
      然后是Message模型:
    • chat:该消息所属群组的_id
    • sender:该消息的发送人。
    • text:消息本体。
    • seenBy:该消息订阅者的_id数组(包括自己),在项目中好像只用于判断该消息是否是当前用户发送的,那用sender不就行了?为什么还要整一个seenBy?个人猜测是一种规范,在更为复杂的项目中可能会用到。

这个项目其实还有很多地方可以探究,但是我打算通过一个基于nextron的实时通讯实战来巩固所学知识点(我学习这个项目就是为了做一个自己的ChatApp),就不浪费时间去写随笔了。

  • 17
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值