ChenZhen 搜索
首页 标签 归档 留言板 友链 ChatGPT 提示库 AI工具导航网 🚇开往 关于我

Prisma 学习笔记(从 Spring Boot + MyBatis-Plus 转向 Next.js + Prisma)

你好!从 **Java (Spring Boot + MyBatis-Plus)** 转向 **Next.js + Prisma** 是一个非常丝滑的体验。你会发现,虽然语言变了,但很多架构思想是相通的。 作为 Java 后端出身,你习惯了 `Entity`、`Service`、`Mapper` 和 `Strong Typing`。Prisma 的核心优势正是**类型安全**,这会让你感觉像回到了家。

ChenZhen 2026-03-01T15:36:39

Prisma 学习笔记(从 Spring Boot + MyBatis-Plus 转向 Next.js + Prisma)

你好!从 Java (Spring Boot + MyBatis-Plus) 转向 Next.js + Prisma 是一个非常丝滑的体验。你会发现,虽然语言变了,但很多架构思想是相通的。

作为 Java 后端出身,你习惯了 EntityServiceMapperStrong Typing。Prisma 的核心优势正是类型安全,这会让你感觉像回到了家。

以下是我为你定制的 “从 MyBatis-Plus 到 Prisma” 快速上手计划:


核心概念映射(帮你在脑中建立桥梁)

在开始动手前,先理解这两个世界的对应关系:

功能Spring Boot + MyBatis-PlusNext.js + Prisma
定义表结构数据库 DDL 或 @TableName 实体类schema.prisma 文件
代码生成MyBatis-Plus Generatornpx prisma generate
CRUD 操作IService / BaseMapperprisma.user.findMany() / create()
关联查询XML 联表或 @TableField(exist=false)核心优势:Schema 定义 Relation,直接 include
数据库迁移Flyway / Liquibasenpx prisma migrate dev

学习计划:三步走策略

既然数据库已经在 Supabase 选好了地区并创建完成,现在的任务是让你的 Next.js 项目能和它“说上话”。

我们将使用 Prisma。它是目前 React/Next.js 生态中最强大的 ORM(对象关系映射),能让你像操作 JavaScript 对象一样操作数据库,而不需要写一行 SQL。


1. 安装 Prisma 依赖

在你的项目根目录下运行:

npm install prisma --save-dev
npm install @prisma/client

然后初始化 Prisma 配置:

npx prisma init

2. 配置环境变量

初始化后,你会发现根目录多了一个 .env 文件。 打开它,把你在 Supabase 拿到的 Connection String 填进去:

# .env
# 记得把 [YOUR-PASSWORD] 换成你在 Supabase 设置的项目密码
DATABASE_URL="postgresql://postgres:[YOUR-PASSWORD]@db.xxxx.supabase.co:5432/postgres"

注意:如果你的连接字符串后面没有 ?pgbouncer=true 且你在使用云端环境,建议在 Supabase 的连接设置里选择 Transaction 模式 的端口(通常是 6543)。


3. 设计数据模型 (Schema)

打开 prisma/schema.prisma 文件。我们要定义一个简单的“博客文章”模型。

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
 
}

// 定义我们的文章表
model Post {
  id        String   @id @default(cuid()) // 自动生成唯一 ID
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}


4. 同步到数据库 (Migration)

1. 这一步最关键。它会做两件事:

  1. 在 Supabase 的数据库里真实地创建那张 Post 表。
  2. 在本地生成 TypeScript 类型定义(这样你写代码时就有自动补全了)。

运行:

npx prisma migrate dev --name init

Java 视角:这相当于你写好了 Entity,它自动帮你生成了 DDL 脚本并推送到数据库执行,而且还帮你记录了版本历史。


2. 它是你的“代码生成器”(类似 MyBatis-Plus Generator)

这是 Prisma 最强大的地方。当 SQL 执行完毕后,它会紧接着触发 prisma generate

  • 它会根据最新的数据库结构,在 node_modules/.prisma/client 中生成一套全类型的 SDK
  • 效果:你在写 JS/TS 代码时,调用 prisma.user.findUnique(...) 会有极其精准的字段提示。如果数据库里没有 age 这个字段,你代码里写 age 直接会报红。

3. 命令参数拆解

  • migrate: 数据库迁移指令。
  • dev: 表示这是开发环境模式。它会检测 schema.prisma 的变化,并允许在必要时重置数据库。
  • --name init: 给这次改动起个名字。生成的文件夹会叫 20260206_init 这种格式,方便你追溯“哪次改动增加了哪个字段”。

避坑小贴士

  • 不要手动去数据库改表:如果你用 Navicat 手动改了字段,但没有改 schema.prisma,下次 migrate 时 Prisma 可能会因为检测到冲突而要求重置数据库(导致数据丢失)。永远坚持“Schema 优先”原则。
  • 生产环境:在生产环境中,千万不要用 migrate dev,而是使用 npx prisma migrate deploy。它只会执行现有的 SQL 脚本,不会尝试检测变化或重置数据。

5. 在项目里实例化 Prisma

为了不在每次请求时都创建一个新的数据库连接,我们需要一个单例模式。

在 Prisma 7 中,官方对配置方式做了重大调整:不再建议在 schema.prisma 中写 url = env("DATABASE_URL"),而是改用 prisma.config.ts 进行配置。

1. 创建文件 lib/db.ts

我们需要从 @prisma/adapter-pg(或其他对应驱动)中引入适配器,并将其传递给 PrismaClient。但为了最简单地解决你现在的报错,我们需要在构造函数中明确传入连接配置。

由于你使用的是 PostgreSQL,我们需要安装并配置驱动。请先执行以下命令安装适配器:

npm install @prisma/adapter-pg pg
npm install -D @types/pg

创建文件 lib/db.ts

import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";

const connectionString = `${process.env.DATABASE_URL}`;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);

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

