跳到主要内容

68-实战篇 t3-app 实战 身份认证与主题切换

前言

我们使用 Typescript + Tailwind + Prisma + MySQL + Zod + Shadcn UI + React Hook Form + Clerk + Server Actions 开发一个清单项目。

项目效果如下:

t32.gif

1. 项目初始化

运行:

npm create t3-app@latest
  1. Will you be using TypeScript or JavaScript?
    • TypeScript
  2. Will you be using Tailwind CSS for styling?
    • Yes
  3. Would you like to use tRPC?
    • No
  4. What authentication provider would you like to use?
    • None(因为接入 Clerk)
  5. What database ORM would you like to use?
    • Prisma
  6. Would you like to use Next.js App Router?
    • Yes
  7. What database provider would you like to use?
    • MySQL(选择其他喜欢的数据库也行)
  8. Should we initialize a Git repository and stage the changes?
    • Yes
  9. Should we run 'npm install' for you?
    • Yes
  10. What import alias would you like to use?
    • @(按照个人习惯设置即可)

本地开启 MySQL 数据库,修改 .env中的数据库地址:

# 修改用户名、密码,用于连接本地数据库
# next-t3-todo 表示数据库名,数据库不需要先行创建
DATABASE_URL="mysql://username:password@localhost:3306/next-t3-todo"

运行:

# 进入项目目录
cd next-t3-todo
# 相当于 prisma db push
npm run db:push

效果如下:

image.png

此时会创建数据库,并将数据库和 Prisma schema 同步。

运行:

# 开发模式
npm run dev
# 提交代码
git commit -m "initial commit"

我们的项目就正式开始了。

2. 功能:身份认证

我们先做身份认证,毕竟身份认证是基础,且创建清单的时候需要用户信息。

为了快速实现,最便捷的方式是接入 Clerk。我们将接入 Clerk 并实现界面的汉化。

2.1. 接入 Clerk

安装依赖项:

# 安装依赖项,其中 lodash.merge 用于界面汉化
npm i --save @clerk/localizations @clerk/nextjs lodash.merge
# 安装开发依赖项
npm i --save-dev @types/lodash.merge

Clerk 创建一个应用,创建后查看密钥信息。

项目根目录新建 .env.local文件,代码如下:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

新建 src/middleware.ts文件,代码如下:

import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

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

注意: middleware.ts 并不一定放在项目根目录,放在与 app 同级目录位置

修改 src/app/layout.tsx,代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
ClerkProvider,
SignInButton,
SignedIn,
SignedOut,
UserButton,
} from "@clerk/nextjs";

export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header>
<main>{children}</main>
</body>
</html>
</ClerkProvider>
);
}

重新运行 npm run dev,此时效果如下:

image.png

2.2. 自定义登录和注册地址

当我们点击 Sign in 的时候,跳转的其实是 Clerk 的地址,如果要修改为我们自己的路由地址该怎么做呢?

新建 src/app/(auth)/sign-in/[[...sign-in]]/page.tsx,代码为:

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

export default function Page() {
return <SignIn />;
}

新建 src/app/(auth)/sign-up/[[...sign-up]]/page.tsx,代码为:

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

export default function Page() {
return <SignUp />;
}

修改 .env.local文件,添加如下代码:

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

重新运行 npm run dev,浏览器效果如下:

t33.gif

效果描述:当点击登录的时候,跳转的是 http://localhost/sign-in

2.3. 界面汉化

修改 src/app/layout.tsx,完整代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
ClerkProvider,
SignInButton,
SignedIn,
SignedOut,
UserButton,
} from "@clerk/nextjs";
// Step1: 引入汉化文件
import { zhCN } from "@clerk/localizations";

export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
// Step2: 设置 ClerkProvider 的 localization
return (
<ClerkProvider localization={zhCN}>
{/** Step3: 别忘了 html lang */}
<html lang="zh-CN">
<body>
<header>
<SignedOut>
{/** Step4: 如果按钮文案要改为中文 */}
<SignInButton>登录</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header>
<main>{children}</main>
</body>
</html>
</ClerkProvider>
);
}

此时界面基本完成汉化,浏览器效果如下:

t34.gif

但查看用户界面,你就会发现,这个汉化并不全面!就比如删除按钮这里的文案依然是英文……

新建 src/locales/zh.json,代码如下:

{
"userProfile": {
"start": {
"dangerSection": {
"deleteAccountButton": "删除账户",
"title": "账户终止"
}
}
}
}

修改 src/app/layout.tsx,代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
ClerkProvider,
SignInButton,
SignedIn,
SignedOut,
UserButton,
} from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
// Step1: 引入翻译文件
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";

export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
// Step2: 合并翻译文件
const localization = merge(zhCN, zhCNlocales);
// Step3: 设置 ClerkProvider 的 localization
return (
<ClerkProvider localization={localization}>
<html lang="zh-CN">
<body>
<header>
<SignedOut>
<SignInButton>登录</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header>
<main>{children}</main>
</body>
</html>
</ClerkProvider>
);
}

此时删除账户界面就改为了中文:

image.png

那么问题来了:如果还有其他需要汉化的位置,怎么知道具体的字段位置呢?就比如为什么“删除账户”,它对应的字段位置是 userProfile.start.dangerSection.deleteAccountButton 呢?

其实这是个体力活:打开原本的英文,搜索对应文案,就可以找到具体的字段位置了。

注意:目前中文翻译并不全面,这是一个给 Clerk 提 PR 的好机会!

2.4. 设置路由保护

毕竟我们开发的是一个清单项目,当用户打开首页的时候,应该是登录后才能查看到自己创建的清单。所以我们实现一个路由保护,当用户访问 /的时候,会跳转到 /sign-in,引导用户登录。

修改 src/middleware.ts,完整代码如下:

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

