Next.js
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 (opens in a new tab) 创建一个应用,创建后查看密钥信息。

项目根目录新建 .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 呢?

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

注意:目前中文翻译并不全面,这是一个给 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 与组件库》 (opens in a new tab)

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 (opens in a new tab)
  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 实现清单的创建和查询功能。