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

文章首发: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-...
相关推荐
在下千玦17 小时前
#管理Node.js的多个版本
node.js
你的人类朋友18 小时前
MQTT协议是用来做什么的?此协议常用的概念有哪些?
javascript·后端·node.js
还是鼠鼠21 小时前
Node.js中间件的5个注意事项
javascript·vscode·中间件·node.js·json·express
南通DXZ1 天前
Win7下安装高版本node.js 16.3.0 以及webpack插件的构建
前端·webpack·node.js
你的人类朋友1 天前
浅谈Object.prototype.hasOwnProperty.call(a, b)
javascript·后端·node.js
前端太佬1 天前
暂时性死区(Temporal Dead Zone, TDZ)
前端·javascript·node.js
Mintopia1 天前
Node.js 中 http.createServer API 详解
前端·javascript·node.js
你的人类朋友1 天前
CommonJS模块化规范
javascript·后端·node.js
Mintopia2 天前
Node.js 中 fs.readFile API 的使用详解
前端·javascript·node.js
咖啡教室2 天前
nodejs开发后端服务详细学习笔记
后端·node.js