使用 Next.js、Tailwind、tRPC 和 Prisma ORM 构建全栈应用程序简介先决条件入门

2025-05-24

使用 Next.js、Tailwind、tRPC 和 Prisma ORM 构建全栈应用程序

介绍

先决条件

入门

当我们创建一个同时具有 Rest Api 和 Web 应用程序的 TypeScript 项目时,从长远来看保持类型定义简洁变得具有挑战性。

如果我们创建了 GraphQL Api,对话可能会发生变化,因为我们可以使用代码生成,但我们仍然必须在后端维护模式。

所以基本上,在这两种选择中,我们总是必须维护一个模式或某种类型定义。

介绍

tRPC正是为此而生。借助此工具包,我们只需使用推理即可创建完全类型安全的应用程序。当我们在后端进行微小更改时,最终会在前端反映相同的更改。

先决条件

在继续之前,您需要:

  • 节点
  • TypeScript
  • Next.js
  • 顺风
  • 新公共管理

此外,您还需要具备这些技术的基本知识。

入门

项目设置

让我们设置 next.js 并导航到项目目录:

npx create-next-app@latest --ts grocery-list
cd grocery-list
Enter fullscreen mode Exit fullscreen mode

我们tsconfig.json将添加路径别名,以便更容易地使用相对路径:

// @/tsconfig.json
{
  "compilerOptions": {
    // ...
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "src/*"
      ],
    }
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

安装 Tailwind CSS:

npm install @fontsource/poppins
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

在文件中tailwind.config.js添加页面和组件文件夹的路径:

// @/tailwind.config.js
module.exports = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx}",
    "./src/components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

现在让我们将 Tailwind 指令添加到我们的globals.css

/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

* {
    font-family: "Poppins";
  }
Enter fullscreen mode Exit fullscreen mode

您可能已经注意到,我们所有的源代码(包括样式)都位于该/src文件夹中。

设置 Prisma

首先让我们安装必要的依赖项:

npm install prisma
Enter fullscreen mode Exit fullscreen mode

现在让我们初始化棱镜设置:

npx prisma init
Enter fullscreen mode Exit fullscreen mode

让我们将以下模式添加到我们的schema.prisma

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

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model GroceryList {
  id      Int      @id @default(autoincrement())
  title   String
  checked Boolean? @default(false)
}
Enter fullscreen mode Exit fullscreen mode

定义好模式后,您可以运行我们的第一次迁移:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

最后我们可以安装 prisma 客户端:

npm install @prisma/client
Enter fullscreen mode Exit fullscreen mode

随着我们项目基础配置的完成,我们可以进入下一步。

配置 tRPC

首先,让我们确保tsconfig.json已启用严格模式:

// @/tsconfig.json
{
  "compilerOptions": {
    // ...
    "strict": true
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

然后我们可以安装以下依赖项:

npm install @trpc/client @trpc/server @trpc/react @trpc/next zod react-query
Enter fullscreen mode Exit fullscreen mode

安装依赖项后,我们可以创建/server文件夹并创建上下文。

上下文用于将上下文数据传递给所有路由器解析器。在我们的上下文中,我们只传递 Prism 客户端实例。

// @/src/server/context.ts
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import { PrismaClient } from "@prisma/client";

export async function createContext(opts?: trpcNext.CreateNextContextOptions) {
const prisma = new PrismaClient();

return { prisma };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

通过创建上下文(createContext())并从中推断出数据类型(Context),我们可以继续定义路由器,但在此之前,请务必记住:

  • 一个端点称为一个过程;
  • 一个过程可以有两种类型的操作(查询和变异);
  • 查询负责获取数据,而变异负责更改数据(服务器端)。

考虑到这些要点,我们现在可以定义我们的路由器:

// @/src/server/router.ts
import * as trpc from "@trpc/server";
import { z } from "zod";

import { Context } from "./context";

export const serverRouter = trpc
  .router<Context>()
  .query("findAll", {
    resolve: async ({ ctx }) => {
      return await ctx.prisma.groceryList.findMany();
    },
  })
  .mutation("insertOne", {
    input: z.object({
      title: z.string(),
    }),
    resolve: async ({ input, ctx }) => {
      return await ctx.prisma.groceryList.create({
        data: { title: input.title },
      });
    },
  })
  .mutation("updateOne", {
    input: z.object({
      id: z.number(),
      title: z.string(),
      checked: z.boolean(),
    }),
    resolve: async ({ input, ctx }) => {
      const { id, ...rest } = input;

      return await ctx.prisma.groceryList.update({
        where: { id },
        data: { ...rest },
      });
    },
  })
  .mutation("deleteAll", {
    input: z.object({
      ids: z.number().array(),
    }),
    resolve: async ({ input, ctx }) => {
      const { ids } = input;

      return await ctx.prisma.groceryList.deleteMany({
        where: {
          id: { in: ids },
        },
      });
    },
  });

export type ServerRouter = typeof serverRouter;
Enter fullscreen mode Exit fullscreen mode

根据前面的代码片段,您可能已经注意到以下内容:

  • 我们的上下文的数据类型在我们的路由器中用作通用类型,以便我们拥有类型化的上下文对象(以便访问我们的 prisma 实例);
  • 我们的后端一共有四个流程;
  • 我们导出了我们的路由器(serverRouter)及其数据类型(ServerRouter)。

配置好路由器后,我们需要创建一个Next.js 的API 路由,并将我们的处理程序 API 添加到其中。在处理程序 API 中,我们将传递路由器和上下文(每次请求时都会调用)。

// @/src/pages/api/trpc/[trpc].ts
import * as trpcNext from "@trpc/server/adapters/next";

import { serverRouter } from "@/server/router";
import { createContext } from "@/server/context";

export default trpcNext.createNextApiHandler({
  router: serverRouter,
  createContext,
});
Enter fullscreen mode Exit fullscreen mode

现在是时候配置_app.tsx文件了,如下所示:

// @/src/pages/_app.tsx
import "../styles/globals.css";
import "@fontsource/poppins";
import { withTRPC } from "@trpc/next";
import { AppType } from "next/dist/shared/lib/utils";
import type { ServerRouter } from "@/server/router";

const App: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

export default withTRPC<ServerRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : "http://localhost:3000/api/trpc";

    return { url };
  },
  ssr: true,
})(App);
Enter fullscreen mode Exit fullscreen mode

然后我们将创建 tRPC 钩子,并将路由器的数据类型作为createReactQueryHooks()函数的泛型添加到其中,以便我们可以进行 api 调用:

// @/src/utils/trpc.ts
import type { ServerRouter } from "@/server/router";
import { createReactQueryHooks } from "@trpc/react";

export const trpc = createReactQueryHooks<ServerRouter>();
Enter fullscreen mode Exit fullscreen mode

创建前端

首先让我们处理应用程序的组件,为了简单起见,我将所有内容放在/components文件夹中的单个文件中。

从卡片开始,让我们创建卡片的容器、标题和内容:

// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";

interface CardProps {
  children: React.ReactNode;
}

export const Card: NextPage<CardProps> = ({ children }) => {
  return (
    <div className="h-screen flex flex-col justify-center items-center bg-slate-100">
      {children}
    </div>
  );
};

export const CardContent: NextPage<CardProps> = ({ children }) => {
  return (
    <div className="bg-white w-5/6 md:w-4/6 lg:w-3/6 xl:w-2/6 rounded-lg drop-shadow-md">
      {children}
    </div>
  );
};

interface CardHeaderProps {
  title: string;
  listLength: number;
  clearAllFn?: () => void;
}

export const CardHeader: NextPage<CardHeaderProps> = ({
  title,
  listLength,
  clearAllFn,
}) => {
  return (
    <div className="flex flex-row items-center justify-between p-3 border-b border-slate-200">
      <div className="flex flex-row items-center justify-between">
        <h1 className="text-base font-medium tracking-wide text-gray-900 mr-2">
          {title}
        </h1>
        <span className="h-5 w-5 bg-blue-200 text-blue-600 flex items-center justify-center rounded-full text-xs">
          {listLength}
        </span>
      </div>
      <button
        className="text-sm font-medium text-gray-600 underline"
        type="button"
        onClick={clearAllFn}
      >
        Clear all
      </button>
    </div>
  );
};

// ...
Enter fullscreen mode Exit fullscreen mode

现在我们已经创建了卡片,我们可以创建列表的组件:

// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";

// ...

export const List: NextPage<CardProps> = ({ children }) => {
  return <div className="overflow-y-auto h-72">{children}</div>;
};