export const db =
  globalForPrisma.prisma ||
  new PrismaClient({
    adapter, // Prisma 7 推荐使用适配器
  });

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

2. 还原 prisma/schema.prisma

既然 Prisma 7 不再支持在 schema 中写 url,我们保持它原来的简洁样子即可:

datasource db {
  provider = "postgresql"
  // 这里不需要写 url 了,Prisma 7 会从配置文件或构造函数中读取
}

为什么 Prisma 7 要这么改?

Prisma 7 引入了“驱动适配器(Driver Adapters)”的概念,目的是为了让 Prisma 能在更多环境下(如 Edge Functions, Cloudflare Workers)运行。

  • 之前:Prisma 内部自带了复杂的 Rust 引擎来连接数据库。
  • 现在:Prisma 建议你使用标准的 JavaScript 数据库驱动(如 pg),然后通过 adapter 传给 Prisma。

操作建议

  1. 运行上面的 npm install 命令
  2. 修改 lib/db.ts,像我上面展示的那样传入 adapter
  3. 运行 npx prisma generate

如果你觉得这个过程对新手来说太复杂,你也可以尝试将 package.json 中的 Prisma 版本降级到 ^6.0.0,那样就可以使用传统的 url = env(...) 写法了。


Supabase CLI

1. 核心架构:多项目并行

你需要配置两个环境变量文件,并让你的自动化部署流程(如 Vercel, GitHub Actions)自动识别。

环境变量管理

  • .env.development:填入测试项目的 URL 和 Anon Key。
  • .env.production:填入生产项目的 URL 和 Anon Key。

在前端初始化 Supabase 时,代码应该是动态的:

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)


2. 同步方案:不要手动点网页!

最忌讳的是在测试环境改了表结构(Schema),然后靠记忆去生产环境重复操作。这一定会导致线上报错。

推荐工具:Supabase CLI (迁移管理)

Supabase CLI 是同步两个线上环境的神器。

  1. 拉取测试环境的变更: 你在测试环境项目修改了数据库后,运行:
supabase db diff --project-ref <测试环境ID> -f feature_name

这会生成一个 .sql 文件,记录了所有变更。 2. 应用到生产环境

supabase db push --project-ref <生产环境ID>


3. 分阶段部署流程 (Best Practice)

第一步:本地 Mock (Local)

在本地运行 supabase start(基于 Docker)。在这里进行所有“毁灭性”的尝试,完全免费。

第二步:线上测试 (Staging/Dev)

当你本地代码写好了,提交到 Git 的 develop 分支。

  • 自动化部署:配置 CI/CD 自动将 develop 分支的代码部署到你的测试域名(如 dev.myapp.com)。
  • 数据库迁移:运行 supabase db push 到测试环境。在这个环境进行真人测试或接口联调。

第三步:线上生产 (Production)

测试通过后,将代码合并到 main 分支。

  • 生产部署:代码同步到生产域名。
  • 数据同步:执行迁移 SQL 到生产项目。

4. 特别注意:两个环境的“差异化”

功能测试环境配置生产环境配置
Auth开启允许未验证邮箱登录(方便测试)必须验证邮箱,开启 MFA
Email使用测试发信配置或直接看日志配置 SendGrid/Resend 真实发信
Storage随便上传,定期清理开启 CDN,设置严格的 RLS 策略
Webhooks指向测试版的测试 API指向生产版的生产 API

CRUD 测试

现在回到你的 app/page.tsx,我们尝试从数据库里读出文章列表。

// app/page.tsx
import { db } from "@/lib/db";
import { Button } from "@/components/ui/button";

