全栈 | 实践:构建类型安全的全栈应用(基础篇)

文章首发:fujia.site
文章采用意译,而非直译,如有不准确的,请在评论区指出!
我们塑造了工具,然后工具塑造了我们 ------马歇尔·麦克卢汉(Marshall McLuhan)

在本文中,我们将从零开始构建一个全栈的、端到端类型安全的pnpm monorepo风格的项目,使用的技术栈如下:

  • NestJS;
  • NextJS 13(使用新的 /app 目录);
  • tRPC

最后, 我们将使用docker 部署到云服务器上。

对前端工程师来说,我非常喜欢这个技术栈,因为它的开发者体验非常好!阅读完本文后,我相信你也会喜欢的。

下面我先简单概述下使用这种技术栈的好处:

  • 完全"端到端"的类型安全;
  • tRPC 服务被完全集成进了 NestJS,因此你可以使用 NestJS的所有功能,如:依赖注入;
  • 与 NextJS 13结合使用,客户端使用服务端渲染;
  • 前后端分离,但使用monorepo 风格的项目组织,可以将他们无缝地组合在一起。

前置准备: 你需要在本机上安装 pnpm 和 NestJS CLI。

本文的示例代码已上传到 github repo,你可以点击查看。

准备好了吗?让我们开始吧~

使用 pnpm workspaces 设置 Monorepo

首先,打开终端,切换到工作目录下,使用下面的命令创建一个空白的项目,推荐使用 VSCode 编辑编写项目:

sh 复制代码
mkdir nestjs-trpc

cd nestjs-trpc

pnpm init

code .

上面的命令会在根目录下创建一个 package.json 文件。

接下来,初始化git并添加.gitignore文件阻止git追踪声明的文件和文件夹:

sh 复制代码
git init
touch .gitignore

.gitignore文件中,添加下面的内容:

sh 复制代码
node_modules
dist
build
.env

我们使用 pnpm workspace构建我们的 monorepo,这样我们在同一个仓库中构建多个应用并使用pnpm进行包管理。

在项目根目录下新建 pnpm-workspace.yaml文件用来设置pnpm 工作区并编写下面的内容:

sh 复制代码
touch pnpm-workspace.yaml

# pnpm-workspace.yaml
packages:
	# all apps in direct subdirs of apps/
  - 'apps/*'

文件内容表示apps/目录下的目录都会包含在pnpm 的工作区中。

最后,在项目根目录下创建apps/目录:

sh 复制代码
mkdir apps

很好!现在我们就可以在apps/目录下创建"前端"和"后端"应用了。

TIPS: 如果不愿意敲命令,可以直接在VSCode中操作。

添加 NestJS 应用

我们新增一个名为"server"的NestJS 应用,名字可以取你喜欢的,但一般建议要"语义明确"。

使用之前在本机安装的NestJS CLI,运行下面的命令:

sh 复制代码
cd apps/

nest new server --strict --skip-git --package-manager=pnpm

上面命令中参数都比较重要,我们注意解释下:

  • --strict,确保 NestJS 的 TypeScript 编译器配置使用了"严格模式",因为不使用严格模式,就没有必要使用typescript了;
  • --skip-git,当我们创建一个NestJS应用时,默认会为我们初始化git,由于我们已经在根目录下初始化了git,所以要跳过默认的git行为;
  • --package-manager=pnpm(简写形式:-p pnpm),确保应用使用pnpm作为包管理器。

在开始之前,我们再次查看NestJS 服务器的目录结构并启动本地服务器:

sh 复制代码
cd /server
pnpm start:dev

很好!我们已经在monorepo项目中添加了一个NestJS 应用。

在开始之前,我们需要稍微调整下NestJS 服务器的监听端口。默认情况下,NestJS使用 3000 端口,这与 NextJS使用端口一致。

为了避免端口冲突,我们将NestJS的端口改为4000

这里我们建议使用一个环境变量来设置服务端口并使用4000作为备用端口。这在部署应用时非常有用,服务器提供商可以自动替换端口。