interface ListItemProps {
  item: GroceryList;
  onUpdate?: (item: GroceryList) => void;
}

const ListItemComponent: NextPage<ListItemProps> = ({ item, onUpdate }) => {
  return (
    <div className="h-12 border-b flex items-center justify-start px-3">
      <input
        type="checkbox"
        className="w-4 h-4 border-gray-300 rounded mr-4"
        defaultChecked={item.checked as boolean}
        onChange={() => onUpdate?.(item)}
      />
      <h2 className="text-gray-600 tracking-wide text-sm">{item.title}</h2>
    </div>
  );
};

export const ListItem = memo(ListItemComponent);

// ...
Enter fullscreen mode Exit fullscreen mode

最后,只需创建表单即可将新元素添加到列表中:

// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";

// ...

interface CardFormProps {
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  submit: () => void;
}

export const CardForm: NextPage<CardFormProps> = ({
  value,
  onChange,
  submit,
}) => {
  return (
    <div className="bg-white w-5/6 md:w-4/6 lg:w-3/6 xl:w-2/6 rounded-lg drop-shadow-md mt-4">
      <div className="relative">
        <input
          className="w-full py-4 pl-3 pr-16 text-sm rounded-lg"
          type="text"
          placeholder="Grocery item name..."
          onChange={onChange}
          value={value}
        />
        <button
          className="absolute p-2 text-white -translate-y-1/2 bg-blue-600 rounded-full top-1/2 right-4"
          type="button"
          onClick={submit}
        >
          <svg
            className="w-4 h-4"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              d="M12 6v6m0 0v6m0-6h6m-6 0H6"
            />
          </svg>
        </button>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

一切准备就绪后,我们就可以开始制作主页了。主页如下:

// @/src/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
import { useCallback, useState } from "react";
import { trpc } from "@/utils/trpc";

import {
  Card,
  CardContent,
  CardForm,
  CardHeader,
  List,
  ListItem,
} from "../components";
import { GroceryList } from "@prisma/client";

const Home: NextPage = () => {
  const [itemName, setItemName] = useState<string>("");

  const { data: list, refetch } = trpc.useQuery(["findAll"]);
  const insertMutation = trpc.useMutation(["insertOne"], {
    onSuccess: () => refetch(),
  });
  const deleteAllMutation = trpc.useMutation(["deleteAll"], {
    onSuccess: () => refetch(),
  });
  const updateOneMutation = trpc.useMutation(["updateOne"], {
    onSuccess: () => refetch(),
  });

  const insertOne = useCallback(() => {
    if (itemName === "") return;

    insertMutation.mutate({
      title: itemName,
    });

    setItemName("");
  }, [itemName, insertMutation]);

  const clearAll = useCallback(() => {
    if (list?.length) {
      deleteAllMutation.mutate({
        ids: list.map((item) => item.id),
      });
    }
  }, [list, deleteAllMutation]);

  const updateOne = useCallback(
    (item: GroceryList) => {
      updateOneMutation.mutate({
        ...item,
        checked: !item.checked,
      });
    },
    [updateOneMutation]
  );

  return (
    <>
      <Head>
        <title>Grocery List</title>
        <meta name="description" content="Visit www.mosano.eu" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <Card>
          <CardContent>
            <CardHeader
              title="Grocery List"
              listLength={list?.length ?? 0}
              clearAllFn={clearAll}
            />
            <List>
              {list?.map((item) => (
                <ListItem key={item.id} item={item} onUpdate={updateOne} />
              ))}
            </List>
          </CardContent>
          <CardForm
            value={itemName}
            onChange={(e) => setItemName(e.target.value)}
            submit={insertOne}
          />
        </Card>
      </main>
    </>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

完成本文所有步骤后,预期的最终结果如下:

图像

如果您只是想克隆项目并创建此应用程序的自己的版本,您可以单击链接访问本文的存储库。

我希望您觉得这篇文章有用,下次再见。

文章来源:https://dev.to/franciscomendes10866/build-a-full-stack-app-with-nextjs-tailwind-trpc-and-prisma-orm-4ail
PREV
如何使用 React 和 Tailwind 创建通知/Toast 让我们来编码
NEXT
Guia Completo para Usar o 虚拟环境 (venv) 无 Python