export default async function Home() {
  // 直接查询数据库!
  const posts = await db.post.findMany();

  return (
    <main className="p-4">
      <h1 className="text-2xl font-bold mb-4">文章列表</h1>
      {posts.length === 0 ? (
        <p className="text-gray-500">暂时没有文章</p>
      ) : (
        <ul className="space-y-2">
          {posts.map((post) => (
            <li key={post.id} className="p-4 border rounded-lg shadow-sm">
              {post.title}
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

既然你的数据库和 Prisma 已经就绪,现在我们就来完成 CRUD 中的 C (Create)

在 Next.js 中,最推荐的做法是使用 Server Actions。它能让你在前端表单中直接调用后端的异步函数,且无需手动编写 API 接口。


第一步:创建 Server Action

app 目录下建议新建一个 actions 文件夹(或者直接在 lib 下),创建 post-actions.ts

// app/actions/post-actions.ts
"use server"; // 必须在文件顶部,声明这里全是服务端逻辑

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

// 1. 定义校验规则 (Schema)
const PostSchema = z.object({
  title: z.string().min(2, "标题至少2个字符"),
  content: z.string().min(5, "内容太短了"),
});

export async function createPost(prevState: any, formData: FormData) {
  // 2. 从表单提取并校验数据
  const validatedFields = PostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!validatedFields.success) {
    // 返回具体的字段级错误信息supabase db
    return {
      success: false,
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
  // 3. 写入数据库
  await db.post.create({
    data: validatedFields.data,
  });

  // 4. 【关键】告诉 Next.js 列表页缓存过期了,需要重新拉取
  revalidatePath("/");
  
  // 5. 跳转回首页
  redirect("/");
}

第二步:构建 shadcn 表单界面

我们将创建一个新页面 /posts/new 来放置表单。你可以直接使用原生的 form 标签配合 shadcn 的样式。

// app/posts/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "../../actions/post-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";

export default function NewPostPage() {
  // state: Action 返回的对象 (初始值为第二个参数)
  // formAction: 绑定到 form 标签的新函数
  // isPending: 是否正在提交
  const [state, formAction, isPending] = useActionState(createPost, {
    success: false,
    errors: {},
  });

  return (
    <div className="max-w-md mx-auto mt-10">
      <h1 className="text-2xl font-bold mb-6">发布新文章</h1>
      
      {/* 直接将 Server Action 传给 action 属性 */}
      <form action={formAction} className="space-y-4">
        <div>
          <label className="text-sm font-medium">标题</label>
          <Input name="title" placeholder="输入文章标题..."  />
        </div>
        
        <div>
          <label className="text-sm font-medium">内容</label>
          <Textarea name="content" placeholder="写点什么吧..." rows={5}  />
        </div>

        {state?.errors && <p className="text-red-500">{state.errors.content}</p>}
        {state?.success && <p className="text-green-500">{state.success}</p>}

        <Button disabled={isPending} type="submit" className="w-full">提交发布</Button>
      </form>
    </div>
  );
}

第三步:原理剖析 (Vue 开发者视角)

  • revalidatePath("/"):这是 Next.js 的灵魂。Vue 项目通常需要手动再次 fetch 列表,而这里你只需告诉 Next.js:“首页的数据旧了,请刷新”,它会自动处理。
  • 安全性:由于 createPostasync 且标记了 'use server',代码逻辑永远不会被下载到浏览器,数据库凭证非常安全。

在完成“新增”后,挑战“删除”功能是理解 Next.js 全栈数据流的关键。

在 Vue 中,删除通常是 axios.delete('/api/post/123')。在 Next.js Server Actions 中,挑战点在于:如何把 ID 传给服务器函数?


1. 编写删除 Action

在你的 app/actions/post-actions.ts 中添加删除逻辑:

// app/actions/post-actions.ts
'use server'

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function deletePost(id: string) {
  // 直接调用 Prisma 删除
  await db.post.delete({
    where: { id }
  });

  // 告诉 Next.js 首页数据变了,请刷新
  revalidatePath("/");
}


2. 在列表页实现删除按钮

这里有两种主流传参方式,推荐第一种(最符合 React 习惯):

方案 A:使用 .bind() 传参 (推荐)

bind 可以预设函数的参数,这是 Next.js 官方推荐的向 Server Action 传递额外数据(如 ID)的方式。

// app/page.tsx
import { db } from "@/lib/db";
import { deletePost } from "@/app/actions/post-actions";
import { Button } from "@/components/ui/button";

export default async function HomePage() {
  const posts = await db.post.findMany();

  return (
    <div className="space-y-4">
      {posts.map((post) => {
        // 使用 bind 预设 ID 参数
        const deletePostWithId = deletePost.bind(null, post.id);

        return (
          <div key={post.id} className="flex justify-between border p-4 items-center">
            <span>{post.title}</span>
            
            {/* 用一个小的 form 包裹按钮 */}
            <form action={deletePostWithId}>
              <Button variant="destructive" type="submit">
                删除
              </Button>
            </form>
          </div>
        );
      })}
    </div>
  );
}

方案 B:使用隐藏域 (type="hidden")

这种方式更接近传统 HTML 表单。

<form action={deletePost}>
  <input type="hidden" name="id" value={post.id} />
  <Button type="submit">删除</Button>
</form>

// 对应的 Action 里需要用 formData.get("id") 获取


3. 为什么一定要用 <form> 包裹删除按钮?

作为 Vue 开发者可能会问:我能不能直接给 Button 绑定一个 onClick={deletePost(id)}

  • 原因deletePost 是一个 Server Action(服务器函数)。在服务器组件(Server Components)里,是没有 onClick 这种浏览器事件的。
  • 优势:使用 <form> 的好处是 “渐进增强”。即使用户的浏览器由于网络原因还没加载完 JavaScript,点击这个按钮,删除功能依然有效

4. 挑战升级:确认弹窗 (Dialog)

如果你想在删除前弹出一个“你确定吗?”的对话框,就需要用到 shadcn/uiAlertDialog 组件。因为弹窗涉及浏览器交互,这时候就需要用到 'use client'

这个挑战非常经典!它涉及到了 Next.js 开发中最高频的场景:如何在保持全栈能力的服务器组件中,加入需要交互的客户端 UI?

为了实现带确认弹窗的删除,我们需要把“删除按钮”抽离成一个 Client Component


1. 安装 shadcn 弹窗组件

首先,安装 shadcn 提供的 AlertDialog

npx shadcn@latest add alert-dialog


2. 创建客户端删除组件

components/DeleteButton.tsx 中编写代码。注意这里我们要用到 useTransition,它可以让我们在执行 Server Action 时展示加载状态。

'use client' // 必须是客户端组件

import { useTransition } from "react"
import { deletePost } from "@/app/actions/post-actions"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Loader2, Trash2 } from "lucide-react" // 需要安装 lucide-react

export function DeleteButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition()

  return (
    <AlertDialog>
      {/* 1. 触发按钮 */}
      <AlertDialogTrigger asChild>
        <Button variant="outline" size="icon" className="text-destructive">
          <Trash2 className="h-4 w-4" />
        </Button>
      </AlertDialogTrigger>

      {/* 2. 弹窗内容 */}
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>确定要删除吗?</AlertDialogTitle>
          <AlertDialogDescription>
            此操作无法撤销,这篇文章将从数据库中永久移除。
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>取消</AlertDialogCancel>
          {/* 3. 确认执行删除 */}
          <AlertDialogAction
            onClick={() => startTransition(() => deletePost(id))}
            className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
            disabled={isPending}
          >
            {isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
            确定删除
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}


3. 在列表页引用

回到你的 app/page.tsx(服务器组件),直接使用这个按钮:

// app/page.tsx
import { db } from "@/lib/db";
import { DeleteButton } from "@/components/DeleteButton";

export default async function HomePage() {
  const posts = await db.post.findMany();

  return (
    <div className="grid gap-4">
      {posts.map((post) => (
        <div key={post.id} className="p-4 border rounded flex justify-between items-center">
          <div>
            <h3 className="font-bold">{post.title}</h3>
            <p className="text-sm text-muted-foreground">{post.content?.substring(0, 30)}...</p>
          </div>
          
          {/* 使用我们封装的带弹窗的删除组件 */}
          <DeleteButton id={post.id} />
        </div>
      ))}
    </div>
  );
}


4. 关键点剖析(Vue 开发者必看)

  1. 混合模式 (Hybrid)page.tsx 是服务器组件,它负责读数据DeleteButton 是客户端组件,它负责交互。Next.js 支持这种无缝嵌套。

  2. useTransition

    • 在 Vue 中,你可能会定义一个 isLoading 响应式变量。
    • 在 React/Next.js 中,startTransition 会自动处理异步操作。当 deletePost(Server Action)在后台执行并调用 revalidatePath 时,页面会自动重新渲染,而这个过程中的状态都会被 isPending 捕获。
  3. 参数传递:我们直接把 id 作为 Props 传给客户端组件,然后由客户端组件在调用 deletePost(id) 时传回给服务器。


鉴权

在 Next.js 的全栈开发中,身份认证(Auth)曾是最让人头疼的部分,但现在业界已经有了非常成熟的方案。

对于 Next.js 项目,我强烈推荐你使用 Clerk

  • 为什么不选 Auth.js (NextAuth)? Auth.js 很强大,但配置极其繁琐(需要自己搞定数据库适配、邮件服务等)。
  • 为什么选 Clerk? 它是目前的“版本答案”。它提供现成的登录/注册组件(带 UI),自带用户管理后台,且与 Next.js App Router 完美集成。5 分钟就能配置好。

1. 快速上手 Clerk

第一步:安装

npm install @clerk/nextjs

第二步:配置环境变量

Clerk 官网 创建一个项目,你会得到两个 Key,填入 .env.local

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

第三步:包裹你的应用

在根布局中加入 ClerkProvider

// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  )
}


2. 保护你的路由 (Middleware)

你肯定不希望未登录用户能看到 /posts/new。Clerk 通过 middleware.ts 轻松实现这一点。

在根目录创建 middleware.tsNext.js >15 后是proxy.ts

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

// 定义哪些路径是“受保护”的
const isProtectedRoute = createRouteMatcher(['/posts/new(.*)', '/posts/(.*)/edit']);

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) await auth.protect(); // 如果没登录,自动重定向到登录页
});

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};


3. 在 UI 中显示用户信息

Clerk 提供了现成的 UI 组件,你可以直接放在你的 SidebarNavbar 里。

import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";

export default function Navbar() {
  return (
    <nav className="flex justify-between p-4 border-b">
      <span>我的博客</span>
      <div>
        {/* 如果没登录,显示登录按钮 */}
        <SignedOut>
          <SignInButton mode="modal">
            <button className="btn">登录</button>
          </SignInButton>
        </SignedOut>
        
        {/* 如果已登录,显示用户头像/管理菜单 */}
        <SignedIn>
          <UserButton showName />
        </SignedIn>
      </div>
    </nav>
  );
}


4. 在后端 (Server Actions) 获取用户

这是最重要的一步:如何确定是谁在发帖?

// app/actions/post-actions.ts
import { auth } from "@clerk/nextjs/server";

export async function createPost(formData: FormData) {
  'use server'
  
  // 从 Clerk 获取当前用户 ID
  const { userId } = await auth();

  if (!userId) throw new Error("请先登录");

  await db.post.create({
    data: {
      title: formData.get("title") as string,
      content: formData.get("content") as string,
      authorId: userId, // 记得在 Prisma Schema 里加上这个字段
    },
  });
}


5. 挑战进阶:关联用户与文章

为了让 CRUD 更完善,你需要修改你的 prisma/schema.prisma

model Post {
  id       String @id @default(cuid())
  title    String
  content  String?
  authorId String // 存储 Clerk 的用户 ID
  // ...
}

修改 Schema 后记得运行 npx prisma migrate dev


6. 修改登录表单样式

要让 Clerk 的登录面板看起来完全像 shadcn/ui 的组件,最优雅的方式是利用 Clerk 的 appearance 属性,将 Tailwind CSS 类名shadcn 的全局变量注入进去。

这样你既能享受 Clerk 自动处理验证码、找回密码等复杂逻辑的便利,又能获得 100% 匹配的视觉风格。


方案:全局统一风格 (推荐)

你可以在 app/layout.tsxClerkProvider 中进行全局配置,这样所有的登录、注册、个人设置组件都会自动应用风格。

// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // app/layout.tsx
		<ClerkProvider
		  appearance={{
		    variables: {
		      colorPrimary: 'oklch(0.852 0.199 91.936)', // 对应 shadcn 的 --primary
		      colorBackground: 'oklch(1 0 0)',          // 对应 --background
		      colorText: 'oklch(0.145 0 0)',            // 对应 --foreground
		      borderRadius: '0.625rem',                 // 对应 --radius
		    },
		    elements: {
		      card: 'shadow-md border border-border',   // 登录卡片样式
		      headerTitle: 'text-2xl font-bold tracking-tight',
		      headerSubtitle: 'text-muted-foreground',
		      formButtonPrimary: 'bg-primary text-primary-foreground hover:bg-primary/90', // 主按钮
		      socialButtonsBlockButton: 'border-input hover:bg-accent hover:text-accent-foreground', // 社交登录按钮
		      footerActionLink: 'text-primary hover:text-primary/90', // 底部链接
		    }
		  }}
		>
		  {/* ... */}
		</ClerkProvider>
  );
}


核心配置项说明

  1. elements: 这里可以使用任意 Tailwind 类名。你可以直接去 components/ui/button.tsxinput.tsx 里复制 shadcn 的默认类名粘贴进来。
  2. formFieldInput: 这是修改输入框的关键。上面的代码模拟了 shadcn Input 组件的边框、环绕阴影和聚焦效果。
  3. formButtonPrimary: 这是修改登录按钮的关键。它会将 Clerk 默认的蓝色按钮改为你在 globals.css 中定义的 primary 颜色。

进阶:如何适配深色模式?

如果你的项目支持深色模式,Clerk 提供了一个官方主题包,能更好地处理背景色切换。

  1. 安装主题包
npm install @clerk/themes

  1. 在配置中引入
import { dark } from "@clerk/themes";

<ClerkProvider
  appearance={{
    baseTheme: dark, // 如果用户系统是深色,Clerk 会自动调整基础色调
    elements: { /* 你的自定义类名 */ }
  }}
>


要让样式生效,你需要告诉 Clerk:“不要去你的官网登录,去我项目里自己写的 /sign-in 页面登录。”


  1. 宿主环境改变:之前是在 Clerk 官网登录(样式不受控);现在是在你的 Next.js 路由里登录。
  2. Context 注入:你在 layout.tsx 里写的 <ClerkProvider appearance={...}> 会通过 React Context 将样式传递给所有后代组件。当路由跳转到 /sign-in 时,页面里的 <SignIn /> 就能拿到你配置的那些 oklch 变量和 Tailwind 类名了。

7. 自定义登录表单

如果你觉得 Clerk 的默认卡片式 UI 限制太多(即使去掉了边框、阴影和标志依然不满意),2026 年最流行的做法是 Custom Flow(自定义流程)

Clerk 提供了一系列 Headless Hooks(如 useSignIn, useSignUp),你可以用自己写的 HTML/Tailwind 输入框来构建登录界面,完全不使用 Clerk 的 组件。

这样你就能拥有 100% 自由度,没有任何 Clerk 的默认样式或标签。


1. 核心逻辑拆解

要同时支持 Google 登录邮箱/密码登录,流程如下:

  • Google: 调用 signIn.authenticateWithRedirect()
  • 邮箱密码: 调用 signIn.create() 然后用 signIn.attemptFirstFactor() 处理。

2. 创建自定义登录页面

app 目录下创建目录 app/sign-in/[[...sign-in]]/page.tsx

"use client";

import * as React from "react";
import { useSignIn } from "@clerk/nextjs";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EyeIcon } from "lucide-react";
import Link from "next/link";