修改后的内容如下:

ts 复制代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT || 4000);
}
bootstrap();

要使用环境变量,需要使用"config module",可以查看这篇文章了解更多NestJS Config Module: Using environment variables

现在我们的本地服务器就运行在:http://locahost:4000

接下来,我们设置NextJS应用。

添加 NextJS 应用

我们添加一个名为"web"的NextJS 应用。

首先返回到apps/目录并运行下面的命令:

sh 复制代码
cd ..

pnpx create-next-app@latest

在安装过程,会给出一些提示,可以参考下面进行配置:

sh 复制代码
What is your project named? # web (change to whatever you want)
Would you like to use TypeScript with this project? # Yes
Would you like to use ESLint with this project? # Yes
Would you like to use Tailwind CSS with this project? Yes # Yes
Would you like to use `src/` directory with this project? # No
Use App Router (recommended)? # Yes
Would you like to customize the default import alias? # No

这样,在 apps/ 目录下,我们就创建了一个新的NextJS项目(这里名为web/)。

启动一个本地服务检查下NextJS 项目是否能正常运行。

sh 复制代码
cd /web

pnpm dev

这样在我们的monorepo项目中,有两个应用在本地运行。

Monorepo 配置更新和DX(Developer Experience)技巧

接下来,我们将会添加 tRPC,tRPC 服务端安装在NestJS 应用中,而tRPC客户端安装NextJS应用中。

tRPC客户端需要访问定义在NestJS应用中的AppRouter 类型(我们将在下一部分讨论)。

在当前的设置中,这是不可能的 ------ 你尽可以从你所在的应用中引入文件。

因此,我们需要修改 TypeScript 编译器的配置来实现它。在项目的根目录下创建一个新的tsconfig.json文件,让其他的应用继承它。

回到项目根目录创建tsconfig.json文件:

sh 复制代码
touch tsconfig.json

首先,更新NestJS 的tsconfig:

json 复制代码
{
  "extends": "../../tsconfig.json", // Extend the config options from the root
  "compilerOptions": {
    // The following options are not required as they've been moved to the root tsconfig
    // "baseUrl": "./",
    // "emitDecoratorMetadata": true,
    // "experimentalDecorators": true,
    // "incremental": true,
    // "skipLibCheck": true,
    // "strictNullChecks": true,
    // "noImplicitAny": true,
    // "strictBindCallApply": true,
    // "forceConsistentCasingInFileNames": true,
    // "noFallthroughCasesInSwitch": true
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
  }
}

然后更新 NextJS 的 tsconfig:

json 复制代码
{
  "extends": "../../tsconfig.json", // Extend the config options from the root,
  "compilerOptions": {
    // The following options are not required as they've been moved to the root tsconfig
    // "paths": {
    //   "@/*": ["./*"]
    // }
    // "incremental": true,
    // "forceConsistentCasingInFileNames": true,
    // "skipLibCheck": true,
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "plugins": [
      {
        "name": "next"
      }
    ],
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

由于我们修改了两个应用的TypeScript 编译器选项,重新启动下两个本地服务确保它们能正常运行。

我们不需要切换目录并为每个应用新建一个终端,而是在根目录下定义一个命令一次启动多个应用,修改步骤如下:

在项目根目录下,打开package.json文件,添加一个新的名为dev的脚本命令:

package.json 复制代码
{
  "name": "nestjs-trpc",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "pnpm run --parallel dev",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

这样在apps/目录下的任何名为dev的脚本都会并行运行。因此,我们需要将 NestJS本地开发服务脚本由start:dev修改为dev

在根目录下运行pnpm dev命令,可以看到两个应用已经并行运行起来了。

在安装 tRPC之前的最后两件事:

第一是一个小提示:

安装npm 包时,我们需要在相应的应用下安装它们,而不是在根目录。这需要不断地切换目录,有些麻烦,一种更好的方式是使用 --filter的参数。

举个例子,在根目录下为NestJS应用安装@nestjs/config包,可以运行下面的命令:

sh 复制代码
pnpm add @nestjs/config --filter=server

这导致了第二件事情,如果你在根目录下安装了一个NestJS 应用的包,可能会引起相应依赖缺失的错误:

sh 复制代码
apps/server
└─┬ ts-loader 9.4.3
  └── ✕ missing peer webpack@^5.0.0
Peer dependencies that should be installed:
  webpack@^5.0.0

该错误在单独的 NestJS 应用中不会发生,为什么在这里发生呢?

原因是monorepo的设置。在workspace中,包管理器不会自动地为每个应用处理相应的依赖。因此,我们需要在 NestJS应用下手动安装它:

sh 复制代码
pnpm add -D webpack@^5.0.0 --filter=server

干的好!这样我们就处理好这个错误了。

在 NestJS & NextJS中使用 tRPC

先安装依赖,在 NestJS 应用中安装tRPC server和在 NextJS应用中安装tRPC client

在 NestJS中添加 tRPC server

执行下面的命令安装相关依赖:

sh 复制代码
pnpm add @trpc/server zod --filter=server

接下来,我们添加 tRPC 模块,它会封装与 tRPC server相关的代码。

我们新建一个trpc的目录并创建三个文件:

sh 复制代码
mkdir apps/server/src/trpc

touch apps/server/src/trpc/trpc.module.ts
touch apps/server/src/trpc/trpc.service.ts
touch apps/server/src/trpc/trpc.router.ts

接着编写模块内容:

ts 复制代码
// trpc.module.ts

import { Module } from '@nestjs/common';

@Module({
  imports: [],
  controllers: [],
  providers: [],
})
export class TrpcModule {}

并在 AppModule模块中引入该模块:

ts 复制代码
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TrpcModule } from '@server/trpc/trpc.module';

@Module({
  imports: [TrpcModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

trpc.service.ts文件中添加一个类,导出必要的属性:

ts 复制代码
// trpc.service.ts
import { Injectable } from '@nestjs/common';
import { initTRPC } from '@trpc/server';

@Injectable()
export class TrpcService {
  trpc = initTRPC.create();
  procedure = this.trpc.procedure;
  router = this.trpc.router;
  mergeRouters = this.trpc.mergeRouters;
}

在 TrpcModule 文件中引入上面的service作为一个provider:

ts 复制代码
// trpc.module.ts

import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';

@Module({
  imports: [],
  controllers: [],
  providers: [TrpcService],
})
export class TrpcModule {}

trpc.router.ts文件中添加一个类,实现下面的功能:

  • 定义 trpc 路由(即,trpc客户端可以调用的方法);
  • 添加中间件方法导出 NestJS server 的trpc api;
  • 导出AppRouter类型(我们将在trpc客户端中使用)。

代码如下:

ts 复制代码
// trpc.router.ts
import { INestApplication, Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express';

@Injectable()
export class TrpcRouter {
  constructor(private readonly trpc: TrpcService) {}

  appRouter = this.trpc.router({
    hello: this.trpc.procedure
      .input(
        z.object({
          name: z.string().optional(),
        }),
      )
      .query(({ input }) => {
        const { name } = input;
        return {
          greeting: `Hello ${name ? name : `Bilbo`}`,
        };
      }),
  });

  async applyMiddleware(app: INestApplication) {
    app.use(
      `/trpc`,
      trpcExpress.createExpressMiddleware({
        router: this.appRouter,
      }),
    );
  }
}

export type AppRouter = TrpcRouter[`appRouter`];

现在我们可以在路由中使用依赖注入了,这是多么美好的事情。

这意味着我们可以在trpc 路由中注入其它的services,从而保证路由器的纯净、最小化并与业务逻辑解耦。请参考下面的实例说明:

ts 复制代码
// trpc.router.ts
import { INestApplication, Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express';

@Injectable()
export class TrpcRouter {
  constructor(
    private readonly trpc: TrpcService,
    private readonly userService: UserService // injected service
  ) {}

  appRouter = this.trpc.router({
    getUsers: this.trpc.procedure
      .input(
        z.object({
          name: z.string(),
        }),
      )
      .query(async ({ input }) => {
        const { name } = input;
        return await this.userService.getUsers(name); // random example showing you how you can now use dependency injection
      }),
  });

  async applyMiddleware(app: INestApplication) {
    app.use(
      `/trpc`,
      trpcExpress.createExpressMiddleware({
        router: this.appRouter,
      }),
    );
  }
}

export type AppRouter = TrpcRouter[`appRouter`];

请注意,在上面的示例中,我们添加了一个简单的路由示例,这是有意的。因为我们将在下一步的trpc客户端中展示如何使用它。

随着添加的路由越来越多,这会导致路由文件内容变得非常长且混乱。此时我们可以考虑使用merging routers进行优化。

在 TrpcModule 模块文件中引入它并注册为一个 Provider。

ts 复制代码
// trpc.module.ts
import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { TrpcRouter } from '@server/trpc/trpc.router';

@Module({
  imports: [],
  controllers: [],
  providers: [TrpcService, TrpcRouter],
})
export class TrpcModule {}

完成tRPC server编写的最后一件事是更新 main.ts文件,包括:

  • 应用我们在上面路由器中定义的中间件;
  • 启用CORS(即,允许跨域)
ts 复制代码
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TrpcRouter } from '@server/trpc/trpc.router';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors();
  const trpc = app.get(TrpcRouter);
  trpc.applyMiddleware(app);
  await app.listen(4000);
}
bootstrap();

注意:一定要配置服务器允许跨域,否则在客户端会遇到一个「跨域异常」。

到这里,我们就开发完了 tRPC server,下一步我们就开始创建 tRPC client

在 NextJS 中添加 tRPC

在开始之前,我们简单地讨论下 NextJS 13,这对我们后面的开发有帮助,它最新的 App 路由器会影响我们如何使用 tRPC client。

在 NextJS 13之前,所有的网络请求都是在客户端发起的(即,来自浏览器)。所有开发者都是如此使用且它也能很好地工作。我们构建了一个庞大冗余的状态管理系统(PS: 这里原文是crazy, 可以译为:极度烦恼的,(角度)极其歪斜的,因为翻译时小编正在做低代码项目的开发,项目中维护了一棵非常庞大的状态树,认为它被滥用了,因此就如此翻译了。多说一句,这里其实也可以看出,译文很受翻译者本身认知影响,强烈建议优先阅读原文!!!)并到处使用 useEffects hook

但是现在,NextJS 尝试了一些范式转变(有些讽刺的是,这种转变跟那些旧式的框架,如:Ruby-on-rails & Laravel的工作方式越来越一致),将更多的职责转移到服务端。

注意,这里讨论的「服务端」不是上面运行的 NestJS 服务端应用,而是 NextJS 应用的服务端。

例如,当用户在 NextJS 13应用中不同路由之间跳转时,他们每改变一次路由都会向 NextJS server 发生一个请求并将UI返回给浏览器。

因此,在NextJS 应用的服务端我们就有机会在 UI 渲染之前发起网络调用,要实现这一点,需要引入Server Components

使用这种方式有很多的优势,但对于过去几年前端工程师构建应用的方式来说,需要很大的思维模式转变。

NextJS的讨论先到这里,我们先添加 tRPC client

运行下面的命令在 NextJS 应用中安装客户端包:

sh 复制代码
pnpm add @trpc/client @trpc/server --filter=web

注意:@trpc/server 包是需要安装的,否则,会出现peer dependency 异常。

app/ 目录中,添加一个新文件 trpc.ts

sh 复制代码
touch apps/web/app/trpc.ts

在该文件中,我们将定义 tRPC client。为了创建 tRPC client,我们需要使用 AppRouter 类型,这是在上一步从tRPC server导出的。而也是为什么可以保证应用是一个「端到端类型安全」的。

ts 复制代码
// trpc.ts
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { AppRouter } from '@server/trpc/trpc.router'

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "http://localhost:4000/trpc", // you should update this to use env variables
    }),
  ],
});