const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);

export default clerkMiddleware((auth, request) => {
if (!isPublicRoute(request)) {
auth().protect();
}
});

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

在这段代码中,我们设置了 /sign-in/sign-up 为公共路由,访问其他路由,都会触发路由保护,跳转到登录页面。

注意:这里中间件的 matcher 逻辑是,除了内部路由 _next 和静态文件之外,其他都会受到保护

修改 src/app/layout.tsx,代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
ClerkProvider,
SignInButton,
SignedIn,
SignedOut,
UserButton,
} from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";

export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const localization = merge(zhCN, zhCNlocales);
return (
<ClerkProvider localization={localization}>
<html lang="zh-CN">
<body>
{/* 注释或删除下面这段代码,在使用路由保护的时候会导致错误 */}
{/* <header>
<SignedOut>
<SignInButton>登录</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header> */}
<main>{children}</main>
</body>
</html>
</ClerkProvider>
);
}

这是因为使用 <SignedIn><SignedOut> 组件会导致报错:

image.png

现在我们就完成了 Clerk 的基础设置。浏览器效果如下:

t35.gif

效果描述:访问首页,会跳转到登录页面,登录完成后,跳转会首页。

3. 功能:支持深色模式

接下来我们支持深色模式,我们借助 Shadcn UI 实现。

3.1. 接入 Shadcn UI

初始化 Shadcn UI:

npx shadcn-ui@latest init

命令行效果如下:

image.png

添加组件:

npx shadcn-ui@latest add

因为用到的组件很多,干脆全装了。敲击键盘的a,作用是全选组件(再敲击一次就是取消全选),然后进行安装。

3.2. 实现主题切换器

修改 src/app/page.tsx,代码如下:

export default function HomePage() {
return (
<main className="flex w-full flex-col items-center">
<div>Hello World!</div>
</main>
);
}

修改 src/app/layout.tsx,完整代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import { ClerkProvider } from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";
// Step1: 添加组件
import ThemeProvider from "@/components/ThemeProvider";
import Header from "@/components/Header";

export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const localization = merge(zhCN, zhCNlocales);
return (
<ClerkProvider localization={localization}>
{/* Step2: 设置 suppressHydrationWarning */}
<html lang="zh-CN" suppressHydrationWarning>
<body>
{/* Step3: 设置 ThemeProvider */}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Header />
<div className="flex w-full flex-col items-center">{children}</div>
</ThemeProvider>
</body>
</html>
</ClerkProvider>
);
}

新建 src/components/ThemeProvider.tsx,代码如下:

"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";

export default function ThemeProvider({
children,
...props
}: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

新建 src/components/Header.tsx,代码如下:

import { UserButton } from "@clerk/nextjs";
import ThemeToggle from "./ThemeToggle";

export default function Header() {
return (
<nav className="flex h-[60px] w-full items-center justify-between p-4">
<h1>嗒嗒清单</h1>
<div className="flex items-center gap-2">
<UserButton />
<ThemeToggle />
</div>
</nav>
);
}

新建 src/components/ThemeToggle.tsx,代码如下:

"use client";

import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export default function ModeToggle() {
const { setTheme } = useTheme();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

此时浏览器效果如下:

t3-6.gif

注:更复杂的效果,比如修改主题色、增加主题请查看《实战篇 | Shadcn UI 与组件库》

4. 功能:欢迎信息

修改 src/app/page.tsx,完整代码如下:

import { Suspense } from "react";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { currentUser } from "@clerk/nextjs/server";
import CreateListModal from "@/components/createListModal";

async function Welcome() {
const user = await currentUser();

if (!user) return null;

return (
<Card className="w-full sm:col-span-2" x-chunk="dashboard-05-chunk-0">
<CardHeader className="pb-3">
<CardTitle className="text-lg">
欢迎 {user.firstName} {user.lastName}!
</CardTitle>
<CardDescription className="max-w-lg text-balance leading-relaxed">
道虽迩,不行不至;事虽小,不为不成
</CardDescription>
</CardHeader>
<CardFooter>
<CreateListModal />
</CardFooter>
</Card>
);
}

function WelcomeFallback() {
return <Skeleton className="h-[180px] w-full" />;
}

export default function HomePage() {
return (
<main className="flex w-full flex-col items-center px-4">
<Suspense fallback={<WelcomeFallback />}>
<Welcome />
</Suspense>
</main>
);
}

在这段代码中,我们创建了一个 <Welcome>组件,当涉及到服务端请求时,应该尽可能将请求放到 <Suspense> 中,这样就不会阻塞页面的请求和渲染。

新建 src/components/CreateListModal.tsx,代码如下:

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";

export default function Sidebar() {
return (
<Sheet>
<SheetTrigger asChild>
<Button>添加清单</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>添加清单</SheetTitle>
<SheetDescription>
清单是任务的集合,比如“工作”、“生活”、“副业”
</SheetDescription>
</SheetHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
清单名称:
</Label>
<Input
id="name"
value="工作"
onChange={() => {
console.log(1);
}}
className="col-span-3"
/>
</div>
</div>
<SheetFooter>
<SheetClose asChild>
<Button type="submit">创建</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

此时浏览器效果如下:

t3-7.gif

下一篇

  1. 功能实现:t3-app 身份认证和深色模式
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-t3-todo
  3. 下载代码:git clone -b next-t3-todo git@github.com:mqyqingfeng/next-app-demo.git

目前我们已经用 Clerk 实现了身份认证,使用 Shadcn UI + next-themes 实现了深色模式切换。当点击“添加清单”按钮的时候,右侧会弹出创建清单的表单,现在我们只是简单模拟了下大致效果。下一篇我们会用 Shadcn UI + React Hook Form + Zod + Server Actions 实现清单的创建和查询功能。