export default function CustomSignInPage() {
  const { isLoaded, signIn, setActive } = useSignIn();
  const [email, setEmail] = React.useState("");
  const [password, setPassword] = React.useState("");
  const router = useRouter();

  if (!isLoaded) return null;

  // --- 1. 处理谷歌登录 ---
  const signInWithGoogle = () => {
    return signIn.authenticateWithRedirect({
      strategy: "oauth_google",
      redirectUrl: "/sso-callback", // 登录成功后的中转页
      redirectUrlComplete: "/",      // 最终跳转页
    });
  };

  // --- 2. 处理邮箱密码登录 ---
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const result = await signIn.create({
        identifier: email,
        password,
      });
      if (result.status === "complete") {
        await setActive({ session: result.createdSessionId });
        router.push("/");
      } else {
        console.log("还需要其他验证步骤(如 MFA):", result);
      }
    } catch (err: any) {
      console.error("登录失败:", err.errors[0].message);
    }
  };

  return (
    <div className="min-h-screen w-full flex flex-col items-center justify-center bg-white px-4">
      {/* Logo 部分 */}
      <div className="mb-12">
        <h1 className="text-xl font-bold tracking-tight font-mono">ElevenLabs</h1>
      </div>

      <div className="w-full max-w-[400px] space-y-6">
        {/* 标题 */}
        <div className="text-center mb-8">
          <h2 className="text-2xl font-extrabold tracking-tight">
            Welcome back
          </h2>
        </div>

        {/* 第三方登录按钮组 */}
        <div className="space-y-3">
          <Button
            variant="outline"
            onClick={signInWithGoogle}
            className="w-full h-12 rounded-xl cursor-pointer font-medium"
          >
            <svg width="20" height="20" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg"><title>Google</title><g clip-path="url(#clip0_95_488)"><path d="M24.2663 12.7764C24.2663 11.9607 24.2001 11.1406 24.059 10.3381H12.7402V14.9591H19.222C18.953 16.4494 18.0888 17.7678 16.8233 18.6056V21.6039H20.6903C22.9611 19.5139 24.2663 16.4274 24.2663 12.7764Z" fill="#4285F4"></path><path d="M12.7401 24.5008C15.9766 24.5008 18.7059 23.4382 20.6945 21.6039L16.8276 18.6055C15.7517 19.3375 14.3627 19.752 12.7445 19.752C9.61388 19.752 6.95946 17.6399 6.00705 14.8003H2.0166V17.8912C4.05371 21.9434 8.2029 24.5008 12.7401 24.5008Z" fill="#34A853"></path><path d="M6.00277 14.8003C5.50011 13.3099 5.50011 11.6961 6.00277 10.2057V7.11481H2.01674C0.314734 10.5056 0.314734 14.5004 2.01674 17.8912L6.00277 14.8003Z" fill="#FBBC04"></path><path d="M12.7401 5.24966C14.4509 5.2232 16.1044 5.86697 17.3434 7.04867L20.7695 3.62262C18.6001 1.5855 15.7208 0.465534 12.7401 0.500809C8.2029 0.500809 4.05371 3.05822 2.0166 7.11481L6.00264 10.2058C6.95064 7.36173 9.60947 5.24966 12.7401 5.24966Z" fill="#EA4335"></path></g><defs><clipPath id="clip0_95_488"><rect width="24" height="24" fill="white" transform="translate(0.5 0.5)"></rect></clipPath></defs></svg>
            Sign in with Google
          </Button>  
        </div>

        {/* 分割线 */}
        <div className="relative py-4">
          <div className="absolute inset-0 flex items-center">
            <span className="w-full border-t border-gray-200" />
          </div>
        </div>

        {/* 表单部分 */}
        <form className="space-y-4" onSubmit={handleSubmit}>
          <div className="space-y-2">
            <Label htmlFor="email" className="text-sm font-blod">
              Email
            </Label>
            <Input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="h-12 rounded-xl"
            />
          </div>

          <div className="space-y-2">
            <div className="flex items-center justify-between">
              <Label
                htmlFor="password"
                className="text-sm font-medium text-gray-700"
              >
                Password
              </Label>
              <button className="text-xs text-gray-400 hover:underline">
                Forgot your password?
              </button>
            </div>
            <div className="relative">
              <Input
                id="password"
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="h-12 rounded-xl"
              />
              <button className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
                <EyeIcon size={18} />
              </button>
            </div>
          </div>

          <Button className="w-full h-12 font-bold rounded-xl">
            Sign in
          </Button>
        </form>

        {/* 注册跳转 */}
        <div className="text-center pt-2">
          <p className="text-sm text-gray-600">
            Don't have an account?{" "}
            <Link href="/sign-up" className="text-gray-900 font-medium hover:underline">
              Sign up
            </Link>
          </p>
        </div>
      </div>
    
    </div>
  );
}