因为我们一致化了 TSconfig 并配置了paths,所以我们可以在 NextJS 应用中导入 AppRouter(即使类型来自于另一个应用)。

url 属性指向了 tRPC 服务器,在我们的示例中是:http://localhost:4000/trpc。如果你要部署,需要使用「环境变量」来更新它。

现在我们可以在 NextJS 中调用服务器侧的 tRPC了。

在 NextJS 应用 /app 目录下,定位到 page.tsx文件。将函数更新为 async function,然后使用 trpc client 调用我们在 tRPC server 中定义的hello程序:

ts 复制代码
// apps/web/app/page.tsx
import { trpc } from "@web/app/trpc";

export default async function Home() {
  const { greeting } = await trpc.hello.query({ name: `Tom` });
  return <div>{greeting}</div>;
}

上面展示的是如何使用tRPC client发起一个服务器侧的调用。客户端侧如何调用呢?

让我们尝试一下吧。

新增一个名为ClientSide.tsx的组件,内容如下:

ts 复制代码
"use client";

import { trpc } from "@web/app/trpc";
import { useEffect, useState } from "react";

export default function ClientSide() {
  const [greeting, setGreeting] = useState("");
  useEffect(() => {
    trpc.hello
      .query({ name: `Tom` })
      .then(({ greeting }) => setGreeting(greeting));
  }, []);
  return <p>I am client side: {greeting}</p>;
}

