我们使用 Typescript + Tailwind + Prisma + MySQL + Zod + Shadcn UI + React Hook Form + Clerk + Server Actions 开发一个清单项目。
项目效果如下:
运行:
npm create t3-app@latest
- Will you be using TypeScript or JavaScript?
- TypeScript
- Will you be using Tailwind CSS for styling?
- Yes
- Would you like to use tRPC?
- No
- What authentication provider would you like to use?
- None(因为接入 Clerk)
- What database ORM would you like to use?
- Prisma
- Would you like to use Next.js App Router?
- Yes
- What database provider would you like to use?
- MySQL(选择喜欢的数据库即可,使用 Prisma)
- Should we initialize a Git repository and stage the changes?
- Yes
- Should we run 'npm install' for you?
- Yes
- 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
效果如下:
此时会创建数据库,并将数据库和 Prisma schema 同步。
运行:
# 开发模式
npm run dev
# 提交代码
git commit -m "initial commit"
我们的项目就正式开始了。
我们先做身份认证,毕竟身份认证是基础,且创建清单的时候需要用户信息。
为了快速实现,最便捷的方式是接入 Clerk。我们将接入 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
,此时效果如下:
当我们点击 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
,浏览器效果如下:
效果描述:当点击登录的时候,跳转的是 http://localhost/sign-in
修改 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>
);
}
此时界面基本完成汉化,浏览器效果如下:
但查看用户界面,你就会发现,这个汉化并不全面!就比如删除按钮这里的文案依然是英文……
新建 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>
);
}
此时删除账户界面就改为了中文:
那么问题来了:如果还有其他需要汉化的位置,怎么知道具体的字段位置呢?就比如为什么“删除账户”,它对应的字段位置是 userProfile.start.dangerSection.deleteAccountButton 呢?
其实这是个体力活:打开原本的英文,搜索对应文案,就可以找到具体的字段位置了。
注意:目前中文翻译并不全面,这是一个给 Clerk 提 PR 的好机会!
毕竟我们开发的是一个清单项目,当用户打开首页的时候,应该是登录后才能查看到自己创建的清单。所以我们实现一个路由保护,当用户访问 /
的时候,会跳转到 /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>
组件会导致报错:
现在我们就完成了 Clerk 的基础设置。浏览器效果如下:
效果描述:访问首页,会跳转到登录页面,登录完成后,跳转会首页。
接下来我们支持深色模式,我们借助 Shadcn UI 实现。
初始化 Shadcn UI:
npx shadcn-ui@latest init
命令行效果如下:
添加组件:
npx shadcn-ui@latest add
因为用到的组件很多,干脆全装了。敲击键盘的a
,作用是全选组件(再敲击一次就是取消全选),然后进行安装。
修改 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>
);
}
此时浏览器效果如下:
注:更复杂的效果,比如修改主题色、增加主题请查看《实战篇 | Shadcn UI 与组件库》
修改 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-app 身份认证和深色模式
- 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-t3-todo
- 下载代码:
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 实现清单的创建和查询功能。