同样地,建议也创建一个 app/sign-up/[[...sign-up]]/page.tsx 用于注册。


3. 在 .env.local 中配置路径

你需要显式告诉 Clerk 你的自定义页面地址,这样 auth.protect() 才知道往哪跳:

# .env.local
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

4. 关键配置:SSO 回调页

在使用 OAuth(如 Google)时,Clerk 需要一个页面来处理重定向回来的数据。你需要创建一个文件 app/sso-callback/page.tsx

import { AuthenticateWithRedirectCallback } from "@clerk/nextjs";

export default function SSOCallback() {
  // 这个组件是 Clerk 提供的,专门处理 OAuth 回调逻辑
  return <AuthenticateWithRedirectCallback />;
}


5. 为什么这样做能解决你的问题?

  • 完全去掉了默认样式: 没有阴影、没有边框、没有 "Powered by Clerk",因为你根本没用 <SignIn /> 组件。
  • 结构完全可控: 你可以把 Google 登录放在上面,也可以放在下面,或者只显示一个图标。
  • 开发环境标签: 请注意,即使使用 Headless Hooks,如果你是在开发环境(Development Instance),某些网络请求或控制台仍会提示。但 UI 上绝对不会再出现那个粉红色的浮窗了。

⚠️ 注意事项

  1. 错误处理: 自定义 UI 需要你自己写错误提示(比如“密码错误”)。
  2. 加载状态: 建议在提交时添加 loading 状态防止用户重复点击。
  3. 安全性: Clerk 依然会在后台处理 Session 和 Cookie,安全性与默认组件一致。