就是这样简单!可以看到,在IDE中"一切"都是类型安全的,当你尝试从返回的query中访问一个不存在的属性时,就会得到一个错误提示。

总结

文章到这里,我们先告一段落,一个全栈(前端技术栈)项目的基础技术选型和"架子"就确定好了。

但我们知道这远远不够,项目中的真正的"硬菜"都还没上桌,所以不着急,我们一步步来。

另外,我们预告下,因为最近正在使用上面的技术栈做个人站点的重构。刚开始的时候只是觉得文章不错想翻译,写着写着就多出了一些其它的想法,如:看下能不能把他做成一个系列(包括但不限于:部署,数据库,工程化,脚手架(已经开源了一套),支付,AI接入(最近也一直在学习本地优先的LLM部署,如 ollama.ai)等等),一边重构网站,一边输出,想想还不错...

最后的最后,想提一点"非常个人"的建议:对前端工程师来说,在学习新的技术时,请一定考虑把"英语"加入你的课程表,持之以恒地精进她。关于学习英语的利弊或有什么难点并不想讨论,先试一试!

坚定相信的力量,重复做正确的事情! 这世上聪明的人好多,"笨笨"的把自己认为值得的事情做好,也不错哦...

敬请期待... :)

参考资料

  1. Building a full-stack, fully type-safe pnpm monorepo with NestJS, NextJS & tRPC - www.tomray.dev/nestjs-next...
  2. tRPC - trpc.io/
  3. Difference between dependencies, devDependencies and peerDependencies - www.geeksforgeeks.org/difference-...
相关推荐
秋雨凉人心2 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
哥谭居民00014 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
土豆炒马铃薯。6 小时前
【Vue】前端使用node.js对数据库直接进行CRUD操作
前端·javascript·vue.js·node.js·html5
CC__xy6 小时前
Node二、Node.js 模块化、es6 软件包、Express - 框架
前端·node.js
bin91538 小时前
npm报错
前端·npm·node.js
阿智@1110 小时前
Node.js 助力前端开发:自动化操作实战
运维·前端·node.js·自动化
秋雨凉人心13 小时前
上传npm包加强
开发语言·前端·javascript·webpack·npm·node.js
东方小月16 小时前
如何使用GitHub Actions自动部署我们的项目
前端·github·nestjs
Domain-zhuo17 小时前
Git和SVN有什么区别?
前端·javascript·vue.js·git·svn·webpack·node.js
涔溪18 小时前
node.js高级用法
node.js