你需要我帮你补充“注册(Sign Up)”部分的 Hooks 逻辑,还是针对这个登录表单做更细致的 Tailwind 样式美化?

8. 总结

  1. 前端:用 Clerk 的 <SignedIn><UserButton> 搞定 UI。
  2. 中间层:用 middleware.ts 拦截未登录访问。
  3. 后端:用 auth() 获取 userId 存入数据库。

这样,你的 Demo 就从一个“匿名留言板”进化成了一个真正的“多用户博客系统”。

现在你的项目已经有了:UI (shadcn) + 数据库 (Supabase) + 逻辑 (Next.js) + 权限 (Clerk)。

支线知识 ↓

Zod

既然你熟悉 Java 体系,理解 Zod 最快的方式就是把它看作 “TypeScript 界的 Hibernate Validator (JSR-303)”

在 Java 中,你会用 @NotNull, @Size(min=5) 来校验 DTO。在 Next.js 全栈开发中,Zod 就是干这个活的,而且它还额外承担了 “类型推导” 的工作。


1. 逐行拆解

import { z } from "zod"; // 引入 zod 库,约定俗成简写为 z

// 定义一个“校验模式”(就像在 Java 中定义一个带注解的 DTO 类)
const PostSchema = z.object({
  
  // title 必须是字符串,且长度至少为 1,不满足则返回自定义错误消息
  title: z.string().min(1, "标题不能为空"), 
  
  // content 必须是字符串,且长度至少为 5
  content: z.string().min(5, "内容至少需要 5 个字"),
  
});

2. 它解决了什么痛点?

在 Java 中,编译器能保证类型安全。但在 TypeScript 中,运行时(Runtime) 的数据(比如用户提交的表单、API 返回的 JSON)是不可控的。

Zod 的作用是:“先校验,再使用”


3. Java 开发者最关心的:如何使用?

定义好 PostSchema 后,你通常会有两个动作:

A. 运行时校验(相当于校验请求参数)

// 模拟一段不合法的数据
const rawData = { title: "", content: "hi" };

// 校验
const result = PostSchema.safeParse(rawData);

if (!result.success) {
  // 如果校验失败,打印错误消息
  console.log(result.error.format()); 
  // 输出:{ title: { _errors: ['标题不能为空'] }, content: { _errors: ['内容至少需要 5 个字'] } }
}

B. 静态类型推导(最神奇的地方)

在 Java 里你需要手动写个 PostDTO 类。在 Zod 里,你可以直接从模式中提取出 TypeScript 类型

// 这一行代码直接生成了一个 TS 接口/类型
type PostDTO = z.infer<typeof PostSchema>;

// 现在 PostDTO 等同于:
// interface PostDTO {
//   title: string;
//   content: string;
// }


4. 为什么 Prisma 学习路径中会出现 Zod?

这是一个全栈最佳实践

  1. 用户前端输入:用 Zod 校验表单。
  2. Server Action (后端):用 Zod 二次校验(防止绕过前端)。
  3. Prisma:校验通过后,放心交给 Prisma 写入数据库。

这样你就构建了一套从页面到数据库的“防弹”链路。


5. 举一反三:更多常用校验

如果你想在 PostSchema 里加更多约束,非常直观:

  • email: z.string().email("邮箱格式不对")
  • age: z.number().int().min(18)
  • tags: z.array(z.string()) (字符串数组)

useActionState

既然你已经了解了 "use server";Zod,那么 useActionState 就是把它们粘合在一起的**“ 胶水”**。

作为 Java 后端,你一定熟悉处理表单提交的逻辑:用户提交 -> 后端校验 -> 校验失败返回错误信息 -> 前端显示错误。在传统的 React 中,这需要写很多 useState 来管理加载状态和错误消息。

useActionState 是 React 19 和 Next.js 推荐的官方 Hook,专门用来管理 Server Action 的状态。


1. 核心定义

useActionState 是一个客户端 Hook,它接收一个 Server Action,并返回:

  1. 当前的 Action 状态(比如后端返回的错误消息、成功提示)。
  2. 一个包装后的 Action 函数(你直接绑定到 form 的 action 属性上)。
  3. 是否正在挂起 (Pending)(类似于加载中状态)。

2. 语法结构

const [state, formAction, isPending] = useActionState(action, initialState);
  • action: 你写的那个带有 "use server"; 的函数。
  • initialState: 初始状态(通常是 { message: "" })。
  • state: 后端返回的最新的值。
  • formAction: 传递给 <form action={...}> 的函数。
  • isPending: 布尔值,如果为 true,表示后端逻辑正在执行(你可以用来禁用按钮或显示 Loading)。

3. 实战示例:Zod + Server Action + useActionState

让我们把之前的知识点串起来。

后端:actions.ts

"use server";
import { z } from "zod";

const schema = z.object({
  title: z.string().min(1, "标题不能为空"),
});

export async function createPost(prevState: any, formData: FormData) {
  // 模拟延迟
  await new Promise((res) => setTimeout(res, 1000));

  const validatedFields = schema.safeParse({
    title: formData.get("title"),
  });

  if (!validatedFields.success) {
    return { error: validatedFields.error.flatten().fieldErrors.title?.[0] };
  }

  // 执行 Prisma 存库操作...
  return { success: "发布成功!" };
}

前端:PostForm.tsx

"use client";
import { useActionState } from "react";
import { createPost } from "./actions";

export default function PostForm() {
  // 绑定 Action
  const [state, formAction, isPending] = useActionState(createPost, { error: "", success: "" });

  return (
    <form action={formAction}>
      <input name="title" className="border p-2" />
      
      {/* 显示校验错误 */}
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">{state.success}</p>}

      <button 
        type="submit" 
        disabled={isPending}
        className="bg-blue-500 text-white p-2 disabled:bg-gray-400"
      >
        {isPending ? "提交中..." : "发布帖子"}
      </button>
    </form>
  );
}


useTransition

既然你提到了 useActionState,那么理解 useTransition 就非常简单了。它们两个长得很像,但分工不同。

简单来说:useActionState 专门用于管理“表单”的状态,而 useTransition 是一个更通用的“手动挡”,用于处理任何不依赖于自动表单提交的异步操作。


1. 核心定义

useTransition 是 React 提供的一个 Hook,它可以让你在不阻塞 UI 的情况下更新状态。它会给你一个 isPending 标志,告诉你异步任务(比如数据库操作)是否正在执行。

2. 语法结构

const [isPending, startTransition] = useTransition();

  • isPending: 告诉你是“正在处理中”。
  • startTransition: 一个函数,你把需要执行的异步逻辑(比如调用 Prisma 的 Server Action)丢进去。

3. Java 开发者视角的对比:它和 useActionState 有什么区别?

我们可以用一个简单的表格来区分:

特性useActionState (自动挡)useTransition (手动挡)
主要用途处理 <form> 表单提交处理 点击事件(按钮、删除图标等)
数据管理自动管理后端返回的 state (错误信息等)只负责管理 isPending 状态,不存返回数据
触发方式通过 form 的 action 属性触发通过 startTransition(() => { ... }) 手动触发

4. 实战场景:删除一个用户

假设你在做一个用户列表,每个用户后面有一个“删除”按钮。这里没有表单,最适合用 useTransition

后端:actions.ts

"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function deleteUser(id: number) {
  await prisma.user.delete({ where: { id } });
  revalidatePath("/users"); // 告诉 Next.js 刷新页面数据
}

前端:UserRow.tsx

"use client";
import { useTransition } from "react";
import { deleteUser } from "./actions";

export default function UserRow({ user }) {
  const [isPending, startTransition] = useTransition();

  return (
    <div className={isPending ? "opacity-50" : "opacity-100"}>
      <span>{user.name}</span>
      <button
        disabled={isPending}
        onClick={() => {
          // 手动包裹异步逻辑
          startTransition(async () => {
            await deleteUser(user.id);
          });
        }}
      >
        {isPending ? "删除中..." : "删除"}
      </button>
    </div>
  );
}


5. 为什么要用它?(性能与体验)

在 React 18 之前,当你点击删除按钮,页面可能“卡死”一下直到后端响应。 使用了 useTransition 后:

  1. UI 响应极快:React 会把 startTransition 里的操作标记为“非紧急”。
  2. 状态反馈:你可以利用 isPending 立刻给用户一个视觉反馈(比如让按钮变灰或显示菊花图),这和你在 Vue 中手动维护 loading 变量的目的一样,但代码更简洁。

总结你的“工具箱”

现在你的全栈开发工具箱里有这三样东西:

  1. 直接调用:在 Server Component 里直接 await prisma...(用于初始化加载数据)。
  2. useActionState:用于 Form 表单(登录、注册、发布文章)。
  3. useTransition:用于 独立按钮操作(点赞、删除、切换状态)。

.bind

在 Next.js 的 Server Actions 开发中,.bind 是一个非常关键的技巧。

作为 Java 开发者,你可能习惯了通过 URL 传参(如 /api/user/{id})或者在 DTO 里加隐藏字段。但在 Next.js 中,.bind 提供了一种更优雅、类型安全的方式来给后端函数“预传参数”。


1. 核心定义

.bind 是 JavaScript 的原生方法。它会创建一个新函数,并在调用时,将你指定的参数“锁死”在函数的前几个参数位上。

在 Next.js 中,它主要用于:在不使用隐藏 <input> 的情况下,将特定数据(如 ID)传给 Server Action。


2. 为什么要用它?(对比 Java 思路)

假设你要实现“删除文章”功能,后端需要一个 postId

传统思路(Java/Vue):

你在表单里写一个 <input type="hidden" name="id" value="123" />。这样提交时,后端能从 FormData 里拿到 ID。

Next.js + .bind 思路:

你直接把 ID “绑定”到函数上,不需要在 HTML 里塞隐藏域。


3. 代码演示

后端 Action (actions.ts):

注意这里的参数顺序,绑定后的参数会出现在 formData 之前

"use server";

// postId 是我们通过 .bind 传进来的
export async function deletePost(postId: number, formData: FormData) {
  await prisma.post.delete({ where: { id: postId } });
}

前端组件 (DeleteButton.tsx):

"use client";
import { deletePost } from "./actions";

export default function DeleteButton({ id }: { id: number }) {
  // 使用 .bind 锁死第一个参数为当前文章的 id
  // 第一个参数是 this 的指向(通常传 null),第二个参数开始才是我们要传的值
  const deletePostWithId = deletePost.bind(null, id);

  return (
    <form action={deletePostWithId}>
      <button type="submit">删除文章 {id}</button>
    </form>
  );
}


4. 它的三大优势

  1. 安全性:数据在服务器端生成的函数中闭包存储,不像 type="hidden" 那样容易在浏览器 F12 被用户篡改值。
  2. 类型安全:如果你在 actions.ts 里定义第一个参数是 number,而你在 .bind 时传了 string,TypeScript 会直接报错。这比从 FormData 里手动 get 出来再转型要稳健得多。
  3. 代码简洁:不需要在表单里维护各种隐藏字段,逻辑更清晰。

5. Java 视角深度类比

你可以把 .bind 看作是一种 “动态创建的带参构造函数”

  • 普通 Action: 就像一个无参构造的类,只能通过 setter (FormData) 传值。
  • .bind 后的 Action: 就像是一个已经通过构造函数注入了特定 ID 的 Service 对象,你只需要调用它的 execute() 方法即可,它已经知道自己要操作哪个 ID 了。

解构语法

对于 Java 开发者来说,解构(Destructuring) 是 JavaScript/TypeScript 中最能提高“爽感”的语法之一。

在 Java 中,如果你想从一个对象里拿几个属性,你得不停地调用 getter;但在 JS 中,你可以直接把对象“拆解”开。


1. 对象解构 (Object Destructuring)

这是你之前看到的语法:const { id: idStr } = await params;

Java 的写法 (对比)

// 假设 Params 是一个类
Params p = params.get();
String idStr = p.getId();

JavaScript 的写法

const user = { name: "张三", age: 18, city: "北京" };

// 基础写法:变量名必须和属性名一致
const { name, age } = user;
console.log(name); // "张三"

// 进阶写法:起别名(你代码里的用法)
const { name: userName } = user;
console.log(userName); // "张三"


2. 数组解构 (Array Destructuring)

你在 useActionStateuseState 中看到的 [state, formAction] 就是数组解构。

语法示例

const colors = ["red", "green", "blue"];

// 按照顺序取出前两个
const [firstColor, secondColor] = colors;

console.log(firstColor); // "red"

Java 视角: 这在 Java 里通常需要通过索引 colors[0] 来获取,解构让它变得非常直观。


3. 函数参数解构 (最常用的场景)

在 Next.js 的组件开发中,你会疯狂用到这个。

普通写法

function UserCard(props) {
  return <div>{props.name} - {props.age}</div>
}

解构写法 (推荐)

// 直接在参数位把 props 拆开
function UserCard({ name, age }) {
  return <div>{name} - {age}</div>
}


4. 为什么解构在 Prisma/Next.js 中很重要?

  1. 处理异步数据:如你所见,const { id } = await params。因为 params 是个 Promise,我们先 await 拿到结果对象,然后瞬间解构出我们要的字段。
  2. 减少冗余:当一个对象有 20 个属性,而你只需要其中 2 个时,解构能让代码清爽很多。
  3. 配合 Spread (展开运算符)
const { id, ...rest } = post; 
// id 拿出来了,剩下的属性全在 rest 这个对象里
// 类似于 Java 中从 Map 里 remove 掉一个 key,剩下的组成新 Map


5. 一个容易踩的坑 (Java 开发注意)

  • 必须同名:解构对象时,变量名必须和对象里的 Key 一模一样。如果不一样,必须用冒号起别名,否则拿到的就是 undefined
  • 空值检查:如果被解构的对象是 nullundefined,代码会直接崩溃(抛出 TypeError)。所以通常要配合可选链或者默认值。
const { name = "匿名" } = user || {}; // 这种防御性写法在 JS 中很常见


总结

  • { } 用于解构对象。
  • [ ] 用于解构数组。
  • : 用于起别名。
  • = 用于设置默认值。

你现在已经掌握了 TS 中最灵活的语法!既然你已经理解了如何获取 ID,下一步我们是不是该聊聊如何用这个 ID 去 Prisma 里做 update 更新操作了?

© 版权声明
😀😃😄😁😆😅🤣😂🙂🙃😉😊😇🥰😍🤩😘😗😚😙😋😛😜🤪😝🤑🤗🤭🤫🤔🤐🤨😐😑😶😏😒🙄😬🤥😌😔😪🤤😴😷🤒🤕🤢🤮🤧🥵🥶🥴😵🤯🤠🥳😎🤓🧐😕😟🙁☹️😮😯😲😳🥺😦😧😨😰😥😢😭😱😖😣😞😓😩😫🥱😤😡😠🤬